From 8ef59a821580a4fbc5c2cafdc439f85d6e8b1b7f Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 14 Aug 2026 16:47:10 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B1=84=ED=8C=85=20UI=20=EA=B0=9C=ED=8E=B8:?= =?UTF-8?q?=20=ED=95=9C=20=EC=A4=84=20=ED=8B=B0=EC=BB=A4=20+=20=EC=9A=B0?= =?UTF-8?q?=EC=B8=A1=20=EC=82=AC=EC=9D=B4=EB=93=9C=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=EC=B0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 티커: 반투명 검은 가로 박스, 새 채팅을 한 줄씩 7초간 순차 표시, 길면 말줄임(…) 처리. 접속 이후 도착한 메시지만 흘림 - 💬 버튼 → 기존 메뉴와 같은 우측 사이드패널로 전체 채팅창 (히스토리 스크롤 + 말풍선 + 입력창, 내 메시지는 오른쪽 초록) - SSE 연결은 chatClient 싱글톤(useSyncExternalStore)으로 티커·패널 공유 - 1:1 친구 채팅 로드맵을 docs/GAME_DESIGN.md 12절에 기록 Co-Authored-By: Claude Fable 5 --- docs/GAME_DESIGN.md | 16 +++ src/components/game/ChatPanel.tsx | 89 +++++++++++++++ src/components/game/ChatTicker.tsx | 168 +++++++++-------------------- src/components/game/Game.tsx | 15 ++- src/lib/game/chatClient.ts | 71 ++++++++++++ 5 files changed, 239 insertions(+), 120 deletions(-) create mode 100644 src/components/game/ChatPanel.tsx create mode 100644 src/lib/game/chatClient.ts diff --git a/docs/GAME_DESIGN.md b/docs/GAME_DESIGN.md index da8fd6a..83e8dca 100644 --- a/docs/GAME_DESIGN.md +++ b/docs/GAME_DESIGN.md @@ -217,3 +217,19 @@ 2. **서버 연동**: DB 세이브 동기화 (Phase D). 친구 방문은 보류 3. **시간**: 날짜·낮/밤 없음, 항상 낮. 페이스는 체력·행복도로 조절 4. 이름: 랜덤+변경 가능 / 외형: 일러스트 확정 / 수용 초과: 입양 제한 유지 + +--- + +## 12. 소셜 기능 로드맵 (2026-08-14 추가) + +현재 구현: **전체 채팅** — 우측 상단 반투명 검은 티커(새 채팅 한 줄씩, 말줄임 처리) + 💬 버튼으로 우측 사이드패널 채팅창. SSE 실시간, 닉네임+토큰 인증, 1.5초 쿨다운, 120자 제한. + +### 친구 기능 구현 후 추가할 것 (예정) + +1. **친구 시스템 선행 필요**: 친구 요청/수락, 친구 목록 (villages 테이블 기반) +2. **1:1 채팅**: 친구끼리만 가능 + - 채팅 패널 상단에 탭 추가: `[전체] [친구1] [친구2] …` — 현재 ChatPanel은 이 확장을 염두에 둔 구조 + - DB: chat_messages에 `recipient` 컬럼(NULL=전체 채팅) 추가하는 방향 + - SSE 스트림에서 수신자 필터링 (본인 대상 메시지만 푸시) + - 안 읽은 메시지 뱃지 (티커/💬 버튼에 표시) +3. **연계 후보**: 친구 마을 방문(읽기 전용)과 함께 구현하면 시너지 diff --git a/src/components/game/ChatPanel.tsx b/src/components/game/ChatPanel.tsx new file mode 100644 index 0000000..dd5dac5 --- /dev/null +++ b/src/components/game/ChatPanel.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { sendChatMessage, useChatMessages } from "@/lib/game/chatClient"; + +/** 우측 사이드패널에 들어가는 전체 채팅 본문 (추후 친구 1:1 채팅 탭 추가 예정) */ +export default function ChatPanel({ + nickname, + token, +}: { + nickname: string; + token: string; +}) { + const messages = useChatMessages(); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const listRef = useRef(null); + + // 새 메시지 도착 시 맨 아래로 스크롤 + useEffect(() => { + const el = listRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [messages.length]); + + const send = async () => { + const text = draft.trim(); + if (!text || sending) return; + setSending(true); + const ok = await sendChatMessage(nickname, token, text); + if (ok) setDraft(""); + setSending(false); + }; + + return ( +
+
+ {messages.length === 0 && ( +

+ 아직 대화가 없어요. 첫 인사를 남겨보세요! 🐾 +

+ )} + {messages.map((m) => { + const mine = m.nickname === nickname; + return ( +
+
+ {mine ? "나" : m.nickname} + + {new Date(m.at).toLocaleTimeString("ko-KR", { + hour: "2-digit", + minute: "2-digit", + })} + +
+
+ {m.text} +
+
+ ); + })} +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) void send(); + }} + maxLength={120} + placeholder="마을 사람들에게 한마디…" + className="w-full rounded-lg border-2 border-[rgba(126,95,48,.3)] bg-white/80 px-3 py-2 text-sm text-[#3f2c14] outline-none focus:border-[#7fb539]" + /> + +
+
+ ); +} diff --git a/src/components/game/ChatTicker.tsx b/src/components/game/ChatTicker.tsx index 462fdeb..211486a 100644 --- a/src/components/game/ChatTicker.tsx +++ b/src/components/game/ChatTicker.tsx @@ -1,134 +1,66 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { + chatBootAt, + useChatMessages, + type ChatMessage, +} from "@/lib/game/chatClient"; -interface ChatMessage { - id: number; - nickname: string; - text: string; - at: number; -} +const LINE_MS = 7000; // 티커 한 줄이 머무는 시간 -const SHOW_MS = 20000; // 한 줄이 머무는 시간 -const MAX_LINES = 6; +/** + * 오른쪽 상단 채팅 티커: 반투명 검은 가로 박스에 새 채팅을 한 줄씩 표시. + * 접속 이후 도착한 메시지만 순서대로 흘리고, 너무 길면 … 처리. + */ +export default function ChatTicker({ onOpen }: { onOpen: () => void }) { + const messages = useChatMessages(); + const [current, setCurrent] = useState(null); + const queue = useRef([]); + const queued = useRef>(new Set()); + const timer = useRef | null>(null); -/** 오른쪽 상단 실시간 마을 채팅 티커 */ -export default function ChatTicker({ - nickname, - token, -}: { - nickname: string; - token: string; -}) { - const [messages, setMessages] = useState([]); - const [open, setOpen] = useState(false); - const [draft, setDraft] = useState(""); - const [sending, setSending] = useState(false); - const [now, setNow] = useState(() => Date.now()); - const seen = useRef>(new Set()); - const boot = useRef(Date.now()); - - // SSE 구독 (EventSource가 끊기면 자동 재접속) + // 새로 도착한 실시간 메시지를 티커 큐에 적재 useEffect(() => { - const es = new EventSource("/api/chat/stream"); - es.onmessage = (e) => { - try { - const msg = JSON.parse(e.data) as ChatMessage; - if (seen.current.has(msg.id)) return; - seen.current.add(msg.id); - // 접속 시점 백로그는 "이미 지난 대화" — 은은하게 잠깐만 보여주도록 시각 보정 - if (msg.at < boot.current - SHOW_MS) msg.at = boot.current - SHOW_MS * 0.7; - // 백로그가 실시간보다 늦게 도착할 수 있어 id 순으로 정렬 유지 - setMessages((prev) => - [...prev, msg].sort((a, b) => a.id - b.id).slice(-50), - ); - } catch { - /* ignore */ - } - }; - return () => es.close(); - }, []); - - // 오래된 줄 페이드아웃용 시계 - useEffect(() => { - const t = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(t); - }, []); - - const visible = messages.filter((m) => now - m.at < SHOW_MS).slice(-MAX_LINES); - - const send = async () => { - const text = draft.trim(); - if (!text || sending) return; - setSending(true); - try { - await fetch("/api/chat/send", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ nickname, token, text }), - }); - setDraft(""); - } finally { - setSending(false); + for (const m of messages) { + if (m.at < chatBootAt || queued.current.has(m.id)) continue; + queued.current.add(m.id); + queue.current.push(m); } + if (!current && queue.current.length > 0) advance(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages]); + + const advance = () => { + const next = queue.current.shift() ?? null; + setCurrent(next); + if (timer.current) clearTimeout(timer.current); + if (next) timer.current = setTimeout(advance, LINE_MS); }; - return ( -
- {visible.map((m) => { - const age = now - m.at; - const fading = age > SHOW_MS - 4000; - return ( -
- {m.nickname} - · - {m.text} -
- ); - })} + useEffect( + () => () => { + if (timer.current) clearTimeout(timer.current); + }, + [], + ); - {open ? ( -
- setDraft(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.nativeEvent.isComposing) void send(); - if (e.key === "Escape") setOpen(false); - }} - maxLength={120} - autoFocus - placeholder="마을 사람들에게 한마디…" - className="w-full rounded-lg border-2 border-[rgba(126,95,48,.4)] bg-[rgba(255,250,238,.95)] px-2.5 py-1.5 text-xs text-[#3f2c14] outline-none focus:border-[#7fb539]" - /> - - + return ( +
+ {current && ( +
+ {current.nickname} + · + {current.text}
- ) : ( - )} +
); } diff --git a/src/components/game/Game.tsx b/src/components/game/Game.tsx index 02bb401..1494aa2 100644 --- a/src/components/game/Game.tsx +++ b/src/components/game/Game.tsx @@ -6,6 +6,7 @@ import VillageView, { type PlacingState } from "@/components/game/VillageView"; import DbStatus from "@/components/DbStatus"; import NicknameModal from "@/components/game/NicknameModal"; import ChatTicker from "@/components/game/ChatTicker"; +import ChatPanel from "@/components/game/ChatPanel"; import { engine, effectiveRate, @@ -112,6 +113,7 @@ type Panel = | { type: "shop" } | { type: "cats" } | { type: "deco"; id: string } + | { type: "chat" } | null; export default function Game() { @@ -233,9 +235,13 @@ export default function Game() { } /> - {/* ── 실시간 마을 채팅 (닉네임 확정 후) ── */} + {/* ── 실시간 마을 채팅 티커 (닉네임 확정 후) ── */} {state.nickname && state.syncToken && ( - + + setPanel(panel?.type === "chat" ? null : { type: "chat" }) + } + /> )} {/* ── 닉네임 입력 (인사 후, 미입력 시 게임 차단) ── */} @@ -430,6 +436,11 @@ export default function Game() {
{/* ── 우측 패널 ── */} + {panel?.type === "chat" && ( + setPanel(null)}> + + + )} {panel?.type === "hall" && ( setPanel(null)}> (); +const listeners = new Set<() => void>(); +/** 페이지 접속 시각 — 이 이후 도착한 메시지만 티커에 흘림 (백로그 재생 방지) */ +export const chatBootAt = Date.now(); + +function notify() { + for (const fn of listeners) fn(); +} + +function ensureStream() { + if (es || typeof window === "undefined") return; + es = new EventSource("/api/chat/stream"); + es.onmessage = (e) => { + try { + const msg = JSON.parse(e.data) as ChatMessage; + if (seen.has(msg.id)) return; + seen.add(msg.id); + // 백로그가 실시간보다 늦게 도착할 수 있어 id 순 정렬 유지 + messages = [...messages, msg].sort((a, b) => a.id - b.id).slice(-100); + notify(); + } catch { + /* ignore */ + } + }; +} + +function subscribe(fn: () => void): () => void { + ensureStream(); + listeners.add(fn); + return () => listeners.delete(fn); +} + +const getSnapshot = () => messages; +const getServerSnapshot = () => messages; + +/** 채팅 메시지 목록 (오래된 → 최신) */ +export function useChatMessages(): ChatMessage[] { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +/** 메시지 전송. 성공 시 true */ +export async function sendChatMessage( + nickname: string, + token: string, + text: string, +): Promise { + try { + const res = await fetch("/api/chat/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nickname, token, text }), + }); + return res.ok; + } catch { + return false; + } +}