예제 소스
오목 Next.js 웹과 VLM 착수 API 연결하기 · 파일 59개
page.tsx
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { OmokBoard } from "@/components/OmokBoard";
import { suggestMove } from "@/lib/ai";
import {
BLACK,
DEFAULT_SIZE,
EMPTY,
PLAY_SIZES,
WHITE,
canPlace,
emptyBoard,
playAt,
rejectLabel,
replayMoves,
type GameStatus,
type PlayBoardSize,
type Point,
type Stone,
} from "@/lib/board";
import type { GameRow, MoveRec } from "@/lib/gameTypes";
import {
gameAuthQs,
getOrCreateGuestId,
loadRememberedGame,
rememberGame,
} from "@/lib/guest";
import { requestVlmMove } from "@/lib/vlmClient";
import styles from "./play.module.css";
type Status =
| { kind: "idle" }
| { kind: "thinking" }
| { kind: "error"; message: string }
| { kind: "ended"; text: string };
/** 백(AI) 상대: 로컬 휴리스틱 | VLM TCP */
type OpponentMode = "local" | "vlm";
const HUMAN = BLACK;
const AI = WHITE;
const OPPONENT_KEY = "omook_opponent_mode";
let lastAiAskKey = "";
function loadOpponentMode(): OpponentMode {
if (typeof window === "undefined") return "local";
const v = localStorage.getItem(OPPONENT_KEY);
return v === "vlm" ? "vlm" : "local";
}
function saveOpponentMode(mode: OpponentMode): void {
if (typeof window === "undefined") return;
localStorage.setItem(OPPONENT_KEY, mode);
}
function opponentLabel(mode: OpponentMode): string {
return mode === "vlm" ? "VLM" : "로컬 AI";
}
function endText(gs: GameStatus): string {
if (gs === "black_wins") return "흑 승리";
if (gs === "white_wins") return "백 승리";
if (gs === "draw") return "무승부";
return "";
}
function statusFromGame(g: GameRow): Status {
if (g.status === "ended") {
return { kind: "ended", text: g.end_text || "대국 종료" };
}
return { kind: "idle" };
}
function gameStatusFromRow(g: GameRow): GameStatus {
if (g.status !== "ended") return "playing";
const t = g.end_text || "";
if (t.includes("흑")) return "black_wins";
if (t.includes("백")) return "white_wins";
if (t.includes("무승부")) return "draw";
return "playing";
}
type PersistSlice = {
size?: number;
board?: Stone[];
toPlay?: Stone;
lastMove?: Point | null;
moveNo?: number;
epoch?: number;
started?: boolean;
status?: Status;
moves?: MoveRec[];
};
export default function PlayPage() {
const [ready, setReady] = useState(false);
const [guestId, setGuestId] = useState("");
const [gameCode, setGameCode] = useState("");
const [accessKey, setAccessKey] = useState("");
const [size, setSize] = useState<PlayBoardSize>(DEFAULT_SIZE as PlayBoardSize);
const [board, setBoard] = useState<Stone[]>(() => emptyBoard(DEFAULT_SIZE));
const [toPlay, setToPlay] = useState<Stone>(BLACK);
const [pending, setPending] = useState<Point | null>(null);
const [lastMove, setLastMove] = useState<Point | null>(null);
const [gameStatus, setGameStatus] = useState<GameStatus>("playing");
const [moveNo, setMoveNo] = useState(0);
const [moves, setMoves] = useState<MoveRec[]>([]);
const [viewPly, setViewPly] = useState(0);
const [epoch, setEpoch] = useState(0);
const [status, setStatus] = useState<Status>({ kind: "idle" });
const [showResult, setShowResult] = useState(false);
const [started, setStarted] = useState(false);
const [opponent, setOpponent] = useState<OpponentMode>("local");
const boardRef = useRef(board);
const toPlayRef = useRef(toPlay);
const sizeRef = useRef(size);
const gameStatusRef = useRef(gameStatus);
const moveNoRef = useRef(moveNo);
const movesRef = useRef(moves);
const viewPlyRef = useRef(viewPly);
const epochRef = useRef(epoch);
const statusRef = useRef(status);
const startedRef = useRef(started);
const lastMoveRef = useRef(lastMove);
const guestIdRef = useRef(guestId);
const gameCodeRef = useRef(gameCode);
const accessKeyRef = useRef(accessKey);
const opponentRef = useRef(opponent);
const pendingAiResume = useRef(false);
boardRef.current = board;
toPlayRef.current = toPlay;
sizeRef.current = size;
gameStatusRef.current = gameStatus;
moveNoRef.current = moveNo;
movesRef.current = moves;
viewPlyRef.current = viewPly;
epochRef.current = epoch;
statusRef.current = status;
startedRef.current = started;
lastMoveRef.current = lastMove;
guestIdRef.current = guestId;
gameCodeRef.current = gameCode;
accessKeyRef.current = accessKey;
opponentRef.current = opponent;
const reviewing = viewPly < moves.length;
const shown = reviewing ? replayMoves(moves, viewPly, size) : null;
const boardShown = shown?.board ?? board;
const lastShown = shown?.lastMove ?? lastMove;
const toPlayShown = shown?.toPlay ?? toPlay;
const moveNoShown = reviewing ? viewPly : moveNo;
const banned = useMemo(() => {
if (reviewing || gameStatus !== "playing" || toPlay !== HUMAN) return [];
const pts: Point[] = [];
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (board[r * size + c] !== EMPTY) continue;
const reason = canPlace(board, r, c, HUMAN, size);
if (reason === "sansan" || reason === "changmok") pts.push({ r, c });
}
}
return pts;
}, [board, gameStatus, reviewing, size, toPlay]);
const persist = useCallback(async (slice?: PersistSlice) => {
const code = gameCodeRef.current;
if (!code) return;
const st = slice?.status ?? statusRef.current;
const body = {
guestId: guestIdRef.current,
accessKey: accessKeyRef.current,
size: slice?.size ?? sizeRef.current,
board: slice?.board ?? boardRef.current,
to_play: slice?.toPlay ?? toPlayRef.current,
last_move:
slice?.lastMove !== undefined ? slice.lastMove : lastMoveRef.current,
move_no: slice?.moveNo ?? moveNoRef.current,
epoch: slice?.epoch ?? epochRef.current,
started: slice?.started ?? startedRef.current,
status: st.kind === "ended" ? ("ended" as const) : ("playing" as const),
end_text: st.kind === "ended" ? st.text : null,
moves: slice?.moves ?? movesRef.current,
};
try {
await fetch(`/api/game/${code}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
} catch {
/* 다음 진입 시 이어두기로 복구 */
}
}, []);
const applyGameRow = useCallback((g: GameRow) => {
const sz = (PLAY_SIZES as readonly number[]).includes(g.size)
? (g.size as PlayBoardSize)
: (DEFAULT_SIZE as PlayBoardSize);
rememberGame(g.code, g.access_key);
setGameCode(g.code);
setAccessKey(g.access_key);
setSize(sz);
setBoard(g.board as Stone[]);
setToPlay(g.to_play === WHITE ? WHITE : BLACK);
setLastMove(g.last_move);
setMoveNo(g.move_no);
setMoves(g.moves);
setViewPly(g.moves.length);
setEpoch(g.epoch);
setStarted(g.started);
setGameStatus(gameStatusFromRow(g));
setStatus(statusFromGame(g));
setShowResult(g.status === "ended");
setPending(null);
lastAiAskKey = "";
}, []);
const createAndApply = useCallback(
async (gid: string, nextSize: PlayBoardSize) => {
const res = await fetch("/api/game", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ guestId: gid, size: nextSize }),
});
if (!res.ok) throw new Error(`새 대국 실패 HTTP ${res.status}`);
const data = (await res.json()) as { game: GameRow };
applyGameRow(data.game);
return data.game;
},
[applyGameRow],
);
useEffect(() => {
let cancelled = false;
(async () => {
const gid = getOrCreateGuestId();
setGuestId(gid);
const remembered = loadRememberedGame();
try {
if (remembered) {
const res = await fetch(
`/api/game/${remembered.code}${gameAuthQs(gid, remembered.accessKey)}`,
);
if (res.ok) {
const data = (await res.json()) as { game: GameRow };
if (!cancelled && data.game.status === "playing") {
applyGameRow(data.game);
if (data.game.to_play === AI && data.game.started) {
pendingAiResume.current = true;
}
setReady(true);
return;
}
}
}
const latest = await fetch(`/api/game?guestId=${encodeURIComponent(gid)}`);
if (latest.ok) {
const data = (await latest.json()) as { game: GameRow | null };
if (!cancelled && data.game) {
applyGameRow(data.game);
if (data.game.to_play === AI && data.game.started) {
pendingAiResume.current = true;
}
setReady(true);
return;
}
}
if (!cancelled) {
await createAndApply(gid, DEFAULT_SIZE as PlayBoardSize);
setReady(true);
}
} catch (e) {
if (!cancelled) {
setStatus({ kind: "error", message: String(e) });
setReady(true);
}
}
})();
return () => {
cancelled = true;
};
}, [applyGameRow, createAndApply]);
useEffect(() => {
setOpponent(loadOpponentMode());
}, []);
const resetGame = useCallback(
async (nextSize?: PlayBoardSize) => {
const sz = nextSize ?? sizeRef.current;
const gid = guestIdRef.current || getOrCreateGuestId();
lastAiAskKey = "";
pendingAiResume.current = false;
try {
await createAndApply(gid, sz);
setShowResult(false);
setStatus({ kind: "idle" });
} catch (e) {
setStatus({ kind: "error", message: String(e) });
}
},
[createAndApply],
);
const applyMove = useCallback(
(r: number, c: number, stone: Stone): boolean => {
if (viewPlyRef.current < movesRef.current.length) return false;
const sz = sizeRef.current;
const res = playAt(boardRef.current, r, c, stone, sz, gameStatusRef.current);
if (!res.ok) {
setStatus({ kind: "error", message: rejectLabel(res.reason) });
return false;
}
const nextPly = moveNoRef.current + 1;
const rec: MoveRec = { ply: nextPly, color: stone as 1 | 2, r, c };
const nextMoves = [...movesRef.current, rec];
movesRef.current = nextMoves;
moveNoRef.current = nextPly;
boardRef.current = res.board;
lastMoveRef.current = res.last;
startedRef.current = true;
setBoard(res.board);
setLastMove(res.last);
setPending(null);
setMoveNo(nextPly);
setMoves(nextMoves);
setViewPly(nextMoves.length);
setStarted(true);
setGameStatus(res.status);
if (res.status !== "playing") {
const ended: Status = { kind: "ended", text: endText(res.status) };
setStatus(ended);
setShowResult(true);
void persist({
board: res.board,
lastMove: res.last,
moveNo: nextPly,
moves: nextMoves,
toPlay: stone === BLACK ? WHITE : BLACK,
started: true,
status: ended,
});
return true;
}
const nextToPlay = stone === BLACK ? WHITE : BLACK;
toPlayRef.current = nextToPlay;
setToPlay(nextToPlay);
setStatus({ kind: "idle" });
void persist({
board: res.board,
lastMove: res.last,
moveNo: nextPly,
moves: nextMoves,
toPlay: nextToPlay,
started: true,
status: { kind: "idle" },
});
return true;
},
[persist],
);
const askAi = useCallback(async () => {
if (gameStatusRef.current !== "playing") return;
if (toPlayRef.current !== AI) return;
if (statusRef.current.kind === "thinking") return;
if (viewPlyRef.current < movesRef.current.length) return;
const sz = sizeRef.current;
const askEpoch = epochRef.current;
const mode = opponentRef.current;
const key = `${mode}|${sz}|${moveNoRef.current}|${boardRef.current.join(",")}`;
if (key === lastAiAskKey) return;
lastAiAskKey = key;
setStatus({ kind: "thinking" });
try {
let r: number;
let c: number;
if (mode === "local") {
// UI 한 프레임 양보 후 즉시 착수 (VLM 서버 불필요)
await new Promise((resolve) => setTimeout(resolve, 30));
if (askEpoch !== epochRef.current) return;
if (viewPlyRef.current < movesRef.current.length) return;
const mv = suggestMove(boardRef.current, AI, sz);
r = mv.r;
c = mv.c;
} else {
const resp = await requestVlmMove({
board: boardRef.current as number[],
size: sz,
aiSide: AI,
});
if (askEpoch !== epochRef.current) return;
if (viewPlyRef.current < movesRef.current.length) return;
if (!resp.ok) {
setStatus({ kind: "error", message: resp.error });
lastAiAskKey = "";
return;
}
r = resp.row;
c = resp.col;
if (canPlace(boardRef.current, r, c, AI, sz) !== "none") {
const fallback = suggestMove(boardRef.current, AI, sz);
r = fallback.r;
c = fallback.c;
}
}
applyMove(r, c, AI);
} catch (e) {
if (askEpoch !== epochRef.current) return;
setStatus({ kind: "error", message: String(e) });
lastAiAskKey = "";
}
}, [applyMove]);
useEffect(() => {
if (!ready) return;
if (pendingAiResume.current) {
pendingAiResume.current = false;
void askAi();
return;
}
if (
gameStatus === "playing" &&
toPlay === AI &&
status.kind !== "error" &&
!reviewing
) {
void askAi();
}
}, [askAi, gameStatus, toPlay, status.kind, moveNo, ready, reviewing]);
const maybeResumeAiAfterReview = useCallback(() => {
if (
startedRef.current &&
statusRef.current.kind !== "ended" &&
statusRef.current.kind !== "thinking" &&
toPlayRef.current === AI &&
movesRef.current.length > 0 &&
viewPlyRef.current >= movesRef.current.length
) {
void askAi();
}
}, [askAi]);
const stepView = (delta: number) => {
const n = movesRef.current.length;
const prev = viewPlyRef.current;
const next = Math.max(0, Math.min(n, prev + delta));
setViewPly(next);
setPending(null);
if (next === n && prev < n) maybeResumeAiAfterReview();
};
/** 맨 마지막 수로 복귀 (열람만 취소) */
const resumePlay = () => {
setViewPly(movesRef.current.length);
setPending(null);
maybeResumeAiAfterReview();
};
/** 지금 보는 수부터 이어두기 — 이후 기보 삭제 */
const continueFromView = () => {
const ply = viewPlyRef.current;
const all = movesRef.current;
if (ply >= all.length) {
maybeResumeAiAfterReview();
return;
}
if (statusRef.current.kind === "thinking") return;
const truncated = all.slice(0, ply).map((m, i) => ({ ...m, ply: i + 1 }));
const state = replayMoves(truncated, ply, sizeRef.current);
const nextEpoch = (epochRef.current + 1) >>> 0;
lastAiAskKey = "";
epochRef.current = nextEpoch;
movesRef.current = truncated;
moveNoRef.current = ply;
boardRef.current = state.board;
lastMoveRef.current = state.lastMove;
toPlayRef.current = state.toPlay;
gameStatusRef.current = state.status;
startedRef.current = true;
statusRef.current = state.status === "playing" ? { kind: "idle" } : {
kind: "ended",
text: endText(state.status),
};
setEpoch(nextEpoch);
setMoves(truncated);
setBoard(state.board);
setLastMove(state.lastMove);
setToPlay(state.toPlay);
setMoveNo(ply);
setViewPly(ply);
setPending(null);
setGameStatus(state.status);
setStarted(true);
if (state.status !== "playing") {
setStatus(statusRef.current);
setShowResult(true);
} else {
setStatus({ kind: "idle" });
setShowResult(false);
}
void persist({
board: state.board,
lastMove: state.lastMove,
moveNo: ply,
moves: truncated,
toPlay: state.toPlay,
epoch: nextEpoch,
started: true,
status: statusRef.current,
});
if (state.status === "playing" && state.toPlay === AI) {
void askAi();
}
};
const onSelect = (r: number, c: number) => {
if (reviewing) return;
if (gameStatus !== "playing" || toPlay !== HUMAN || status.kind === "thinking") return;
if (!pending || pending.r !== r || pending.c !== c) {
const reason = canPlace(board, r, c, HUMAN, size);
if (reason !== "none") {
setStatus({ kind: "error", message: rejectLabel(reason) });
setPending(null);
return;
}
setPending({ r, c });
setStatus({ kind: "idle" });
return;
}
applyMove(r, c, HUMAN);
};
const confirmPending = () => {
if (!pending || reviewing) return;
applyMove(pending.r, pending.c, HUMAN);
};
const interactive =
!reviewing &&
gameStatus === "playing" &&
toPlay === HUMAN &&
status.kind !== "thinking";
const sizeLocked = started && gameStatus === "playing";
const thinking = status.kind === "thinking";
const statusLabel = (() => {
if (status.kind === "thinking") return `${opponentLabel(opponent)} 생각 중…`;
if (status.kind === "error") return status.message;
if (status.kind === "ended") return status.text;
if (reviewing) {
const m = moves[viewPly - 1];
const who = m ? (m.color === BLACK ? "흑" : "백") : "";
const mv = m ? `${m.r},${m.c}` : "";
return `열람 ${viewPly}/${moves.length}` + (m ? ` · ${who} ${mv}` : "");
}
const turn = toPlayShown === BLACK ? "흑" : "백";
const who = toPlayShown === HUMAN ? "당신" : opponentLabel(opponent);
return `${moveNoShown}수 · ${turn}(${who}) 차례`;
})();
const reviewControls = (
<div className={styles.reviewRow}>
<button
type="button"
className={styles.btn}
onClick={() => stepView(-1)}
disabled={viewPly <= 0}
>
이전
</button>
<span className={styles.reviewPos}>
{viewPly}/{moves.length}
</span>
<button
type="button"
className={styles.btn}
onClick={() => stepView(1)}
disabled={viewPly >= moves.length}
>
이후
</button>
<button
type="button"
className={styles.btn}
onClick={continueFromView}
disabled={!started || !reviewing || thinking}
title="지금 보는 수부터 이후 기보를 버리고 이어둡니다"
>
이어두기
</button>
<button
type="button"
className={styles.btnPrimary}
onClick={resumePlay}
disabled={!reviewing || thinking}
title="맨 마지막 수로 돌아가 이어서 둡니다"
>
다시 두기
</button>
</div>
);
if (!ready) {
return (
<div className={styles.page}>
<div className={styles.status}>
<strong>대국 불러오는 중…</strong>
</div>
</div>
);
}
return (
<div className={styles.page}>
<header className={styles.top}>
<h1 className={styles.brand}>Omook</h1>
<div className={styles.topControls}>
<select
className={styles.sizeSelect}
value={opponent}
disabled={sizeLocked || thinking}
aria-label="상대"
onChange={(e) => {
const mode = e.target.value as OpponentMode;
setOpponent(mode);
saveOpponentMode(mode);
lastAiAskKey = "";
if (
gameStatusRef.current === "playing" &&
toPlayRef.current === AI &&
viewPlyRef.current >= movesRef.current.length
) {
void askAi();
}
}}
>
<option value="local">로컬 AI</option>
<option value="vlm">VLM</option>
</select>
<select
className={styles.sizeSelect}
value={size}
disabled={sizeLocked}
aria-label="판 크기"
onChange={(e) => void resetGame(Number(e.target.value) as PlayBoardSize)}
>
{PLAY_SIZES.map((s) => (
<option key={s} value={s}>
{s}×{s}
</option>
))}
</select>
</div>
</header>
<div className={styles.boardWrap}>
<OmokBoard
board={boardShown}
size={size}
lastMove={lastShown}
pending={reviewing ? null : pending}
banned={banned}
onSelect={onSelect}
interactive={interactive}
/>
</div>
<div className={styles.actions}>
<button type="button" className={styles.btn} onClick={() => void resetGame()}>
새 대국
</button>
<button
type="button"
className={styles.btnPrimary}
disabled={!pending || !interactive}
onClick={confirmPending}
>
착수
</button>
<button
type="button"
className={styles.btn}
disabled={!pending || reviewing}
onClick={() => setPending(null)}
>
취소
</button>
</div>
{reviewControls}
<div className={styles.status}>
<strong className={thinking ? styles.waiting : undefined}>{statusLabel}</strong>
<span>당신=흑 · {opponentLabel(opponent)}=백</span>
</div>
<nav className={styles.nav}>
<Link href="/">홈</Link>
<span className={styles.active}>대국</span>
</nav>
{showResult && status.kind === "ended" ? (
<div className={styles.overlay} role="dialog">
<div className={styles.dialog}>
<h2>{status.text}</h2>
<p>{moveNo}수 만에 종료</p>
<div className={styles.reviewRow} style={{ padding: "0 0 16px" }}>
<button
type="button"
className={styles.btn}
onClick={() => stepView(-1)}
disabled={viewPly <= 0}
>
이전
</button>
<span className={styles.reviewPos}>
{viewPly}/{moves.length}
</span>
<button
type="button"
className={styles.btn}
onClick={() => stepView(1)}
disabled={viewPly >= moves.length}
>
이후
</button>
<button
type="button"
className={styles.btn}
onClick={continueFromView}
disabled={!reviewing || thinking}
>
이어두기
</button>
<button
type="button"
className={styles.btn}
onClick={resumePlay}
disabled={!reviewing}
>
다시 두기
</button>
</div>
<button type="button" className={styles.btnPrimary} onClick={() => void resetGame()}>
새 대국
</button>
</div>
</div>
) : null}
</div>
);
}