初始化五子棋
This commit is contained in:
commit
9c50c92f25
|
|
@ -0,0 +1,12 @@
|
|||
# 操作系统生成文件
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
.DS_Store
|
||||
|
||||
# 编辑器 / IDE 配置
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# 本地测试过程产物(GUI 测试截图)
|
||||
gui-test-screenshots/
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# 五子棋网页版 · 最小可玩版本计划
|
||||
|
||||
## 目标
|
||||
15×15 棋盘网页五子棋:**双人对战 + 打分法人机对战**,双击 index.html 即可游玩,零依赖零构建。架构上为后续增强(Minimax、悔棋、难度分级)预留接口。
|
||||
|
||||
## 文件结构
|
||||
```
|
||||
D:\五子棋\
|
||||
├── index.html # 页面结构,普通 <script> 标签按序引入(file:// 直开兼容)
|
||||
├── css\style.css # 布局与控件样式(左侧棋盘 + 右侧控制面板)
|
||||
├── js\
|
||||
│ ├── engine.js # 规则引擎:棋盘状态、落子校验、五连判定、历史记录(纯逻辑,无 DOM)
|
||||
│ ├── ai.js # 打分法 AI(纯逻辑)
|
||||
│ ├── renderer.js # Canvas 绘制:棋盘、棋子、落子预览、最后一手标记、获胜连线
|
||||
│ ├── ui.js # 交互控制:点击落子、模式切换、状态栏
|
||||
│ └── main.js # 入口,组装各模块
|
||||
└── tests\
|
||||
├── engine.test.js # 规则引擎测试
|
||||
└── ai.test.js # AI 行为测试
|
||||
```
|
||||
|
||||
**跨环境模块写法**:engine.js / ai.js 采用轻量 UMD 模式——浏览器中挂到 `window.GomokuEngine` / `window.GomokuAI`,Node 中走 `module.exports`,同一份代码两处可用。
|
||||
|
||||
## 模块设计
|
||||
|
||||
### 1. 规则引擎 engine.js
|
||||
- `createGame()` 返回游戏对象:`board`(15×15,0空/1黑/2白)、`currentPlayer`、`moveHistory`、`winner`、`gameOver`
|
||||
- `place(x, y)`:校验(界内、空位、未结束)→ 落子 → 从该点向 4 个方向数连子(≥5 即胜,无禁手自由规则,长连也算胜)→ 切换行棋方
|
||||
- `checkWinAt(x, y)` 返回获胜五连的坐标数组(供 UI 高亮)
|
||||
- 棋盘下满为平局
|
||||
- 内部保留 `moveHistory` 栈,为后续悔棋功能预留
|
||||
|
||||
### 2. 打分法 AI ai.js
|
||||
- `nextMove(game)` 返回 `{x, y}`
|
||||
- 候选点生成:仅取已有棋子周围 2 格内的空位(空盘则直接下天元)
|
||||
- 对每个候选点,沿 4 个方向计算「AI 落此处的进攻分」与「对手落此处的防守分」,按棋形打分表计分:连五 1000000 / 活四 100000 / 冲四 10000 / 活三 8000 / 眠三 500 / 活二 400 / 眠二 50
|
||||
- 总分 = 进攻分 + 防守分 × 0.9(自动实现"能赢先赢 > 挡对方活四 > 自己做活三"的优先级),取最高分点,并列时随机取一
|
||||
|
||||
### 3. 渲染 renderer.js
|
||||
- Canvas 绘制:木纹色底、网格线、5 个星位点、径向渐变立体棋子、最后一手红点标记
|
||||
- 鼠标悬停显示半透明落子预览
|
||||
- 获胜时在五连棋子上绘制高亮连线
|
||||
- 按 devicePixelRatio 缩放保证清晰度
|
||||
|
||||
### 4. 交互 ui.js / main.js
|
||||
- 模式选择:双人对战 / 人机对战;人机模式下可选执黑(先手)或执白(后手)
|
||||
- 状态栏:轮到黑方/白方 → 获胜方/平局宣告
|
||||
- 「新对局」按钮;AI 落子加约 300ms 延迟(观感自然且让界面先渲染)
|
||||
- 对局结束或轮到 AI 时禁止点击
|
||||
- 界面文案为中文
|
||||
|
||||
## 实施步骤
|
||||
1. 搭建 index.html + style.css 骨架(布局与控件)
|
||||
2. 实现 engine.js + 编写并跑通 engine.test.js
|
||||
3. 实现 renderer.js + 点击落子,双人模式完整可玩
|
||||
4. 实现 ai.js + 接入人机模式,跑通 ai.test.js
|
||||
5. 状态栏、新对局、悬停预览等细节打磨,浏览器里实际对弈验证两条路径(双人五连胜负判定、人机 AI 应手)
|
||||
|
||||
## 测试与验证
|
||||
- `node --test tests/`(Node ≥18 自带 runner,零依赖):4 方向五连、长连、边界、非法落子、平局、行棋交替;AI 能一手取胜时会取胜、对方冲四时会防守、首着落天元附近
|
||||
- 浏览器端实际点击对弈做端到端验证
|
||||
|
||||
## 暂不做(后续迭代)
|
||||
悔棋按钮、Minimax + Alpha-Beta(难度分级)、Web Worker、音效动画、对局保存复盘、禁手规则、联机对战。引擎的历史栈与 AI 的 `nextMove(game)` 接口已为这些预留。
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
|
||||
background: #efe9dd;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.board-area {
|
||||
flex: 1 1 520px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
aspect-ratio: 1 / 1;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
|
||||
cursor: pointer;
|
||||
background: #d8a860;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 0 0 220px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 15px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f3ede2;
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
select {
|
||||
font: inherit;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #8c5a2b;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #a06a34;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>五子棋</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<main class="board-area">
|
||||
<canvas id="board"></canvas>
|
||||
</main>
|
||||
<aside class="panel">
|
||||
<h1>五子棋</h1>
|
||||
<div class="status" id="status">轮到黑方落子</div>
|
||||
<label>对战模式
|
||||
<select id="mode">
|
||||
<option value="ai" selected>人机对战</option>
|
||||
<option value="pvp">双人对战</option>
|
||||
</select>
|
||||
</label>
|
||||
<label id="side-row">我方执子
|
||||
<select id="side">
|
||||
<option value="1" selected>黑子(先手)</option>
|
||||
<option value="2">白子(后手)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="new-game">新对局</button>
|
||||
<p class="hint">点击棋盘交叉点落子,先连成五子者获胜。切换模式或执子会开始新对局。</p>
|
||||
</aside>
|
||||
</div>
|
||||
<script src="js/engine.js"></script>
|
||||
<script src="js/ai.js"></script>
|
||||
<script src="js/renderer.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* 打分法 AI(贪心策略)
|
||||
* 对每个候选空位计算「己方落此处的进攻分 + 0.9 × 对方落此处的防守分」,
|
||||
* 取总分最高的一点。分值表的相对大小自然形成优先级:
|
||||
* 己方成五 > 挡对方成五 > 己方活四 > 挡对方活四 > …
|
||||
* 浏览器中挂到 window.GomokuAI,Node 中通过 module.exports 导出供测试使用。
|
||||
*/
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = api;
|
||||
} else {
|
||||
root.GomokuAI = api;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : globalThis, function () {
|
||||
|
||||
const SIZE = 15;
|
||||
const EMPTY = 0;
|
||||
const BLACK = 1;
|
||||
const WHITE = 2;
|
||||
|
||||
// 横、竖、两条斜线
|
||||
const DIRECTIONS = [[1, 0], [0, 1], [1, 1], [1, -1]];
|
||||
|
||||
const DEFENSE_WEIGHT = 0.9; // 防守分权重:略低于进攻,能赢时优先赢
|
||||
const CENTER = { x: 7, y: 7 };
|
||||
|
||||
// 棋形分值表:按优先级从高到低排列,每个方向命中一个即止
|
||||
// 模式串中 '1' = 己方,'0' = 空位,'2' = 对方棋子或边界
|
||||
const PATTERN_TABLE = [
|
||||
[['11111'], 1000000], // 连五
|
||||
[['011110'], 100000], // 活四
|
||||
[['011112', '211110', '10111', '11011', '11101'], 10000], // 冲四(含跳四)
|
||||
[['011100', '001110', '010110', '011010'], 8000], // 活三(含跳活三)
|
||||
[['11100', '00111', '11010', '01011', '10110', '01101',
|
||||
'10011', '11001', '10101'], 500], // 眠三
|
||||
[['001100', '01010'], 400], // 活二
|
||||
[['000112', '211000', '001012', '210100', '010012', '210010',
|
||||
'10001'], 50], // 眠二
|
||||
];
|
||||
|
||||
// 以 (x, y) 为中心、沿 (dx, dy) 方向取 9 格窗口,假设己方落在中心后转成模式串
|
||||
function buildWindow(board, x, y, dx, dy, color) {
|
||||
let s = '';
|
||||
for (let i = -4; i <= 4; i++) {
|
||||
if (i === 0) { s += '1'; continue; }
|
||||
const nx = x + dx * i;
|
||||
const ny = y + dy * i;
|
||||
if (nx < 0 || nx >= SIZE || ny < 0 || ny >= SIZE) { s += '2'; continue; }
|
||||
const c = board[ny][nx];
|
||||
s += c === EMPTY ? '0' : c === color ? '1' : '2';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// 评估 color 方落在 (x, y) 的价值:4 个方向的棋形分之和
|
||||
function scorePoint(board, x, y, color) {
|
||||
let total = 0;
|
||||
for (const [dx, dy] of DIRECTIONS) {
|
||||
const win = buildWindow(board, x, y, dx, dy, color);
|
||||
for (const [patterns, score] of PATTERN_TABLE) {
|
||||
if (patterns.some(p => win.includes(p))) {
|
||||
total += score;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function hasNeighbor(board, x, y, range) {
|
||||
for (let dy = -range; dy <= range; dy++) {
|
||||
for (let dx = -range; dx <= range; dx++) {
|
||||
if (dx === 0 && dy === 0) continue;
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx >= 0 && nx < SIZE && ny >= 0 && ny < SIZE && board[ny][nx] !== EMPTY) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 候选点:已有棋子周围 2 格内的空位;空盘直接落天元
|
||||
function findCandidates(board) {
|
||||
const result = [];
|
||||
let hasStone = false;
|
||||
for (let y = 0; y < SIZE; y++) {
|
||||
for (let x = 0; x < SIZE; x++) {
|
||||
if (board[y][x] !== EMPTY) { hasStone = true; break; }
|
||||
}
|
||||
if (hasStone) break;
|
||||
}
|
||||
if (!hasStone) return [CENTER];
|
||||
for (let y = 0; y < SIZE; y++) {
|
||||
for (let x = 0; x < SIZE; x++) {
|
||||
if (board[y][x] === EMPTY && hasNeighbor(board, x, y, 2)) {
|
||||
result.push({ x, y });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function nextMove(game, color) {
|
||||
const me = color || game.currentPlayer;
|
||||
const opp = me === BLACK ? WHITE : BLACK;
|
||||
let best = [];
|
||||
let bestScore = -Infinity;
|
||||
for (const spot of findCandidates(game.board)) {
|
||||
const score = scorePoint(game.board, spot.x, spot.y, me)
|
||||
+ DEFENSE_WEIGHT * scorePoint(game.board, spot.x, spot.y, opp);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = [spot];
|
||||
} else if (score === bestScore) {
|
||||
best.push(spot);
|
||||
}
|
||||
}
|
||||
return best[Math.floor(Math.random() * best.length)];
|
||||
}
|
||||
|
||||
return { nextMove, scorePoint, findCandidates };
|
||||
});
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* 五子棋规则引擎(纯逻辑,无 DOM)
|
||||
* 浏览器中挂到 window.GomokuEngine,Node 中通过 module.exports 导出供测试使用。
|
||||
* 规则:无禁手自由规则,任意方向连成五子及以上即胜。
|
||||
*/
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = api;
|
||||
} else {
|
||||
root.GomokuEngine = api;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : globalThis, function () {
|
||||
|
||||
const SIZE = 15;
|
||||
const EMPTY = 0;
|
||||
const BLACK = 1;
|
||||
const WHITE = 2;
|
||||
|
||||
// 横、竖、两条斜线
|
||||
const DIRECTIONS = [[1, 0], [0, 1], [1, 1], [1, -1]];
|
||||
|
||||
function inBounds(x, y) {
|
||||
return x >= 0 && x < SIZE && y >= 0 && y < SIZE;
|
||||
}
|
||||
|
||||
function createGame() {
|
||||
return {
|
||||
size: SIZE,
|
||||
board: Array.from({ length: SIZE }, () => new Array(SIZE).fill(EMPTY)),
|
||||
currentPlayer: BLACK, // 黑先
|
||||
winner: 0, // 1 黑胜,2 白胜,0 未分
|
||||
draw: false,
|
||||
gameOver: false,
|
||||
winningLine: null, // 获胜连线的坐标数组 [{x, y}, ...]
|
||||
moveHistory: [], // {x, y, player},为悔棋功能预留
|
||||
};
|
||||
}
|
||||
|
||||
// 从 (x, y) 出发沿 4 个方向数连子,返回获胜连线(≥5 个)或 null
|
||||
function checkWinAt(game, x, y) {
|
||||
const player = game.board[y][x];
|
||||
if (player === EMPTY) return null;
|
||||
for (const [dx, dy] of DIRECTIONS) {
|
||||
const line = [{ x, y }];
|
||||
for (let nx = x + dx, ny = y + dy; inBounds(nx, ny) && game.board[ny][nx] === player; nx += dx, ny += dy) {
|
||||
line.push({ x: nx, y: ny });
|
||||
}
|
||||
for (let nx = x - dx, ny = y - dy; inBounds(nx, ny) && game.board[ny][nx] === player; nx -= dx, ny -= dy) {
|
||||
line.unshift({ x: nx, y: ny });
|
||||
}
|
||||
if (line.length >= 5) return line;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isBoardFull(game) {
|
||||
return game.board.every(row => row.every(c => c !== EMPTY));
|
||||
}
|
||||
|
||||
function place(game, x, y) {
|
||||
if (game.gameOver) return { ok: false, reason: '游戏已结束' };
|
||||
if (!inBounds(x, y)) return { ok: false, reason: '超出棋盘范围' };
|
||||
if (game.board[y][x] !== EMPTY) return { ok: false, reason: '该位置已有棋子' };
|
||||
|
||||
game.board[y][x] = game.currentPlayer;
|
||||
game.moveHistory.push({ x, y, player: game.currentPlayer });
|
||||
|
||||
const line = checkWinAt(game, x, y);
|
||||
if (line) {
|
||||
game.winner = game.currentPlayer;
|
||||
game.winningLine = line;
|
||||
game.gameOver = true;
|
||||
return { ok: true, win: true };
|
||||
}
|
||||
if (isBoardFull(game)) {
|
||||
game.draw = true;
|
||||
game.gameOver = true;
|
||||
return { ok: true, draw: true };
|
||||
}
|
||||
game.currentPlayer = game.currentPlayer === BLACK ? WHITE : BLACK;
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
SIZE,
|
||||
EMPTY,
|
||||
BLACK,
|
||||
WHITE,
|
||||
createGame,
|
||||
place,
|
||||
checkWinAt,
|
||||
inBounds,
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/* 入口:组装渲染器与交互控制 */
|
||||
(function () {
|
||||
const canvas = document.getElementById('board');
|
||||
const renderer = GomokuRenderer.createRenderer(canvas);
|
||||
const controller = GomokuUI.createController(canvas, renderer);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
renderer.resize();
|
||||
controller.refresh();
|
||||
});
|
||||
})();
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* Canvas 渲染:木色棋盘、立体棋子、悬停预览、最后一手标记、获胜连线。
|
||||
* 不持有游戏状态,每次 render(game, hover) 根据传入状态整体重绘。
|
||||
*/
|
||||
(function () {
|
||||
|
||||
const SIZE = 15;
|
||||
const STAR_POINTS = [[3, 3], [11, 3], [3, 11], [11, 11], [7, 7]];
|
||||
|
||||
function createRenderer(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
let cssSize = 600;
|
||||
let margin = 0;
|
||||
let cell = 0;
|
||||
|
||||
function resize() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
cssSize = Math.max(200, Math.floor(rect.width));
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(cssSize * dpr);
|
||||
canvas.height = Math.round(cssSize * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
margin = cssSize / (SIZE + 1);
|
||||
cell = (cssSize - margin * 2) / (SIZE - 1);
|
||||
}
|
||||
|
||||
function toPixel(x, y) {
|
||||
return { px: margin + x * cell, py: margin + y * cell };
|
||||
}
|
||||
|
||||
function drawBoard() {
|
||||
const bg = ctx.createLinearGradient(0, 0, cssSize, cssSize);
|
||||
bg.addColorStop(0, '#e3b473');
|
||||
bg.addColorStop(1, '#d19a52');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, cssSize, cssSize);
|
||||
|
||||
ctx.strokeStyle = '#7a5a2e';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < SIZE; i++) {
|
||||
const p = margin + i * cell;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(margin, p);
|
||||
ctx.lineTo(cssSize - margin, p);
|
||||
ctx.moveTo(p, margin);
|
||||
ctx.lineTo(p, cssSize - margin);
|
||||
ctx.stroke();
|
||||
}
|
||||
// 外框加粗一圈
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(margin, margin, cell * (SIZE - 1), cell * (SIZE - 1));
|
||||
|
||||
ctx.fillStyle = '#5a3f1c';
|
||||
for (const [sx, sy] of STAR_POINTS) {
|
||||
const { px, py } = toPixel(sx, sy);
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, Math.max(2.5, cell * 0.09), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
function drawStone(x, y, color, alpha) {
|
||||
const { px, py } = toPixel(x, y);
|
||||
const r = cell * 0.42;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
if (alpha === 1) {
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.35)';
|
||||
ctx.shadowBlur = r * 0.35;
|
||||
ctx.shadowOffsetY = r * 0.12;
|
||||
}
|
||||
const g = ctx.createRadialGradient(px - r * 0.35, py - r * 0.35, r * 0.1, px, py, r);
|
||||
if (color === 1) {
|
||||
g.addColorStop(0, '#6a6a6a');
|
||||
g.addColorStop(1, '#0d0d0d');
|
||||
} else {
|
||||
g.addColorStop(0, '#ffffff');
|
||||
g.addColorStop(1, '#c9c4ba');
|
||||
}
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function render(game, hover) {
|
||||
drawBoard();
|
||||
for (let y = 0; y < SIZE; y++) {
|
||||
for (let x = 0; x < SIZE; x++) {
|
||||
if (game.board[y][x] !== 0) drawStone(x, y, game.board[y][x], 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 最后一手红点标记(有获胜连线时不画,避免遮挡)
|
||||
const last = game.moveHistory[game.moveHistory.length - 1];
|
||||
if (last && !game.winningLine) {
|
||||
const { px, py } = toPixel(last.x, last.y);
|
||||
ctx.fillStyle = '#e6493a';
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, Math.max(2.5, cell * 0.1), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 获胜连线
|
||||
if (game.winningLine) {
|
||||
const a = toPixel(game.winningLine[0].x, game.winningLine[0].y);
|
||||
const b = toPixel(
|
||||
game.winningLine[game.winningLine.length - 1].x,
|
||||
game.winningLine[game.winningLine.length - 1].y
|
||||
);
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(230, 60, 45, 0.9)';
|
||||
ctx.lineWidth = cell * 0.14;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.px, a.py);
|
||||
ctx.lineTo(b.px, b.py);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 悬停预览:半透明显示当前行棋方棋子
|
||||
if (hover && !game.gameOver && game.board[hover.y][hover.x] === 0) {
|
||||
drawStone(hover.x, hover.y, game.currentPlayer, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
// 把鼠标事件换算成最近的交叉点,超出棋盘返回 null
|
||||
function cellFromEvent(evt) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = Math.round((evt.clientX - rect.left - margin) / cell);
|
||||
const y = Math.round((evt.clientY - rect.top - margin) / cell);
|
||||
if (x < 0 || x >= SIZE || y < 0 || y >= SIZE) return null;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
resize();
|
||||
return { render, resize, cellFromEvent };
|
||||
}
|
||||
|
||||
window.GomokuRenderer = { createRenderer };
|
||||
})();
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* 交互控制:点击落子、模式/执子切换、AI 应手、状态栏文案。
|
||||
*/
|
||||
(function () {
|
||||
|
||||
function createController(canvas, renderer) {
|
||||
const E = window.GomokuEngine;
|
||||
const AI = window.GomokuAI;
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
const modeSel = document.getElementById('mode');
|
||||
const sideSel = document.getElementById('side');
|
||||
const sideRow = document.getElementById('side-row');
|
||||
const newBtn = document.getElementById('new-game');
|
||||
|
||||
let game = E.createGame();
|
||||
let mode = modeSel.value; // 'ai' | 'pvp'
|
||||
let humanColor = Number(sideSel.value);
|
||||
let aiThinking = false;
|
||||
|
||||
function isHumanTurn() {
|
||||
return !game.gameOver && (mode === 'pvp' || game.currentPlayer === humanColor);
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
if (game.gameOver) {
|
||||
if (game.draw) {
|
||||
statusEl.textContent = '平局,棋盘已下满';
|
||||
} else if (mode === 'ai') {
|
||||
statusEl.textContent = game.winner === humanColor ? '你赢了!🎉' : 'AI 获胜,再接再厉';
|
||||
} else {
|
||||
statusEl.textContent = game.winner === E.BLACK ? '黑方获胜!' : '白方获胜!';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (aiThinking) {
|
||||
statusEl.textContent = 'AI 思考中…';
|
||||
return;
|
||||
}
|
||||
if (mode === 'ai') {
|
||||
statusEl.textContent = game.currentPlayer === humanColor
|
||||
? `轮到你落子(${humanColor === E.BLACK ? '黑子' : '白子'})`
|
||||
: '轮到 AI 落子';
|
||||
} else {
|
||||
statusEl.textContent = game.currentPlayer === E.BLACK ? '轮到黑方落子' : '轮到白方落子';
|
||||
}
|
||||
}
|
||||
|
||||
function refresh(hover) {
|
||||
renderer.render(game, hover || null);
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
// 轮到 AI 时延迟 300ms 落子:观感自然,也给界面留出渲染时间
|
||||
function maybeAIMove() {
|
||||
if (mode !== 'ai' || game.gameOver || game.currentPlayer === humanColor) return;
|
||||
aiThinking = true;
|
||||
updateStatus();
|
||||
setTimeout(() => {
|
||||
const mv = AI.nextMove(game, game.currentPlayer);
|
||||
E.place(game, mv.x, mv.y);
|
||||
aiThinking = false;
|
||||
refresh();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
canvas.addEventListener('click', (e) => {
|
||||
if (!isHumanTurn() || aiThinking) return;
|
||||
const pos = renderer.cellFromEvent(e);
|
||||
if (!pos) return;
|
||||
const r = E.place(game, pos.x, pos.y);
|
||||
if (!r.ok) return;
|
||||
refresh();
|
||||
maybeAIMove();
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
if (!isHumanTurn() || aiThinking) {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
refresh(renderer.cellFromEvent(e));
|
||||
});
|
||||
canvas.addEventListener('mouseleave', () => refresh());
|
||||
|
||||
function newGame() {
|
||||
game = E.createGame();
|
||||
aiThinking = false;
|
||||
refresh();
|
||||
maybeAIMove(); // 人执白时让 AI(黑)先落子
|
||||
}
|
||||
|
||||
modeSel.addEventListener('change', () => {
|
||||
mode = modeSel.value;
|
||||
sideRow.style.display = mode === 'ai' ? '' : 'none';
|
||||
newGame();
|
||||
});
|
||||
sideSel.addEventListener('change', () => {
|
||||
humanColor = Number(sideSel.value);
|
||||
newGame();
|
||||
});
|
||||
newBtn.addEventListener('click', newGame);
|
||||
|
||||
newGame();
|
||||
return { refresh };
|
||||
}
|
||||
|
||||
window.GomokuUI = { createController };
|
||||
})();
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* 本地静态文件服务器(可选工具):node server.mjs [端口]
|
||||
* 双击 index.html 即可游玩,无需本服务器;仅当浏览器安全策略限制 file:// 页面时使用。
|
||||
*/
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { extname, join, resolve } from 'node:path';
|
||||
|
||||
const root = resolve(process.argv[2] ? '.' : '.');
|
||||
const port = Number(process.argv[2]) || 8765;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
};
|
||||
|
||||
createServer(async (req, res) => {
|
||||
try {
|
||||
const pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
||||
const file = join(root, pathname.endsWith('/') ? join(pathname, 'index.html') : pathname);
|
||||
const data = await readFile(file);
|
||||
res.writeHead(200, { 'content-type': MIME[extname(file)] || 'application/octet-stream' });
|
||||
res.end(data);
|
||||
} catch {
|
||||
res.writeHead(404);
|
||||
res.end('not found');
|
||||
}
|
||||
}).listen(port, '127.0.0.1', () => {
|
||||
console.log(`serving http://127.0.0.1:${port}/`);
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/* AI 行为测试:node --test tests/ai.test.js */
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const E = require('../js/engine.js');
|
||||
const AI = require('../js/ai.js');
|
||||
|
||||
function setStones(game, color, coords) {
|
||||
for (const [x, y] of coords) game.board[y][x] = color;
|
||||
}
|
||||
|
||||
test('空盘首着落天元', () => {
|
||||
const g = E.createGame();
|
||||
assert.deepStrictEqual(AI.nextMove(g, E.BLACK), { x: 7, y: 7 });
|
||||
});
|
||||
|
||||
test('能一手取胜时直接取胜', () => {
|
||||
const g = E.createGame();
|
||||
// 白方横向四连(x=4..7, y=5),左端被黑封堵,唯一成五点是 (8,5)
|
||||
setStones(g, E.WHITE, [[4, 5], [5, 5], [6, 5], [7, 5]]);
|
||||
setStones(g, E.BLACK, [[3, 5]]);
|
||||
assert.deepStrictEqual(AI.nextMove(g, E.WHITE), { x: 8, y: 5 });
|
||||
});
|
||||
|
||||
test('对方冲四时必防守', () => {
|
||||
const g = E.createGame();
|
||||
// 黑方横向四连(x=3..6, y=9),左端 (2,9) 已被白封堵,白必须在 (7,9) 挡
|
||||
setStones(g, E.BLACK, [[3, 9], [4, 9], [5, 9], [6, 9]]);
|
||||
setStones(g, E.WHITE, [[2, 9]]);
|
||||
assert.deepStrictEqual(AI.nextMove(g, E.WHITE), { x: 7, y: 9 });
|
||||
});
|
||||
|
||||
test('对方活三时封挡端点', () => {
|
||||
const g = E.createGame();
|
||||
// 黑方两端开阔的活三,白应落在 (5,7) 或 (9,7) 之一
|
||||
setStones(g, E.BLACK, [[6, 7], [7, 7], [8, 7]]);
|
||||
const mv = AI.nextMove(g, E.WHITE);
|
||||
assert.ok(
|
||||
(mv.x === 5 && mv.y === 7) || (mv.x === 9 && mv.y === 7),
|
||||
`期望封挡 (5,7) 或 (9,7),实际落在 (${mv.x},${mv.y})`
|
||||
);
|
||||
});
|
||||
|
||||
test('己方活三优先于防守对方眠二', () => {
|
||||
const g = E.createGame();
|
||||
// 白有活二 (4,8)(5,8),黑只有零散两子;白应继续连子而非远端防守
|
||||
setStones(g, E.WHITE, [[4, 8], [5, 8]]);
|
||||
setStones(g, E.BLACK, [[10, 3], [3, 11]]);
|
||||
const mv = AI.nextMove(g, E.WHITE);
|
||||
const nearOwn = Math.abs(mv.x - 4) + Math.abs(mv.y - 8) <= 4
|
||||
|| Math.abs(mv.x - 5) + Math.abs(mv.y - 8) <= 4;
|
||||
assert.ok(nearOwn, `期望在己方活二附近行棋,实际落在 (${mv.x},${mv.y})`);
|
||||
});
|
||||
|
||||
test('返回的点永远是空位', () => {
|
||||
const g = E.createGame();
|
||||
setStones(g, E.BLACK, [[7, 7], [8, 8], [6, 8]]);
|
||||
setStones(g, E.WHITE, [[7, 8], [8, 6], [6, 6]]);
|
||||
g.currentPlayer = E.WHITE;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const mv = AI.nextMove(g);
|
||||
assert.strictEqual(g.board[mv.y][mv.x], E.EMPTY);
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
/* 规则引擎测试:node --test tests/ */
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const E = require('../js/engine.js');
|
||||
|
||||
// 直接往棋盘上摆子,用于构造测试局面(绕过轮流落子)
|
||||
function setStones(game, color, coords) {
|
||||
for (const [x, y] of coords) game.board[y][x] = color;
|
||||
}
|
||||
|
||||
test('黑先并且落子后交替行棋', () => {
|
||||
const g = E.createGame();
|
||||
assert.strictEqual(g.currentPlayer, E.BLACK);
|
||||
const r = E.place(g, 7, 7);
|
||||
assert.strictEqual(r.ok, true);
|
||||
assert.strictEqual(g.currentPlayer, E.WHITE);
|
||||
E.place(g, 8, 8);
|
||||
assert.strictEqual(g.currentPlayer, E.BLACK);
|
||||
});
|
||||
|
||||
test('横向五连判胜', () => {
|
||||
const g = E.createGame();
|
||||
setStones(g, E.BLACK, [[3, 7], [4, 7], [5, 7], [6, 7]]);
|
||||
g.currentPlayer = E.BLACK;
|
||||
const r = E.place(g, 7, 7);
|
||||
assert.strictEqual(r.win, true);
|
||||
assert.strictEqual(g.winner, E.BLACK);
|
||||
assert.strictEqual(g.gameOver, true);
|
||||
assert.deepStrictEqual(g.winningLine, [
|
||||
{ x: 3, y: 7 }, { x: 4, y: 7 }, { x: 5, y: 7 }, { x: 6, y: 7 }, { x: 7, y: 7 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('纵向五连判胜', () => {
|
||||
const g = E.createGame();
|
||||
setStones(g, E.WHITE, [[7, 3], [7, 4], [7, 5], [7, 6]]);
|
||||
g.currentPlayer = E.WHITE;
|
||||
const r = E.place(g, 7, 7);
|
||||
assert.strictEqual(r.win, true);
|
||||
assert.strictEqual(g.winner, E.WHITE);
|
||||
});
|
||||
|
||||
test('斜向(左上到右下)五连判胜', () => {
|
||||
const g = E.createGame();
|
||||
setStones(g, E.BLACK, [[3, 3], [4, 4], [5, 5], [6, 6]]);
|
||||
g.currentPlayer = E.BLACK;
|
||||
const r = E.place(g, 7, 7);
|
||||
assert.strictEqual(r.win, true);
|
||||
});
|
||||
|
||||
test('斜向(左下到右上)五连判胜', () => {
|
||||
const g = E.createGame();
|
||||
setStones(g, E.BLACK, [[3, 10], [4, 9], [5, 8], [6, 7]]);
|
||||
g.currentPlayer = E.BLACK;
|
||||
const r = E.place(g, 7, 6);
|
||||
assert.strictEqual(r.win, true);
|
||||
assert.strictEqual(g.winningLine.length, 5);
|
||||
});
|
||||
|
||||
test('长连(六连)同样判胜', () => {
|
||||
const g = E.createGame();
|
||||
setStones(g, E.BLACK, [[3, 5], [4, 5], [5, 5], [6, 5], [8, 5]]);
|
||||
g.currentPlayer = E.BLACK;
|
||||
const r = E.place(g, 7, 5);
|
||||
assert.strictEqual(r.win, true);
|
||||
assert.strictEqual(g.winningLine.length, 6);
|
||||
});
|
||||
|
||||
test('四连不判胜', () => {
|
||||
const g = E.createGame();
|
||||
setStones(g, E.BLACK, [[3, 7], [4, 7], [5, 7], [6, 7]]);
|
||||
g.currentPlayer = E.BLACK;
|
||||
const r = E.place(g, 9, 9); // 与四连无关的落子
|
||||
assert.strictEqual(r.win, undefined);
|
||||
assert.strictEqual(g.gameOver, false);
|
||||
});
|
||||
|
||||
test('非法落子被拒绝:已占用', () => {
|
||||
const g = E.createGame();
|
||||
E.place(g, 7, 7);
|
||||
const r = E.place(g, 7, 7);
|
||||
assert.strictEqual(r.ok, false);
|
||||
});
|
||||
|
||||
test('非法落子被拒绝:越界', () => {
|
||||
const g = E.createGame();
|
||||
assert.strictEqual(E.place(g, -1, 0).ok, false);
|
||||
assert.strictEqual(E.place(g, 0, -1).ok, false);
|
||||
assert.strictEqual(E.place(g, 15, 0).ok, false);
|
||||
assert.strictEqual(E.place(g, 0, 15).ok, false);
|
||||
});
|
||||
|
||||
test('对局结束后拒绝继续落子', () => {
|
||||
const g = E.createGame();
|
||||
setStones(g, E.BLACK, [[3, 7], [4, 7], [5, 7], [6, 7]]);
|
||||
g.currentPlayer = E.BLACK;
|
||||
E.place(g, 7, 7);
|
||||
const r = E.place(g, 10, 10);
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.strictEqual(r.reason, '游戏已结束');
|
||||
});
|
||||
|
||||
test('棋盘下满判平局', () => {
|
||||
const g = E.createGame();
|
||||
// 用 (x + 2y) mod 4 的双色填充,任意方向连续同色不超过 2,不会误判五连
|
||||
for (let y = 0; y < E.SIZE; y++) {
|
||||
for (let x = 0; x < E.SIZE; x++) {
|
||||
if (x === 14 && y === 14) continue;
|
||||
const v = ((x + 2 * y) % 4 + 4) % 4;
|
||||
g.board[y][x] = v === 0 || v === 1 ? E.BLACK : E.WHITE;
|
||||
}
|
||||
}
|
||||
g.currentPlayer = E.BLACK;
|
||||
const r = E.place(g, 14, 14);
|
||||
assert.strictEqual(r.draw, true);
|
||||
assert.strictEqual(g.gameOver, true);
|
||||
assert.strictEqual(g.winner, 0);
|
||||
});
|
||||
|
||||
test('落子历史被完整记录', () => {
|
||||
const g = E.createGame();
|
||||
E.place(g, 7, 7);
|
||||
E.place(g, 8, 8);
|
||||
E.place(g, 7, 8);
|
||||
assert.strictEqual(g.moveHistory.length, 3);
|
||||
assert.deepStrictEqual(g.moveHistory[0], { x: 7, y: 7, player: E.BLACK });
|
||||
assert.deepStrictEqual(g.moveHistory[1], { x: 8, y: 8, player: E.WHITE });
|
||||
assert.deepStrictEqual(g.moveHistory[2], { x: 7, y: 8, player: E.BLACK });
|
||||
});
|
||||
Loading…
Reference in New Issue