Phase D: 닉네임 기반 DB 세이브 동기화
- villages 테이블(pg-jskim): nickname PK + 보호 토큰 + JSONB 세이브 - API: /api/village/check(중복)·register(선점)·save(토큰 검증 업서트)·load - 닉네임 모달: 촌장 냥이 인사 직후 게임 차단, 입력 멈춘 1초 후 자동 중복 체크(디바운스), 사용 가능할 때만 결정 버튼 활성화 한글·영문·숫자 2~12자 검증, 서버 불가 시 로컬 시작 후 재시도 - 동기화: 30초마다 자동 push + 종료 시 push, 부팅 시 서버가 더 최신이면 교체(replaceState → 마이그레이션·오프라인 정산 재수행) - 기존 세이브도 닉네임 미설정이면 모달 표시 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { catSprite } from "@/lib/game/data";
|
||||
import { checkNickname, newSyncToken, registerNickname, pushSave } from "@/lib/game/sync";
|
||||
import { engine } from "@/lib/game/engine";
|
||||
|
||||
type Status =
|
||||
| "idle"
|
||||
| "checking"
|
||||
| "available"
|
||||
| "taken"
|
||||
| "invalid"
|
||||
| "offline";
|
||||
|
||||
const NICKNAME_RE = /^[가-힣a-zA-Z0-9_-]{2,12}$/;
|
||||
|
||||
export default function NicknameModal({ onDone }: { onDone: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const latest = useRef("");
|
||||
|
||||
// 입력이 멈춘 1초 후 자동 중복 체크
|
||||
useEffect(() => {
|
||||
latest.current = name;
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
if (name.length === 0) {
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
if (!NICKNAME_RE.test(name)) {
|
||||
setStatus("invalid");
|
||||
return;
|
||||
}
|
||||
setStatus("checking");
|
||||
timer.current = setTimeout(async () => {
|
||||
const result = await checkNickname(name);
|
||||
if (latest.current !== name) return; // 입력이 바뀌었으면 무시
|
||||
if (result === null) setStatus("offline");
|
||||
else setStatus(result.available ? "available" : "taken");
|
||||
}, 1000);
|
||||
return () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
const confirm = async () => {
|
||||
if (status !== "available" || submitting) return;
|
||||
setSubmitting(true);
|
||||
const token = newSyncToken();
|
||||
const ok = await registerNickname(name, token);
|
||||
if (ok === false) {
|
||||
// 그 사이 누가 선점
|
||||
setStatus("taken");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
// ok === null(서버 불가)이어도 로컬에 기록 — 다음 동기화 때 재등록 시도
|
||||
engine.setNickname(name, token);
|
||||
void pushSave();
|
||||
setSubmitting(false);
|
||||
onDone();
|
||||
};
|
||||
|
||||
const statusLine: Record<Status, { text: string; cls: string }> = {
|
||||
idle: { text: "마을 문패에 걸 이름이야. 한글·영문·숫자 2~12자!", cls: "text-[#8a7551]" },
|
||||
invalid: { text: "한글·영문·숫자·-·_ 로 2~12자만 가능해!", cls: "text-[#b0472a]" },
|
||||
checking: { text: "다른 마을과 겹치는지 확인 중… 🐾", cls: "text-[#8a7551]" },
|
||||
available: { text: "좋아, 이 이름은 비어 있어! ✨", cls: "text-[#5c8a2a]" },
|
||||
taken: { text: "앗, 이미 다른 마을이 쓰고 있는 이름이야 🙀", cls: "text-[#b0472a]" },
|
||||
offline: { text: "서버에 닿지 않아… 잠시 후 다시 시도해줘", cls: "text-[#b0472a]" },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="panel-wood w-full max-w-md p-2.5">
|
||||
<div className="panel-paper flex flex-col items-center p-6 text-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={catSprite("korean_shorthair", "idle")}
|
||||
alt="촌장 냥이"
|
||||
className="h-28 w-28 object-contain drop-shadow-lg"
|
||||
/>
|
||||
<h2 className="font-jua mt-2 text-xl text-[#4a3418]">
|
||||
그런데… 네 이름이 뭐야?
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[#6f5c39]">
|
||||
촌장 냥이가 마을 문패에 걸어둘 이름을 물어봅니다
|
||||
</p>
|
||||
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.trim())}
|
||||
maxLength={12}
|
||||
autoFocus
|
||||
placeholder="닉네임 입력"
|
||||
className="font-jua mt-4 w-full rounded-xl border-2 border-[rgba(126,95,48,.35)] bg-white/80 px-4 py-3 text-center text-lg text-[#3f2c14] outline-none focus:border-[#7fb539]"
|
||||
/>
|
||||
<p className={`mt-2 h-5 text-xs ${statusLine[status].cls}`}>
|
||||
{statusLine[status].text}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={confirm}
|
||||
disabled={status !== "available" || submitting}
|
||||
className="nature-btn mt-3 w-full py-2.5 text-base disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{submitting ? "문패 만드는 중…" : "이 이름으로 할래! 🐾"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
새 이슈에서 참조
사용자 차단