隐藏页面特效

弈悟计划——大一C程期末实验之五子棋AI的开发日志

1|0写在前面


大一C程的期末作业,因为觉得从头学C太没意思,因此报了学校的什么挑战班(早知道不换了去混绩点了)

由于本人懒得要死,刚开始想手搓GUI,然后看了各种图形界面感觉徒增烦恼,就随便找了个看起来最简单的EasyX使用

不过自己码到一半(其实才写了一百行不到)就破防了,感觉再这样下去一个GUI就够我写到学期结束了

遂在网上找了个五子棋图形界面的GUI模板,简单改了一下就只要写核心算法部分就好了

平时由于时间问题,不太能抽出时间写这个,一般就在课上慢慢写了,不过由于写过类似的棋类的OI题(好像是紫书上的),感觉应该会是一段不错的经历

由于要统一代码风格,因此放上来的代码都是格式化过的

然后由于要求要用多文件,因此我很粗暴地分了三个文件再Dev里开了个工程跑(其实分了和没分一样)

其中有两个不变的先放出来

main.cpp

#define _CRT_SECURE_NO_WARNINGS 1 #include "YiWu.h" int main() { Menu m; m.Display(); return 0; }

Yiwu.h

#include <graphics.h> #include <conio.h> #include <windows.h> #define _CRT_SECURE_NO_WARNINGS 1 class Menu { public: void Display(); void ChessBoard(MOUSEMSG m); }; class Play { public: void TwoPlayerGame(MOUSEMSG m); void ComputerUserGame(MOUSEMSG m); void buttonRingth(MOUSEMSG m, MOUSEMSG ms, int win); //判断是否点击右侧工具栏 void displayWin(int n1, int n2);//显示哪一方赢了,n1为0表示双人为1表示人机,n2为0表示黑、人为1表示白、机 void PlayGame(MOUSEMSG ms); int Win(); };

剩下的就是最关键的Yiwu.cpp了,其实应该再细分一下功能的,但是我太懒了就不管了

最后关于这个名字,我取的中文名叫弈悟(不是15!不是15!),算是致敬一波沉迷云顶的洁宝了


2|0大致思路


  • GUI部分:魔改一下板子,图片找不到来源就用画图搓一下
  • 双人对战部分:这部分其实不要求的,但是那个板子里有就顺带留下来了
  • AI部分:重中之重,目前考虑如下(一些具体的部分下面日志里会写):
    • 启发式搜索(评估函数),预期实现6层的棋力(2022/11/15实现)
    • min-max剪枝(2022/11/22实现)
    • 蒙特卡洛树(待定)(2022/12/06正式弃用)
    • Zobrist优化状态数(待定)(2022/12/06实现)
    • 迭代加深与算杀(待定)
  • 希望能达到薄纱我的水平wwwwww

3|0开发日志


3|12022/11/8


魔改UI没什么好说的,主要是修剪图片大小让棋能刚好落在点上

刚开始搞了半天去不掉水印,索性直接摆烂了

然后由于用画图截的辣鸡图差了几个像素点那个棋下不到格点上,魔改参数又花了好久

折腾了一晚上终于算是能下了,顺带保留了原来的板子的双人对战模式

3|22022/11/15


写了评估函数以及找合法落点的函数

首先我的评估算法也很simple,就是分别考虑六个方向,然后根据在这个方向上的棋型来评分:

  • 成五,100000000
  • 活四,10000
  • 活三,1000
  • 活二,100
  • 活一,10

而对于有一边被其它棋子堵住(或者被边界堵住)的棋子,则对应的分数降一档:

  • 死四,1000
  • 死三,100
  • 死二,10

但是写完之后发现我的评估函数有点问题,比如”黑白白白空白黑“会被算成死三+死一而不是冲五,下次要记得修改下

最后写了个找合法落点的函数,因为我们要下的点一般都是在现有棋局的附近,不会跑的太远

因此我们规定每次落子的位置在距离它切比雪夫距离2的范围内要有棋子(不论敌我)

然后我们把这些位置按分数排序后开搜即可


3|32022/11/22


今天开始把min-max剪枝的初代版本基本完成了,大体上还是能跑一下的

不过由于选点的估价函数我直接用全局的那个了,而那个跑一次复杂度就是O(163)的,还有大常数

所以下次要再写一个针对每次下的单个点的估价函数,不然真吃不消这复杂度

而且经过我的研究那个所谓的切比雪夫距离小于等于2根本跑不动,遂放弃成小于等于1的情形

但是即使这样我的初代版本还是薄纱了我两次,让我深刻意识到我五子棋水平的低下

下次应该会得到一个毕竟可观的版本,到时候找别人测试一下大概强度然后决定要再加哪些优化吧


3|42022/11/29


今天过来先填了之前的一些坑,比如优化了之前的估价函数,然后加了个单点评估的函数

虽说可以写成直接判断这个点附近的点和这个点的关系,但是我太懒了还是调用之前的函数来算了,不过复杂度还是实实在在的少了个16

然后为了便于复原棋局加了个记录的trail功能,把棋局保存在文件trail.txt

这样墨迹了将近一个月的弈悟1.0版本也基本出来了,下面放一下初代的代码,后面的各种补丁应该就加在这个版本的上面

#define _CRT_SECURE_NO_WARNINGS 1 #include "YiWu.h" #include <iostream> #include <vector> #include <algorithm> #include <cstdio> using namespace std; FILE *printer = fopen("trail.txt", "w"); int a[16][16] = {0}; const int INF = 1e9; //0空地,1玩家落子,2电脑落子 //1:BLACK 2:WHITE void place1(int col, int hang, int lie) { if (col == 1) setfillcolor(BLACK); else setfillcolor(WHITE); solidcircle(lie, hang, 12); a[hang / 30][lie / 30] = col; } void place2(int col, int x, int y) { setfillcolor(WHITE); a[x][y] = col; solidcircle(20 + y * 30, 20 + x * 30, 12); } /* 对棋局进行评分,分数=AI得分-玩家得分 成五,100000000 活四, 10000 活三,1000 活二,100 活一,10 死四,1000 死三,100 死二,10 */ const int live[5] = {0, 10, 100, 1000, 10000}; const int dead[5] = {0, 1, 10, 100, 1000}; const int rush[6] = {0, 0, 5, 50, 500, 10000}; int calc(vector <int> v, int col) { v.push_back(0); for (int i = v.size() - 1; i > 0; --i) v[i] = v[i - 1]; v[0] = 3 - col; v.push_back(3 - col); int lst = 0, ret = 0; for (int i = 1; i < v.size(); ++i) { if (v[i] != col) { int num = i - 1 - lst; if (num == 5) ret += 100000000; if (!v[lst] && !v[i]) ret += live[num]; else if (!v[lst] || !v[i]) ret += dead[num]; lst = i; } else if (v[i] == 0 && v[i - 1] == col && v[i + 1] == col) { int L; for (L = i - 1; L > 0 && v[L] == col; --L); int R; for (R = i + 1; R < v.size() && v[R] == col; ++R); ret += rush[min(i - 1 - L + R - i - 1, 5)]; } } return ret; } int evaluate(void) { int ret = 0; vector <int> v; for (int i = 0; i < 16; ++i) { v.clear(); for (int j = 0; j < 16; ++j) v.push_back(a[i][j]); ret += calc(v, 2) - calc(v, 1); } for (int j = 0; j < 16; ++j) { v.clear(); for (int i = 0; i < 16; ++i) v.push_back(a[i][j]); ret += calc(v, 2) - calc(v, 1); } for (int i = 0; i < 16; ++i) { v.clear(); for (int x = i, y = 0; x < 16 && y < 16; ++x, ++y) v.push_back(a[x][y]); ret += calc(v, 2) - calc(v, 1); } for (int j = 1; j < 16; ++j) { v.clear(); for (int x = 0, y = j; x < 16 && y < 16; ++x, ++y) v.push_back(a[x][y]); ret += calc(v, 2) - calc(v, 1); } for (int i = 0; i < 16; ++i) { v.clear(); for (int x = i, y = 15; x < 16 && y >= 0; ++x, --y) v.push_back(a[x][y]); ret += calc(v, 2) - calc(v, 1); } for (int j = 0; j < 15; ++j) { v.clear(); for (int x = 0, y = j; x < 16 && y >= 0; ++x, --y) v.push_back(a[x][y]); ret += calc(v, 2) - calc(v, 1); } return ret; } int evaluate_point(int x, int y) { int ret = 0; vector <int> v; v.clear(); for (int i = 0; i < 16; ++i) v.push_back(a[x][i]); ret += calc(v, 2) - calc(v, 1); v.clear(); for (int i = 0; i < 16; ++i) v.push_back(a[i][y]); ret += calc(v, 2) - calc(v, 1); int stx = x, sty = y; while (stx > 0 && sty > 0) --stx, --sty; v.clear(); for (; stx < 16 && sty < 16; ++stx, ++sty) v.push_back(a[stx][sty]); ret += calc(v, 2) - calc(v, 1); stx = x, sty = y; while (stx > 0 && sty < 15) --stx, ++sty; v.clear(); for (; stx < 16 && sty >= 0; ++stx, --sty) v.push_back(a[stx][sty]); ret += calc(v, 2) - calc(v, 1); return ret; } struct Data { int x, y, val; }; vector <Data> generate(int dep) { vector <Data> v; for (int i = 0; i < 16; ++i) for (int j = 0; j < 16; ++j) { if (a[i][j]) continue; bool flag = 0; for (int p = max(0, i - 1); p <= min(15, i + 1) && !flag; ++p) for (int q = max(0, j - 1); q <= min(15, j + 1) && !flag; ++q) if (a[p][q]) v.push_back({i, j, 0}), flag = 1; if (dep < 2 || flag) continue; for (int p = max(0, i - 2); p <= min(15, i + 2) && !flag; ++p) for (int q = max(0, j - 2); q <= min(15, j + 2) && !flag; ++q) if (a[p][q]) v.push_back({i, j, 0}), flag = 1; } return v; } bool cmp_min(const Data &A, const Data &B) { return A.val < B.val; } bool cmp_max(const Data &A, const Data &B) { return A.val > B.val; } const int MAX_DEEP = 4, MAX_DIS = 1; int nxt_x, nxt_y, cnt; int min_search(int dep, int alpha, int beta); int max_search(int dep, int alpha, int beta); int min_search(int dep, int alpha, int beta) { //col=1 ++cnt; if (dep <= 0) return evaluate(); vector <Data> nxt = generate(MAX_DIS); for (int i = 0; i < nxt.size(); ++i) { int pre = evaluate_point(nxt[i].x, nxt[i].y); a[nxt[i].x][nxt[i].y] = 1; nxt[i].val = evaluate_point(nxt[i].x, nxt[i].y) - pre; a[nxt[i].x][nxt[i].y] = 0; } sort(nxt.begin(), nxt.end(), cmp_min); int best = INF; for (auto it : nxt) { int x = it.x, y = it.y, val = it.val; a[x][y] = 1; int cur = max_search(dep - 1, min(best, alpha), beta); a[x][y] = 0; if (cur < best) best = cur; if (best < beta) break; //alpha-beta cut } return best; } int max_search(int dep, int alpha, int beta) { //col=2 ++cnt; if (dep <= 0) return evaluate(); vector <Data> nxt = generate(MAX_DIS); for (int i = 0; i < nxt.size(); ++i) { int pre = evaluate_point(nxt[i].x, nxt[i].y); a[nxt[i].x][nxt[i].y] = 2; nxt[i].val = evaluate_point(nxt[i].x, nxt[i].y) - pre; a[nxt[i].x][nxt[i].y] = 0; } sort(nxt.begin(), nxt.end(), cmp_max); int best = -INF, tx, ty; for (auto it : nxt) { int x = it.x, y = it.y, val = it.val; a[x][y] = 2; int cur = min_search(dep - 1, alpha, max(beta, best)); a[x][y] = 0; if (cur > best) best = cur, tx = x, ty = y; if (best > alpha) break; //alpha-beta cut } if (dep == MAX_DEEP) nxt_x = tx, nxt_y = ty; return best; } void trail(int x, int y, int col) { if (col == 1) fprintf(printer, "Play Set: Row %d Col %d\n", x, y); else fprintf(printer, "AI Set: Row %d Col %d\n", x, y); } void Play::PlayGame(MOUSEMSG ms) { //玩家走子 int success = 1; while (success) { ms = GetMouseMsg(); for (int lie = 20; lie <= 490; lie += 30) { if (ms.x <= lie + 15 && ms.x >= lie - 15) { for (int hang = 20; hang <= 490; hang += 30) { if (ms.y <= hang + 15 && ms.y >= hang - 15) { if (a[hang / 30][lie / 30] == 0) { place1(1, hang, lie); success = 0; trail(hang / 30, lie / 30, 1); break; } } } } } } int win = Play().Win(); if (win == 1) { //黑棋赢 return; } //AI走子 cnt = 0; max_search(MAX_DEEP, INF, -INF); //printf("Have tried %d status\n", cnt); place2(2, nxt_x, nxt_y); trail(nxt_x, nxt_y, 2); win = Play().Win(); if (win == 2) { //黑棋赢 return; } } void Menu::Display() { //初始化绘图窗口 initgraph(416, 624, SHOWCONSOLE); /*设置背景图*/ IMAGE img; //缩放因子,例如设置宽度为100的单元格,实际的绘制宽度为(100*缩放因子) setaspectratio(1.1, 1); //从图片文件获取图像(图像的image指针,图像名,资源名称,图片的拉伸宽度、高度,是否自适应图片大小) loadimage(&img, "init.png", 377, 624, 1); putimage(0, 0, &img); /*控制鼠标移动操作*/ MOUSEMSG m;//鼠标操作 while (true) { m = GetMouseMsg();//获取鼠标消息 //左键按下:WM_LBUTTONDOWN if (m.uMsg == WM_LBUTTONDOWN && (m.x >= 72 && m.x <= 307 && m.y >= 340 && m.y <= 400 || m.x >= 72 && m.x <= 307 && m.y >= 420 && m.y <= 480)) { //uMsg鼠标信息 WM_MOUSEMOVE鼠标移动消息 x y表示鼠标位置坐标 //当鼠标在"人机对战、双人对战"上时,显示红色边框 if (m.x >= 72 && m.x <= 307 && m.y >= 340 && m.y <= 400) { setlinecolor(YELLOW); setlinestyle(PS_SOLID | PS_JOIN_ROUND, 2); //空心矩形框 rectangle(72, 340, 300, 400); } else if (m.x >= 72 && m.x <= 307 && m.y >= 420 && m.y <= 480) { setlinecolor(YELLOW); //空心矩形框 rectangle(72, 420, 300, 480); } Sleep(500); //清除屏幕内容 cleardevice(); //休眠五秒 Sleep(300); //关闭窗口 closegraph(); //使用匿名对象打开棋盘界面 Menu().ChessBoard(m); break; } } } void Menu::ChessBoard(MOUSEMSG m) { //初始化绘图窗口 initgraph(665, 490, SHOWCONSOLE); /*设置棋盘背景背景图*/ IMAGE img; //缩放因子,例如设置宽度为100的单元格,实际的绘制宽度为(100*缩放因子) //setaspectratio(1.1, 1); //从图片文件获取图像(图像的image指针,图像名,资源名称,图片的拉伸宽度、高度,是否自适应图片大小) loadimage(&img, "chessborad.png", 665, 490); putimage(0, 0, &img); //绘制棋盘 while (true) { for (int i = 20; i <= 470; i += 30) { setlinecolor(WHITE); line(20, i, 470, i); line(i, 20, i, 470); } //如果左键双人,跳入双人游戏 if (m.uMsg == WM_LBUTTONDOWN && m.x >= 72 && m.x <= 307 && m.y >= 420 && m.y <= 480) { Play().TwoPlayerGame(m); } else { Play().ComputerUserGame(m); } } } void Play::buttonRingth(MOUSEMSG m, MOUSEMSG ms, int win) { if (ms.x >= 500 && ms.x <= 655 && ms.y >= 30 && ms.y <= 80) { memset(a, 0, sizeof(a)); //重新开始 setlinecolor(RED); //空心矩形框 rectangle(500, 30, 655, 80); Sleep(300); Menu().ChessBoard(m); } else if (ms.x >= 500 && ms.x <= 655 && ms.y >= 115 && ms.y <= 165) { memset(a, 0, sizeof(a)); //返回菜单 setlinecolor(RED); //空心矩形框 rectangle(500, 115, 655, 165); Sleep(300); Menu().Display(); } else if (win == 0 && ms.x >= 500 && ms.x <= 655 && ms.y >= 200 && ms.y <= 250) { //悔棋 setlinecolor(RED); //空心矩形框 rectangle(500, 200, 655, 250); } } void Play::displayWin(int n1, int n2) { memset(a, 0, sizeof(a)); //显示哪一方赢了,n1为0表示双人为1表示人机,n2为0表示黑、人为1表示白、机 IMAGE img; // 读取图片至绘图窗口 if (n1 == 0 && n2 == 0) loadimage(&img, "victory.jpg", 700, 600); if (n1 == 0 && n2 == 1) loadimage(&img, "defeat.jpg", 700, 600); if (n1 == 1 && n2 == 0) loadimage(&img, "victory.jpg", 700, 600); if (n1 == 1 && n2 == 1) loadimage(&img, "defeat.jpg", 700, 600); putimage(0, 0, &img); MOUSEMSG m;//鼠标操作 while (1) { m = GetMouseMsg(); if (m.uMsg == WM_LBUTTONDOWN && m.x >= 215 && m.x <= 270 && m.y >= 285 && m.y <= 320) { setlinecolor(YELLOW); //空心矩形框 rectangle(215, 285, 270, 320); Sleep(300); Menu().Display(); break; } else if (m.uMsg == WM_LBUTTONDOWN) exit(0); } } void Play::TwoPlayerGame(MOUSEMSG m) { int win = 0; int play1 = 1, play2 = 0; MOUSEMSG ms; //一直获取鼠标信息,判断操做 while (win == 0) { //判断是否点击右侧工具栏或者棋盘 ms = GetMouseMsg(); if (ms.uMsg == WM_LBUTTONDOWN) { //判断是否点击右侧工具栏 buttonRingth(m, ms, win); //判断是否点击棋盘 for (int lie = 20; lie <= 490; lie += 30) { if (ms.x <= lie + 15 && ms.x >= lie - 15) { for (int hang = 20; hang <= 490; hang += 30) { if (ms.y <= hang + 15 && ms.y >= hang - 15) { if (play1 == 1 && a[hang / 30 - 1][lie / 30 - 1] == 0) { place1(1, hang, lie); play1 = 0; break; } if (play1 == 0 && a[hang / 30 - 1][lie / 30 - 1] == 0) { place1(2, hang, lie); play1 = 1; break; } } } } } //判断玩家是否赢 //DEBUG /*printf("%d\n",evaluate()); if (play1==1) { vector <pair<int,int>> nxt=generate(2); for (auto it:nxt) { printf("%d %d %d\n",it.x,it.y,it.val); } }*/ win = Play().Win(); if (win == 1) { //黑棋赢 displayWin(0, 0); break; } else if (win == 2) { //白棋赢 displayWin(0, 1); break; } } } } void Play::ComputerUserGame(MOUSEMSG m) { int win = 0; MOUSEMSG ms; //一直获取鼠标信息,判断操做 while (win == 0) { //判断是否点击右侧工具栏或者棋盘 ms = GetMouseMsg(); if (ms.uMsg == WM_LBUTTONDOWN) { //判断是否点击右侧工具栏 buttonRingth(m, ms, win); //判断是否点击棋盘并且判断是否该玩家落子 Play::PlayGame(ms); //判断玩家是否赢 win = Play().Win(); if (win == 1) { //人赢 displayWin(1, 0); break; } else if (win == 2) { //电脑赢 displayWin(1, 1); break; } } } } int Play::Win() { int win = 0; //判断是否赢 for (int j = 0; j < 16 && (win == 0); j++) { for (int i = 0; i < 16; i++) { if ((a[j][i] == 1 && a[j][i + 1] == 1 && a[j][i + 2] == 1 && a[j][i + 3] == 1 && a[j][i + 4] == 1) || (a[i][j] == 1 && a[i + 1][j] == 1 && a[i + 2][j] == 1 && a[i + 3][j] == 1 && a[i + 4][j] == 1)) { //横纵是5个子play1 win win = 1; Sleep(100); break; } if ((a[j][i] == 2 && a[j][i + 1] == 2 && a[j][i + 2] == 2 && a[j][i + 3] == 2 && a[j][i + 4] == 2) || (a[i][j] == 2 && a[i + 1][j] == 2 && a[i + 2][j] == 2 && a[i + 3][j] == 2 && a[i + 4][j] == 2)) { //横纵是5个子play2 win win = 2; Sleep(100); break; } } } for (int j = 0; j < 12 && (win == 0); j++) { for (int i = 0; i < 12; i++) { if (a[j][i] == 1 && a[j + 1][i + 1] == 1 && a[j + 2][i + 2] == 1 && a[j + 3][i + 3] == 1 && a[j + 4][i + 4] == 1) { //向右倾斜时候play1 win win = 1; Sleep(100); break; } if (a[j][i] == 2 && a[j + 1][i + 1] == 2 && a[j + 2][i + 2] == 2 && a[j + 3][i + 3] == 2 && a[j + 4][i + 4] == 2) { //向右倾斜时候play2 win win = 2; Sleep(100); break; } } for (int i = 4; i < 16 && (win == 0); i++) { if (a[j][i] == 1 && a[j + 1][i - 1] == 1 && a[j + 2][i - 2] == 1 && a[j + 3][i - 3] == 1 && a[j + 4][i - 4] == 1) { //向左倾斜时候play1 win win = 1; Sleep(100); break; } if (a[j][i] == 2 && a[j + 1][i - 1] == 2 && a[j + 2][i - 2] == 2 && a[j + 3][i - 3] == 2 && a[j + 4][i - 4] == 2) { //向左倾斜时候play2 win win = 2; Sleep(100); break; } } } return win; }

然后我就开始了简单的测试,结果刚开始被AI杀的落花流水,被薄纱了连着十余把

于是我发现自己并不擅长测试,遂为我们的弈悟计划找来了一位专属测试师——ztc(咱还要给人家开工资呢)

在ztc的帮助下发现了AI容易沉迷进攻而忽略防守,经常出现玩家有死四的局面还不防守去做活四去了

同时据说还存在局面到后面出现未知错误导致电脑自动帮玩家走子的BUG

初步认为是哪里越界了导致的,不过由于没有复现遂暂时不管它

于是下次得给AI加一个防守补丁,强制它进行防守死四等局面

同时由于下到后盘状态数增大AI的计算速度明显加慢,而AI还喜欢调戏人,有死四成五的杀棋不下去下别的死四调戏玩家

遂决定顺便加一个算杀板块,到后面就直接杀杀杀

嘛这么一看后面的工作量还挺大,下次争取把两个补丁写了然后加一个Zobrist优化状态数


3|52022/12/06


感觉今天的进度平平啊,这样下去有点难办了的说

首先过来先写之前说好的防守补丁,然后写完发现又漏了下在中间成五的情况了,遂又删了改成了模块化更强的写法

然后本来想着可能时间挺多的就去看了眼蒙特卡洛树,结果如果要用这个的话相当于要从头写AI

因为本质上蒙特卡洛树已经有机器学习的思想了,它是基于随机选取后继不断根据表现修正的算法

而我们的启发式搜索本质还是一种稳定算法,不依靠随机等因素纯考虑所有后继,通过剪枝来降低复杂度

嘛所以接下来重心还是放在怎么加各种模块来优化这个启发式搜索了

所以今天按计划先给代码加了个Zobrist优化,其实就是一个Hash的思想

我们把棋盘映射成一个unsigned long long值,具体方法的话就是随机给两种颜色的棋盘的每个位置一个随机数

然后不管是落子还是悔棋都只要异或上对应颜色的棋盘的对应位置上的数即可

结果实现后发现在增加了大概0.3的常数后得到了减少0.1状态数的优秀效果,不过后面的算杀部分应该会更有意义

然后就是开始肝算杀部分了,这部分的实现其实基本又要写一个min-max搜索来完成

就是电脑不断下活三和冲四,玩家找坑防守,据说算杀一般一颗算1216层的说,在状态数暴增的中后盘意义非凡

结果不知道怎么了,在没加各种优化的时候AI突然成智障了,我下活三它不管去做自己的活三了

嘛下次的任务看来还是很重的说……


3|6After


由于一些不可抗力因素(被新冠Gank了)后面写不动代码了

然后摆了一个寒假之后不得不先准备实验报告了,后面代码的开发就只能停止了

弈悟计划也最终停留在了初代版本的说(不过水平也不错,和我大概五五开,和我爸对弈直接薄纱他)


4|0结语


虽然说实话这次实验确实有点摆,但也算是有些不错的收获的说

至少对于GUI的实现以及棋类AI的通用算法有了比较深刻的了解,同时第一次写这种偏向工程类的代码也对自己是个很好的锻炼的说

最后的总结的实验报告(要交老师打分的)详见:弈悟计划实验报告


__EOF__

本文作者hl666
本文链接https://www.cnblogs.com/cjjsb/p/16916618.html
关于博主:复活的ACM新生,目前爱好仅剩Gal/HBR/雀魂/单机/OSU
版权声明:转载请注明出处
声援博主:欢迎加QQ:2649020702来DD我
posted @   空気力学の詩  阅读(234)  评论(3编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律
点击右上角即可分享
微信分享提示