채팅 UI 개편: 한 줄 티커 + 우측 사이드패널 채팅창
- 티커: 반투명 검은 가로 박스, 새 채팅을 한 줄씩 7초간 순차 표시, 길면 말줄임(…) 처리. 접속 이후 도착한 메시지만 흘림 - 💬 버튼 → 기존 메뉴와 같은 우측 사이드패널로 전체 채팅창 (히스토리 스크롤 + 말풍선 + 입력창, 내 메시지는 오른쪽 초록) - SSE 연결은 chatClient 싱글톤(useSyncExternalStore)으로 티커·패널 공유 - 1:1 친구 채팅 로드맵을 docs/GAME_DESIGN.md 12절에 기록 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
@@ -217,3 +217,19 @@
|
|||||||
2. **서버 연동**: DB 세이브 동기화 (Phase D). 친구 방문은 보류
|
2. **서버 연동**: DB 세이브 동기화 (Phase D). 친구 방문은 보류
|
||||||
3. **시간**: 날짜·낮/밤 없음, 항상 낮. 페이스는 체력·행복도로 조절
|
3. **시간**: 날짜·낮/밤 없음, 항상 낮. 페이스는 체력·행복도로 조절
|
||||||
4. 이름: 랜덤+변경 가능 / 외형: 일러스트 확정 / 수용 초과: 입양 제한 유지
|
4. 이름: 랜덤+변경 가능 / 외형: 일러스트 확정 / 수용 초과: 입양 제한 유지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 소셜 기능 로드맵 (2026-08-14 추가)
|
||||||
|
|
||||||
|
현재 구현: **전체 채팅** — 우측 상단 반투명 검은 티커(새 채팅 한 줄씩, 말줄임 처리) + 💬 버튼으로 우측 사이드패널 채팅창. SSE 실시간, 닉네임+토큰 인증, 1.5초 쿨다운, 120자 제한.
|
||||||
|
|
||||||
|
### 친구 기능 구현 후 추가할 것 (예정)
|
||||||
|
|
||||||
|
1. **친구 시스템 선행 필요**: 친구 요청/수락, 친구 목록 (villages 테이블 기반)
|
||||||
|
2. **1:1 채팅**: 친구끼리만 가능
|
||||||
|
- 채팅 패널 상단에 탭 추가: `[전체] [친구1] [친구2] …` — 현재 ChatPanel은 이 확장을 염두에 둔 구조
|
||||||
|
- DB: chat_messages에 `recipient` 컬럼(NULL=전체 채팅) 추가하는 방향
|
||||||
|
- SSE 스트림에서 수신자 필터링 (본인 대상 메시지만 푸시)
|
||||||
|
- 안 읽은 메시지 뱃지 (티커/💬 버튼에 표시)
|
||||||
|
3. **연계 후보**: 친구 마을 방문(읽기 전용)과 함께 구현하면 시너지
|
||||||
|
|||||||
@@ -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<HTMLDivElement | null>(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 (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-4 py-3">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<p className="mt-8 text-center text-xs text-[#8a7551]">
|
||||||
|
아직 대화가 없어요. 첫 인사를 남겨보세요! 🐾
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{messages.map((m) => {
|
||||||
|
const mine = m.nickname === nickname;
|
||||||
|
return (
|
||||||
|
<div key={m.id} className={mine ? "text-right" : "text-left"}>
|
||||||
|
<div className="font-jua mb-0.5 text-[11px] text-[#8a6316]">
|
||||||
|
{mine ? "나" : m.nickname}
|
||||||
|
<span className="font-num ml-1.5 text-[10px] text-[#a3906c]">
|
||||||
|
{new Date(m.at).toLocaleTimeString("ko-KR", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`inline-block max-w-[85%] break-words rounded-xl px-3 py-1.5 text-left text-[13px] leading-relaxed ${
|
||||||
|
mine
|
||||||
|
? "bg-[#dff0c8] text-[#3c5218]"
|
||||||
|
: "bg-[rgba(126,95,48,.12)] text-[#3f2c14]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 border-t border-[rgba(126,95,48,.18)] px-3 py-2.5">
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => 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]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => void send()}
|
||||||
|
disabled={sending || !draft.trim()}
|
||||||
|
className="nature-btn shrink-0 px-3.5 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
📨
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+50
-118
@@ -1,134 +1,66 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
chatBootAt,
|
||||||
|
useChatMessages,
|
||||||
|
type ChatMessage,
|
||||||
|
} from "@/lib/game/chatClient";
|
||||||
|
|
||||||
interface ChatMessage {
|
const LINE_MS = 7000; // 티커 한 줄이 머무는 시간
|
||||||
id: number;
|
|
||||||
nickname: string;
|
|
||||||
text: string;
|
|
||||||
at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SHOW_MS = 20000; // 한 줄이 머무는 시간
|
/**
|
||||||
const MAX_LINES = 6;
|
* 오른쪽 상단 채팅 티커: 반투명 검은 가로 박스에 새 채팅을 한 줄씩 표시.
|
||||||
|
* 접속 이후 도착한 메시지만 순서대로 흘리고, 너무 길면 … 처리.
|
||||||
|
*/
|
||||||
|
export default function ChatTicker({ onOpen }: { onOpen: () => void }) {
|
||||||
|
const messages = useChatMessages();
|
||||||
|
const [current, setCurrent] = useState<ChatMessage | null>(null);
|
||||||
|
const queue = useRef<ChatMessage[]>([]);
|
||||||
|
const queued = useRef<Set<number>>(new Set());
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
/** 오른쪽 상단 실시간 마을 채팅 티커 */
|
// 새로 도착한 실시간 메시지를 티커 큐에 적재
|
||||||
export default function ChatTicker({
|
|
||||||
nickname,
|
|
||||||
token,
|
|
||||||
}: {
|
|
||||||
nickname: string;
|
|
||||||
token: string;
|
|
||||||
}) {
|
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [draft, setDraft] = useState("");
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
const seen = useRef<Set<number>>(new Set());
|
|
||||||
const boot = useRef(Date.now());
|
|
||||||
|
|
||||||
// SSE 구독 (EventSource가 끊기면 자동 재접속)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const es = new EventSource("/api/chat/stream");
|
for (const m of messages) {
|
||||||
es.onmessage = (e) => {
|
if (m.at < chatBootAt || queued.current.has(m.id)) continue;
|
||||||
try {
|
queued.current.add(m.id);
|
||||||
const msg = JSON.parse(e.data) as ChatMessage;
|
queue.current.push(m);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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 (
|
useEffect(
|
||||||
<div className="pointer-events-none absolute right-3 top-15 z-30 flex w-64 flex-col items-end gap-1">
|
() => () => {
|
||||||
{visible.map((m) => {
|
if (timer.current) clearTimeout(timer.current);
|
||||||
const age = now - m.at;
|
},
|
||||||
const fading = age > SHOW_MS - 4000;
|
[],
|
||||||
return (
|
);
|
||||||
<div
|
|
||||||
key={m.id}
|
|
||||||
className="max-w-full truncate rounded-lg bg-[rgba(43,29,12,.62)] px-2.5 py-1 text-[11px] leading-snug text-[#f3e6c8] shadow-sm transition-opacity duration-1000"
|
|
||||||
style={{ opacity: fading ? 0.25 : 0.92 }}
|
|
||||||
title={`${m.nickname}: ${m.text}`}
|
|
||||||
>
|
|
||||||
<span className="font-jua text-[#ffd97a]">{m.nickname}</span>
|
|
||||||
<span className="mx-1 text-[#c9b088]">·</span>
|
|
||||||
{m.text}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{open ? (
|
return (
|
||||||
<div className="pointer-events-auto mt-1 flex w-full items-center gap-1">
|
<div className="pointer-events-none absolute right-3 top-15 z-30 flex items-center gap-1.5">
|
||||||
<input
|
{current && (
|
||||||
value={draft}
|
<div className="w-80 truncate rounded-lg bg-black/55 px-3 py-1.5 text-xs leading-snug text-[#f3e6c8] shadow-md">
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
<span className="font-jua text-[#ffd97a]">{current.nickname}</span>
|
||||||
onKeyDown={(e) => {
|
<span className="mx-1.5 text-[#c9b088]">·</span>
|
||||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) void send();
|
{current.text}
|
||||||
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]"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => void send()}
|
|
||||||
disabled={sending || !draft.trim()}
|
|
||||||
className="wood-pill shrink-0 px-2.5 py-1.5 text-xs hover:brightness-125 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
📨
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="wood-pill shrink-0 px-2 py-1.5 text-xs hover:brightness-125"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
className="wood-pill pointer-events-auto mt-1 px-2.5 py-1 text-xs hover:brightness-125"
|
|
||||||
title="마을 채팅"
|
|
||||||
>
|
|
||||||
💬 채팅
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onOpen}
|
||||||
|
className="wood-pill pointer-events-auto shrink-0 px-2.5 py-1.5 text-xs hover:brightness-125"
|
||||||
|
title="마을 채팅 열기"
|
||||||
|
>
|
||||||
|
💬
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import VillageView, { type PlacingState } from "@/components/game/VillageView";
|
|||||||
import DbStatus from "@/components/DbStatus";
|
import DbStatus from "@/components/DbStatus";
|
||||||
import NicknameModal from "@/components/game/NicknameModal";
|
import NicknameModal from "@/components/game/NicknameModal";
|
||||||
import ChatTicker from "@/components/game/ChatTicker";
|
import ChatTicker from "@/components/game/ChatTicker";
|
||||||
|
import ChatPanel from "@/components/game/ChatPanel";
|
||||||
import {
|
import {
|
||||||
engine,
|
engine,
|
||||||
effectiveRate,
|
effectiveRate,
|
||||||
@@ -112,6 +113,7 @@ type Panel =
|
|||||||
| { type: "shop" }
|
| { type: "shop" }
|
||||||
| { type: "cats" }
|
| { type: "cats" }
|
||||||
| { type: "deco"; id: string }
|
| { type: "deco"; id: string }
|
||||||
|
| { type: "chat" }
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
export default function Game() {
|
export default function Game() {
|
||||||
@@ -233,9 +235,13 @@ export default function Game() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── 실시간 마을 채팅 (닉네임 확정 후) ── */}
|
{/* ── 실시간 마을 채팅 티커 (닉네임 확정 후) ── */}
|
||||||
{state.nickname && state.syncToken && (
|
{state.nickname && state.syncToken && (
|
||||||
<ChatTicker nickname={state.nickname} token={state.syncToken} />
|
<ChatTicker
|
||||||
|
onOpen={() =>
|
||||||
|
setPanel(panel?.type === "chat" ? null : { type: "chat" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 닉네임 입력 (인사 후, 미입력 시 게임 차단) ── */}
|
{/* ── 닉네임 입력 (인사 후, 미입력 시 게임 차단) ── */}
|
||||||
@@ -430,6 +436,11 @@ export default function Game() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 우측 패널 ── */}
|
{/* ── 우측 패널 ── */}
|
||||||
|
{panel?.type === "chat" && (
|
||||||
|
<SidePanel title="💬 마을 채팅" onClose={() => setPanel(null)}>
|
||||||
|
<ChatPanel nickname={state.nickname ?? ""} token={state.syncToken ?? ""} />
|
||||||
|
</SidePanel>
|
||||||
|
)}
|
||||||
{panel?.type === "hall" && (
|
{panel?.type === "hall" && (
|
||||||
<SidePanel title={`🏛️ 마을 회관 Lv${state.hall.level}`} onClose={() => setPanel(null)}>
|
<SidePanel title={`🏛️ 마을 회관 Lv${state.hall.level}`} onClose={() => setPanel(null)}>
|
||||||
<HallPanel
|
<HallPanel
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: number;
|
||||||
|
nickname: string;
|
||||||
|
text: string;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE 연결 싱글톤 — 티커·채팅 패널이 같은 스트림을 공유
|
||||||
|
let es: EventSource | null = null;
|
||||||
|
let messages: ChatMessage[] = [];
|
||||||
|
const seen = new Set<number>();
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
새 이슈에서 참조
사용자 차단