Skip to content

Commit db2c023

Browse files
committed
初步支持 AI 下棋
1 parent 59d0311 commit db2c023

File tree

4 files changed

+552
-81
lines changed

4 files changed

+552
-81
lines changed

src/app/[lang]/games/gomoku/ai.js

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
import { boardSize, directions, checkWin, checkDoubleThree, checkOverline, checkDoubleFours } from "./move";
2+
3+
const DEFAULT_ATTACK_WEIGHT = 1;
4+
const DEFAULT_DEFENSE_WEIGHT = 0.9;
5+
const CENTER_WEIGHT = 10;
6+
7+
export const aiRankPresets = {
8+
novice: {
9+
attackWeight: 0.65,
10+
defenseWeight: 0.45,
11+
randomness: 1,
12+
counterWeight: 0,
13+
},
14+
expert: {
15+
attackWeight: 1.1,
16+
defenseWeight: 1,
17+
randomness: 0.25,
18+
counterWeight: 0.35,
19+
},
20+
master: {
21+
attackWeight: 1.4,
22+
defenseWeight: 1.25,
23+
randomness: 0,
24+
counterWeight: 0.75,
25+
},
26+
};
27+
28+
const SCORE_PATTERNS = {
29+
five: 100000,
30+
openFour: 12000,
31+
closedFour: 6000,
32+
openThree: 2500,
33+
closedThree: 300,
34+
openTwo: 80,
35+
closedTwo: 20,
36+
};
37+
38+
const neighborOffsets = [];
39+
for (let dx = -2; dx <= 2; dx += 1) {
40+
for (let dy = -2; dy <= 2; dy += 1) {
41+
if (dx === 0 && dy === 0) continue;
42+
neighborOffsets.push([dx, dy]);
43+
}
44+
}
45+
46+
function inBounds(row, col) {
47+
return row >= 0 && row < boardSize && col >= 0 && col < boardSize;
48+
}
49+
50+
function hasNeighbor(board, row, col) {
51+
for (const [dx, dy] of neighborOffsets) {
52+
const nr = row + dx;
53+
const nc = col + dy;
54+
if (inBounds(nr, nc) && board[nr][nc] !== "") {
55+
return true;
56+
}
57+
}
58+
return false;
59+
}
60+
61+
function patternScore(count, emptySlots, openEnds) {
62+
if (count >= 5) return SCORE_PATTERNS.five;
63+
64+
if (count === 4) {
65+
if (openEnds === 2 && emptySlots === 1) return SCORE_PATTERNS.openFour;
66+
if (openEnds === 1 && emptySlots === 1) return SCORE_PATTERNS.closedFour;
67+
}
68+
69+
if (count === 3) {
70+
if (openEnds === 2 && emptySlots === 2) return SCORE_PATTERNS.openThree;
71+
if (openEnds >= 1 && emptySlots === 2) return SCORE_PATTERNS.closedThree;
72+
}
73+
74+
if (count === 2) {
75+
if (openEnds === 2 && emptySlots === 3) return SCORE_PATTERNS.openTwo;
76+
if (openEnds >= 1 && emptySlots === 3) return SCORE_PATTERNS.closedTwo;
77+
}
78+
79+
return 2;
80+
}
81+
82+
function evaluateDirection(board, row, col, dx, dy, player) {
83+
let best = 0;
84+
85+
for (let offset = -4; offset <= 0; offset += 1) {
86+
let count = 0;
87+
let emptySlots = 0;
88+
let valid = true;
89+
90+
for (let i = 0; i < 5; i += 1) {
91+
const r = row + (offset + i) * dx;
92+
const c = col + (offset + i) * dy;
93+
94+
if (!inBounds(r, c)) {
95+
valid = false;
96+
break;
97+
}
98+
99+
const value = board[r][c];
100+
101+
if (value === player) {
102+
count += 1;
103+
} else if (value === "") {
104+
emptySlots += 1;
105+
} else {
106+
valid = false;
107+
break;
108+
}
109+
}
110+
111+
if (!valid || count === 0) continue;
112+
113+
const beforeR = row + (offset - 1) * dx;
114+
const beforeC = col + (offset - 1) * dy;
115+
const afterR = row + (offset + 5) * dx;
116+
const afterC = col + (offset + 5) * dy;
117+
118+
let openEnds = 0;
119+
if (inBounds(beforeR, beforeC) && board[beforeR][beforeC] === "") openEnds += 1;
120+
if (inBounds(afterR, afterC) && board[afterR][afterC] === "") openEnds += 1;
121+
122+
const score = patternScore(count, emptySlots, openEnds);
123+
if (score > best) best = score;
124+
}
125+
126+
return best;
127+
}
128+
129+
function evaluateBoardContribution(board, row, col, player) {
130+
let total = 0;
131+
for (const [dx, dy] of directions) {
132+
total += evaluateDirection(board, row, col, dx, dy, player);
133+
}
134+
return total;
135+
}
136+
137+
function applyForbiddenRules(board, row, col, player, forbiddenRules) {
138+
if (!forbiddenRules || forbiddenRules.length === 0 || forbiddenRules.includes("noRestriction")) {
139+
return false;
140+
}
141+
142+
const targetRow = board[row];
143+
144+
try {
145+
targetRow[col] = player;
146+
147+
if (forbiddenRules.includes("threeThree")) {
148+
const { isForbidden } = checkDoubleThree(board, row, col, player);
149+
if (isForbidden) {
150+
return true;
151+
}
152+
}
153+
154+
if (forbiddenRules.includes("longConnection")) {
155+
const overlines = checkOverline(board, row, col, player);
156+
if (overlines.length > 0) {
157+
return true;
158+
}
159+
}
160+
161+
if (forbiddenRules.includes("fourFour")) {
162+
const { isDoubleFour } = checkDoubleFours(board, row, col, player);
163+
if (isDoubleFour) {
164+
return true;
165+
}
166+
}
167+
168+
return false;
169+
} finally {
170+
targetRow[col] = "";
171+
}
172+
}
173+
174+
function isBoardEmpty(board) {
175+
for (let row = 0; row < boardSize; row += 1) {
176+
for (let col = 0; col < boardSize; col += 1) {
177+
if (board[row][col] !== "") return false;
178+
}
179+
}
180+
return true;
181+
}
182+
183+
function evaluateBestOpponentResponse(board, opponent, aiColor, forbiddenRules, enforceForbidden, center) {
184+
let bestScore = -Infinity;
185+
186+
for (let row = 0; row < boardSize; row += 1) {
187+
for (let col = 0; col < boardSize; col += 1) {
188+
if (board[row][col] !== "") continue;
189+
if (!hasNeighbor(board, row, col)) continue;
190+
if (enforceForbidden(opponent) && applyForbiddenRules(board, row, col, opponent, forbiddenRules)) {
191+
continue;
192+
}
193+
194+
board[row][col] = opponent;
195+
196+
if (checkWin(board, row, col, opponent)) {
197+
board[row][col] = "";
198+
return SCORE_PATTERNS.five;
199+
}
200+
201+
const attackScore = evaluateBoardContribution(board, row, col, opponent);
202+
const defenseScore = evaluateBoardContribution(board, row, col, aiColor);
203+
const centerBias = CENTER_WEIGHT - (Math.abs(center - row) + Math.abs(center - col));
204+
const moveScore = attackScore * 1.1 + defenseScore * 0.9 + centerBias;
205+
206+
if (moveScore > bestScore) {
207+
bestScore = moveScore;
208+
}
209+
210+
board[row][col] = "";
211+
}
212+
}
213+
214+
return bestScore === -Infinity ? 0 : bestScore;
215+
}
216+
217+
export function getAIMove(board, aiColor, options = {}) {
218+
const {
219+
forbiddenRules = [],
220+
enforceForbiddenFor = "black",
221+
rank = "expert",
222+
} = options;
223+
224+
const preset = aiRankPresets[rank] || aiRankPresets.expert;
225+
const attackWeight = options.attackWeight ?? preset.attackWeight ?? DEFAULT_ATTACK_WEIGHT;
226+
const defenseWeight = options.defenseWeight ?? preset.defenseWeight ?? DEFAULT_DEFENSE_WEIGHT;
227+
const randomness = options.randomness ?? preset.randomness ?? 0;
228+
const counterWeight = options.counterWeight ?? preset.counterWeight ?? 0;
229+
230+
if (!Array.isArray(board) || board.length !== boardSize) {
231+
throw new Error("Unexpected board state passed to AI");
232+
}
233+
234+
const opponent = aiColor === "black" ? "white" : "black";
235+
236+
if (isBoardEmpty(board)) {
237+
const center = Math.floor(boardSize / 2);
238+
return { row: center, col: center };
239+
}
240+
241+
const candidateMoves = [];
242+
243+
for (let row = 0; row < boardSize; row += 1) {
244+
for (let col = 0; col < boardSize; col += 1) {
245+
if (board[row][col] !== "") continue;
246+
if (!hasNeighbor(board, row, col)) continue;
247+
candidateMoves.push({ row, col });
248+
}
249+
}
250+
251+
if (candidateMoves.length === 0) {
252+
const center = Math.floor(boardSize / 2);
253+
return { row: center, col: center };
254+
}
255+
256+
const enforceForbidden = (color) => color === enforceForbiddenFor;
257+
258+
for (const { row, col } of candidateMoves) {
259+
if (enforceForbidden(aiColor) && applyForbiddenRules(board, row, col, aiColor, forbiddenRules)) {
260+
continue;
261+
}
262+
263+
board[row][col] = aiColor;
264+
const winning = checkWin(board, row, col, aiColor);
265+
board[row][col] = "";
266+
267+
if (winning) {
268+
return { row, col };
269+
}
270+
}
271+
272+
for (const { row, col } of candidateMoves) {
273+
board[row][col] = opponent;
274+
const wouldWin = checkWin(board, row, col, opponent);
275+
board[row][col] = "";
276+
277+
if (wouldWin && (!enforceForbidden(aiColor) || !applyForbiddenRules(board, row, col, aiColor, forbiddenRules))) {
278+
return { row, col };
279+
}
280+
}
281+
282+
const center = Math.floor(boardSize / 2);
283+
const evaluatedMoves = [];
284+
285+
for (const { row, col } of candidateMoves) {
286+
if (enforceForbidden(aiColor) && applyForbiddenRules(board, row, col, aiColor, forbiddenRules)) {
287+
continue;
288+
}
289+
290+
board[row][col] = aiColor;
291+
const attackScore = evaluateBoardContribution(board, row, col, aiColor);
292+
const defenseScore = evaluateBoardContribution(board, row, col, opponent);
293+
const centerBias = CENTER_WEIGHT - (Math.abs(center - row) + Math.abs(center - col));
294+
295+
let totalScore = attackScore * attackWeight + defenseScore * defenseWeight + centerBias;
296+
297+
if (counterWeight > 0) {
298+
const counterScore = evaluateBestOpponentResponse(
299+
board,
300+
opponent,
301+
aiColor,
302+
forbiddenRules,
303+
enforceForbidden,
304+
center,
305+
);
306+
totalScore -= counterScore * counterWeight;
307+
}
308+
309+
evaluatedMoves.push({ row, col, score: totalScore });
310+
board[row][col] = "";
311+
}
312+
313+
if (evaluatedMoves.length === 0) {
314+
const fallback = candidateMoves[0];
315+
return { row: fallback.row, col: fallback.col };
316+
}
317+
318+
evaluatedMoves.sort((a, b) => b.score - a.score);
319+
320+
const selectionWindow = Math.max(
321+
1,
322+
Math.min(evaluatedMoves.length, Math.round(1 + Math.max(0, Math.min(1, randomness)) * 5)),
323+
);
324+
const choiceIndex = Math.floor(Math.random() * selectionWindow);
325+
const choice = evaluatedMoves[choiceIndex];
326+
327+
return { row: choice.row, col: choice.col };
328+
}

0 commit comments

Comments
 (0)