ルーム読み込み中...
+ }
+
+ // エラーが発生している場合のみ警告を表示する
+ let errorView = null
+ if (error) {
+ errorView =
{/* ===== ページヘッダー =====
@@ -124,18 +147,50 @@ export default function LobbyPage() {
{/* ===== ローディング/エラー表示 ===== */}
- {loading && ルーム読み込み中...
}
- {error &&
-
{room ? room.name : 'ルーム'}
+
{roomName}
入室: {presence}人
@@ -478,32 +818,28 @@ export default function RoomPage() {
- {error &&
{error}
}
- {notice &&
{notice}
}
+ {errorView}
+ {noticeView}
- 状態: {statusLabel(room?.status || 'waiting')}
+ 状態: {roomStatusText}
あなた: {mySeatText}
- 対局: {gameStatusLabel(game?.status || 'waiting')}
+ 対局: {gameStatusText}
手番: {turnLabel}
- {game?.status === 'playing' && game?.passed && (
-
- {seatLabel(game.passed)}は打つ手がないためパス
-
- )}
+ {passNotice}
- 準備: 黒 {ready.black ? '●' : '○'} / 白 {ready.white ? '●' : '○'}
+ 準備: 黒 {blackReadyMark} / 白 {whiteReadyMark}
持ち駒: 黒 {Math.max(0, 6 - (placed.black || 0))} / 白 {Math.max(0, 6 - (placed.white || 0))}
@@ -511,11 +847,7 @@ export default function RoomPage() {
- {resultLabel && (
-
- {resultLabel}
-
- )}
+ {resultView}
{/*
盤面は「残っている高さ」と「横幅」の狭いほうに合わせて縮む。
@@ -527,11 +859,45 @@ export default function RoomPage() {
+ {/* 5x5のマスを行ごとに走査して1マスずつボタンを描画する */}
{board.map((row, rowIndex) =>
row.map((cell, colIndex) => {
const key = `${rowIndex}-${colIndex}`
- const isSelected = selected?.row === rowIndex && selected?.col === colIndex
const isMoveable = moveTargets.has(`${rowIndex},${colIndex}`)
+
+ // 現在選択中のマスかどうかを判定する
+ let isSelected = false
+ if (selected && selected.row === rowIndex && selected.col === colIndex) {
+ isSelected = true
+ }
+
+ // 選択中は枠を強調し、それ以外はホバー時だけ薄く塗る
+ let highlightClass = "group-hover/cell:bg-primary/10"
+ if (isSelected) {
+ highlightClass = "bg-secondary/15 ring-2 ring-secondary"
+ }
+
+ // 駒があるマスだけ石を描画し、色に応じて見た目を変える
+ let stone = null
+ if (cell) {
+ let stoneColorClass = "bg-gray-100 border border-gray-300"
+ if (cell === 'black') {
+ stoneColorClass = "bg-gray-900 border border-gray-800"
+ }
+ stone = (
+
+ )
+ }
+
+ // 移動先の目印。塗り円ではなく小さな点にして盤面を汚さない
+ let moveHint = null
+ if (isMoveable && !cell) {
+ moveHint =
+ }
+
return (
- {cell && (
-
- )}
- {/* 移動先の目印。塗り円ではなく小さな点にして盤面を汚さない */}
- {isMoveable && !cell && (
-
- )}
+ {stone}
+ {moveHint}
)
})
@@ -597,33 +953,8 @@ export default function RoomPage() {
黒席
-
{displayName(room?.seats.black)}
- {mySeat === 'black' ? (
-
-
- {game?.status !== 'playing' && (
-
- )}
-
- ) : !room?.seats.black ? (
- opponentColor === 'black' ? (
-
- ) : (
-
- )
- ) : room?.seats.black?.loginId === 'cpu' && canReleaseCpu ? (
-
- ) : null}
+
{displayName(blackSeat)}
+ {buildSeatActions('black', blackSeat)}
@@ -631,42 +962,13 @@ export default function RoomPage() {
白席
- {displayName(room?.seats.white)}
- {mySeat === 'white' ? (
-
-
- {game?.status !== 'playing' && (
-
- )}
-
- ) : !room?.seats.white ? (
- opponentColor === 'white' ? (
-
- ) : (
-
- )
- ) : room?.seats.white?.loginId === 'cpu' && canReleaseCpu ? (
-
- ) : null}
+ {displayName(whiteSeat)}
+ {buildSeatActions('white', whiteSeat)}
- {cpuError && (
-
- {cpuError}
-
- )}
+ {cpuErrorView}
@@ -675,21 +977,7 @@ export default function RoomPage() {
- {chat.length === 0 ? (
-
まだメッセージはありません。
- ) : (
- chat.map((entry) => (
-
-
-
- {entry.nickname || '名無しプレイヤー'}
-
- {new Date(entry.created_at).toLocaleTimeString('ja-JP')}
-
-
{entry.message}
-
- ))
- )}
+ {chatView}
@@ -192,8 +235,8 @@ export default function SettingsPage() {
/>
6文字以上で入力してください。
- {passwordError &&
{passwordError}
}
- {passwordNotice &&
{passwordNotice}
}
+ {passwordErrorView}
+ {passwordNoticeView}
diff --git a/client/src/socket.js b/client/src/socket.js
index 6e6956a..b1962f3 100644
--- a/client/src/socket.js
+++ b/client/src/socket.js
@@ -33,7 +33,12 @@ const PING_INTERVAL_MS = 30000
function buildUrl(query) {
const base = API_BASE_URL || window.location.origin
const url = new URL('/ws', base)
- url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+ // HTTPSで配信されている場合は wss、それ以外は ws で接続する
+ if (url.protocol === 'https:') {
+ url.protocol = 'wss:'
+ } else {
+ url.protocol = 'ws:'
+ }
url.search = query
return url.toString()
}
@@ -69,7 +74,11 @@ function createSocket(query) {
*/
const dispatch = (event, payload) => {
const handlers = listeners.get(event)
- if (!handlers) return
+ // 登録済みハンドラが無いイベントは配信対象が無いので何もしない
+ if (!handlers) {
+ return
+ }
+ // ハンドラ内で登録解除されても走査が壊れないよう複製してから呼び出す
for (const handler of [...handlers]) {
try {
handler(payload)
@@ -83,9 +92,13 @@ function createSocket(query) {
* 送信待ちのメッセージをすべて送ります。
*/
const flushQueue = () => {
- if (!ws || ws.readyState !== WebSocket.OPEN) return
+ // 接続が確立していない間は送信できないので何もしない
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ return
+ }
const items = queue
queue = []
+ // 溜まっていた送信待ちメッセージを順番に送る
for (const item of items) {
ws.send(item)
}
@@ -95,8 +108,12 @@ function createSocket(query) {
* 接続を開きます。切断されたら自動で再接続します。
*/
const connect = () => {
- if (closed) return
+ // close() 済みの接続は再接続しない
+ if (closed) {
+ return
+ }
+ // WebSocket接続を開始する
ws = new WebSocket(buildUrl(query))
ws.addEventListener('open', () => {
@@ -104,6 +121,7 @@ function createSocket(query) {
flushQueue()
// ハイバネーション中のDurable Objectを起こさない自動応答pingを使う
pingTimer = setInterval(() => {
+ // 接続中のときだけpingを送る
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send('ping')
}
@@ -112,18 +130,27 @@ function createSocket(query) {
})
ws.addEventListener('message', (event) => {
- if (event.data === 'pong') return
+ // pingへの自動応答は処理不要なので読み飛ばす
+ if (event.data === 'pong') {
+ return
+ }
let message
try {
+ // 受信データをプロトコルのJSONとして解釈する
message = JSON.parse(event.data)
} catch {
return
}
- if (!message || typeof message !== 'object') return
+ // JSONオブジェクトとして解釈できない内容は無視する
+ if (!message || typeof message !== 'object') {
+ return
+ }
+ // emitのack応答なら、待機中のリクエストへ結果を返す
if (message.t === 'res') {
const entry = pending.get(message.id)
+ // 既にタイムアウト済みの場合は待機情報が無いので何もしない
if (entry) {
clearTimeout(entry.timer)
pending.delete(message.id)
@@ -132,6 +159,7 @@ function createSocket(query) {
return
}
+ // サーバーからのプッシュイベントなら登録済みハンドラへ配信する
if (message.t === 'ev') {
dispatch(message.event, message.payload)
}
@@ -143,7 +171,10 @@ function createSocket(query) {
ws = null
dispatch('disconnect', undefined)
- if (closed) return
+ // 明示的に閉じた場合は再接続しない
+ if (closed) {
+ return
+ }
// 再接続(指数バックオフ)
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]
@@ -168,9 +199,12 @@ function createSocket(query) {
* @param {Function} [ack] - サーバーからの応答を受け取るコールバック
*/
emit(event, payload, ack) {
- const id = ack ? nextRequestId++ : null
+ let id = null
+ // ackコールバックがある場合のみリクエストIDを採番して応答待ちに登録する
if (ack) {
+ id = nextRequestId++
+ // 応答が返らないまま待ち続けないようタイムアウトを仕掛ける
const timer = setTimeout(() => {
pending.delete(id)
ack({ ok: false, error: 'timeout' })
@@ -180,6 +214,7 @@ function createSocket(query) {
const message = JSON.stringify({ t: 'req', id, event, payload: payload || {} })
+ // 接続中なら即送信し、未接続なら接続後に送るためキューへ積む
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(message)
} else {
@@ -196,14 +231,18 @@ function createSocket(query) {
* @param {Function} handler - ハンドラ
*/
on(event, handler) {
+ // 初めて登録するイベント名ならハンドラ集合を用意する
if (!listeners.has(event)) {
listeners.set(event, new Set())
}
listeners.get(event).add(handler)
+ // 接続済みでconnectを登録した場合は取りこぼしを防ぐため一度だけ呼ぶ
if (event === 'connect' && ws && ws.readyState === WebSocket.OPEN) {
queueMicrotask(() => {
- if (listeners.get('connect')?.has(handler)) {
+ const connectHandlers = listeners.get('connect')
+ // マイクロタスクが走るまでに解除されていないか確認する
+ if (connectHandlers && connectHandlers.has(handler)) {
handler(undefined)
}
})
@@ -216,7 +255,11 @@ function createSocket(query) {
* @param {Function} handler - 解除するハンドラ
*/
off(event, handler) {
- listeners.get(event)?.delete(handler)
+ const handlers = listeners.get(event)
+ // 未登録のイベント名なら解除対象が無いので何もしない
+ if (handlers) {
+ handlers.delete(handler)
+ }
},
/**
@@ -227,11 +270,13 @@ function createSocket(query) {
clearInterval(pingTimer)
pingTimer = null
queue = []
+ // ack待ちのタイムアウトタイマーをすべて止める
for (const entry of pending.values()) {
clearTimeout(entry.timer)
}
pending.clear()
listeners.clear()
+ // 接続が残っていれば閉じる
if (ws) {
ws.close()
ws = null
@@ -258,6 +303,7 @@ let roomSocketId = null
* @returns {Object} 接続オブジェクト
*/
export function getLobbySocket() {
+ // アプリ全体で1本を共有するため、未接続のときだけ新規作成する
if (!lobbySocket) {
lobbySocket = createSocket('lobby=1')
}
@@ -272,11 +318,13 @@ export function getLobbySocket() {
* @returns {Object} 接続オブジェクト
*/
export function getRoomSocket(roomId) {
+ // 別のルームに繋がっている場合は張り替えるため既存の接続を閉じる
if (roomSocket && roomSocketId !== roomId) {
roomSocket.close()
roomSocket = null
roomSocketId = null
}
+ // 接続が無ければ指定ルームへ新規接続する
if (!roomSocket) {
roomSocket = createSocket(`roomId=${roomId}`)
roomSocketId = roomId
@@ -288,6 +336,7 @@ export function getRoomSocket(roomId) {
* 現在のルーム接続を閉じます(ルーム画面を離れるとき)。
*/
export function closeRoomSocket() {
+ // ルーム接続が残っていれば閉じる
if (roomSocket) {
roomSocket.close()
roomSocket = null
@@ -300,6 +349,7 @@ export function closeRoomSocket() {
*/
export function resetSocket() {
closeRoomSocket()
+ // ロビー接続が残っていれば閉じる
if (lobbySocket) {
lobbySocket.close()
lobbySocket = null
diff --git a/worker/src/ai.js b/worker/src/ai.js
index 9b9bbcb..9cd0325 100644
--- a/worker/src/ai.js
+++ b/worker/src/ai.js
@@ -13,8 +13,19 @@ import {
getCellType,
getOpponent,
applyAction,
+ normalizeState,
} from "./game.js";
+/**
+ * 探索中の applyAction に渡すオプション。
+ *
+ * 探索が扱う局面は、入口で1度 normalizeState した状態か、その applyAction の
+ * 結果しかない。つまり常に正規化済みなので、1ノードごとの再正規化と
+ * 着手時刻の生成を省ける(ここが探索コストのおよそ3割を占めていた)。
+ * @type {{trusted: boolean}}
+ */
+const SEARCH_APPLY = { trusted: true };
+
/**
* 8方向の移動ベクトル
* 縦・横・斜めすべての方向を含む
@@ -69,6 +80,7 @@ function listActions(state, color) {
* @param {string} key - 重複チェック用のキー
*/
const addAction = (action, key) => {
+ // 同じ移動先へ複数の経路がある場合があるため、キーで重複を弾く
if (!used.has(key)) {
used.add(key);
actions.push(action);
@@ -78,6 +90,7 @@ function listActions(state, color) {
// === 駒を打つアクション ===
// 持ち駒が残っている場合のみ
if (state.placed[color] < MAX_PIECES) {
+ // 盤面全体を走査し、空きマスをすべて「打つ」手として列挙する
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
// 空きマスに配置可能
@@ -89,6 +102,7 @@ function listActions(state, color) {
}
// === 駒を移動するアクション ===
+ // 盤面全体を走査し、自分の駒を1つずつ移動元として扱う
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
// 自分の駒でなければスキップ
@@ -99,6 +113,7 @@ function listActions(state, color) {
const from = { row, col };
// --- 1マス移動(8方向) ---
+ // 隣接8方向それぞれについて、空いていれば移動先にする
for (const [dr, dc] of DIRECTIONS) {
const toRow = row + dr;
const toCol = col + dc;
@@ -123,8 +138,10 @@ function listActions(state, color) {
continue;
}
+ // 斜め4方向について、自分の色のマスが続く限り滑れる先を集める
for (const [dr, dc] of DIAG_DIRECTIONS) {
let step = 1;
+ // 進めなくなる条件(盤外・色違い・駒あり)に当たるまで1マスずつ伸ばす
while (true) {
const toRow = row + dr * step;
const toCol = col + dc * step;
@@ -169,6 +186,7 @@ function listActions(state, color) {
*/
function countPieces(board, color) {
let total = 0;
+ // 盤面全体を走査して該当色のマスを数える
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
if (board[row][col] === color) {
@@ -197,6 +215,7 @@ function lineCounts(board, color) {
[-1, 1], // 左下斜め
];
+ // 盤面全体を走査し、各マスを起点にラインを数える
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
// 指定色の駒でなければスキップ
@@ -204,6 +223,7 @@ function lineCounts(board, color) {
continue;
}
+ // 縦・横・斜め2種の4方向について、そのマスから伸びる長さを測る
for (const [dr, dc] of scanDirs) {
// ラインの先頭からのみカウント(重複防止)
const prevRow = row - dr;
@@ -216,6 +236,7 @@ function lineCounts(board, color) {
let length = 0;
let r = row;
let c = col;
+ // 同じ色が続く限り進み、その長さを数える
while (inBounds(r, c) && board[r][c] === color) {
length += 1;
r += dr;
@@ -249,18 +270,22 @@ function countMobility(state, color) {
let mobility = 0;
let emptyCells = 0;
+ // 盤面全体を走査し、空きマス数と自分の駒から動ける先の数を数える
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
const cell = board[row][col];
+ // 空きマスは「打つ」手の候補になるので別に数える
if (cell === null) {
emptyCells += 1;
continue;
}
+ // 相手の駒はここでは数えない
if (cell !== color) {
continue;
}
+ // 自分の駒の隣接8方向のうち、空いている数だけ動ける先がある
for (const [dr, dc] of DIRECTIONS) {
const nextRow = row + dr;
const nextCol = col + dc;
@@ -295,6 +320,7 @@ function countMobility(state, color) {
function evaluateState(state, color) {
// 終了状態の場合は勝敗で決定的なスコアを返す
if (state.status === "finished") {
+ // 勝ち・負け・引き分けで決定的なスコアを返す
if (state.winner === color) {
return 100000; // 勝利
}
@@ -353,19 +379,38 @@ function evaluateState(state, color) {
function serializeState(state) {
const board = state.board;
let code = 0;
+ // 各マスを2ビットに詰め、盤面全体を1つの整数にまとめる
for (let row = 0; row < BOARD_SIZE; row += 1) {
const line = board[row];
for (let col = 0; col < BOARD_SIZE; col += 1) {
const cell = line[col];
// 空き=0 / 黒=1 / 白=2
- const bits = cell === null ? 0 : cell === "black" ? 1 : 2;
+ let bits = 0;
+ if (cell === "black") {
+ bits = 1;
+ } else if (cell !== null) {
+ bits = 2;
+ }
code = code * 4 + bits;
}
}
+
+ // 手番も1桁で表す(同じ盤面でも手番が違えば別の局面)
+ let turnBit = 1;
+ if (state.turn === "black") {
+ turnBit = 0;
+ }
+
// 手番と持ち駒の消費数もキーに含める(同じ盤面でも合法手が変わるため)
- return `${code.toString(36)}.${state.turn === "black" ? 0 : 1}${state.placed.black}${state.placed.white}`;
+ return `${code.toString(36)}.${turnBit}${state.placed.black}${state.placed.white}`;
}
+/**
+ * 予算切れで1手も評価できなかったときに、深さ1で読み直すためのノード数。
+ * 1局面の子ノードを見るだけなので、これで足りる。
+ */
+const RETRY_NODE_BUDGET = 200;
+
/** トランスポジションテーブルの評価値の種類 */
const TT_EXACT = 0;
const TT_LOWER = 1;
@@ -389,14 +434,16 @@ function orderingScore(state, action, color) {
const opponent = getOpponent(color);
let score = 0;
- // 相手の駒に隣接する手は挟み(裏返し)につながりやすい
+ // 着手先の隣接8方向を見て、相手の駒に隣接する手ほど高く評価する
for (let i = 0; i < DIRECTIONS.length; i += 1) {
const row = to.row + DIRECTIONS[i][0];
const col = to.col + DIRECTIONS[i][1];
+ // 盤外は評価対象にならない
if (!inBounds(row, col)) {
continue;
}
const cell = board[row][col];
+ // 相手の駒に接する手は挟み(裏返し)につながりやすい
if (cell === opponent) {
score += 4;
} else if (cell === color) {
@@ -421,14 +468,31 @@ function orderingScore(state, action, color) {
* 分割探索では毎回このリストの index で再開するので、
* 同じ局面なら必ず同じ順序になる必要がある(乱数を使わない)。
*
+ * 反復深化では、1つ浅い深さでの最善手を hint として必ず先頭に置く。
+ * これがあるおかげで、深い探索を読み切れずに打ち切っても
+ * 「前の深さの最善手か、それより良いと分かった手」しか返らない。
+ *
* @param {Object} state - 現在のゲーム状態
* @param {'black'|'white'} color - 手番の色
+ * @param {Object} [hint=null] - 先頭に置く手(1つ浅い深さでの最善手)
* @returns {Array