├── src
├── ai
│ ├── __init__.py
│ ├── zobrist.py
│ ├── func.py
│ ├── minimax.py
│ └── evaluate.py
├── chess
│ ├── readme.md
│ ├── 1.txt
│ ├── 2.txt
│ └── draw.txt
├── assets
│ ├── ai.png
│ ├── go.png
│ ├── com.png
│ ├── easy.png
│ ├── net.png
│ ├── advance.png
│ ├── favicon.ico
│ ├── medium.png
│ ├── player.png
│ ├── server.ico
│ └── server.png
├── utils
│ ├── __init__.py
│ ├── json_byte.py
│ ├── get_ip.py
│ └── page_style.py
├── constants.py
├── chat.py
├── file.py
├── page.py
├── board.py
├── client.py
└── boards.py
├── .gitignore
├── game.py
├── requirements.txt
├── docs
├── assets
│ ├── 202111111652624.jpg
│ ├── 202111111919929.jpg
│ ├── 202111112047620.jpg
│ ├── 202111112147272.jpg
│ ├── 202111112227746.jpg
│ ├── 202111121109240.jpg
│ ├── 202111121348693.jpg
│ ├── 202111121352259.jpg
│ ├── 202111121358688.jpg
│ ├── 202111121416220.jpg
│ ├── 202111121434589.jpg
│ ├── 202111121443129.jpg
│ ├── 202111121555618.jpg
│ ├── 202111202038959.png
│ ├── 202111202039662.png
│ ├── 202111202043419.png
│ ├── 202111202045433.png
│ ├── 202111202046217.png
│ ├── 202111202048721.png
│ ├── 202111202049247.png
│ ├── 202111241324219.png
│ ├── 202111250943169.png
│ └── 202111251346688.png
├── Game-Tree.md
├── Alpha-Beta.md
├── deeping.md
├── evaluate.md
├── Zobrist.md
├── MiniMax.md
└── Heuristic.md
├── readme.md
└── server.py
/src/ai/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/chess/readme.md:
--------------------------------------------------------------------------------
1 | 保存棋盘的地方....
--------------------------------------------------------------------------------
/src/chess/1.txt:
--------------------------------------------------------------------------------
1 | {"steps": [[7, 7], [8, 5], [7, 6], [8, 8]], "first": false}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | **/__pycache__
2 | log.txt
3 | game.spec
4 | build
5 | dist
6 | server.spec
--------------------------------------------------------------------------------
/game.py:
--------------------------------------------------------------------------------
1 | import src.page as p
2 |
3 |
4 | if __name__ == "__main__":
5 | p.HOME()
6 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/requirements.txt
--------------------------------------------------------------------------------
/src/assets/ai.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/src/assets/ai.png
--------------------------------------------------------------------------------
/src/assets/go.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/src/assets/go.png
--------------------------------------------------------------------------------
/src/assets/com.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/src/assets/com.png
--------------------------------------------------------------------------------
/src/assets/easy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/src/assets/easy.png
--------------------------------------------------------------------------------
/src/assets/net.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/src/assets/net.png
--------------------------------------------------------------------------------
/src/chess/2.txt:
--------------------------------------------------------------------------------
1 | {"steps": [[5, 5], [8, 7], [6, 6], [7, 6], [8, 5], [9, 6], [7, 7]], "first": false}
--------------------------------------------------------------------------------
/src/assets/advance.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/src/assets/advance.png
--------------------------------------------------------------------------------
/src/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/src/assets/favicon.ico
--------------------------------------------------------------------------------
/src/assets/medium.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/src/assets/medium.png
--------------------------------------------------------------------------------
/src/assets/player.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/src/assets/player.png
--------------------------------------------------------------------------------
/src/assets/server.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/src/assets/server.ico
--------------------------------------------------------------------------------
/src/assets/server.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/src/assets/server.png
--------------------------------------------------------------------------------
/docs/assets/202111111652624.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111111652624.jpg
--------------------------------------------------------------------------------
/docs/assets/202111111919929.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111111919929.jpg
--------------------------------------------------------------------------------
/docs/assets/202111112047620.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111112047620.jpg
--------------------------------------------------------------------------------
/docs/assets/202111112147272.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111112147272.jpg
--------------------------------------------------------------------------------
/docs/assets/202111112227746.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111112227746.jpg
--------------------------------------------------------------------------------
/docs/assets/202111121109240.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111121109240.jpg
--------------------------------------------------------------------------------
/docs/assets/202111121348693.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111121348693.jpg
--------------------------------------------------------------------------------
/docs/assets/202111121352259.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111121352259.jpg
--------------------------------------------------------------------------------
/docs/assets/202111121358688.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111121358688.jpg
--------------------------------------------------------------------------------
/docs/assets/202111121416220.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111121416220.jpg
--------------------------------------------------------------------------------
/docs/assets/202111121434589.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111121434589.jpg
--------------------------------------------------------------------------------
/docs/assets/202111121443129.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111121443129.jpg
--------------------------------------------------------------------------------
/docs/assets/202111121555618.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111121555618.jpg
--------------------------------------------------------------------------------
/docs/assets/202111202038959.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111202038959.png
--------------------------------------------------------------------------------
/docs/assets/202111202039662.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111202039662.png
--------------------------------------------------------------------------------
/docs/assets/202111202043419.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111202043419.png
--------------------------------------------------------------------------------
/docs/assets/202111202045433.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111202045433.png
--------------------------------------------------------------------------------
/docs/assets/202111202046217.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111202046217.png
--------------------------------------------------------------------------------
/docs/assets/202111202048721.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111202048721.png
--------------------------------------------------------------------------------
/docs/assets/202111202049247.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111202049247.png
--------------------------------------------------------------------------------
/docs/assets/202111241324219.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111241324219.png
--------------------------------------------------------------------------------
/docs/assets/202111250943169.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111250943169.png
--------------------------------------------------------------------------------
/docs/assets/202111251346688.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arcturus-school/gobang/HEAD/docs/assets/202111251346688.png
--------------------------------------------------------------------------------
/src/utils/__init__.py:
--------------------------------------------------------------------------------
1 | from .get_ip import getIPv4 # noqa E401
2 | from .json_byte import json_to_byte, byte_to_json # noqa E401
3 | from .page_style import windowStyle, menu, about # noqa E401
4 |
--------------------------------------------------------------------------------
/src/utils/json_byte.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 |
4 | def json_to_byte(json_):
5 | return json.dumps(json_).encode("gb2312")
6 |
7 |
8 | def byte_to_json(byte_):
9 | return json.loads(byte_.decode("gb2312"))
10 |
--------------------------------------------------------------------------------
/src/utils/get_ip.py:
--------------------------------------------------------------------------------
1 | from socket import socket, AF_INET, SOCK_DGRAM
2 |
3 |
4 | # 获取本机 IPv4 地址
5 | def getIPv4() -> str:
6 | try:
7 | s = socket(AF_INET, SOCK_DGRAM)
8 | s.connect(("8.8.8.8", 80))
9 | ip, _ = s.getsockname()
10 | except Exception as e:
11 | print(f"获取本机 IP 地址出错, {e}")
12 | finally:
13 | s.close()
14 | return ip
15 |
--------------------------------------------------------------------------------
/docs/Game-Tree.md:
--------------------------------------------------------------------------------
1 | # 游戏规则
2 |
3 | 假设俩个人轮流报数,可以报 1、2、3 这三个数,然后积分榜累加这俩个人报的数,最先加到 6 的人输
4 |
5 | 这个游戏存在先手优势,即谁最先报数,就有必胜的方案
6 |
7 | # 博弈树
8 |
9 | > 博弈树的树叶表示游戏的结局
10 |
11 | 下图中方块表示乙报完数后的局面(此时甲要开始报数了),圆圈表示甲报完数后的局面,由图可知甲先报数
12 |
13 | 
14 |
15 | 对于甲来说,第一次不能报 2 和 3,因为这样乙总有办法让甲输,即图中红色路线
16 |
17 | 如果甲报数 1,那么无论第二次乙报什么数,甲总有路线让乙输,即图中蓝色路线
18 |
19 | 
20 |
--------------------------------------------------------------------------------
/src/chess/draw.txt:
--------------------------------------------------------------------------------
1 | {"steps": [[7, 8], [6, 8], [5, 8], [4, 8], [3, 8], [2, 8], [1, 8], [0, 8], [8, 7], [8, 6], [7, 6], [7, 7], [6, 6], [6, 7], [5, 6], [5, 7], [4, 6], [4, 7], [3, 7], [3, 6], [2, 7], [2, 6], [1, 7], [1, 6], [0, 7], [0, 6], [8, 5], [8, 4], [7, 4], [7, 5], [6, 4], [6, 5], [5, 4], [5, 5], [4, 4], [4, 5], [3, 5], [3, 4], [2, 5], [2, 4], [1, 5], [1, 4], [0, 5], [0, 4], [8, 3], [8, 2], [7, 2], [7, 3], [6, 2], [6, 3], [5, 2], [5, 3], [4, 2], [4, 3], [3, 3], [3, 2], [2, 3], [2, 2], [1, 3], [1, 2], [0, 3], [0, 2], [8, 1], [8, 0], [7, 0], [7, 1], [6, 0], [6, 1], [5, 0], [5, 1], [4, 0], [4, 1], [3, 1], [3, 0], [2, 1], [2, 0], [1, 1], [1, 0], [0, 1], [0, 0]], "first": false}
--------------------------------------------------------------------------------
/src/utils/page_style.py:
--------------------------------------------------------------------------------
1 | from tkinter import Menu, Tk
2 | import webbrowser
3 | import ctypes
4 |
5 |
6 | # 设置窗口样式
7 | def windowStyle(
8 | w: Tk,
9 | title="五子棋",
10 | bg="#e6e6e6",
11 | ico="src/assets/favicon.ico",
12 | ):
13 | # 标题
14 | w.title(title)
15 |
16 | # 窗口背景
17 | w.configure(bg=bg)
18 |
19 | # 图标
20 | w.iconbitmap(ico)
21 |
22 | # 调用 api 设置成由应用程序缩放
23 | ctypes.windll.shcore.SetProcessDpiAwareness(1)
24 |
25 | # 调用 api 获得当前的缩放因子
26 | ScaleFactor = ctypes.windll.shcore.GetScaleFactorForDevice(0)
27 |
28 | # 设置缩放因子
29 | w.tk.call("tk", "scaling", ScaleFactor / 75)
30 |
31 |
32 | # 顶部菜单栏
33 | def menu(w: Tk, m: dict):
34 | menubar = Menu(w)
35 | for i in m.items():
36 | a = Menu(menubar, tearoff=0)
37 | for j in i[1].items():
38 | a.add_command(label=j[0], command=j[1])
39 | menubar.add_cascade(label=i[0], menu=a)
40 |
41 | w["menu"] = menubar
42 |
43 |
44 | # 打开浏览器
45 | def about():
46 | webbrowser.open("https://github.com/ICE99125/gobang.git")
47 |
--------------------------------------------------------------------------------
/docs/Alpha-Beta.md:
--------------------------------------------------------------------------------
1 | # Alpha Beta 剪枝
2 |
3 | > 核心是固定深度
4 |
5 | 剪去 MAX 层叫 Alpha 剪枝
6 |
7 | 剪去 MIN 层叫 Beta 剪枝
8 |
9 | ## 触发剪枝的条件
10 |
11 | * 当极小层某节点的 α 大于等于 β 时不需要继续遍历其子节点
12 |
13 | > 下图中 α=5,说明我们存在一个使我们得分至少为 5 的情况,如果在遍历子节点的过程中,发现 β 小于 α 了,不会继续遍历后面的节点,因为后面的分数如果更大,对手不可能会选,如果后面的分数更小,对手肯定会选,那我们更加不能选这条路,因此不需要继续考虑了
14 |
15 | 
16 |
17 | 对于[极大极小值搜索](MiniMax.md)一章的博弈树进行剪枝可得
18 |
19 | 
20 |
21 | * 当极大层某节点的 α 大于等于 β 时不需要继续遍历
22 |
23 | > 因为如果后面的分数更低,我们没必要选,如果后面的分数高,会导致这条路分数更高,对手不会选这条路,没必要继续考虑
24 |
25 |
26 | ## 代码实现
27 |
28 | ```python
29 | # minimax.
30 | def r(..., alpha, ...):
31 | # ...
32 |
33 | # 将 alpha 值与子节点分数做比较, 选出最大的分数给 alpha
34 | alpha = max(best["score"], alpha)
35 |
36 | # alpha-beta 剪枝
37 | if func.greatOrEqualThan(a, beta):
38 | ABcut += 1 # 剪枝数加一
39 | v["score"] = MAX - 1 # 被剪枝的用极大值来记录, 但是必须比 MAX 小
40 | v["abcut"] = 1 # 剪枝标记
41 | return v
42 | ```
43 |
44 |
45 |
46 | ## 参考资料
47 |
48 | 1. [极大极小值搜索和alpha-beta剪枝](https://www.codetd.com/article/7205806)
49 |
--------------------------------------------------------------------------------
/src/constants.py:
--------------------------------------------------------------------------------
1 | #########################
2 | # 角色 #
3 | #########################
4 |
5 | R = {
6 | "empty": 0,
7 | "rival": 1,
8 | "oneself": 2,
9 | } # 角色表
10 |
11 | r = {
12 | 1: "black",
13 | 2: "white",
14 | } # 执子颜色
15 |
16 | tr = {
17 | 1: "黑",
18 | 2: "白",
19 | } # 执子方
20 |
21 | NO = [x for x in range(1, 20)] # 1 到 20 列表, 用于生成棋盘标识
22 |
23 | #########################
24 | # 评分标准 #
25 | #########################
26 |
27 | S = {
28 | "ONE": 10, # 活一
29 | "TWO": 100, # 活二
30 | "THREE": 1000, # 活三
31 | "FOUR": 100000, # 活四
32 | "FIVE": 10000000, # 连五
33 | # 当一侧被封死
34 | "BLOCKED_ONE": 1, # 眠一
35 | "BLOCKED_TWO": 10, # 眠二
36 | "BLOCKED_THREE": 100, # 眠三
37 | "BLOCKED_FOUR": 10000, # 眠四
38 | }
39 |
40 | #########################
41 | # 阈值 #
42 | #########################
43 | threshold = 1.15
44 |
45 | #########################
46 | # 配置项 #
47 | #########################
48 | C = {
49 | "countLimit": 20,
50 | "cache": True,
51 | } # gen 函数返回的节点数量上限
52 |
--------------------------------------------------------------------------------
/docs/deeping.md:
--------------------------------------------------------------------------------
1 | # 迭代加深
2 |
3 | 每次尝试偶数层, 逐渐增加搜索深度
4 |
5 | 如果较低的深度能够获胜, 可以不必要增加深度, 提高效率
6 |
7 | ```python
8 | def deeping(board, candidates, role, deep):
9 |
10 | # 每次仅尝试偶数层
11 | for i in range(2, deep, 2):
12 | bestScore = negamax(board, candidates, role, i, MIN, MAX)
13 | if func.greatOrEqualThan(bestScore, S["FIVE"]):
14 | # 能赢了就不用再循环了
15 | break
16 |
17 | # 结果重组
18 | def rearrange(d):
19 | r = {
20 | "p": [d["p"][0], d["p"][1]],
21 | "score": d["v"]["score"],
22 | "step": d["v"]["step"],
23 | "steps": d["v"]["steps"]
24 | }
25 | return r
26 |
27 | candidates = list(map(rearrange, candidates))
28 |
29 | # 过滤出分数大于等于 0 的
30 | c = list(filter(lambda x: x["score"] >= 0, candidates))
31 | if len(c) == 0:
32 | # 如果分数都不大于 0
33 | # 找一个步骤最长的挣扎一下
34 | candidates.sort(key=lambda x: x["step"], reverse=True)
35 | else:
36 | # 分数大于 0, 先找到分数高的, 分数一样再找步骤少的
37 | candidates.sort(key=lambda x: (x["score"], -x["step"]), reverse=True)
38 |
39 | return candidates[0]
40 | ```
41 |
42 |
--------------------------------------------------------------------------------
/src/ai/zobrist.py:
--------------------------------------------------------------------------------
1 | """
2 | Zobrist 哈希算法, 对重复棋盘的优化
3 | """
4 | from ..constants import R
5 | import numpy as np
6 | import secrets
7 |
8 |
9 | class Zobrist:
10 | def __init__(self, n=15, m=15):
11 | # 初始化 Zobrist 哈希值
12 | self.code = self._rand()
13 |
14 | # 初始化两个 n × m 的空数组
15 | self._com = np.empty([n, m], dtype=int)
16 | self._hum = np.empty([n, m], dtype=int)
17 |
18 | # 数组与棋盘相对应
19 | # 给每一个位置附上一个随机数, 代表不同的状态
20 | for x, y in np.nditer([self._com, self._hum], op_flags=["writeonly"]):
21 | x[...] = self._rand()
22 | y[...] = self._rand()
23 |
24 | def _rand(self, k=31):
25 | """
26 | 生成 k 位随机数(k<=31)
27 | :param {int} k: 随机数位数
28 | :return {int} k 位随机数
29 | """
30 | return secrets.randbits(k)
31 |
32 | def go(self, x: int, y: int, role: int):
33 | """
34 | x: 行坐标
35 | y: 列坐标
36 | role: AI or 玩家
37 | """
38 | # 判断本次操作是 AI 还是人, 并返回相应位置的随机数
39 | code = self._com[x, y] if role == R["rival"] else self._hum[x, y]
40 | # 当前键值异或位置随机数
41 | self.code = self.code ^ code
42 |
--------------------------------------------------------------------------------
/src/chat.py:
--------------------------------------------------------------------------------
1 | from tkinter import Text, Tk
2 | from tkinter.scrolledtext import ScrolledText
3 | from tkinter.constants import WORD, FLAT, BOTTOM, N, S, END
4 | from time import strftime, localtime
5 | import tkinter.ttk as ttk
6 |
7 |
8 | # 聊天类
9 | class Chat:
10 | # 聊天界面
11 | def interfaces(self, w: Tk):
12 | # 聊天框
13 | self.t1 = ScrolledText(
14 | w,
15 | width=40,
16 | height=24,
17 | wrap=WORD,
18 | relief=FLAT,
19 | )
20 |
21 | self.t1.grid(row=0, columnspan=2)
22 | self.t1["state"] = "disabled" # 聊天框不允许输入
23 |
24 | # 输入框
25 | self.t2 = Text(w, width=35, height=3, relief=FLAT)
26 | self.t2.grid(row=1, pady=10)
27 |
28 | # 发送按钮
29 | self.button = ttk.Button(
30 | w,
31 | text="发送",
32 | width=5,
33 | compound=BOTTOM,
34 | )
35 |
36 | self.button.grid(
37 | row=1,
38 | column=1,
39 | sticky=(N, S),
40 | padx=(10, 0),
41 | pady=10,
42 | )
43 |
44 | # 向 ScrolledText 写入数据
45 | def writeToText(self, content: str, IP: str, PORT: int):
46 | # 当前时间
47 | t = strftime("%Y-%m-%d %H:%M:%S", localtime())
48 |
49 | # 向 ScrolledText 写入数据
50 | self.t1["state"] = "normal"
51 | chat_record = f"[{t}] # {IP}:{PORT}>\n{content}\n"
52 |
53 | self.t1.insert(END, chat_record)
54 | self.t1["state"] = "disabled"
55 | self.t2.delete("1.0", "end")
56 |
--------------------------------------------------------------------------------
/src/ai/func.py:
--------------------------------------------------------------------------------
1 | """
2 | 一些必要的功能
3 | """
4 | from ..constants import S, threshold
5 | import math
6 |
7 |
8 | # 角色互换
9 | def reverse(r: int):
10 | return 2 if r == 1 else 1
11 |
12 |
13 | # 等于
14 | def equal(a: int, b: int):
15 | b = b or 0.01
16 | if b >= 0:
17 | return a >= b / threshold and a <= b * threshold
18 | else:
19 | return a >= b * threshold and a <= b / threshold
20 |
21 |
22 | # 大于
23 | def greatThan(a: int, b: int):
24 | if b >= 0:
25 | return a >= (b + 0.1) * threshold
26 | else:
27 | return a >= (b + 0.1) / threshold
28 |
29 |
30 | # 大于等于
31 | def greatOrEqualThan(a: int, b: int):
32 | return equal(a, b) or greatThan(a, b)
33 |
34 |
35 | # 小于
36 | def littleThan(a: int, b: int):
37 | if b >= 0:
38 | return a <= (b - 0.1) / threshold
39 | else:
40 | return a <= (b - 0.1) * threshold
41 |
42 |
43 | # 小于等于
44 | def littleOrEqualThan(a: int, b: int):
45 | return equal(a, b) or littleThan(a, b)
46 |
47 |
48 | # “四舍五入”
49 | def round(score: int):
50 | neg = -1 if score < 0 else 1
51 | abs = math.abs(score)
52 | if abs <= S["ONE"] / 2:
53 | return 0
54 | if abs <= S["TWO"] / 2 and abs > S["ONE"] / 2:
55 | return neg * S["ONE"]
56 | if abs <= S["THREE"] / 2 and abs > S["TWO"] / 2:
57 | return neg * S["TWO"]
58 | if abs <= S["THREE"] * 1.5 and abs > S["THREE"] / 2:
59 | return neg * S["THREE"]
60 | if abs <= S["FOUR"] / 2 and abs > S["THREE"] * 1.5:
61 | return neg * S["THREE"] * 2
62 | if abs <= S["FIVE"] / 2 and abs > S["FOUR"] / 2:
63 | return neg * S["FOUR"]
64 | return neg * S["FIVE"]
65 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 |
五子棋小游戏-tkinter版
2 |
3 | > 作为软件概论大作业的仓库...
4 |
5 | # 一、实现内容
6 |
7 | - [x] 图形界面
8 | - [x] 局域网联机
9 | - [x] 人机对战
10 | - [x] 悔棋
11 | - [x] 先后手
12 | - [x] 重新开始
13 | - [x] 导出/导入棋盘
14 |
15 | # 二、工作量
16 |
17 | /(ㄒoㄒ)/~~ 左右互博和局域网联机做了我快一个星期, 一开始用的 pygame, 感觉按钮啊提示框啥的都要自己实现, 有点儿麻烦, 所以改用 tkinter了, 没想到这个也挺麻烦的, 网上的教程也很少
18 |
19 | # 三、结果
20 |
21 | 1. 首页
22 |
23 | 
24 |
25 | 2. 本地开局
26 |
27 | 
28 |
29 | 3. 获胜界面
30 |
31 | 
32 |
33 | 4. 网络联机
34 |
35 | > 需要先运行 server.py
36 |
37 | 
38 |
39 | 询问是否接受对战邀请
40 |
41 | 
42 |
43 | 可边下棋边聊天
44 |
45 | 
46 |
47 | 可拒绝/接受对方悔棋
48 |
49 | 
50 |
51 | 5. 人机模式
52 |
53 | 
54 |
55 | # 五、总结
56 |
57 | 大作业害人不浅 (╯°□°)╯︵ ┻━┻
58 |
59 | # 六、本地开发
60 |
61 | ```bash
62 | pip install virtualenv
63 | ```
64 |
65 | ```bash
66 | virtualenv venv
67 | ```
68 |
69 | ```bash
70 | pip install -r requirements.txt
71 | ```
72 |
73 | # 附录
74 |
75 | 1. [引言](Documents/Game-Tree.md)
76 | 2. [评分函数](Documents/evaluate.md)
77 | 3. [极大极小值搜索](Documents/MiniMax.md)
78 | 4. [alpha-beta剪枝](Documents/Alpha-Beta.md)
79 | 5. [Zobrist散列](Documents/Zobrist.md)
80 | 6. [启发式搜索](Documents/Heuristic.md)
81 | 7. [迭代加深](Documents/deeping.md)
82 |
83 | # 参考资料
84 |
85 | 1. [lihongxun945/gobang](https://github.com/lihongxun945/gobang.git)
86 | 2. [colingogogo/gobang_AI](https://github.com/colingogogo/gobang_AI.git)
87 | 2. [如何设计一个还可以的五子棋AI](https://kimlongli.github.io/2016/12/14/如何设计一个还可以的五子棋AI/)
88 |
89 | > 除了 AI 有参考资料,其他的都太零碎了,面向百度编程,也没什么好引用的...
--------------------------------------------------------------------------------
/docs/evaluate.md:
--------------------------------------------------------------------------------
1 | # 基本原理
2 |
3 | 根据评分表对某个位置进行评分
4 |
5 | 
6 |
7 | 图中白子位置上
8 |
9 | - —:+++AO+++
10 | - |:+++AO+++
11 | - \:+++O+++
12 | - /:+++AO+++
13 |
14 | > A 代表敌方棋子,O 代表我方棋子
15 |
16 | 然后遍历棋盘的每一个位置,找到评分最高的位置落子,缺点是只顾眼前利益,电脑只能预测一步
17 |
18 | # 评分表
19 |
20 | | 特征 | 分数 |
21 | | :--: | :------: |
22 | | 活一 | 10 |
23 | | 活二 | 100 |
24 | | 活三 | 1000 |
25 | | 活四 | 100000 |
26 | | 连五 | 10000000 |
27 | | 眠一 | 1 |
28 | | 眠二 | 10 |
29 | | 眠三 | 100 |
30 | | 眠四 | 10000 |
31 |
32 | # 代码实现
33 |
34 | ```python
35 | # evaluate.py
36 | # (px, py) 位置坐标
37 | def s(b, px, py, role, dir=None):
38 | board = b._board # 当前棋盘
39 | rlen = board.shape[0] # 棋盘行数
40 | clen = board.shape[1] # 棋盘列数
41 |
42 | result = 0 # 最后分数
43 | empty = -1
44 | count = 1 # 一侧的连子数(因为包括当前要走的棋子,所以初始为 1)
45 | secondCount = 0 # 另一侧的连子数
46 | block = 0 # 被封死数
47 |
48 | # 横向 ——
49 | if dir is None or dir == 0:
50 | # ...
51 |
52 | # 落子在这个位置后左右两边的连子数
53 | count += secondCount
54 |
55 | # 将落子在这个位置后横向分数放入 AI 或玩家的 scoreCache 数组对应位置
56 | b.scoreCache[role][0][px, py] = countToScore(count, block, empty)
57 |
58 | result += b.scoreCache[role][0][px, py]
59 |
60 | # 纵向 |
61 | if dir is None or dir == 1:
62 | # ...
63 |
64 | count += secondCount
65 |
66 | b.scoreCache[role][1][px][py] = countToScore(count, block, empty)
67 |
68 | result += b.scoreCache[role][1][px][py]
69 |
70 | # 斜向 \
71 | if dir is None or dir == 2:
72 | # ...
73 |
74 | count += secondCount
75 |
76 | b.scoreCache[role][2][px][py] = countToScore(count, block, empty)
77 |
78 | result += b.scoreCache[role][2][px][py]
79 |
80 | # 斜向 /
81 | if dir is None or dir == 3:
82 | # ...
83 | count += secondCount
84 |
85 | b.scoreCache[role][3][px][py] = countToScore(count, block, empty)
86 |
87 | result += b.scoreCache[role][3][px][py]
88 |
89 | return result
90 | ```
91 |
--------------------------------------------------------------------------------
/docs/Zobrist.md:
--------------------------------------------------------------------------------
1 | # Zobrist 散列算法
2 |
3 | ## 基本过程
4 |
5 | 
6 |
7 | 不同的走法最终达到的局势相同, 则可以重复利用缓存中原来计算过的结果
8 |
9 | 根据 A^B^C = A^C^B 可知, 不同步骤只要进行异步运算的值相同, 则最终值相同, 利用 code 作字典的键值可以快速找到缓存中的数据
10 |
11 | ## 代码实现
12 |
13 | ```python
14 | # zobrist.py
15 | class Zobrist:
16 | def __init__(self, n=15, m=15):
17 | # 初始化 Zobrist 哈希值
18 | self.code = self._rand()
19 |
20 | # 初始化两个 n × m 的空数组
21 | self._com = np.empty([n, m], dtype=int)
22 | self._hum = np.empty([n, m], dtype=int)
23 |
24 | # 数组与棋盘相对应
25 | # 给每一个位置附上一个随机数, 代表不同的状态
26 | for x, y in np.nditer([self._com, self._hum], op_flags=["writeonly"]):
27 | x[...] = self._rand()
28 | y[...] = self._rand()
29 |
30 | def _rand(self, k=31):
31 | return secrets.randbits(k)
32 |
33 | def go(self, x, y, role):
34 | # 判断本次操作是 AI 还是人, 并返回相应位置的随机数
35 | code = self._com[x, y] if role == R["rival"] else self._hum[x, y]
36 | # 当前键值异或位置随机数
37 | self.code = self.code ^ code
38 | ```
39 |
40 | ```python
41 | # minimax.py
42 | # 开启缓存
43 | if C["cache"]:
44 | # 获取缓存中与当前 zobrist 散列键值相同的缓存数据
45 | c = Cache.get(board._zobrist.code)
46 | if c:
47 | if c["deep"] >= deep:
48 | # 如果缓存中的结果搜索深度不比当前小, 则结果完全可用
49 | cacheGet += 1 # 缓存命中
50 | return {
51 | "score": c["score"]["score"],
52 | "steps": steps,
53 | "step": step + c["score"]["step"]
54 | }
55 | else:
56 | if (func.greatOrEqualThan(c["score"]["score"], S["FOUR"]) or func.littleOrEqualThan(c["score"]["score"], -S["FOUR"])):
57 | # 如果缓存的结果中搜索深度比当前小
58 | # 那么任何一方出现双三及以上结果的情况下可用
59 | cacheGet += 1
60 | return {
61 | "score": c["score"]["score"],
62 | "steps": steps,
63 | "step": step + c["score"]["step"]
64 | }
65 | ```
66 |
67 | ```python
68 | # minimax.py
69 | def cache(board, deep, score):
70 | '''
71 | 分数缓存
72 | '''
73 | if not C["cache"]:
74 | # 如果不开启缓存, 直接退出
75 | return
76 | if score.get("abcut"):
77 | # 该节点被标记为剪枝的, 直接退出
78 | return
79 |
80 | # 利用字典进行缓存
81 | Cache[board._zobrist.code] = {
82 | "deep": deep,
83 | "score": {
84 | "score": score["score"],
85 | "steps": score["steps"],
86 | "step": score["step"]
87 | }
88 | }
89 | # ...
90 | ```
91 |
92 | ```python
93 | def AIput(self, p, role):
94 | # ...
95 | self._zobrist.go(p[0], p[1], role) # 每次落子后修改 zobrist.code d
96 | ```
97 |
98 | ## 参考资料
99 |
100 | 1. [维基百科](https://en.wikipedia.org/wiki/Zobrist_hashing)
101 | 2. [Zobrist缓存](https://www.bookstack.cn/read/lihongxun945-gobang-ai/fddd888addab81b9.md)
102 | 3. [Zobrist哈希](https://blog.csdn.net/yzfydit/article/details/52459479)
--------------------------------------------------------------------------------
/src/file.py:
--------------------------------------------------------------------------------
1 | from tkinter import filedialog, messagebox
2 |
3 | from .ai.func import reverse
4 | from .constants import R
5 | from . import boards
6 |
7 | import os
8 | import json
9 |
10 |
11 | # 保存棋盘
12 | def save_chess_manual(steps, first=False):
13 | steps.reverse()
14 | path = os.getcwd() + "/src/chess"
15 |
16 | data = {
17 | "steps": steps,
18 | "first": first,
19 | }
20 |
21 | filename = filedialog.asksaveasfilename(
22 | title="保存棋盘",
23 | filetypes=[("TXT", ".txt")],
24 | defaultextension=".txt",
25 | initialdir=(path),
26 | )
27 |
28 | if filename:
29 | with open(file=filename, mode="w", encoding="utf-8") as file:
30 | file.write(json.dumps(data))
31 |
32 |
33 | # 导入棋谱
34 | def import_chess_manual(board: boards.AI_board | boards.Oneself_board):
35 |
36 | path = os.getcwd() + "/src/chess"
37 |
38 | filename = filedialog.askopenfilename(title="导入棋盘", initialdir=(path))
39 |
40 | if filename:
41 | from .ai import minimax
42 |
43 | with open(file=filename, mode="r", encoding="utf-8") as file:
44 | data = json.loads(file.read())
45 | board.resume() # 导入时先清空一下棋盘
46 |
47 | if data["first"]:
48 | # 如果玩家是先手...
49 | board.WHO = R["oneself"]
50 |
51 | r = board.m
52 | c = board.n
53 |
54 | for i in data["steps"]:
55 | x, y = i
56 |
57 | if x >= c or y >= r:
58 | messagebox.showinfo(title="导入失败", message="棋谱大小不正确")
59 | return
60 |
61 | board.put(i, board.WHO)
62 | board.ZOBRIST.go(i[0], i[1], reverse(board.WHO))
63 | board.updateScore(i)
64 |
65 | if board.__class__.__name__ != "Oneself_board":
66 | if len(data["steps"]) % 2 != 0:
67 | # 如果步数不是偶数, 说明电脑还要再走一步
68 | p = minimax.deepAll(board, board.DEPTH)
69 | board.put(p, board.WHO)
70 |
71 | print("白棋先手, 棋盘导入成功")
72 |
73 | elif board.FIRST == data["first"]:
74 | # 从本地棋盘导入到人机时
75 | # 本地默认黑棋先走(first=False)
76 | # 如果从 AI 先手导入...
77 | r = board.m
78 | c = board.n
79 |
80 | for i in data["steps"]:
81 | x, y = i
82 |
83 | if x >= c or y >= r:
84 | messagebox.showinfo(title="导入失败", message="棋谱大小不正确")
85 | return
86 |
87 | board.put(i, board.WHO)
88 |
89 | if board.__class__.__name__ != "Oneself_board":
90 | if len(data["steps"]) % 2 == 0:
91 | # 如果步数是偶数, 说明电脑还要再走一步
92 | p = minimax.deepAll(board, board.DEPTH)
93 | board.put(p, board.WHO)
94 |
95 | print("黑棋先手, 棋盘导入成功")
96 |
97 | else:
98 | # 从本地黑棋先手到人机的玩家(白棋)先手
99 | messagebox.showinfo(title="导入失败", message="请使用电脑先手重试")
100 |
--------------------------------------------------------------------------------
/docs/MiniMax.md:
--------------------------------------------------------------------------------
1 | # 极大极小搜索
2 |
3 | 将[报数游戏](Game-Tree.md)中,将甲(自己)获胜用 1 代替,将乙(对手)获胜用 -1 代替
4 |
5 | 以根节点(树的最顶端)作为第一个极大层(Max),极小层(Min)和极大层交替出现
6 |
7 | 极小层中选择子节点最小的数,极大层选择子节点最大的数
8 |
9 | > 先手开始选择总是在偶数层(0、2、4...),而第二个选手开始选择总是在奇数层(1、3、5...),对应于先手位于极大层,第二个选手位于极小层,也就意味着,位于极大层的选手需要将自身利益最大化,会选择子节点中较大的那个,而位于极小层的选手会将对手的利益最小化,而选择子节点中最小的那个
10 | >
11 |
12 | 
13 |
14 | > 根据上图可知,抽离博弈树的一部分后,这个子树的根节点是 -1,也就是说轮到乙选择了,局势变成这样的话,甲是不可能获胜的,因为乙肯定会选择对甲不利的 -1 这条路线
15 |
16 | 博弈树的最后结果
17 |
18 | 
19 |
20 | 整个博弈树根节点的值所代表的就是先手在这场博弈中的结果(如果比赛双方都遵循最大最小值原则)
21 |
22 | 那些 -1 都是对甲不利的局面,也就代表了本场比赛的决胜权被对手掌握
23 |
24 | # 井字游戏
25 |
26 | 
27 |
28 | ## 打分函数
29 |
30 | 乙会尽力让评分降低,甲需要让评分更高,因此通过深度优先遍历,选择一条分值高的路径
31 |
32 | > 打分函数根据日常经验来制定
33 | >
34 | > 令 α 为 -∞ 是为了让接下来任意一个比这个大的数可以替换掉 -∞
35 |
36 | 1. 根据给定深度进行遍历(图中仅仅遍历 5 层)
37 |
38 | 首先将父节点的 α、β 值传递到叶子节点
39 |
40 | ```python
41 | negamax(board, candidates, role, i, MIN, MAX)
42 | ```
43 |
44 | 
45 |
46 | 2. 进行回溯,节点处于 Max 层,因此 α 变成 5
47 |
48 | 
49 |
50 | ```python
51 | if v["score"] > best["score"]:
52 | best = v
53 | # best在遍历子节点时选取分数最高f
54 | ```
55 |
56 | 3. 继续遍历兄弟树
57 |
58 | 从子节点中 7、4、5 选一个最小的 4 作为 β 的值
59 |
60 | > 由于该兄弟节点的评分较小,回溯时不会改变 Max 层的 α 值
61 |
62 | 
63 |
64 | 3. 继续回溯至第 1 层,由于是最小层,因此 β 改为 5(目前子节点最小只有 5)
65 |
66 | 
67 |
68 | 5. 继续遍历第一层第一个节点的右子树
69 |
70 | 
71 |
72 | 5. 以此类推,获得的最优选择是评分为 6 的路径
73 |
74 | > 这是全部遍历的情况,需要继续优化,参考[α-β剪枝](Alpha-Beta.md)
75 |
76 | 
77 |
78 | ## 代码实现
79 |
80 | ```python
81 | # minimax.py
82 | def r(board, deep, alpha, beta, role, step, steps):
83 | # ...
84 |
85 | # 获取当前j棋盘分数
86 | _e = board.evaluate(role)
87 |
88 | leaf = {"score": _e, "step": step, "steps": steps}
89 |
90 | # 搜索到底(搜索范围:给定 depth ~ 0)或者已经胜利, 返回叶子节点
91 | if (deep <= 0 or func.greatOrEqualThan(_e, S["FIVE"])
92 | or func.littleOrEqualThan(_e, -S["FIVE"])):
93 | return leaf
94 |
95 | best = {"score": MIN, "step": step, "steps": steps}
96 |
97 | onlyThree = step > 1 if len(board.allSteps) > 10 else step > 3
98 | points = board.gen(role, onlyThree) # 启发式评估, 获取整个棋盘下一步可能获胜的节点
99 |
100 | # 如果没有节点, 即当前节点是树叶, 直接返回
101 | if len(points) == 0:
102 | return leaf
103 |
104 | # 对可能获胜节点进行遍历
105 | for item in points:
106 | board.AIput(item["p"], role) # 在可能获胜的节点上模拟落子
107 | _deep = deep - 1 # 深度减一
108 |
109 | _steps = steps.copy() # 复制一下之前的步骤
110 | _steps.append(item) # 步骤增加当前遍历的节点
111 |
112 | # 进行递归, 总步数加一
113 | v = r(board, _deep, -beta, -alpha, func.reverse(role), step + 1, _steps)
114 |
115 | # 下一步是对手, 对手分数越高, 代表自己分数越低, 所以分数取反
116 | v["score"] = - v["score"]
117 |
118 | board.AIremove(item["p"]) # 在棋盘上删除这个子(恢复原来的棋盘)
119 |
120 | if v["score"] > best["score"]:
121 | best = v
122 |
123 | # ...
124 |
125 | return best
126 | ```
127 |
128 | ```python
129 | # minimax.py
130 | def negmax(..., alpha, beta, ...):
131 | # 当前处于极大层(根节点), 对 candidates 里的落子点进行遍历
132 | # 找到最优解(alpha最大的)
133 | for item in candidates:
134 | # 在棋盘上落子
135 | board.AIput(item["p"], role)
136 | # 注意, alpha/beta 交换了, 因为每一层的 alpha 或 beta 只有一个会变
137 | # 但是递归时需要传那个不变的进去
138 | v = r(board, deep - 1, -beta, -alpha, func.reverse(role), 1, [item])
139 | v["score"] = -1 * v["score"]
140 | alpha = max(alpha, v["score"])
141 | # 从棋盘上移除这个子
142 | board.AIremove(item["p"])
143 | item["v"] = v
144 | return alpha
145 | ```
146 |
--------------------------------------------------------------------------------
/docs/Heuristic.md:
--------------------------------------------------------------------------------
1 | # 启发式评估
2 |
3 | 对整个棋盘空位进行评分, 判断是否能够成五、活四等等
4 | 优先对这些可能会获胜的点进行递归, 能够提高搜索速度/剪枝效率
5 |
6 | ```python
7 | def gen(self, role, onlyThrees):
8 | if len(self.allSteps) == 0:
9 | return [7, 7]
10 |
11 | fives = [] # 连五
12 | com_fours = [] # AI 活四
13 | hum_fours = [] # 玩家活四
14 | com_blocked_fours = [] # AI 眠四
15 | hum_blocked_fours = [] # 玩家眠四
16 | com_double_threes = [] # AI 双三
17 | hum_double_threes = [] # 玩家双三
18 | com_threes = [] # AI 活三
19 | hum_threes = [] # 玩家活三
20 | com_twos = [] # AI 活二
21 | hum_twos = [] # 玩家活二
22 | neighbors = [] # 附近点
23 |
24 | board = self._board
25 |
26 | for i, item in enumerate(board):
27 | for j, item_ in enumerate(item):
28 | if item_ == R["empty"]:
29 | if len(self.allSteps) < 6:
30 | if not self.hasNeighbor(i, j, 1, 1):
31 | # 以 [i, j] 为中心的边长为 3 格的方形范围内
32 | # 不存在棋子, 不用考虑这个点了
33 | continue
34 | elif not self.hasNeighbor(i, j, 2, 2):
35 | continue
36 |
37 | scoreHum = self.humScore[i, j]
38 | scoreCom = self.comScore[i, j]
39 | # 比较 (i,j) 位置 AI 和人谁的评分更高
40 | maxScore = max(scoreCom, scoreHum)
41 |
42 | if onlyThrees and maxScore < S["THREE"]:
43 | continue
44 |
45 | p = {"p": [i, j], "score": maxScore}
46 |
47 | if scoreCom >= S["FIVE"] or scoreHum >= S["FIVE"]:
48 | # 先看 AI 能不能“连五”, 再看玩家能不能“连五”
49 | fives.append(p)
50 | elif scoreCom >= S["FOUR"]:
51 | # AI 有没有活四
52 | com_fours.append(p)
53 | elif scoreHum >= S["FOUR"]:
54 | # 玩家有没有活四
55 | hum_fours.append(p)
56 | elif scoreCom >= S["BLOCKED_FOUR"]:
57 | # AI 有没有眠四
58 | com_blocked_fours.append(p)
59 | elif scoreHum >= S["BLOCKED_FOUR"]:
60 | # 玩家有没有眠四
61 | hum_blocked_fours.append(p)
62 | elif scoreCom >= 2 * S["THREE"]:
63 | # AI 有没有双三
64 | com_double_threes.append(p)
65 | elif scoreHum >= 2 * S["THREE"]:
66 | # 玩家有没有双三
67 | hum_double_threes.append(p)
68 | elif scoreCom >= S["THREE"]:
69 | # AI 有没有活三
70 | com_threes.append(p)
71 | elif scoreHum >= S["THREE"]:
72 | # 玩家有没有活三
73 | hum_threes.append(p)
74 | elif scoreCom >= S["TWO"]:
75 | # AI 有没有活二
76 | com_twos.append(p)
77 | elif scoreHum >= S["TWO"]:
78 | # 玩家有没有活二
79 | hum_twos.append(p)
80 | else:
81 | # 什么都没有的就落子在附近
82 | neighbors.append(p)
83 |
84 | # 成五
85 | if len(fives):
86 | return fives
87 |
88 | # 活四
89 | if role == R["rival"] and len(com_fours):
90 | return com_fours
91 | if role == R["oneself"] and len(hum_fours):
92 | return hum_fours
93 |
94 | # AI 无冲四玩家有活四
95 | if role == R["rival"] and len(hum_fours) and len(com_blocked_fours) == 0:
96 | return hum_fours
97 | # 玩家不能冲四但 AI 有活四
98 | if role == R["oneself"] and len(com_fours) and len(hum_blocked_fours) == 0:
99 | return com_fours
100 |
101 | # 冲四/活四
102 | if role == R["rival"]:
103 | # 将 AI 活四排在前面
104 | fours = com_fours + hum_fours
105 | else:
106 | # 将玩家活四排在前面
107 | fours = hum_fours + com_fours
108 |
109 | if role == R["rival"]:
110 | blockedfours = com_blocked_fours + hum_blocked_fours
111 | else:
112 | blockedfours = hum_blocked_fours + com_blocked_fours
113 |
114 | if len(fours):
115 | return fours + blockedfours
116 |
117 | # 双三/活三/眠三等情况
118 | result = []
119 | if role == R["rival"]:
120 | result = (com_double_threes + hum_double_threes +
121 | com_blocked_fours + result + hum_blocked_fours +
122 | com_threes + hum_threes)
123 | if role == R["oneself"]:
124 | result = (hum_double_threes + com_double_threes +
125 | hum_blocked_fours + com_blocked_fours + hum_threes +
126 | com_threes)
127 |
128 | if len(com_double_threes) or len(hum_double_threes):
129 | return result
130 |
131 | # 如果只考虑双三的话...
132 | # 可以直接退出了
133 | if onlyThrees:
134 | return result
135 |
136 | # 有活二等情况
137 | if role == R["rival"]:
138 | # AI 活二排前面
139 | twos = com_twos + hum_twos
140 | else:
141 | twos = hum_twos + com_twos
142 |
143 | # i
144 | twos.sort(key=lambda x: x["score"], reverse=True)
145 |
146 | # 如果没有活二就找附近点...
147 | result.extend(twos if len(twos) else neighbors)
148 |
149 | # 分数低的不用全部计算了
150 | # 即 gen 返回的节点数不能超过给定值 C["countLimit"]
151 | if len(result) > C["countLimit"]:
152 | return result[0:C["countLimit"]]
153 |
154 | return result
155 | ```
156 |
157 |
--------------------------------------------------------------------------------
/src/page.py:
--------------------------------------------------------------------------------
1 | from re import compile
2 |
3 | from tkinter import Tk, PhotoImage, IntVar, StringVar, messagebox
4 | from tkinter.constants import TOP
5 | import tkinter.ttk as ttk
6 |
7 | from .utils.get_ip import getIPv4
8 | from .utils.page_style import windowStyle
9 |
10 | from .boards import Oneself_board, AI_board
11 | from .client import Client
12 |
13 |
14 | # 选择棋盘大小
15 | def select(board, depth=None, role=None):
16 | selected = Tk()
17 | windowStyle(selected, "选择棋盘")
18 | list = tuple([i for i in range(9, 20)])
19 |
20 | ttk.Label(selected, text="行", background="#e6e6e6").grid(pady=5)
21 | r = IntVar()
22 | row = ttk.Combobox(selected, textvariable=r)
23 | row.state(["readonly"])
24 | row["values"] = list
25 | row.set(9)
26 | row.grid(row=1, column=0, padx=10, pady=20)
27 |
28 | ttk.Label(selected, text="列", background="#e6e6e6").grid(
29 | row=0,
30 | column=1,
31 | pady=5,
32 | )
33 |
34 | c = IntVar()
35 | col = ttk.Combobox(selected, textvariable=c)
36 | col.state(["readonly"])
37 | col["values"] = list
38 | col.set(9)
39 | col.grid(row=1, column=1, padx=10, pady=20)
40 |
41 | def start():
42 | selected.destroy()
43 | # 新建一个棋盘对象
44 | if depth and role is not None:
45 | b = board(r.get(), c.get(), 5, depth, role)
46 | else:
47 | b = board(r.get(), c.get(), 5)
48 | b.start(HOME)
49 |
50 | button = ttk.Button(selected, text="开始游戏", command=start)
51 | button.grid(row=2, column=0, columnspan=2, pady=20)
52 |
53 | def quit():
54 | selected.destroy()
55 | HOME()
56 |
57 | # 监听窗口关闭事件
58 | selected.protocol("WM_DELETE_WINDOW", quit)
59 | selected.mainloop()
60 |
61 |
62 | # 主页
63 | def HOME():
64 | home = Tk()
65 | windowStyle(home)
66 |
67 | p1 = PhotoImage(file="src/assets/ai.png").subsample(3, 3)
68 | btn1 = ttk.Button(home, text="人机对战", width=10, image=p1, compound=TOP)
69 | btn1["command"] = lambda: ai(home)
70 | btn1.grid(padx=10, pady=10)
71 |
72 | p2 = PhotoImage(file="src/assets/net.png").subsample(3, 3)
73 | btn2 = ttk.Button(home, text="联网对战", width=10, image=p2, compound=TOP)
74 | btn2["command"] = lambda: play_online(home)
75 | btn2.grid(row=0, column=1, padx=10, pady=10)
76 |
77 | p3 = PhotoImage(file="src/assets/go.png").subsample(3, 3)
78 | btn3 = ttk.Button(home, text="左右互博", width=10, image=p3, compound=TOP)
79 | btn3.grid(row=0, column=2, padx=10, pady=10)
80 | btn3["command"] = lambda: amuse_oneself(home)
81 |
82 | # 启动窗口
83 | home.mainloop()
84 |
85 |
86 | # 人机对战
87 | def ai(h):
88 | h.destroy()
89 | select_mode()
90 |
91 |
92 | # 返回主页
93 | def quit(root):
94 | root.destroy()
95 | HOME()
96 |
97 |
98 | # 选择难度
99 | def select_mode():
100 | root = Tk()
101 | windowStyle(root, "模式")
102 |
103 | p1 = PhotoImage(file="src/assets/easy.png").subsample(3, 3)
104 | btn1 = ttk.Button(root, text="简单", image=p1, compound=TOP)
105 | btn1["command"] = lambda: who_first(root, 4)
106 | btn1.grid(padx=10, pady=10)
107 |
108 | p2 = PhotoImage(file="src/assets/medium.png").subsample(3, 3)
109 | btn2 = ttk.Button(root, text="中等", image=p2, compound=TOP)
110 | btn2["command"] = lambda: who_first(root, 6)
111 | btn2.grid(row=0, column=1, padx=10, pady=10)
112 |
113 | p3 = PhotoImage(file="src/assets/advance.png").subsample(3, 3)
114 | btn3 = ttk.Button(root, text="困难", image=p3, compound=TOP)
115 | btn3.grid(row=0, column=2, padx=10, pady=10)
116 | btn3["command"] = lambda: who_first(root, 8)
117 |
118 | root.protocol("WM_DELETE_WINDOW", lambda: quit(root))
119 | root.mainloop()
120 |
121 |
122 | # 先后手选择
123 | def who_first(w, depth):
124 | w.destroy()
125 | root = Tk()
126 | windowStyle(root, "谁先开局")
127 |
128 | p1 = PhotoImage(file="src/assets/com.png").subsample(3, 3)
129 | btn1 = ttk.Button(root, text="电脑", width=10, image=p1, compound=TOP)
130 | btn1["command"] = lambda: ai_start(root, depth, False)
131 | btn1.grid(padx=10, pady=10)
132 |
133 | p2 = PhotoImage(file="src/assets/player.png").subsample(3, 3)
134 | btn2 = ttk.Button(root, text="玩家", width=10, image=p2, compound=TOP)
135 | btn2["command"] = lambda: ai_start(root, depth, True)
136 | btn2.grid(row=0, column=1, padx=10, pady=10)
137 |
138 | root.protocol("WM_DELETE_WINDOW", lambda: quit(root))
139 | root.mainloop()
140 |
141 |
142 | # 进入游戏
143 | def ai_start(w, depth, role):
144 | w.destroy()
145 | select(AI_board, depth, role)
146 |
147 |
148 | # 网络联机
149 | def play_online(h):
150 | h.destroy()
151 | input_ip = Tk()
152 | windowStyle(input_ip, "服务器地址")
153 |
154 | ttk.Label(input_ip, text="服务器IP地址", background="#e6e6e6").grid(
155 | pady=5,
156 | padx=10,
157 | )
158 |
159 | server_ip = StringVar()
160 | ip = ttk.Entry(input_ip, textvariable=server_ip, width=30)
161 | ip.insert(0, getIPv4()) # 插入默认值
162 | ip.grid(row=1, padx=10)
163 |
164 | def connect():
165 | r = r"25[0-5]|2[0-4]\d|[0-1]\d{2}|[1-9]?\d"
166 | RegEx = compile(f"^({r}).({r}).({r}).({r})$")
167 | ip_ = ip.get()
168 |
169 | if RegEx.search(ip_):
170 | # 开启客户端
171 | input_ip.destroy()
172 | client = Client(ip_, 50007)
173 | client.invite_window(HOME)
174 | else:
175 | messagebox.showinfo(title="(╯°□°)╯", message="输个对的IP啊")
176 |
177 | button = ttk.Button(input_ip, text="连接", command=connect)
178 | button.grid(row=2, pady=20)
179 |
180 | def quit():
181 | input_ip.destroy()
182 | HOME()
183 |
184 | # 监听窗口关闭事件
185 | input_ip.protocol("WM_DELETE_WINDOW", quit)
186 | input_ip.mainloop()
187 |
188 |
189 | # 普通模式, 左右手互搏
190 | def amuse_oneself(h):
191 | h.destroy()
192 | select(Oneself_board)
193 |
--------------------------------------------------------------------------------
/server.py:
--------------------------------------------------------------------------------
1 | from socket import socket, AF_INET, SOCK_STREAM
2 | from threading import Thread
3 | from time import strftime, localtime
4 | from src.utils.json_byte import json_to_byte, byte_to_json
5 | from src.utils.get_ip import getIPv4
6 |
7 |
8 | # 服务器
9 | class server:
10 | def __init__(self, HOST: str, PORT: int) -> None:
11 | self.HOST = HOST # 服务器 IP
12 | self.PORT = PORT # 服务器端口
13 | self.online: list[socket] = [] # 连接玩家的套接字对象
14 | self.IP_PORT = [] # 与套接字对象对应的 ip 和端口列表
15 | self.contact = {} # 玩家对战关联表
16 |
17 | # 开启服务器
18 | def start(self):
19 | s = socket(AF_INET, SOCK_STREAM)
20 | # 绑定服务器使用的端口和 IP 地址
21 | s.bind((self.HOST, self.PORT))
22 | # 服务器监听
23 | s.listen()
24 |
25 | print(f"服务器IP: {self.HOST}, 端口号: {self.PORT}")
26 |
27 | while True:
28 | conn, addr = s.accept()
29 | self.online.append(conn) # 保存已建立连接 conn 对象
30 | self.IP_PORT.append(conn.getpeername()) # 保存对应的 IP 和端口
31 |
32 | print(f"来自 {addr[0]}:{addr[1]} 的连接")
33 |
34 | self.sendOnline() # 只要有人连接, 就发送在线人员信息
35 |
36 | # 开启新线程读取消息
37 | t = Thread(target=self.receive, args=(conn, addr))
38 | t.start()
39 |
40 | # 根据 IP 和 Port 利用对战关联表找到对手的套接字对象
41 | def search_rival_conn(self, IP: str, Port: int):
42 | try:
43 | f = f"{IP}:{Port}"
44 | t = self.contact[f].split(":")
45 | index = self.IP_PORT.index((t[0], int(t[1])))
46 | return self.online[index]
47 | except Exception:
48 | print("找不到目标地址")
49 |
50 | # 根据 IP 和 Port 寻找对应套接字对象
51 | def search_conn(self, IP: str, Port: int):
52 | index = self.IP_PORT.index((IP, Port))
53 | return self.online[index]
54 |
55 | # 接收客户端消息
56 | def receive(self, conn: socket, addr):
57 | ip_1, port_1 = addr
58 |
59 | while True:
60 | try:
61 | res = conn.recv(1024)
62 | # 如果读入数据失败, 说明远程主机主动关闭连接
63 | if not res:
64 | print(f"客户端 {ip_1}:{port_1} 已断开连接...")
65 | # 删掉对应的信息
66 | self.online.remove(conn)
67 | self.IP_PORT.remove(conn.getpeername())
68 | # 关闭发送客户端
69 | conn.close()
70 | # 重新发送在线人员列表
71 | self.sendOnline()
72 | break
73 | else:
74 | res_ = byte_to_json(res)
75 |
76 | # 将收到的数据写入日志文件
77 | self.write(ip=ip_1, port=port_1, res=res_)
78 |
79 | if "invite_OK" in res_:
80 | ip_2 = res_["info"]["IP"]
81 | port_2 = res_["info"]["Port"]
82 |
83 | c = self.search_conn(ip_2, port_2)
84 |
85 | if res_["invite_OK"]:
86 | print(f"{ip_1}:{port_1} 接受 {ip_2}:{port_2} 对战")
87 |
88 | # 建立对战关联表
89 | vs_dict = {
90 | f"{ip_1}:{port_1}": f"{ip_2}:{port_2}",
91 | f"{ip_2}:{port_2}": f"{ip_1}:{port_1}",
92 | }
93 |
94 | self.contact.update(vs_dict)
95 | s = json_to_byte(
96 | {
97 | "invite_OK": True,
98 | "info": {
99 | "IP": ip_1,
100 | "Port": port_1,
101 | "role": res_["info"]["role"],
102 | },
103 | }
104 | )
105 | else:
106 | print(f"{ip_1}:{port_1} 拒绝 {ip_2}:{port_2} 对战")
107 | s = json_to_byte(
108 | {
109 | "invite_OK": False,
110 | "info": {
111 | "IP": ip_1,
112 | "Port": port_1,
113 | },
114 | }
115 | )
116 | c.send(s)
117 | elif "invite" in res_:
118 | ip_2 = res_["invite"]["IP"]
119 | port_2 = res_["invite"]["Port"]
120 |
121 | print(f"{ip_1}:{port_1} 邀请 {ip_2}:{port_2} 对战")
122 |
123 | c = self.search_conn(ip_2, port_2)
124 | s = json_to_byte(
125 | {
126 | "invite": {
127 | "IP": ip_1,
128 | "Port": port_1,
129 | }
130 | }
131 | )
132 | c.send(s)
133 | else:
134 | c = self.search_rival_conn(ip_1, port_1)
135 | c.send(res)
136 |
137 | if "quit" in res_:
138 | # 有用户退出了就删掉对战关联表对应内容
139 | self.contact.pop(f"{ip_1}:{port_1}")
140 |
141 | except ConnectionResetError:
142 | ip, port = conn.getpeername()
143 | print(f"客户端 {ip}:{port} 强制关闭了连接...")
144 |
145 | # 清除远程主机信息
146 | self.online.remove(conn)
147 | self.IP_PORT.remove(conn.getpeername())
148 |
149 | # 重新发送在线人员列表
150 | self.sendOnline()
151 | break
152 |
153 | # 服务器收到的信息写入 txt 文件
154 | def write(self, **kwarg):
155 | # 获取当前时间
156 | t = strftime("%Y-%m-%d %H:%M:%S", localtime())
157 | ip = kwarg["ip"]
158 | port = kwarg["port"]
159 | res = kwarg["res"]
160 | chatRecord = f"[{t}] # {ip}:{port}>\n{res}\n"
161 | # 在文末追加写入内容
162 | f = open("log.txt", "a", encoding="utf-8")
163 | f.write(chatRecord)
164 |
165 | # 向客户端发送在线人员信息
166 | def sendOnline(self):
167 | onlinePeople = json_to_byte({"online": self.IP_PORT})
168 |
169 | for conn in self.online:
170 | conn.send(onlinePeople)
171 |
172 |
173 | if __name__ == "__main__":
174 | s = server(getIPv4(), 50007)
175 | s.start()
176 |
--------------------------------------------------------------------------------
/src/board.py:
--------------------------------------------------------------------------------
1 | from tkinter import Canvas, messagebox, Tk, ttk
2 | from .constants import R, r, tr, NO
3 | import numpy as np
4 | import string
5 |
6 |
7 | # 棋盘类
8 | class Board:
9 | def __init__(self, m: int, n: int, num: int):
10 | self.m = m # m 行
11 | self.n = n # n 列
12 | self.NUM = num # 多少连子获胜
13 | self.ALLSTEPS: list[list[int]] = [] # 下棋步骤
14 | self.BG = "white" # 棋盘颜色
15 | self.GAP = 30 # 棋盘格子边长
16 | self.WIDTH = self.GAP * (n - 1) # 棋盘宽度
17 | self.HEIGHT = self.GAP * (m - 1) # 棋盘高度
18 | self.MARGIN = 30 # 棋盘外边距
19 | self.IDs = [] # 各棋子 ID
20 | self.RADIUS = 10 # 棋子半径
21 | self.STEPTAILS: list[tuple[list[int], int]] = [] # 悔棋步骤
22 | self.BOARD = np.zeros([m, n], dtype=int) # 棋盘数组
23 | self.ISFINISH: bool = False # 本局游戏是否结束
24 | self.WHO: int = R["rival"] # 默认黑棋先手
25 | self.CANVAS: Canvas | None = None
26 | self.WHOID: ttk.Label | None = None
27 |
28 | # 鼠标位置转成棋盘坐标
29 | def find_pos(self, x: int, y: int):
30 | """
31 | x 鼠标行坐标
32 | y 鼠标列坐标
33 | """
34 | gap = self.GAP
35 | half_gap = gap / 2
36 | x -= self.MARGIN
37 | y -= self.MARGIN
38 | i = x // gap + 1 if x % gap > half_gap else x // gap
39 | j = y // gap + 1 if y % gap > half_gap else y // gap
40 |
41 | # 只返回棋盘数组相符合的坐标
42 | if i >= 0 and i < self.m and j >= 0 and j < self.n:
43 | return i, j
44 | else:
45 | return None, None
46 |
47 | # 绘制棋盘
48 | def drawBoard(self, window: Tk):
49 | margin = self.MARGIN
50 | gap = self.GAP
51 | width = self.WIDTH + margin * 2
52 | height = self.HEIGHT + margin * 2
53 |
54 | self.CANVAS = Canvas(
55 | window,
56 | bg=self.BG,
57 | width=width,
58 | height=height,
59 | )
60 |
61 | self.CANVAS.grid(rowspan=6)
62 | self.CANVAS.config(highlightthickness=0)
63 |
64 | # 绘制格子
65 | for i in range(self.n):
66 | # 棋盘上 1 2 3 4 ...
67 | self.CANVAS.create_text(
68 | gap * i + margin,
69 | margin - 20,
70 | font=("Consolas", 10),
71 | text=NO[i],
72 | )
73 |
74 | col = (
75 | gap * i + margin,
76 | margin,
77 | gap * i + margin,
78 | self.HEIGHT + margin,
79 | )
80 |
81 | # 竖线
82 | self.CANVAS.create_line(col)
83 |
84 | for i in range(self.m):
85 | # 棋盘上 A B C D ...
86 | self.CANVAS.create_text(
87 | margin - 20,
88 | gap * i + margin,
89 | font=("Consolas", 10),
90 | text=string.ascii_uppercase[i],
91 | )
92 |
93 | row = (
94 | margin,
95 | gap * i + margin,
96 | self.WIDTH + margin,
97 | gap * i + margin,
98 | )
99 |
100 | # 横线
101 | self.CANVAS.create_line(row)
102 |
103 | # 棋盘中心锚点
104 | cx = (self.m // 2) * gap + margin
105 | cy = (self.n // 2) * gap + margin
106 | center = (cx - 3, cy - 3, cx + 3, cy + 3)
107 | self.CANVAS.create_oval(center, fill="black")
108 |
109 | # 落子
110 | def put(self, p: list[int], role: int, clear=True):
111 | """
112 | p: 落子坐标
113 | role: 落子方
114 | clear 是否清空悔棋步骤
115 | """
116 |
117 | if self.BOARD[p[0], p[1]] == R["empty"]:
118 | # 只有棋盘上该位置为空才能落子
119 | gap = self.GAP
120 | margin = self.MARGIN
121 | radius = self.RADIUS
122 | x = p[0] * gap + margin
123 | y = p[1] * gap + margin
124 | coords = (x - radius, y - radius, x + radius, y + radius) # 棋子坐标
125 |
126 | id = self.CANVAS.create_oval(coords, fill=r[role]) # 根据角色选择颜色
127 | self.IDs.append(id) # 保存节点 ID
128 | self.ALLSTEPS.append(p) # 保存落子步骤
129 | self.BOARD[p[0], p[1]] = role # 落子
130 |
131 | if clear:
132 | self.STEPTAILS.clear() # 下棋后就不能悔棋了
133 | winList = self.succession([p[0], p[1]], role)
134 |
135 | if winList:
136 | self.afterWin(winList)
137 | self.isfinish = True # 获胜了不能再继续落子了
138 | self.WHOID["text"] = f"{tr[role]}方获胜"
139 | self.WHOID["foreground"] = "red"
140 | return
141 |
142 | self.reverse() # 下一次棋翻转一次角色
143 | self.WHOID["text"] = f"轮到{tr[self.WHO]}方执棋"
144 | self.draw()
145 |
146 | # 判断位置 p 是否获胜
147 | def succession(self, p: list[int], role: int):
148 | """
149 | p: 元素坐标, [i, j]
150 | role: AI or 玩家
151 | winList: 获胜坐标
152 | """
153 | # 四个方向(水平, 垂直, 左上到右下, 右上到左下)
154 | dir = [(0, 1), (1, 0), (1, 1), (1, -1)]
155 | # 棋子坐标
156 | x, y = p
157 |
158 | board = self.BOARD
159 | rlen = board.shape[0] # 棋盘行数
160 | clen = board.shape[1] # 棋盘列数
161 |
162 | for item in dir:
163 | # 连子位置
164 | winList = [[x, y]]
165 | for i in range(1, self.NUM):
166 | x_ = x + i * item[0]
167 | y_ = y + i * item[1]
168 |
169 | if (
170 | x_ < rlen
171 | and y_ < clen
172 | and y_ >= 0
173 | and board[x_, y_] == role # noqa E501
174 | ):
175 | winList.append([x_, y_])
176 | else:
177 | # 不在范围内的直接退出循环
178 | break
179 |
180 | for i in range(1, self.NUM):
181 | x_ = x - i * item[0]
182 | y_ = y - i * item[1]
183 |
184 | if x_ >= 0 and y_ >= 0 and y_ < clen and board[x_, y_] == role:
185 | winList.append([x_, y_])
186 | else:
187 | break
188 |
189 | if len(winList) >= self.NUM:
190 | print(f"获胜坐标: {winList}")
191 | return winList
192 |
193 | # 获胜后改变连子边框颜色
194 | def afterWin(self, winList: list[list[int]]):
195 | for i in winList:
196 | # allSteps 里坐标与 ids 元素一一对应
197 | index = self.ALLSTEPS.index(i)
198 | id = self.IDs[index]
199 | self.CANVAS.itemconfigure(id, outline="red", width=2)
200 |
201 | # 翻转角色
202 | def reverse(self):
203 | self.WHO = R["oneself"] if self.WHO == R["rival"] else R["rival"]
204 |
205 | # 重新开局
206 | def resume(self, first=True):
207 | for i in self.IDs:
208 | self.CANVAS.delete(i)
209 |
210 | self.ALLSTEPS.clear() # 清空下棋步骤
211 | self.IDs.clear() # 清空棋子 ID
212 | self.STEPTAILS.clear() # 清空悔棋步骤
213 | self.ISFINISH = False # 游戏未结束
214 | self.BOARD = np.zeros([self.m, self.n], dtype=int) # 棋盘置空
215 | self.WHO = R["rival"] # 恢复开局方
216 | self.WHOID["text"] = f"轮到{tr[self.WHO]}方执棋"
217 | self.WHOID["foreground"] = "black"
218 |
219 | if not first:
220 | # 电脑先手, 重新开局还是下正中间
221 | self.put([7, 7], self.WHO)
222 |
223 | # 平局判断
224 | def draw(self):
225 | if (
226 | len(np.extract(self.BOARD == 0, self.BOARD)) == 0
227 | and not self.ISFINISH # noqa E501
228 | ):
229 | self.WHOID["text"] = "平局"
230 | self.WHOID["foreground"] = "red"
231 | messagebox.showinfo(title="啊", message="平局了")
232 |
--------------------------------------------------------------------------------
/src/ai/minimax.py:
--------------------------------------------------------------------------------
1 | """
2 | 极大极小值搜索
3 | """
4 |
5 |
6 | from time import time
7 | from ..constants import S, C, R
8 | from .. import boards
9 | from . import func
10 |
11 | # 正负无穷
12 | MAX = S["FIVE"] * 10
13 | MIN = -MAX
14 |
15 | count = 0 # 每次思考的节点数
16 | ABcut = 0 # alpha-beta 剪枝次数
17 | Cache = {} # zobrist 缓存
18 | cacheCount = 0 # zobrist 缓存节点数
19 | cacheGet = 0 # zobrist 缓存命中数量
20 |
21 |
22 | def r(
23 | board: boards.AI_board,
24 | deep: int,
25 | alpha: int,
26 | beta: int,
27 | role: int,
28 | step: int,
29 | steps: list[int],
30 | ) -> dict:
31 | global cacheGet, count, ABcut
32 |
33 | # 开启缓存
34 | if C["cache"]:
35 | # 获取缓存中与 zobrist 散列键值相同的缓存数据
36 | c = Cache.get(board.ZOBRIST.code)
37 | if c:
38 | if c["deep"] >= deep:
39 | # 如果缓存中的结果搜索深度不比当前小, 则结果完全可用
40 | cacheGet += 1
41 | return {
42 | "score": c["score"]["score"],
43 | "steps": steps,
44 | "step": step + c["score"]["step"],
45 | }
46 | else:
47 | if func.greatOrEqualThan(
48 | c["score"]["score"],
49 | S["FOUR"],
50 | ) or func.littleOrEqualThan(
51 | c["score"]["score"],
52 | -S["FOUR"],
53 | ):
54 | # 如果缓存的结果中搜索深度比当前小
55 | # 那么任何一方出现双三及以上结果的情况下可用
56 | cacheGet += 1
57 | return {
58 | "score": c["score"]["score"],
59 | "steps": steps,
60 | "step": step + c["score"]["step"],
61 | }
62 |
63 | # 获取当前棋盘分数
64 | _e = board.evaluate(role)
65 |
66 | leaf = {
67 | "score": _e,
68 | "step": step,
69 | "steps": steps,
70 | }
71 |
72 | count += 1 # 思考节点数加一
73 |
74 | # 搜索到底(搜索范围 self.depth ~ 0)或者已经胜利
75 | # 因为本次直接返回结果并没有下一步棋
76 | if (
77 | deep <= 0
78 | or func.greatOrEqualThan(_e, S["FIVE"])
79 | or func.littleOrEqualThan(_e, -S["FIVE"])
80 | ):
81 | return leaf
82 |
83 | best = {
84 | "score": MIN,
85 | "step": step,
86 | "steps": steps,
87 | }
88 |
89 | onlyThree = step > 1 if len(board.ALLSTEPS) > 10 else step > 3
90 | points = board.gen(role, onlyThree) # 启发式评估, 获取整个棋盘下一步可能获胜的节点
91 |
92 | # 如果没有节点, 即当前节点是树叶, 直接返回
93 | if len(points) == 0:
94 | return leaf
95 |
96 | # 对需要评分的子节点进行循环遍历
97 | for item in points:
98 | board.AIput(item["p"], role) # 在可能获胜的节点上模拟落子
99 | _deep = deep - 1
100 |
101 | _steps = steps.copy() # 复制一下之前的步骤
102 | _steps.append(item) # 步骤增加当前遍历的节点
103 |
104 | # 进行递归, 总步数加一
105 | v = r(
106 | board,
107 | _deep,
108 | -beta,
109 | -alpha,
110 | func.reverse(role),
111 | step + 1,
112 | _steps,
113 | )
114 |
115 | # 下一步是对手, 对手分数越高, 代表自己分数越低, 所以分数取反
116 | v["score"] = -v["score"]
117 |
118 | board.AIremove(item["p"]) # 在棋盘上删除这个子
119 |
120 | # 注意, 这里决定剪枝时使用的值必须比 MAX 小
121 | if v["score"] > best["score"]:
122 | best = v
123 |
124 | # 将 alpha 值与子节点分数做比较, 选出最大的分数给 alpha
125 | alpha = max(best["score"], alpha)
126 |
127 | # alpha-beta 剪枝
128 | # if func.greatOrEqualThan(v["score"], beta):
129 | if func.greatOrEqualThan(alpha, beta):
130 | ABcut += 1 # 剪枝数加一
131 | v["score"] = MAX - 1 # 被剪枝的用极大值来记录, 但是必须比 MAX 小
132 | v["abcut"] = 1 # 剪枝标记
133 | return v
134 |
135 | cache(board, deep, best)
136 |
137 | return best
138 |
139 |
140 | # 分数缓存
141 | def cache(board: boards.AI_board, deep: int, score: dict):
142 | if not C["cache"]:
143 | # 不开启缓存
144 | return
145 | if score.get("abcut"):
146 | # 被剪枝的, 不是所有都有 abcut, 因此要用 get
147 | return
148 |
149 | Cache[board.ZOBRIST.code] = {
150 | "deep": deep,
151 | "score": {
152 | "score": score["score"],
153 | "steps": score["steps"],
154 | "step": score["step"],
155 | },
156 | }
157 |
158 | global cacheCount
159 | cacheCount += 1 # 缓存数加一
160 |
161 |
162 | def negamax(
163 | board: boards.AI_board,
164 | candidates: list[int],
165 | role: int,
166 | deep: int,
167 | alpha: int,
168 | beta: int,
169 | ) -> int:
170 | """
171 | 负极大值搜索
172 | role: 落子方
173 | deep: 搜索深度
174 | alpha
175 | beta
176 | return alpha
177 | """
178 | # 每次搜索时重置
179 | global count, ABcut
180 | count = 0
181 | ABcut = 0
182 | board.CURRENTSTEPS = []
183 |
184 | for item in candidates:
185 | # 在棋盘上落子
186 | board.AIput(item["p"], role)
187 | # 注意, alpha/beta 交换了, 因为每一层的 alpha 或 beta 只有一个会变
188 | # 但是递归时需要传那个不变的进去
189 | v = r(
190 | board,
191 | deep - 1,
192 | -beta,
193 | -alpha,
194 | func.reverse(role),
195 | 1,
196 | [item],
197 | )
198 |
199 | v["score"] = -1 * v["score"]
200 | alpha = max(alpha, v["score"])
201 |
202 | # 从棋盘上移除这个子
203 | board.AIremove(item["p"])
204 | item["v"] = v
205 |
206 | return alpha
207 |
208 |
209 | # 展示运算情
210 | def print_Info(func):
211 | def inner(*arg):
212 | start = time()
213 | p = func(*arg)
214 | end = time()
215 | print("=".center(40, "="))
216 | print(f"本次计算耗时{end-start}s")
217 | print(f"位置分数{p['score']}, 步数{p['step']}")
218 | print(f"搜索节点数{count}, AB剪枝数{ABcut}")
219 | print(f"搜索缓存数{cacheCount}, 命中数{cacheGet}")
220 | return p
221 |
222 | return inner
223 |
224 |
225 | @print_Info
226 | def deeping(
227 | board: boards.AI_board,
228 | candidates: list[int],
229 | role: int,
230 | deep: int,
231 | ) -> list[int]:
232 | """
233 | 迭代加深
234 | Alpha-beta 剪枝能够找到最优解
235 | 比如在第四层找到了一个双三, 但因为评分一致 AI 随机走一条
236 | 导致在第六层才找到双三, 虽然都可能赢, 但多走了几步
237 | 通过迭代加深找到最短路径
238 | role: 落子方
239 | deep: 搜索深度
240 | return 落子坐标
241 | """
242 |
243 | # 每次仅尝试偶数层
244 | for i in range(2, deep, 2):
245 | """
246 | 传入 candidates 进行迭代
247 | 同时修改 candidates 里的 step、score 等值
248 | 一开始是没有 v 的, 只有 p, 经过 negamax 后才有
249 | candidate = [{
250 | "p": [x, y],
251 | "v": {
252 | "score": xxx,
253 | "step": xxx,
254 | "steps": [{
255 | "p": [x, y],
256 | "step": xxx,
257 | "steps": xxx
258 | }, {{
259 | "p": [x, y],
260 | "step": xxx,
261 | "steps": xxx
262 | }}]
263 | }
264 | },{...}]
265 | """
266 | bestScore = negamax(board, candidates, role, i, MIN, MAX)
267 | if func.greatOrEqualThan(bestScore, S["FIVE"]):
268 | # 能赢了就不用再循环了
269 | break
270 |
271 | # 结果重排
272 | def rearrange(d):
273 | r = {
274 | "p": [d["p"][0], d["p"][1]],
275 | "score": d["v"]["score"],
276 | "step": d["v"]["step"],
277 | "steps": d["v"]["steps"],
278 | }
279 |
280 | return r
281 |
282 | candidates = list(map(rearrange, candidates))
283 |
284 | # 过滤出分数大于等于 0 的
285 | c = list(filter(lambda x: x["score"] >= 0, candidates))
286 |
287 | if len(c) == 0:
288 | # 如果分数都不大于 0
289 | # 找一个步骤最长的挣扎一下
290 | candidates.sort(key=lambda x: x["step"], reverse=True)
291 | else:
292 | # 分数大于 0, 先找到分数高的, 分数一样再找步骤少的
293 | candidates.sort(key=lambda x: (x["score"], -x["step"]), reverse=True)
294 |
295 | return candidates[0]
296 |
297 |
298 | def deepAll(board: boards.AI_board, deep: int, role=R["rival"]):
299 | # 获取启发式搜索返回的优先搜搜列表
300 | candidates = board.gen(role, len(board.ALLSTEPS) > 10)
301 | return deeping(board, candidates, role, deep)["p"]
302 |
--------------------------------------------------------------------------------
/src/ai/evaluate.py:
--------------------------------------------------------------------------------
1 | """
2 | 评分函数
3 | """
4 | from ..constants import R, S
5 | from .. import boards
6 |
7 |
8 | def s(
9 | b: boards.AI_board,
10 | px: int,
11 | py: int,
12 | role: int,
13 | dir: int | None = None,
14 | ) -> int:
15 | """
16 | 给棋盘上某个位置打分, 表示如果下这里会得多少分
17 | b: 棋盘对象
18 | px: 评分位置的行坐标
19 | py: 评分位置的列坐标
20 | role: 需要评分的角色
21 | dir: 需要评分的方向
22 | - None:全部方向
23 | - 0: 横向
24 | - 1: 纵向
25 | - 2: 斜向
26 | - 3: 斜向
27 | return: result 评分结果
28 | """
29 |
30 | board = b.BOARD
31 |
32 | # 注: 四子棋的行数不等于列数
33 | rlen = board.shape[0] # 棋盘行数
34 | clen = board.shape[1] # 棋盘列数
35 |
36 | result = 0 # 最后分数
37 | empty = -1
38 | count = 1 # 一侧的连子数(因为包括当前要走的棋子,所以初始为 1)
39 | secondCount = 0 # 另一侧的连子数
40 | block = 0 # 被封死数
41 |
42 | def reset():
43 | nonlocal count, secondCount, block, empty
44 | count = 1
45 | secondCount = 0
46 | block = 0
47 | empty = -1
48 |
49 | # 横向 ——
50 | if dir is None or dir == 0:
51 | j = py + 1
52 | while True:
53 | if j >= clen:
54 | # 该位置在棋盘边缘,如果下这里,一侧会被封死
55 | block += 1
56 | break
57 |
58 | t = board[px, j]
59 | """
60 | 从想要落子的位置一直往右
61 | 如果一直未被封死
62 | 遇到己方棋子则连子数加一
63 | 遇到对方就退出,不再继续
64 | 碰到空格,那就看它下一个位置是不是己方的
65 | 如果是,那就将 empty 置为 count
66 | 表示该空格到欲落子处有 count 距离
67 | 再继续循环,直到下一次遇到空格或对方棋子时退出
68 | 也就是说空格必须被己方的子给包住
69 | 如果下一个位置不是己方,退出循环
70 | """
71 | if t == R["empty"]:
72 | if empty == -1 and j < clen - 1 and board[px, j + 1] == role:
73 | empty = count
74 | else:
75 | break
76 | # 棋子右侧是己方时
77 | elif t == role:
78 | count += 1
79 | # 棋子右侧是对手时
80 | else:
81 | # 右侧被封死
82 | block += 1
83 | break
84 | j += 1
85 |
86 | j = py - 1
87 | while True:
88 | if j < 0:
89 | block += 1
90 | break
91 |
92 | t = board[px, j]
93 | """
94 | 从想要落子处一直向左
95 | 遇到己方棋子, 如果之前右边存在空格, empty 就加一
96 | 因为左边多一个棋子,那么起始点到右边空格距离就要加一
97 | 遇到空格, 如果之前右边遍历时不存在空格(即 empty 还是-1)
98 | 那就看下一个位置是不是己方的
99 | 如果是, 那么空格位就在起始位(empty=0)
100 | 继续循环, 遇到己方棋子就让空格往右走一位(empty+=1)
101 | 遇到对方棋子就退出
102 | 如果下一位不是己方棋子, 直接退出, 并且空格位还是之前右边的(如果有的话)
103 | """
104 | if t == R["empty"]:
105 | if empty == -1 and j > 0 and board[px, j - 1] == role:
106 | empty = 0
107 | else:
108 | break
109 | elif t == role:
110 | secondCount += 1
111 | if empty != -1:
112 | empty += 1
113 | else:
114 | block += 1
115 | break
116 | j -= 1
117 |
118 | # 落子在这个位置后左右两边的连子数
119 | count += secondCount
120 |
121 | # 将落子在这个位置后横向分数放入 AI 或玩家的 scoreCache 数组对应位置
122 | b.SCORECACHE[role][0][px, py] = countToScore(count, block, empty)
123 |
124 | result += b.SCORECACHE[role][0][px, py]
125 |
126 | # 纵向 |
127 | if dir is None or dir == 1:
128 | reset()
129 | i = px + 1
130 |
131 | while True:
132 | if i >= rlen:
133 | block += 1
134 | break
135 |
136 | t = board[i, py]
137 |
138 | if t == R["empty"]:
139 | if empty == -1 and i < rlen - 1 and board[i + 1, py] == role:
140 | empty = count
141 | else:
142 | break
143 | elif t == role:
144 | count += 1
145 | else:
146 | block += 1
147 | break
148 | i += 1
149 |
150 | i = px - 1
151 | while True:
152 | if i < 0:
153 | block += 1
154 | break
155 |
156 | t = board[i, py]
157 |
158 | if t == R["empty"]:
159 | if empty == -1 and i > 0 and board[i - 1, py] == role:
160 | empty = 0
161 | else:
162 | break
163 | elif t == role:
164 | secondCount += 1
165 | if empty != -1:
166 | empty += 1
167 | else:
168 | block += 1
169 | break
170 | i -= 1
171 |
172 | count += secondCount
173 |
174 | b.SCORECACHE[role][1][px][py] = countToScore(count, block, empty)
175 |
176 | result += b.SCORECACHE[role][1][px][py]
177 |
178 | # 斜向 \
179 | if dir is None or dir == 2:
180 | reset()
181 | i = 1
182 | while True:
183 | x = px + i
184 | y = py + i
185 | if x >= rlen or y >= clen:
186 | block += 1
187 | break
188 |
189 | t = board[x, y]
190 | if t == R["empty"]:
191 | if (
192 | empty == -1
193 | and x < rlen - 1
194 | and y < clen - 1
195 | and board[x + 1, y + 1] == role
196 | ):
197 | empty = count
198 | else:
199 | break
200 | elif t == role:
201 | count += 1
202 | else:
203 | block += 1
204 | break
205 | i += 1
206 |
207 | i = 1
208 | while True:
209 | x = px - i
210 | y = py - i
211 | if x < 0 or y < 0:
212 | block += 1
213 | break
214 |
215 | t = board[x, y]
216 | if t == R["empty"]:
217 | if (
218 | empty == -1
219 | and x > 0
220 | and y > 0
221 | and board[x - 1, y - 1] == role # noqa E501
222 | ):
223 | empty = 0
224 | else:
225 | break
226 | elif t == role:
227 | secondCount += 1
228 | if empty != -1:
229 | empty += 1
230 | else:
231 | block += 1
232 | break
233 | i += 1
234 |
235 | count += secondCount
236 |
237 | b.SCORECACHE[role][2][px][py] = countToScore(count, block, empty)
238 |
239 | result += b.SCORECACHE[role][2][px][py]
240 |
241 | # 斜向 /
242 | if dir is None or dir == 3:
243 | reset()
244 | i = 1
245 | while True:
246 | x = px + i
247 | y = py - i
248 |
249 | if y < 0 or x >= rlen:
250 | block += 1
251 | break
252 |
253 | t = board[x, y]
254 | if t == R["empty"]:
255 | if (
256 | empty == -1
257 | and x < rlen - 1
258 | and y > 0
259 | and board[x + 1, y - 1] == role
260 | ):
261 | empty = count
262 | else:
263 | break
264 | elif t == role:
265 | count += 1
266 | else:
267 | block += 1
268 | break
269 | i += 1
270 |
271 | i = 1
272 | while True:
273 | x = px - i
274 | y = py + i
275 | if x < 0 or y >= clen:
276 | block += 1
277 | break
278 | t = board[x, y]
279 | if t == R["empty"]:
280 | if (
281 | empty == -1
282 | and x > 0
283 | and y < clen - 1
284 | and board[x - 1, y + 1] == role
285 | ):
286 | empty = 0
287 | else:
288 | break
289 | elif t == role:
290 | secondCount += 1
291 | if empty != -1:
292 | empty += 1
293 | else:
294 | block += 1
295 | break
296 | i += 1
297 |
298 | count += secondCount
299 |
300 | b.SCORECACHE[role][3][px][py] = countToScore(count, block, empty)
301 |
302 | result += b.SCORECACHE[role][3][px][py]
303 |
304 | return result
305 |
306 |
307 | def countToScore(count: int, block: int, empty: int) -> int:
308 | """
309 | description: 分数计算
310 | pcount: 连子数
311 | block: 被封死数
312 | empty: 空格位置
313 | return 分数
314 | """
315 |
316 | if empty <= 0:
317 | """
318 | empty = -1: 没有空格
319 | empty = 0: 不存在这种情况
320 | """
321 | if count >= 5:
322 | # 出现“连五”
323 | return S["FIVE"]
324 | if block == 0:
325 | # 活棋
326 | match count:
327 | case 1:
328 | return S["ONE"]
329 | case 2:
330 | return S["TWO"]
331 | case 3:
332 | return S["THREE"]
333 | case 4:
334 | return S["FOUR"]
335 | elif block == 1:
336 | # 一侧被堵
337 | match count:
338 | case 1:
339 | return S["BLOCKED_ONE"]
340 | case 2:
341 | return S["BLOCKED_TWO"]
342 | case 3:
343 | return S["BLOCKED_THREE"]
344 | case 4:
345 | return S["BLOCKED_FOUR"]
346 | elif empty == 1 or empty == count - 1:
347 | # 表示空格在第二个或者倒数第二个 ●◻●●● 或 ●●◻●
348 | if count >= 6:
349 | return S["FIVE"]
350 | if block == 0:
351 | match count:
352 | case 2:
353 | return S["TWO"] / 2
354 | case 3:
355 | return S["THREE"]
356 | case 4:
357 | return S["BLOCKED_FOUR"]
358 | case 5:
359 | return S["FOUR"]
360 | elif block == 1:
361 | match count:
362 | case 2:
363 | return S["BLOCKED_TWO"]
364 | case 3:
365 | return S["BLOCKED_THREE"]
366 | case 4:
367 | return S["BLOCKED_FOUR"]
368 | case 5:
369 | return S["BLOCKED_FOUR"]
370 | elif empty == 2 or empty == count - 2:
371 | # 表示空格在第三个或者倒数第二个 ●●◻●●● 或 ●●●◻●●
372 | if count >= 7:
373 | return S["FIVE"]
374 | if block == 0:
375 | match count:
376 | case 3:
377 | return S["THREE"]
378 | case 5:
379 | return S["BLOCKED_FOUR"]
380 | case 6:
381 | return S["FOUR"]
382 | elif block == 1:
383 | match count:
384 | case 3:
385 | return S["BLOCKED_THREE"]
386 | case 4:
387 | return S["BLOCKED_FOUR"]
388 | case 5:
389 | return S["BLOCKED_FOUR"]
390 | case 6:
391 | return S["FOUR"]
392 | elif block == 2:
393 | match count:
394 | case 6:
395 | return S["BLOCKED_FOUR"]
396 | elif empty == 3 or empty == count - 3:
397 | if count >= 8:
398 | return S["FIVE"]
399 | if block == 0:
400 | match count:
401 | case 5:
402 | return S["THREE"]
403 | case 6:
404 | return S["BLOCKED_FOUR"]
405 | case 7:
406 | return S["FOUR"]
407 |
408 | elif block == 1:
409 | match count:
410 | case 6:
411 | return S["BLOCKED_FOUR"]
412 | case 7:
413 | return S["FOUR"]
414 | elif block == 2:
415 | match count:
416 | case 7:
417 | return S["BLOCKED_FOUR"]
418 | elif empty == 4 or empty == count - 4:
419 | if count >= 9:
420 | return S["FIVE"]
421 | if block == 0:
422 | match count:
423 | case 8:
424 | return S["FOUR"]
425 | elif block == 1:
426 | match count:
427 | case 7:
428 | return S["BLOCKED_FOUR"]
429 | case 8:
430 | return S["FOUR"]
431 | elif block == 2:
432 | match count:
433 | case 8:
434 | return S["BLOCKED_FOUR"]
435 | elif empty == 5 or empty == count - 5:
436 | # empty 最多为五
437 | # 表示落一子后空格前可连五(empty=count)
438 | return S["FIVE"]
439 | return 0
440 |
--------------------------------------------------------------------------------
/src/client.py:
--------------------------------------------------------------------------------
1 | import random
2 | from socket import socket, AF_INET, SOCK_STREAM, SHUT_RDWR
3 | from threading import Thread
4 |
5 | import tkinter.ttk as ttk
6 | from tkinter import Scrollbar, Listbox, messagebox, Tk
7 | from tkinter.constants import END, FLAT, N, S, W, E, VERTICAL
8 |
9 | from .boards import Net_board
10 | from .chat import Chat
11 | from .utils.json_byte import json_to_byte, byte_to_json
12 |
13 |
14 | # 随机开局
15 | def randomNum():
16 | # 随机生成包含 200 个 1~20 整数的列表
17 | list_ = [random.randint(1, 20) for _ in range(200)]
18 | # 随机抽取一个, 这个数如果是偶数...
19 | if random.choices(list_, k=1)[0] % 2 == 0:
20 | return 2
21 | else:
22 | return 1
23 |
24 |
25 | # 客户端类
26 | class Client:
27 | def __init__(self, HOST: str, PORT: int, myPort=None):
28 | self.HOST = HOST # 服务器 IP
29 | self.PORT = PORT # 服务器端口
30 | self.onlinePeople = {} # 在线人员列表
31 | self.socket = socket(AF_INET, SOCK_STREAM) # 创建套接字对象
32 |
33 | self.rival_ip = "" # 对手 IP
34 | self.rival_port = 0 # 对手端口
35 | self.role = "" # 当前角色
36 |
37 | if myPort:
38 | self.socket.bind(("", myPort)) # 绑定客户端地址
39 |
40 | # 发送聊天信息
41 | def send_chat(self):
42 | send_data = self.chat.t2.get("0.0", "end")
43 | print(f"将发送的数据: {send_data}")
44 |
45 | # 要求输入不为空才能发送
46 | if send_data:
47 | # 向 ScrolledText 写入数据
48 | self.chat.writeToText(send_data, self.myIP, self.myPort)
49 |
50 | # 向服务器发送
51 | s = json_to_byte(
52 | {
53 | "chat": send_data,
54 | }
55 | )
56 | self.socket.send(s)
57 |
58 | # 发送坐标信息
59 | def sendPosition(self, p):
60 | s = json_to_byte(
61 | {
62 | "board": p,
63 | }
64 | )
65 |
66 | self.socket.send(s)
67 |
68 | # 鼠标左键点击事件
69 | def mouseClick(self, event):
70 | if not self.board.ISFINISH:
71 | x, y = self.board.find_pos(event.x, event.y) # 获取落子坐标
72 | if x is not None and y is not None and self.board.WHO == self.role:
73 | """
74 | 坐标位于棋盘中且当前落子对象(board.who)是自己(role)时
75 | """
76 | self.board.put([x, y], self.board.WHO) # 落子
77 | self.sendPosition([x, y])
78 |
79 | # 邀请窗口
80 | def invite_window(self, HOME):
81 | # 接收服务器数据
82 | def receive():
83 | while True:
84 | try:
85 | res = byte_to_json(self.socket.recv(1024))
86 |
87 | if "online" in res:
88 | self.onlinePeople = res["online"]
89 |
90 | # 展示在线人数
91 | self.onlineList.delete(0, END) # 先清空原来的
92 |
93 | for i in self.onlinePeople:
94 | # 重新展示新的
95 | self.onlineList.insert("end", f"{i[0]}:{i[1]}")
96 |
97 | elif "chat" in res:
98 | ip = self.rival_ip
99 | port = self.rival_port
100 | self.chat.writeToText(res["chat"], ip, port)
101 |
102 | elif "board" in res:
103 | # 获取对方落子坐标
104 | p = res["board"]
105 | self.board.put(p, self.board.WHO)
106 |
107 | elif "invite" in res:
108 | ip = res["invite"]["IP"]
109 | port = res["invite"]["Port"]
110 | accept = messagebox.askyesno(
111 | message=f"是否接受 {ip}:{port} 的对战邀请?",
112 | icon="question",
113 | title="对战邀请",
114 | )
115 |
116 | if accept:
117 | print(f"接受了 {ip}:{port} 的对战邀请")
118 | # 保存对手信息
119 | self.rival_ip = ip
120 | self.rival_port = port
121 |
122 | role = randomNum() # 随机生成先后手
123 |
124 | s = json_to_byte(
125 | {
126 | "invite_OK": True,
127 | "info": {
128 | "IP": ip,
129 | "Port": port,
130 | "role": role,
131 | },
132 | }
133 | )
134 |
135 | self.socket.send(s)
136 |
137 | """
138 | 棋盘默认先手为 1
139 | 并且规定如果发送邀请方产生随机数 1, 则不做先手
140 | """
141 | self.role = 2 if role == 1 else 1
142 | nonlocal invite_panel
143 | invite_panel.destroy()
144 | self.vs_window(HOME)
145 |
146 | else:
147 | print(f"拒绝了 {ip}:{port} 的对战邀请")
148 | s = json_to_byte(
149 | {
150 | "invite_OK": False,
151 | "info": {
152 | "IP": ip,
153 | "Port": port,
154 | },
155 | }
156 | )
157 |
158 | self.socket.send(s)
159 |
160 | elif "invite_OK" in res:
161 | ip = res["info"]["IP"]
162 | port = res["info"]["Port"]
163 |
164 | if res["invite_OK"]:
165 | # 如果对方同意对战
166 | print(f"{ip}:{port} 接受了对战邀请")
167 |
168 | self.rival_ip = ip
169 | self.rival_port = port
170 | self.role = res["info"]["role"]
171 |
172 | # 开启棋盘页面
173 | invite_panel.destroy()
174 | self.vs_window(HOME)
175 |
176 | else:
177 | # 如果被拒绝
178 | print(f"{ip}:{port} 拒绝了对战邀请")
179 | messagebox.showinfo(title="嘤嘤嘤", message="被拒绝了")
180 |
181 | elif "quit" in res:
182 | messagebox.showinfo(title="哼哼哼", message="对方逃掉了")
183 |
184 | # 把棋盘和聊天窗全部关闭
185 | self.frame1.destroy()
186 | self.frame2.destroy()
187 |
188 | btn = ttk.Button(self.net, text="返回主页", command=quit)
189 | btn.grid(pady=300, padx=300)
190 | break
191 |
192 | elif "undo" in res:
193 | accept = messagebox.askyesno(
194 | message="是否同意对方悔棋?",
195 | icon="question",
196 | title="悔棋",
197 | )
198 |
199 | if accept:
200 | s = json_to_byte(
201 | {
202 | "undo_OK": True,
203 | }
204 | )
205 |
206 | self.socket.send(s)
207 | self.board.undo(0)
208 |
209 | else:
210 | s = json_to_byte(
211 | {
212 | "undo_OK": False,
213 | }
214 | )
215 | self.socket.send(s)
216 |
217 | elif "undo_OK" in res:
218 |
219 | if res["undo_OK"]:
220 | # 对方接受悔棋
221 | self.board.undo(1)
222 |
223 | else:
224 | messagebox.showinfo(title="嘤嘤嘤", message="对方不给悔棋")
225 |
226 | elif "resume" in res:
227 | # 收到重开请求
228 | accept = messagebox.askyesno(
229 | message="是否同意对方请求?",
230 | icon="question",
231 | title="重新开局",
232 | )
233 |
234 | if accept:
235 | s = json_to_byte(
236 | {
237 | "resume_OK": True,
238 | }
239 | )
240 |
241 | self.socket.send(s)
242 | self.board.resume()
243 | else:
244 | s = json_to_byte(
245 | {
246 | "resume_OK": False,
247 | }
248 | )
249 |
250 | self.socket.send(s)
251 | elif "resume_OK" in res:
252 | # 收到重开请求反馈
253 | if res["resume_OK"]:
254 | self.board.resume()
255 | else:
256 | messagebox.showinfo(title="嘤嘤嘤", message="对方不想重开")
257 | except ConnectionAbortedError:
258 | # socket 被 recv 阻塞过程中...
259 | # 如果直接 socket.close() 会触发此异常
260 | print("客户端被强制退出...")
261 | break
262 | except OSError:
263 | # 调用 socket.shutdown(SHUT_RDWR) 的后果
264 | print("套接字被删除了...")
265 | break
266 |
267 | # 回主页
268 | def quit():
269 | try:
270 | self.socket.shutdown(SHUT_RDWR)
271 | self.socket.close()
272 | except OSError:
273 | print("套接字不存在, 可能因为连接超时啦")
274 |
275 | self.net.destroy()
276 | HOME()
277 |
278 | # 发送对战邀请
279 | def invite():
280 | index = self.onlineList.curselection()[0]
281 | rival_info = self.onlineList.get(index).split(":") # 获取玩家选择的对手信息
282 |
283 | ip_2 = rival_info[0]
284 | port_2 = int(rival_info[1])
285 |
286 | if ip_2 == self.myIP and port_2 == self.myPort:
287 | messagebox.showinfo(title="提示", message="不可以和自己对战哦")
288 | else:
289 | print(f"邀请 {ip_2}:{port_2} 进行对战...")
290 | s = json_to_byte(
291 | {
292 | "invite": {
293 | "IP": ip_2,
294 | "Port": port_2,
295 | }
296 | }
297 | )
298 | self.socket.send(s)
299 |
300 | self.net = Tk()
301 | self.net.title("在线列表")
302 | self.net.configure(bg="#e6e6e6")
303 | self.net.iconbitmap("src/assets/favicon.ico")
304 |
305 | invite_panel = ttk.Frame(self.net)
306 | invite_panel.pack()
307 |
308 | # 创建一个选项表
309 | self.onlineList = Listbox(invite_panel, relief=FLAT)
310 | self.onlineList.grid(
311 | columnspan=2, sticky=(N, W, E, S), padx=(10, 0), pady=(10, 0)
312 | )
313 |
314 | # 创建一个滚动条
315 | s = Scrollbar(invite_panel, orient=VERTICAL)
316 | s.grid(column=2, row=0, sticky=(N, S), padx=(0, 10), pady=(10, 0))
317 |
318 | # 绑定选项表上下滚动事件
319 | s["command"] = self.onlineList.yview
320 | self.onlineList["yscrollcommand"] = s.set
321 | invite_panel.grid_columnconfigure(0, weight=1)
322 | invite_panel.grid_rowconfigure(0, weight=1)
323 |
324 | # 创建两个按钮
325 | self.btn1 = ttk.Button(invite_panel, text="邀请", command=invite)
326 | self.btn1.grid(row=1, column=0, pady=5, padx=(10, 5))
327 | btn2 = ttk.Button(invite_panel, text="返回", command=quit)
328 | btn2.grid(row=1, column=1, pady=5, padx=(5, 0))
329 |
330 | try:
331 | # 连接服务器
332 | self.socket.connect((self.HOST, self.PORT))
333 | self.myIP = self.socket.getsockname()[0] # 本机 IP
334 | self.myPort = self.socket.getsockname()[1] # 本机端口
335 | # 开启一个线程用于接收服务端消息
336 | t1 = Thread(target=receive)
337 | t1.start()
338 | except ConnectionRefusedError:
339 | print("服务器连接超时...")
340 | self.onlineList.insert("end", "服务器连接超时...")
341 | self.btn1["state"] = "disable" # 连接服务器超时则禁止邀请
342 |
343 | self.net.protocol("WM_DELETE_WINDOW", quit)
344 | self.net.mainloop()
345 |
346 | # 对战窗口
347 | def vs_window(self, HOME):
348 | self.net.title(f"{self.myIP}:{self.myPort}")
349 |
350 | s = ttk.Style()
351 | s.configure("TFrame", background="#e6e6e6")
352 | self.frame1 = ttk.Frame(self.net, padding=10, style="TFrame")
353 | self.frame1.grid(row=0, column=0)
354 | self.frame2 = ttk.Frame(self.net, padding=10, style="TFrame")
355 | self.frame2.grid(row=0, column=1)
356 |
357 | # 创建棋盘界面
358 | self.board = Net_board(15, 15, 5, self.role)
359 | self.board.start(self.net, self.frame1, HOME, self.socket)
360 |
361 | # 创建聊天界面
362 | self.chat = Chat()
363 | self.chat.interfaces(self.frame2)
364 |
365 | # 棋盘绑定鼠标点击事件
366 | self.board.CANVAS.bind("", self.mouseClick)
367 |
368 | # 发送按钮绑定事件
369 | self.chat.button["command"] = self.send_chat
370 |
--------------------------------------------------------------------------------
/src/boards.py:
--------------------------------------------------------------------------------
1 | from tkinter import messagebox, ttk, Tk
2 | from socket import SHUT_RDWR
3 | import numpy as np
4 |
5 | from .ai.zobrist import Zobrist
6 |
7 | from .constants import S, C, R, tr
8 |
9 | from .utils import json_to_byte, windowStyle, menu, about
10 |
11 | from .board import Board
12 |
13 |
14 | # 左右手互博
15 | class Oneself_board(Board):
16 | def __init__(self, m, n, num):
17 | super().__init__(m, n, num)
18 | self.FIRST = False
19 |
20 | # 悔棋
21 | def undo(self):
22 | if len(self.ALLSTEPS) == 0:
23 | messagebox.showinfo(title="提示", message="先走几步再悔棋啦")
24 | elif self.ISFINISH:
25 | messagebox.showinfo(title="提示", message="游戏都结束了啦")
26 | else:
27 | # 获取并删除最后一步棋对应画布 ID
28 | s = self.IDs.pop()
29 | # 获取并删除最后一步棋对应的坐标
30 | p = self.ALLSTEPS.pop()
31 | # 最后一步棋是谁下的
32 | role = self.BOARD[p[0], p[1]]
33 | self.BOARD[p[0], p[1]] = R["empty"] # 将棋盘置空
34 | # 删除画布上对应节点
35 | self.CANVAS.delete(s)
36 | # 把悔棋的步骤保存起来
37 | self.STEPTAILS.append([p, role])
38 | self.reverse() # 悔棋一次翻转一次角色
39 | self.WHOID["text"] = f"轮到{tr[self.WHO]}方执棋"
40 |
41 | # 撤销悔棋
42 | def forward(self):
43 | if len(self.STEPTAILS) == 0:
44 | messagebox.showinfo(title="嘤嘤嘤", message="没办法撤销啦")
45 | else:
46 | # 删除悔棋步骤, 并获取悔棋坐标
47 | p, r = self.STEPTAILS.pop()
48 | # 利用坐标重新下, 并且不需要清空悔棋步骤
49 | self.put(p, r, False)
50 |
51 | # 离开
52 | def quit(self, w: Tk, HOME: callable):
53 | w.destroy()
54 | HOME()
55 |
56 | # 鼠标左键点击事件
57 | def mouseClick(self, event):
58 | if not self.ISFINISH:
59 | x, y = self.find_pos(event.x, event.y) # 获取落子坐标
60 |
61 | if x is not None and y is not None:
62 | # 因为坐标存在 [0, 0] 所以不能直接使用 x and y
63 | self.put([x, y], self.WHO) # 落子
64 |
65 | # 侧边栏按钮
66 | def sidebar(self, w: Tk, undo, quit, resume, forward):
67 | # 执子提示
68 | self.WHOID = ttk.Label(w, width=12, anchor="center")
69 | self.WHOID["background"] = "#e6e6e6"
70 | self.WHOID["text"] = f"轮到{tr[self.WHO]}方执棋"
71 | self.WHOID.grid(row=0, column=1, rowspan=2, padx=5)
72 |
73 | # 四个按钮
74 | button1 = ttk.Button(w, text="悔棋", command=undo, width=5)
75 | button1.grid(row=2, column=1, padx=5)
76 | button2 = ttk.Button(w, text="撤销", command=forward, width=5)
77 | button2.grid(row=3, column=1, padx=5)
78 | button3 = ttk.Button(w, text="重开", command=resume, width=5)
79 | button3.grid(row=4, column=1, padx=5)
80 | button4 = ttk.Button(w, text="返回", command=quit, width=5)
81 | button4.grid(row=5, column=1, padx=5)
82 |
83 | def start(self, HOME: callable):
84 | root = Tk()
85 |
86 | from . import file
87 |
88 | # 菜单栏
89 | m = {
90 | "文件": {
91 | "保存": lambda: file.save_chess_manual(self.ALLSTEPS),
92 | "导入": lambda: file.import_chess_manual(self),
93 | "退出": lambda: self.quit(root, HOME),
94 | },
95 | "帮助": {"关于": about},
96 | }
97 |
98 | menu(root, m)
99 | windowStyle(root, "左右手互博")
100 |
101 | self.drawBoard(root) # 绘制棋盘
102 |
103 | self.sidebar(
104 | root,
105 | self.undo,
106 | lambda: self.quit(root, HOME),
107 | self.resume,
108 | self.forward,
109 | ) # 生成侧边栏
110 |
111 | # 给画布绑定鼠标左键点击事件
112 | self.CANVAS.bind("", self.mouseClick)
113 |
114 | # 监听窗口关闭事件
115 | root.protocol("WM_DELETE_WINDOW", lambda: self.quit(root, HOME))
116 | root.mainloop()
117 |
118 |
119 | # 局域网联机对战
120 | class Net_board(Board):
121 | def __init__(self, m, n, num, role):
122 | super().__init__(m, n, num)
123 | self.IAMWHO = role # 当前玩家是先手还是后手
124 |
125 | def undo(self, type_):
126 | """
127 | 网络对战悔棋操作
128 | type=0, 同意悔棋方
129 | type=1, 想要悔棋方
130 | """
131 |
132 | def a():
133 | # 获取并删除最后一步棋对应画布 ID
134 | s = self.IDs.pop()
135 | # 获取并删除最后一步棋对应的坐标
136 | p = self.ALLSTEPS.pop()
137 | self.BOARD[p[0], p[1]] = R["empty"] # 将棋盘该位置置空
138 | # 删除画布上对应节点
139 | self.CANVAS.delete(s)
140 | self.reverse() # 悔棋一次翻转一次角色
141 | self.WHOID["text"] = f"轮到{tr[self.WHO]}方执棋"
142 |
143 | if type_:
144 | # 想要悔棋
145 | if self.WHO == self.IAMWHO:
146 | """
147 | 当前步骤是自己
148 | 说明对方走了一步
149 | 你上一步棋走错了
150 | 不仅要取消别人的棋, 还要把自己的棋取消
151 | """
152 | for _ in range(2):
153 | a()
154 | else:
155 | """
156 | 当前是对方下棋
157 | 说明对方还没走
158 | 自己刚刚走错了
159 | 只需要悔一步棋
160 | """
161 | a()
162 | else:
163 | # 同意悔棋方
164 | if self.WHO == self.IAMWHO:
165 | """
166 | 当前步骤是自己
167 | 对方想悔棋, 只需要把对方的棋取消
168 | """
169 | a()
170 | else:
171 | """
172 | 当前步骤是对方
173 | 对方想悔棋, 把自己的棋和对方上一步棋取消
174 | """
175 | for _ in range(2):
176 | a()
177 |
178 | def ask_undo(self, socket):
179 | """
180 | 悔棋请求
181 | """
182 | # 没下棋、一方获胜了、棋盘上只有一步棋并且当前是自己下棋都不允许悔棋
183 | if len(self.ALLSTEPS) == 0:
184 | messagebox.showinfo(title="提示", message="先走几步再悔棋啦")
185 | elif self.ISFINISH:
186 | messagebox.showinfo(title="提示", message="游戏都结束了啦")
187 | elif len(self.ALLSTEPS) == 1 and self.WHO == self.IAMWHO:
188 | messagebox.showinfo(title="提示", message="才刚刚开始呢~别那么着急嘛")
189 | else:
190 | s = json_to_byte({"undo": True})
191 | socket.send(s)
192 |
193 | def ask_resume(self, socket):
194 | """重新开局请求"""
195 | s = json_to_byte({"resume": True})
196 | socket.send(s)
197 |
198 | def quit(self, root, HOME, socket):
199 | """返回主页"""
200 | s = json_to_byte({"quit": True})
201 | socket.send(s)
202 | socket.shutdown(SHUT_RDWR)
203 | socket.close()
204 | root.destroy()
205 | HOME()
206 |
207 | # 侧边栏
208 | def sidebar(self, w, undo, quit, resume):
209 | me = ttk.Label(w, background="#e6e6e6", width=12, anchor="center")
210 | me["text"] = f"我是{tr[self.IAMWHO]}方"
211 | me.grid(row=1, column=1, padx=5)
212 |
213 | # 执子提示
214 | self.WHOID = ttk.Label(w, width=12, anchor="center")
215 | self.WHOID["background"] = "#e6e6e6"
216 | self.WHOID["text"] = f"轮到{tr[self.WHO]}方执棋"
217 | self.WHOID.grid(row=0, column=1, padx=5)
218 |
219 | # 四个按钮
220 | button1 = ttk.Button(w, text="悔棋", command=undo, width=5)
221 | button1.grid(row=2, column=1, padx=5)
222 | button2 = ttk.Button(w, text="撤销", width=5)
223 | button2["state"] = "disable"
224 | button2.grid(row=3, column=1, padx=5)
225 | button3 = ttk.Button(w, text="重开", command=resume, width=5)
226 | button3.grid(row=4, column=1, padx=5)
227 | button4 = ttk.Button(w, text="返回", command=quit, width=5)
228 | button4.grid(row=5, column=1, padx=5)
229 |
230 | # 联网对战
231 | def start(self, root, frame, HOME, socket):
232 | self.drawBoard(frame) # 绘制棋盘
233 | # 生成侧边栏
234 | self.sidebar(
235 | frame,
236 | lambda: self.ask_undo(socket),
237 | lambda: self.quit(root, HOME, socket),
238 | lambda: self.ask_resume(socket),
239 | )
240 |
241 | root.protocol(
242 | "WM_DELETE_WINDOW",
243 | lambda: self.quit(root, HOME, socket),
244 | )
245 |
246 |
247 | # 人机模式
248 | class AI_board(Board):
249 | def __init__(self, m: int, n: int, num: int, depth: int, first: bool):
250 | super().__init__(m, n, num)
251 |
252 | self.ZOBRIST = Zobrist(m, n) # 初始化 zobrist 散列对象
253 | self.DEPTH = depth # 搜索深度
254 | self.FIRST = first # 是否先手
255 | self.CURRENTSTEPS = [] # AI 模拟落子的步骤, 区别与棋盘的 allsteps
256 |
257 | # 存储双方得分数组, 与棋盘大小、维数一致
258 | self.COMSCORE = np.zeros([m, n], dtype=int)
259 | self.HUMSCORE = np.zeros([m, n], dtype=int)
260 |
261 | # 某坐标分数缓存数组
262 | self.SCORECACHE = []
263 |
264 | for i in range(3):
265 | if i == 0:
266 | # 第一个为空数组/占位
267 | self.SCORECACHE.append([])
268 | else:
269 | # 代表 AI 和 玩家
270 | # 需要 4 个数组是为了能够存储上、下、斜四个方向上的分数
271 | mylist = []
272 |
273 | for _ in range(4):
274 | mylist.append(np.zeros([m, n], dtype=int))
275 |
276 | self.SCORECACHE.append(mylist)
277 |
278 | # 初始化分数
279 | self.initScore()
280 |
281 | # 侧边栏按钮
282 | def sidebar(self, w: Tk, undo, quit, resume, forward):
283 | # 执子提示
284 | self.WHOID = ttk.Label(w, width=12, anchor="center")
285 | self.WHOID["background"] = "#e6e6e6"
286 | self.WHOID["text"] = f"轮到{tr[self.WHO]}方执棋"
287 | self.WHOID.grid(row=0, column=1, rowspan=2, padx=5)
288 |
289 | # 四个按钮
290 | button1 = ttk.Button(w, text="悔棋", command=undo, width=5)
291 | button1.grid(row=2, column=1, padx=5)
292 | button2 = ttk.Button(w, text="撤销", command=forward, width=5)
293 | button2.grid(row=3, column=1, padx=5)
294 | button3 = ttk.Button(w, text="重开", command=resume, width=5)
295 | button3.grid(row=4, column=1, padx=5)
296 | button4 = ttk.Button(w, text="返回", command=quit, width=5)
297 | button4.grid(row=5, column=1, padx=5)
298 |
299 | # 悔棋
300 | def undo(self):
301 | def remove():
302 | # 获取并删除最后一步棋对应画布 ID
303 | s = self.IDs.pop()
304 | # 获取并删除最后一步棋对应的坐标
305 | p = self.ALLSTEPS.pop()
306 | # 最后一步棋是谁下的
307 | role = self.BOARD[p[0], p[1]]
308 | self.BOARD[p[0], p[1]] = R["empty"] # 将棋盘置空
309 | # 删除画布上对应节点
310 | self.CANVAS.delete(s)
311 | # 把悔棋的步骤保存起来
312 | self.STEPTAILS.append([p, role])
313 | self.reverse() # 悔棋一次翻转一次角色
314 | self.WHOID["text"] = f"轮到{tr[self.WHO]}方执棋"
315 |
316 | # 悔棋后需要更新 zobrist 键值
317 | self.ZOBRIST.go(p[0], p[1], role)
318 | # 更新分数
319 | self.updateScore(p)
320 |
321 | if len(self.ALLSTEPS) == 0:
322 | messagebox.showinfo(title="提示", message="先走几步再悔棋啦")
323 | elif self.ISFINISH:
324 | messagebox.showinfo(title="提示", message="游戏都结束了啦")
325 | elif len(self.ALLSTEPS) == 1:
326 | if self.FIRST:
327 | remove()
328 | else:
329 | messagebox.showinfo(title="提示", message="您还没走呢")
330 | else:
331 | # 与 AI 下棋, 一次需要悔棋两步
332 | for _ in range(2):
333 | remove()
334 |
335 | # 撤销悔棋
336 | def forward(self):
337 | if len(self.STEPTAILS) < 2:
338 | messagebox.showinfo(title="o(TヘTo)", message="当前不能撤销哦")
339 | else:
340 | for _ in range(2):
341 | # 删除悔棋步骤, 并获取悔棋坐标
342 | p, r = self.STEPTAILS.pop()
343 | # 利用坐标重新下, 并且不需要清空悔棋步骤
344 | self.put(p, r, False)
345 |
346 | # 鼠标左键点击事件
347 | def mouseClick(self, event):
348 | if not self.ISFINISH:
349 | x, y = self.find_pos(event.x, event.y) # 获取落子坐标
350 |
351 | if (
352 | x is not None
353 | and y is not None
354 | and self.BOARD[x, y] == R["empty"] # noqa E501
355 | ):
356 | self.put([x, y], R["oneself"]) # 落子
357 | # 落子后更新 zobrist 键值
358 | self.ZOBRIST.go(x, y, R["oneself"])
359 | # 更新分数
360 | self.updateScore([x, y])
361 |
362 | # 只要还没赢 AI 就落子
363 | if not self.ISFINISH:
364 | from .ai import minimax
365 |
366 | # 获取坐标
367 | p = minimax.deepAll(self, self.DEPTH)
368 | self.put(p, R["rival"])
369 | # 电脑落子后更新分数
370 | self.updateScore(p)
371 | # 电脑落子后更新 zobrist 键值
372 | self.ZOBRIST.go(x, y, R["oneself"])
373 |
374 | def quit(self, root: Tk, HOME):
375 | root.destroy()
376 | HOME()
377 |
378 | # 人机对战
379 | def start(self, HOME):
380 | root = Tk()
381 |
382 | from . import file
383 |
384 | # 菜单栏
385 | m = {
386 | "文件": {
387 | "保存": lambda: file.save_chess_manual(
388 | self.ALLSTEPS,
389 | self.FIRST,
390 | ),
391 | "导入": lambda: file.import_chess_manual(self),
392 | "退出": lambda: self.quit(root, HOME),
393 | },
394 | "帮助": {
395 | "关于": about,
396 | },
397 | }
398 |
399 | menu(root, m)
400 | windowStyle(root, "人机对战")
401 |
402 | self.drawBoard(root) # 绘制棋盘
403 |
404 | # 生成侧边栏
405 | self.sidebar(
406 | root,
407 | self.undo,
408 | lambda: self.quit(root, HOME),
409 | lambda: self.resume(self.FIRST),
410 | self.forward,
411 | )
412 |
413 | # 给画布绑定鼠标左键
414 | self.CANVAS.bind("", self.mouseClick)
415 |
416 | if not self.FIRST:
417 | # 电脑先手, 下在中间
418 | self.put([self.m // 2, self.n // 2], self.WHO)
419 |
420 | root.protocol("WM_DELETE_WINDOW", lambda: self.quit(root, HOME))
421 | root.mainloop()
422 |
423 | def hasNeighbor(self, x: int, y: int, distance: int, count: int) -> bool:
424 | """
425 | 判断某点附近是否存在指定数目的棋子
426 | x: 行坐标
427 | y: 列坐标
428 | distance: 判断范围
429 | count: 所需子数最小值
430 | """
431 | board = self.BOARD
432 | n = board.shape[0]
433 | m = board.shape[1]
434 |
435 | startX = x - distance
436 | endX = x + distance
437 | startY = y - distance
438 | endY = y + distance
439 |
440 | for i in range(startX, endX + 1):
441 | # 行越界的邻居跳过
442 | if i < 0 or i >= n:
443 | continue
444 | for j in range(startY, endY + 1):
445 | # 列越界的邻居跳过
446 | if j < 0 or j >= m:
447 | continue
448 | # 如果邻居是自己也跳过
449 | if i == x and j == y:
450 | continue
451 | # 附近有子,计数减一
452 | if board[i, j] != R["empty"]:
453 | count -= 1
454 | # 计数降至 0,说明邻居数超过给定值
455 | # 符合条件,返回 True
456 | if count <= 0:
457 | return True
458 | # 邻居数没有超过给定数目,返回 False
459 | return False
460 |
461 | def gen(self, role: int, onlyThrees: bool):
462 | """
463 | 启发式评估函数
464 | 对整个棋盘空位进行评分, 判断是否能够成五、活四等等
465 | 优先对这些可能会获胜的点进行递归, 能够提高搜索速度/剪枝效率
466 | 注意区别于 evaluate.py(对四个方向进行评分)
467 | """
468 | if len(self.ALLSTEPS) == 0:
469 | return [7, 7]
470 |
471 | fives = [] # 连五
472 | com_fours = [] # AI 活四
473 | hum_fours = [] # 玩家活四
474 | com_blocked_fours = [] # AI 眠四
475 | hum_blocked_fours = [] # 玩家眠四
476 | com_double_threes = [] # AI 双三
477 | hum_double_threes = [] # 玩家双三
478 | com_threes = [] # AI 活三
479 | hum_threes = [] # 玩家活三
480 | com_twos = [] # AI 活二
481 | hum_twos = [] # 玩家活二
482 | neighbors = [] # 附近点
483 |
484 | board = self.BOARD
485 |
486 | for i, item in enumerate(board):
487 | for j, item_ in enumerate(item):
488 | if item_ == R["empty"]:
489 | if len(self.ALLSTEPS) < 6:
490 | if not self.hasNeighbor(i, j, 1, 1):
491 | # 以 [i, j] 为中心的边长为 3 格的方形范围内
492 | # 不存在棋子, 不用考虑这个点了
493 | continue
494 | elif not self.hasNeighbor(i, j, 2, 2):
495 | continue
496 |
497 | scoreHum = self.HUMSCORE[i, j]
498 | scoreCom = self.COMSCORE[i, j]
499 | # 比较 (i,j) 位置 AI 和人谁的评分更高
500 | maxScore = max(scoreCom, scoreHum)
501 |
502 | if onlyThrees and maxScore < S["THREE"]:
503 | continue
504 |
505 | p = {"p": [i, j], "score": maxScore}
506 |
507 | if scoreCom >= S["FIVE"] or scoreHum >= S["FIVE"]:
508 | # 先看 AI 能不能“连五”, 再看玩家能不能“连五”
509 | fives.append(p)
510 | elif scoreCom >= S["FOUR"]:
511 | # AI 有没有活四
512 | com_fours.append(p)
513 | elif scoreHum >= S["FOUR"]:
514 | # 玩家有没有活四
515 | hum_fours.append(p)
516 | elif scoreCom >= S["BLOCKED_FOUR"]:
517 | # AI 有没有眠四
518 | com_blocked_fours.append(p)
519 | elif scoreHum >= S["BLOCKED_FOUR"]:
520 | # 玩家有没有眠四
521 | hum_blocked_fours.append(p)
522 | elif scoreCom >= 2 * S["THREE"]:
523 | # AI 有没有双三
524 | com_double_threes.append(p)
525 | elif scoreHum >= 2 * S["THREE"]:
526 | # 玩家有没有双三
527 | hum_double_threes.append(p)
528 | elif scoreCom >= S["THREE"]:
529 | # AI 有没有活三
530 | com_threes.append(p)
531 | elif scoreHum >= S["THREE"]:
532 | # 玩家有没有活三
533 | hum_threes.append(p)
534 | elif scoreCom >= S["TWO"]:
535 | # AI 有没有活二
536 | com_twos.append(p)
537 | elif scoreHum >= S["TWO"]:
538 | # 玩家有没有活二
539 | hum_twos.append(p)
540 | else:
541 | neighbors.append(p)
542 |
543 | # 成五
544 | if len(fives):
545 | return fives
546 |
547 | # 活四
548 | if role == R["rival"] and len(com_fours):
549 | return com_fours
550 | if role == R["oneself"] and len(hum_fours):
551 | return hum_fours
552 |
553 | # AI 无冲四玩家有活四
554 | if (
555 | role == R["rival"]
556 | and len(hum_fours)
557 | and len(com_blocked_fours) == 0 # noqa E501
558 | ):
559 | return hum_fours
560 | # 玩家不能冲四但 AI 有活四
561 | if (
562 | role == R["oneself"]
563 | and len(com_fours)
564 | and len(hum_blocked_fours) == 0 # noqa E501
565 | ):
566 | return com_fours
567 |
568 | # 冲四/活四
569 | if role == R["rival"]:
570 | fours = com_fours + hum_fours
571 | else:
572 | fours = hum_fours + com_fours
573 | if role == R["rival"]:
574 | blockedfours = com_blocked_fours + hum_blocked_fours
575 | else:
576 | blockedfours = hum_blocked_fours + com_blocked_fours
577 | if len(fours):
578 | return fours + blockedfours
579 |
580 | # 双三/活三/眠三等情况
581 | result = []
582 | if role == R["rival"]:
583 | result = (
584 | com_double_threes
585 | + hum_double_threes
586 | + com_blocked_fours
587 | + result
588 | + hum_blocked_fours
589 | + com_threes
590 | + hum_threes
591 | )
592 | if role == R["oneself"]:
593 | result = (
594 | hum_double_threes
595 | + com_double_threes
596 | + hum_blocked_fours
597 | + com_blocked_fours
598 | + hum_threes
599 | + com_threes
600 | )
601 |
602 | if len(com_double_threes) or len(hum_double_threes):
603 | return result
604 |
605 | # 如果只考虑双三的话...
606 | if onlyThrees:
607 | return result
608 |
609 | # 有活二等情况
610 | if role == R["rival"]:
611 | twos = com_twos + hum_twos
612 | else:
613 | twos = hum_twos + com_twos
614 |
615 | twos.sort(key=lambda x: x["score"], reverse=True)
616 |
617 | # 如果没有活二就下在附近...
618 | result.extend(twos if len(twos) else neighbors)
619 |
620 | # 分数低的不用全部计算了
621 | # 即 gen 返回的节点数不能超过给定值 C["countLimit"]
622 | if len(result) > C["countLimit"]:
623 | return result[0 : C["countLimit"]] # noqa E203
624 |
625 | return result
626 |
627 | # 评估函数的局部刷新
628 | def evaluate(self, role: int) -> int:
629 | comMaxScore = 0
630 | humMaxScore = 0
631 |
632 | board = self.BOARD
633 |
634 | # 遍历棋盘, 获取修正后 AI 和玩家的总分
635 | for i, item in enumerate(board):
636 | for j, item_ in enumerate(item):
637 | # 累加 AI 或人的每一个位置的分数
638 | if item_ == R["rival"]:
639 | comMaxScore += self.fixScore(self.COMSCORE[i, j])
640 | elif item_ == R["oneself"]:
641 | humMaxScore += self.fixScore(self.HUMSCORE[i, j])
642 |
643 | neg = 1 if role == R["rival"] else -1
644 | """
645 | 如果估分对象是 AI,且 comMaxScore - humMaxScore < 0
646 | AI 分数低, result 是负数, 说明该棋面对 AI 不利
647 | 如果估分对象是对手,且 comMaxScore - humMaxScore < 0
648 | AI 分数低, result 是正数, 说明棋面对人有利
649 | """
650 | result = neg * (comMaxScore - humMaxScore)
651 | return result
652 |
653 | # 分数修正
654 | def fixScore(self, score: int):
655 | # 如果分数在活四和眠四之间(10000~100000)
656 | if score < S["FOUR"] and score >= S["BLOCKED_FOUR"]:
657 | # 如果分数小于眠四与活三之和(10000~11000)
658 | if score < S["BLOCKED_FOUR"] + S["THREE"]:
659 | # 降低 AI 冲四行为(通过降低其评分至与活三一致)
660 | # 冲四局面: 再下一个子就能连五了
661 | return S["THREE"]
662 | elif score < S["BLOCKED_FOUR"] * 2:
663 | # 如果分数小于眠四分数的两倍(11000~20000)
664 | # 升高其分数, 使其冲四
665 | return S["FOUR"]
666 | else:
667 | # 双冲四(20000~100000)
668 | return S["FOUR"] * 2
669 | return score
670 |
671 | # 更新一个位置的分数
672 | def updateScore(self, p: tuple[int, int]):
673 | # 更新范围
674 | radius = self.NUM - 1
675 | # 棋盘维度 m × n
676 | m = self.m
677 | n = self.n
678 | # 坐标
679 | x = p[0]
680 | y = p[1]
681 |
682 | from .ai.evaluate import s as scorePoint
683 |
684 | def update(x, y, dir):
685 | role = self.BOARD[x, y]
686 | """
687 | 与 initScore 不同的是为空时不需要判断附近有没有子了
688 | 因此用 != 可以省去判断为空的部分
689 | """
690 | if role != R["oneself"]:
691 | # 如果该点不是玩家(空或者 AI)
692 | cs = scorePoint(self, x, y, R["rival"], dir)
693 | self.COMSCORE[x, y] = cs
694 | else:
695 | # 是人, 则 AI 分数为 0
696 | self.COMSCORE[x, y] = 0
697 |
698 | if role != R["rival"]:
699 | # 如果该点不是 AI(空或玩家)
700 | hs = scorePoint(self, x, y, R["oneself"], dir)
701 | self.HUMSCORE[x, y] = hs
702 | else:
703 | # 如果是 AI,玩家分数为 0
704 | self.HUMSCORE[x, y] = 0
705 |
706 | # 横向 ——
707 | for i in range(-radius, radius + 1):
708 | x_ = x
709 | y_ = y + i
710 | if y_ < 0:
711 | continue
712 | if y_ >= n:
713 | break
714 | update(x_, y_, 0)
715 |
716 | # 纵向 |
717 | for i in range(-radius, radius + 1):
718 | x_ = x + i
719 | y_ = y
720 | if x_ < 0:
721 | continue
722 | if x_ >= m:
723 | break
724 | update(x_, y_, 1)
725 |
726 | # 斜向 \
727 | for i in range(-radius, radius + 1):
728 | x_ = x + i
729 | y_ = y + i
730 | if x_ < 0 or y_ < 0:
731 | continue
732 | if x_ >= m or y_ >= n:
733 | break
734 | update(x_, y_, 2)
735 |
736 | # 斜向 /
737 | for i in range(-radius, radius + 1):
738 | x_ = x + i
739 | y_ = y - i
740 | if x_ < 0 or y_ >= n:
741 | continue
742 | if x_ >= m or y_ < 0:
743 | continue
744 | update(x_, y_, 3)
745 |
746 | # 对当前棋盘进行打分
747 | def initScore(self):
748 | from .ai.evaluate import s as scorePoint
749 |
750 | board = self.BOARD
751 | for i, item in enumerate(board):
752 | for j, item_ in enumerate(item):
753 | # 空位, 双方都打分
754 | if item_ == R["empty"]:
755 | # 但要求以 (i,j) 为中心 5 × 5 范围内存在 2 个邻居
756 | if self.hasNeighbor(i, j, 2, 2):
757 | cs = scorePoint(self, i, j, R["rival"])
758 | hs = scorePoint(self, i, j, R["oneself"])
759 | self.COMSCORE[i, j] = cs
760 | self.HUMSCORE[i, j] = hs
761 | elif item_ == R["rival"]:
762 | # 对 AI 打分, 玩家此位置分数为 0
763 | self.COMSCORE[i, j] = scorePoint(self, i, j, R["rival"])
764 | self.HUMSCORE[i, j] = 0
765 | elif item_ == R["oneself"]:
766 | # 对玩家打分, AI 分数为 0
767 | self.HUMSCORE[i, j] = scorePoint(self, i, j, R["oneself"])
768 | self.COMSCORE[i, j] = 0
769 |
770 | def AIput(self, p, role):
771 | self.BOARD[p[0], p[1]] = role
772 | self.ZOBRIST.go(p[0], p[1], role)
773 | self.updateScore(p)
774 | self.ALLSTEPS.append(p)
775 | self.CURRENTSTEPS.append(p)
776 |
777 | def AIremove(self, p):
778 | r = self.BOARD[p[0], p[1]]
779 | self.ZOBRIST.go(p[0], p[1], r)
780 | self.BOARD[p[0], p[1]] = R["empty"]
781 | self.updateScore(p)
782 | self.ALLSTEPS.pop()
783 | self.CURRENTSTEPS.pop()
784 |
--------------------------------------------------------------------------------