- 등록만 되고 세이브가 저장된 적 없는(data NULL) 닉네임은 중복 체크에서 사용 가능으로, 재등록 시 새 토큰으로 재선점 - 불러오기 실패 시 "서버에 닿지 않아" 대신 "그 이름의 마을은 없는걸?" Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
172 라인
6.4 KiB
TypeScript
172 라인
6.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { catSprite } from "@/lib/game/data";
|
|
import {
|
|
checkNickname,
|
|
newSyncToken,
|
|
registerNickname,
|
|
recoverVillage,
|
|
pushSave,
|
|
} from "@/lib/game/sync";
|
|
import { engine } from "@/lib/game/engine";
|
|
|
|
type Mode = "new" | "recover";
|
|
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 [mode, setMode] = useState<Mode>("new");
|
|
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("");
|
|
|
|
const trimmed = name.trim();
|
|
|
|
// 입력이 멈춘 1초 후 자동 중복 체크
|
|
useEffect(() => {
|
|
latest.current = trimmed;
|
|
if (timer.current) clearTimeout(timer.current);
|
|
if (trimmed.length === 0) {
|
|
setStatus("idle");
|
|
return;
|
|
}
|
|
if (!NICKNAME_RE.test(trimmed)) {
|
|
setStatus("invalid");
|
|
return;
|
|
}
|
|
setStatus("checking");
|
|
timer.current = setTimeout(async () => {
|
|
const result = await checkNickname(trimmed);
|
|
if (latest.current !== trimmed) return; // 입력이 바뀌었으면 무시
|
|
if (result === null) setStatus("offline");
|
|
else setStatus(result.available ? "available" : "taken");
|
|
}, 1000);
|
|
return () => {
|
|
if (timer.current) clearTimeout(timer.current);
|
|
};
|
|
}, [trimmed, mode]);
|
|
|
|
// 모드별 결정 가능 조건: 새 마을=사용 가능, 불러오기=이미 존재
|
|
const canConfirm =
|
|
!submitting &&
|
|
(mode === "new" ? status === "available" : status === "taken");
|
|
|
|
const confirm = async () => {
|
|
if (!canConfirm) return;
|
|
setSubmitting(true);
|
|
if (mode === "new") {
|
|
const token = newSyncToken();
|
|
const ok = await registerNickname(trimmed, token);
|
|
if (ok === false) {
|
|
setStatus("taken"); // 그 사이 누가 선점
|
|
setSubmitting(false);
|
|
return;
|
|
}
|
|
// 서버 불가여도 로컬에 기록 — 다음 동기화 때 재등록 시도
|
|
engine.setNickname(trimmed, token);
|
|
void pushSave();
|
|
} else {
|
|
const found = await recoverVillage(trimmed);
|
|
if (!found) {
|
|
setStatus("available"); // 불러오기 모드에선 "그 이름의 마을은 없는걸?"
|
|
setSubmitting(false);
|
|
return;
|
|
}
|
|
engine.replaceState(found.data);
|
|
engine.setNickname(trimmed, found.token);
|
|
}
|
|
setSubmitting(false);
|
|
onDone();
|
|
};
|
|
|
|
const lines: Record<Mode, Record<Status, { text: string; cls: string }>> = {
|
|
new: {
|
|
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]" },
|
|
},
|
|
recover: {
|
|
idle: { text: "예전에 쓰던 마을 이름을 알려줘!", cls: "text-[#8a7551]" },
|
|
invalid: { text: "한글·영문·숫자·-·_ 로 2~12자만 가능해!", cls: "text-[#b0472a]" },
|
|
checking: { text: "마을 명부를 뒤적이는 중… 🐾", cls: "text-[#8a7551]" },
|
|
available: { text: "그 이름의 마을은 없는걸? 🙀", cls: "text-[#b0472a]" },
|
|
taken: { text: "마을을 찾았어! 데려올게 ✨", cls: "text-[#5c8a2a]" },
|
|
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]">
|
|
{mode === "new" ? "그런데… 네 이름이 뭐야?" : "마을을 찾아줄게!"}
|
|
</h2>
|
|
<p className="mt-1 text-sm text-[#6f5c39]">
|
|
{mode === "new"
|
|
? "촌장 냥이가 마을 문패에 걸어둘 이름을 물어봅니다"
|
|
: "예전 마을 이름을 입력하면 그대로 이어서 할 수 있어요"}
|
|
</p>
|
|
|
|
<input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.nativeEvent.isComposing) void confirm();
|
|
}}
|
|
maxLength={12}
|
|
autoFocus
|
|
placeholder={mode === "new" ? "닉네임 입력" : "기존 마을 이름"}
|
|
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 ${lines[mode][status].cls}`}>
|
|
{lines[mode][status].text}
|
|
</p>
|
|
|
|
<button
|
|
onClick={() => void confirm()}
|
|
disabled={!canConfirm}
|
|
className="nature-btn mt-3 w-full py-2.5 text-base disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
{submitting
|
|
? mode === "new"
|
|
? "문패 만드는 중…"
|
|
: "마을 데려오는 중…"
|
|
: mode === "new"
|
|
? "이 이름으로 할래! 🐾"
|
|
: "이 마을 불러오기 🏡"}
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setMode(mode === "new" ? "recover" : "new")}
|
|
className="mt-3 text-xs text-[#8a7551] underline underline-offset-2 hover:text-[#5c8a2a]"
|
|
>
|
|
{mode === "new"
|
|
? "이미 마을이 있어요 — 불러오기"
|
|
: "새 마을을 만들래요"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|