├── 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 | ![博弈树](assets/202111111652624.jpg) 14 | 15 | 对于甲来说,第一次不能报 2 和 3,因为这样乙总有办法让甲输,即图中红色路线 16 | 17 | 如果甲报数 1,那么无论第二次乙报什么数,甲总有路线让乙输,即图中蓝色路线 18 | 19 | ![](assets/202111111919929.jpg) 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 | ![](assets/202111121434589.jpg) 16 | 17 | 对于[极大极小值搜索](MiniMax.md)一章的博弈树进行剪枝可得 18 | 19 | ![](assets/202111121555618.jpg) 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 | ![](docs/assets/202111202038959.png) 24 | 25 | 2. 本地开局 26 | 27 | ![](docs/assets/202111202039662.png) 28 | 29 | 3. 获胜界面 30 | 31 | ![](docs/assets/202111202043419.png) 32 | 33 | 4. 网络联机 34 | 35 | > 需要先运行 server.py 36 | 37 | ![](docs/assets/202111202045433.png) 38 | 39 | 询问是否接受对战邀请 40 | 41 | ![](docs/assets/202111202046217.png) 42 | 43 | 可边下棋边聊天 44 | 45 | ![](docs/assets/202111202048721.png) 46 | 47 | 可拒绝/接受对方悔棋 48 | 49 | ![](docs/assets/202111202049247.png) 50 | 51 | 5. 人机模式 52 | 53 | ![](docs/assets/202111250943169.png) 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 | ![](assets/202111241324219.png) 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 | ![](assets/202111251346688.png) 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 | ![](assets/202111112147272.jpg) 13 | 14 | > 根据上图可知,抽离博弈树的一部分后,这个子树的根节点是 -1,也就是说轮到乙选择了,局势变成这样的话,甲是不可能获胜的,因为乙肯定会选择对甲不利的 -1 这条路线 15 | 16 | 博弈树的最后结果 17 | 18 | ![](assets/202111112047620.jpg) 19 | 20 | 整个博弈树根节点的值所代表的就是先手在这场博弈中的结果(如果比赛双方都遵循最大最小值原则) 21 | 22 | 那些 -1 都是对甲不利的局面,也就代表了本场比赛的决胜权被对手掌握 23 | 24 | # 井字游戏 25 | 26 | ![井字游戏中的打分](assets/202111112227746.jpg) 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 | ![](assets/202111121348693.jpg) 45 | 46 | 2. 进行回溯,节点处于 Max 层,因此 α 变成 5 47 | 48 | ![](assets/202111121352259.jpg) 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 | ![](assets/202111121358688.jpg) 63 | 64 | 3. 继续回溯至第 1 层,由于是最小层,因此 β 改为 5(目前子节点最小只有 5) 65 | 66 | ![](assets/202111121443129.jpg) 67 | 68 | 5. 继续遍历第一层第一个节点的右子树 69 | 70 | ![](assets/202111121416220.jpg) 71 | 72 | 5. 以此类推,获得的最优选择是评分为 6 的路径 73 | 74 | > 这是全部遍历的情况,需要继续优化,参考[α-β剪枝](Alpha-Beta.md) 75 | 76 | ![](assets/202111121109240.jpg) 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 | --------------------------------------------------------------------------------