MFC五子棋人机对战实现:从双缓冲绘制到AI棋型评分
简介一份基于MFC框架的五子棋人机对战项目面向正在学习Windows桌面应用开发以及游戏人工智能入门的中高级C程序员着力演示如何利用微软基础类库构建带图形界面的交互式对战程序。压缩包内共收录37个文件既包含7个头文件与6个C源文件也附带可直接运行的执行程序、界面图标、棋盘位图以及工程配置文件全部资源约3.55MB。目前已有195人浏览学习。通过阅读源码可以梳理棋盘状态管理、落子合法性判断、横竖斜五子连珠检测等核心逻辑人机对战部分采用极小极大搜索配合Alpha-Beta剪枝算法在效率与棋力之间取得平衡同时人人对战模式与胜负数据记录功能为界面交互和简单持久化存储提供了完整示范。项目结构清晰、注释分明适合对照实践用以巩固MFC消息映射、文档视图架构和基础游戏AI设计。1. MFC五子棋人机从压缩包到能跑通的对战程序关键在这四处如果你拿到一个名为 mfc.rar 的压缩包里面大概率就是一份 MFC 五子棋人机对战的课程设计源码。它要解决的事情并没有想象中复杂把 15×15 的棋盘画得干净不闪烁、把鼠标点击换算成精确的交叉点、让电脑会堵会攻。真正让新手反复翻车的反而不是 AI 算法而是窗口重绘、坐标换算和消息阻塞这些看着不起眼的基础功。这篇文章按我自己的落地顺序拆开讲——从对话框基类选择到双缓冲绘制从胜负判定到棋型评分最后补上悔棋和序列化回放的坑每步都给出能直接抄的代码和参数。2. 搭一个不闪烁的 MFC 五子棋棋盘消息映射、双缓冲和坐标换算2.1 选择对话框还是单文档MFC 画棋盘的基类取舍MFC 里能承载棋盘交互的窗口类无非几类CDialog、CFormView、CView以及自绘CWnd。做五子棋人机我一般直接用CDialogEx生成的对话框程序。原因很直接课程设计最常用它资源编辑器里放按钮、放状态栏提示都方便而CView/CFrameWnd那套 Document/View 框架对这个规模的项目属于杀鸡用牛刀还会牵扯到文档序列化和视窗刷新的额外规则。在对话框的头文件里棋盘数据和关键消息会这样声明// MyGobangDlg.h #pragma once #include afxwin.h #include afxcmn.h enum ChessColor { EMPTY 0, BLACK 1, WHITE 2 }; class CMyGobangDlg : public CDialogEx { public: CMyGobangDlg(CWnd* pParent nullptr); enum { IDD IDD_MYGOBANG_DIALOG }; protected: virtual BOOL OnInitDialog(); afx_msg void OnPaint(); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); DECLARE_MESSAGE_MAP() private: int m_board[15][15]; // 0空、1黑、2白 bool m_bPlayerIsBlack; // 玩家是否执黑 CPoint m_lastMove; // 最后一手坐标 };这里必须解释几个设计点。m_board用二维数组存储棋盘状态15 路棋盘的索引 014正好对应交叉点不用动态分配查表和调试都直观。EMPTY/BLACK/WHITE用枚举类型而不是裸int是为了在轮到判断时避免“1 和 2 谁是谁”的混淆。消息映射在MyGobangDlg.cpp里通过ON_WM_PAINT()和ON_WM_LBUTTONDOWN()注册MFC 才会在窗口重绘和鼠标按下时回调对应函数。有一点容易被忽略对话框的OnPaint在窗口创建初期就会被调用而此时棋盘数组还没初始化。所以OnInitDialog里要先把m_board全部清零否则第一次绘制时会读取到未初始化的栈内存轻则画出色块重则直接崩溃。这也是很多人拿到模板工程最先踩的雷。2.2 像素坐标到交叉点加半个格子比不加直观得多鼠标在客户区点下的point是以像素为单位的。棋盘从一个边距MARGIN开始画每个格子宽度CELL_SIZE那么交叉点的行列可以这样算const int BOARD_SIZE 15; const int CELL_SIZE 40; // 每格 40 像素 const int MARGIN 30; // 棋盘外侧留白 // 鼠标事件里的 point 已经是客户区坐标 int col (point.x - MARGIN CELL_SIZE / 2) / CELL_SIZE; int row (point.y - MARGIN CELL_SIZE / 2) / CELL_SIZE; if (col 0 || col BOARD_SIZE || row 0 || row BOARD_SIZE) return; // 点在棋盘外忽略 if (m_board[row][col] ! EMPTY) return; // 已有棋子忽略关键在 CELL_SIZE / 2如果直接(point.x - MARGIN) / CELL_SIZE那么点击格子左边缘会落到上一列右边缘落到下一列肉眼看到的就是棋子偏移半个格。加CELL_SIZE / 2本质是四舍五入到最近的交叉点这是五子棋落子的标准做法。还有一个坑在“客户区坐标”这四个字上。OnLButtonDown消息里带的CPoint已经由 MFC 从屏幕坐标转换成了对话框客户区坐标可以直接用。但如果你的代码用GetCursorPos获取坐标拿到的是屏幕坐标必须调用ScreenToClient(pt)转换后再进入上述公式否则在带标题栏的对话框上会出现固定偏移棋子全部往下错一截。2.3 双缓冲绘制OnPaint 里 20 行解决 90% 的闪烁MFC 自带的OnPaint默认会用CPaintDC dc(this)直接往窗口设备上下文画。每画一条网格线、每画一个棋子就刷一次屏幕在窗口被拖动、频繁Invalidate时会产生肉眼可见的闪烁。双缓冲的做法是先在一张内存位图上画完整个棋盘再一次性BitBlt到窗口。核心代码void CMyGobangDlg::OnPaint() { if (IsIconic()) { CDialogEx::OnPaint(); return; } CPaintDC dc(this); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, BOARD_PIXEL 2 * MARGIN, BOARD_PIXEL 2 * MARGIN); CBitmap* pOldBmp memDC.SelectObject(bmp); // 背景色 memDC.FillSolidRect(0, 0, BOARD_PIXEL 2 * MARGIN, BOARD_PIXEL 2 * MARGIN, RGB(220, 180, 120)); // 网格线 CPen pen(PS_SOLID, 1, RGB(0, 0, 0)); CPen* pOldPen memDC.SelectObject(pen); for (int i 0; i BOARD_SIZE; i) { memDC.MoveTo(MARGIN, MARGIN i * CELL_SIZE); memDC.LineTo(MARGIN BOARD_PIXEL, MARGIN i * CELL_SIZE); memDC.MoveTo(MARGIN i * CELL_SIZE, MARGIN); memDC.LineTo(MARGIN i * CELL_SIZE, MARGIN BOARD_PIXEL); } // 先画棋盘上所有棋子 for (int r 0; r BOARD_SIZE; r) for (int c 0; c BOARD_SIZE; c) if (m_board[r][c] ! EMPTY) DrawStone(memDC, r, c, m_board[r][c]); // 一次性贴到窗口 dc.BitBlt(0, 0, BOARD_PIXEL 2 * MARGIN, BOARD_PIXEL 2 * MARGIN, memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); memDC.SelectObject(pOldPen); }其中BOARD_PIXEL CELL_SIZE * (BOARD_SIZE - 1)也就是 560 像素。这里有一个细节CreateCompatibleBitmap创建的内存位图默认内容随机如果不先FillSolidRect棋盘外区域会出现花屏。另外SelectObject返回的旧对象必须保存并在退出前选回否则CBitmap析构时可能操作错误的 GDI 对象在调试版里弹断言。DrawStone是独立的绘制函数这样OnPaint只负责遍历棋盘并调用它逻辑清晰也方便以后加预览棋子void CMyGobangDlg::DrawStone(CDC* pDC, int row, int col, int color) { CPoint center(MARGIN col * CELL_SIZE, MARGIN row * CELL_SIZE); int radius CELL_SIZE / 2 - 2; CBrush brush(color BLACK ? RGB(0, 0, 0) : RGB(255, 255, 255)); CBrush* pOldBrush pDC-SelectObject(brush); pDC-SetBkMode(TRANSPARENT); // 防止文字/填充背景覆盖网格 pDC-Ellipse(center.x - radius, center.y - radius, center.x radius, center.y radius); pDC-SelectObject(pOldBrush); }默认的Ellipse会用背景色填充内部空隙如果BK_MODE没有设成透明棋子边缘会泛白影响观感。radius CELL_SIZE / 2 - 2让棋子直径略小于格子间距这样棋子之间不会粘连视觉上也更接近真实棋盘。3. 人机对战的规则引擎五连判断、落子合法性和轮转控制3.1 胜负判定一个函数横扫横竖两条斜线五子棋判赢只需要检查最新落子的交叉点以它为中心向四个方向横、竖、主对角线、副对角线数连续同色棋子数是否达到 5。代码可以写得很紧凑bool CMyGobangDlg::CheckWin(int row, int col, int color) { int dirs[4][2] { {1, 0}, // 竖直方向 {0, 1}, // 水平方向 {1, 1}, // 右下/左上斜线 {1, -1} // 右上/左下斜线 }; for (int d 0; d 4; d) { int count 1; // 当前落子本身 // 向正方向延伸 int r row dirs[d][0]; int c col dirs[d][1]; while (r 0 r BOARD_SIZE c 0 c BOARD_SIZE m_board[r][c] color) { count; r dirs[d][0]; c dirs[d][1]; } // 向反方向延伸 r row - dirs[d][0]; c col - dirs[d][1]; while (r 0 r BOARD_SIZE c 0 c BOARD_SIZE m_board[r][c] color) { count; r - dirs[d][0]; c - dirs[d][1]; } if (count 5) return true; } return false; }这里的边界检查是必不可少的。board[r][c]本身不检查越界更快的写法是r 0时直接 break但判断条件里先做边界比较能避免数组访问越界。另一个容易被忽略的点判断胜负时传入的color必须与棋盘上放置的棋子颜色一致建议在落子之后立刻调用CheckWin(row, col, m_playerColor)而不要自己去猜当前轮是谁避免状态错乱。还要注意“长连”问题。标准五子棋中五连即胜但有些规则要求“六连及以上”不算五连。上面代码用count 5如果项目要求严格五连改成count 5并且额外检查两端是否有异色或边界。不过课程设计通常不区分用 5是行业普遍做法。3.2 轮转控制玩家落子电脑回应别让界面卡成白板整个对局就是一个状态机玩家可落子 → 玩家落子 → 判断胜负 → AI 落子 → 判断胜负 → 回到玩家。最容易出问题的环节在 AI 落子时阻塞了主线程。下面是一段稳妥的OnLButtonDown流程void CMyGobangDlg::OnLButtonDown(UINT nFlags, CPoint point) { if (m_bGameOver || !m_bPlayerTurn) return; // 不在玩家回合忽略 int col (point.x - MARGIN CELL_SIZE / 2) / CELL_SIZE; int row (point.y - MARGIN CELL_SIZE / 2) / CELL_SIZE; if (col 0 || col BOARD_SIZE || row 0 || row BOARD_SIZE) return; if (m_board[row][col] ! EMPTY) return; m_board[row][col] m_playerColor; // 玩家落子 m_lastMove CPoint(row, col); Invalidate(FALSE); // 请求重绘不擦背景 if (CheckWin(row, col, m_playerColor)) { m_bGameOver true; MessageBox(_T(玩家获胜)); return; } m_bPlayerTurn false; // 切换回合 AIPlay(); // AI 走棋 Invalidate(FALSE); UpdateWindow(); // 强制立即重绘 }注意UpdateWindow()和Invalidate(FALSE)的区别Invalidate只是告诉系统客户区无效WM_PAINT 会在消息队列空闲时触发UpdateWindow则立即发送 WM_PAINT 并等待重绘完成。在 AI 计算耗时较长时先Invalidate(FALSE)让玩家刚下的棋子显示出来再调用UpdateWindow刷掉待处理区域可以避免画面停留在上一手。AI 内部的落子也必须走同样的合法性检查。如果 AI 随机挑选空点却忘记判断周围八格是否有棋子就会出现电脑下在离战场很远的孤岛位置看起来像 bug。更合理的做法是 AI 只评估距离已有棋子 2 格以内的空点这部分在下一章展开。3.3 悔棋恢复一次悔棋要回退两步否则 AI 会秒下课程设计必有一项“悔棋”功能。最直接的实现是在vectorCPoint里保存每回合双方落子的坐标序列。玩家点击“悔棋”按钮时不能只弹出最后一颗玩家棋子否则棋盘上还留着 AI 上一手的子轮到玩家又得立刻重新面对这个局面。void CMyGobangDlg::UndoLastTurn() { if (m_moveHistory.size() 2) { MessageBox(_T(没有可悔的棋)); return; } // 回退玩家这一步 CPoint pp m_moveHistory.back(); m_board[pp.x][pp.y] EMPTY; m_moveHistory.pop_back(); // 再回退 AI 这一步 CPoint ap m_moveHistory.back(); m_board[ap.x][ap.y] EMPTY; m_moveHistory.pop_back(); m_bPlayerTurn true; m_bGameOver false; Invalidate(FALSE); }用vectorCPoint时要注意保存的坐标顺序。如果每回合先压玩家后压 AI那么back()是 AI 的最后一步pop_back()两次就完整退回玩家落子前的状态。这个逻辑在玩家执黑和执白时都成立不需要区分当前是什么颜色。还有一个细节悔棋后如果对局已经结束必须把m_bGameOver置回false否则窗口虽然显示棋盘但点击任何地方都不会响应。4. 人机 AI 引擎基于棋型评分的攻防决策与三个必调参数4.1 棋型分值表活三、冲四到底值几分五子棋 AI 最简单又最容易出效果的做法不是深度搜索而是对每一个空点打一个“威胁分”。这个分由该点向四个方向形成的棋型决定。先定义一套通用的棋型分值我常用的是棋型分值出现条件连五10000005 颗连续同色活四100000四颗连续两端均空冲四10000四颗连续一端有堵活三5000三颗连续两端均空眠三1000三颗连续一端有堵活二200两颗连续两端均空眠二50两颗连续一端有堵这个分表不必追求绝对精确只要保证数量级差距就能让 AI 优先堵活三、冲四而不是傻傻地连自己那个眠二。关键在计算时怎么数连续棋子和开放端。下面是一个单方向上的统计实现int CMyGobangDlg::CountDirection(int row, int col, int color, int dIdx) { static int dirs[4][2] {{1,0}, {0,1}, {1,1}, {1,-1}}; int count 1; int openEnds 0; // 正方向延伸 int i 1; while (true) { int r row i * dirs[dIdx][0]; int c col i * dirs[dIdx][1]; if (r 0 || r 15 || c 0 || c 15) break; // 越界视为堵 if (m_board[r][c] color) count; else if (m_board[r][c] EMPTY) { openEnds; break; } else break; i; } // 负方向延伸 i -1; while (true) { int r row i * dirs[dIdx][0]; int c col i * dirs[dIdx][1]; if (r 0 || r 15 || c 0 || c 15) break; if (m_board[r][c] color) count; else if (m_board[r][c] EMPTY) { openEnds; break; } else break; i--; } // 把 count 和 openEnds 换算成分数 if (count 5) return 1000000; if (count 4 openEnds 2) return 100000; if (count 4 openEnds 1) return 10000; if (count 3 openEnds 2) return 5000; if (count 3 openEnds 1) return 1000; if (count 2 openEnds 2) return 200; if (count 2 openEnds 1) return 50; return 0; }这个函数有几个值得注意的边界条件。越界被当作堵因为棋盘边缘确实不能形成开放端遇到对方棋子立即停止并且不增加开放端。举个例子某一方向上连续 4 颗白子但一端是边界另一端是空那么openEnds 1查表得到 10000 的冲四——AI 看到冲四必须堵否则对方下一步连五。如果count 4 openEnds 0它其实是一个死四不会立即形成五连分值会落到 0这个细节让 AI 不会被假的威胁带偏。4.2 两步预判先防对方的杀招再找自己的最佳点有了单点单方向的评分一个完整落子点的得分就是对四个方向求和。但只评估己方的威胁还不够好的 AI 必须同时评估“如果对手下在这里有多危险”。我常用的GetBestMove逻辑分三层CPoint CMyGobangDlg::GetBestMove() { int bestScore -1; CPoint bestMove(-1, -1); for (int r 0; r 15; r) { for (int c 0; c 15; c) { if (m_board[r][c] ! EMPTY) continue; if (!IsNearby(r, c)) // 只评估附近有棋子的点 continue; // 第一优先级我方下一步能赢就走那里 if (WouldWin(r, c, m_aiColor)) return CPoint(r, c); // 第二优先级对方下一步能赢必须堵 if (WouldWin(r, c, m_playerColor)) return CPoint(r, c); // 第三优先级威胁分 己方进攻 对方威胁 * 权重 int score EvaluatePoint(r, c, m_aiColor) (int)(EvaluatePoint(r, c, m_playerColor) * 1.2); if (score bestScore) { bestScore score; bestMove CPoint(r, c); } } } return bestMove; }WouldWin的实现很直接临时把棋子放到该点调用CheckWin然后再恢复成空。这就是为什么说“后悔药”很重要——AI 试探杀招时不得不修改棋盘状态但用完一定得恢复否则棋盘上会多出一堆幽灵棋子。IsNearby的常见做法检查以该点为中心的 5×5 邻域内是否有任意非空棋子如果没有就跳过。这个剪枝能砍掉大部分远离战场的位置让 15×15 的棋盘实际只评估 3050 个候选点计算压力很小。防守权重 1.2 是第一个必调参数。它表达的是“同样的棋型对方形成的威胁比自己形成的进攻更值得重视”。五子棋先手优势明显如果这个权重太低AI 会只顾着做自己的活三被玩家抢先连五权重太高AI 又会过度防守永远赢不了。我建议从 1.0 到 1.5 之间逐档调先手玩家时设 1.2AI 自己先手时设 1.0效果比较平衡。4.3 三个必调参数候选范围、防守权重和随机扰动除了防守权重另外两个参数直接决定“AI 强不强”以及“AI 是不是每次都一样”。候选范围指IsNearby的半径常见值是 1 到 3 格。半径越大AI 看得越远但计算量按平方上升15 路棋盘我一般用 2 格半径既能抓住对方隔着两个空位的冲四又不至于每次循环都扫全盘。第三个参数是随机扰动。当多个空点的总分相同时AI 永远选第一个会出现玩家固定走某条线就必赢的套路。解决办法是给最终得分加上一个占最终分数 03% 的随机偏移score (rand() % 100) * 0.01; // 0~1 分的轻微扰动这样同分决胜点时电脑不会每次走同一个位置棋局变化更多。扰动比例不能超过 5%否则会覆盖真实差距让 AI 忽略真正的杀棋。这三个参数就是调难度的核心把候选范围调大、防守权重调到 1.3、扰动压到 1%AI 会显得稳健又多变玩家胜率能明显下降。5. MFC 五子棋人机踩坑实录重绘闪烁、坐标漂移和 AI 假死这样解5.1 窗口拖动后棋盘出现黑色残影现象窗口缩小、拖动或切换后棋盘区域出现一团团黑色块甚至有些棋子像“拖了影子”。原因这是典型的 WM_PAINT 处理错误。很多人直接在OnPaint里用CClientDC代替CPaintDC导致两个设备上下文同时操作窗口BeginPaint/EndPaint不匹配系统无法正确裁剪无效区域另一种原因是Invalidate(TRUE)擦除背景而背景刷子不是自己设置的产生黑底。解决OnPaint里始终用CPaintDC dc(this)不要手动创建第二个上下文调用重绘时用Invalidate(FALSE)并配合双缓冲。检查你的OnPaint是不是在CPaintDC之外又创建了CClientDC如果有删掉后者。5.2 点击落子位置和鼠标点偏了半个格子现象鼠标点在两个交叉点中间棋子却落在其中一个上视觉上总是歪一格。原因坐标换算是直接整数除法没有做四舍五入。比如point.x - MARGIN等于 1919 / 40 0而 19 离 20 更近本应归到第 1 列另外如果通过GetCursorPos拿的是屏幕坐标没有ScreenToClient也会造成整体偏移。解决统一用(point.x - MARGIN CELL_SIZE / 2) / CELL_SIZE后判断边界并且确认OnLButtonDown里传入的point没有被手动转换过。若用了GetCursorPos请先ScreenToClient。5.3 AI 下棋时窗口卡死标题栏显示“未响应”现象玩家落子后整个窗口无法拖动等好几秒才恢复CPU 占用高任务管理器里能看到主线程繁忙。原因AI 计算尤其是不慎改成递归搜索后在主线程内长循环没有让消息泵运行。Windows 的消息机制里窗口在同一个线程内如果持续执行耗时逻辑WM_PAINT、WM_MOVE 等消息都会被阻塞系统就判定窗口“未响应”。解决先保证 AI 算法本身轻量只评估附近的空点计算前后用PeekMessage处理必要的绘制消息。最常见做法是在AIPlay()开头先UpdateWindow()强制刷新玩家这手棋计算结束后再Invalidate(FALSE) UpdateWindow()。如果以后要加深搜索应把 AI 放到工作线程用 PostMessage 通知主线程重绘。5.4 悔棋后电脑立刻又走一步现象玩家点击悔棋棋盘上自己上一步消失了但紧接着电脑马上又下一步根本没有重新思考的时间。原因悔棋逻辑只回退了玩家的落子没有回退 AI 的上一步或者回退了棋子但没有把m_bPlayerTurn置为true消息循环检测到不是玩家回合AI 又被触发。解决确保一次悔棋弹出历史栈中两个坐标玩家和 AI 各一步并把m_bGameOver置为false、m_bPlayerTurn置为true。如果使用vectorCPoint注意先查size()是否大于等于 2避免访问空栈。5.5 Debug 模式下ASSERT弹窗指向dumpcont.cpp现象程序运行时突然弹出断言错误文件指向dumpcont.cpp类似位置的ATLTRACE相关代码。原因这是 MFC 的调试机制在检测 GDI 对象泄漏或设备上下文不平衡。常见于OnPaint里创建了CClientDC、CPen、CBrush但没有恢复和释放或者SelectObject后没把旧对象选回。解决养成所有 GDI 对象成对恢复的习惯pOldPen pDC-SelectObject(newPen)用完pDC-SelectObject(pOldPen)局部变量CBrush、CPen在析构时会自己删除但必须保证脱离SelectObject状态。双缓冲代码里尤其注意CBitmap和旧位图的恢复顺序。6. 把整局存成文件MFC 五子棋的序列化回放与复盘小工具做到这步你的 MFC 五子棋人机已经可以完整对局了。最后一件事往往被忽略把对局过程保存下来错了能复盘赢了能分析 AI 是哪一手走偏的。用 MFC 的CArchive序列化落子序列是成本最低、最稳的方案。先在类里加一个记录坐标的容器std::vectorCPoint m_moveHistory; // 按顺序保存 (row, col)保存时注意区分坐标顺序我每回合并不会混着存而是约定偶数下标注玩家、奇数下标 AI。写入文件void CMyGobangDlg::SaveGame(LPCTSTR filename) { CFile file(filename, CFile::modeCreate | CFile::modeWrite); CArchive ar(file, CArchive::store); ar m_playerColor m_aiColor; ar (int)m_moveHistory.size(); for (size_t i 0; i m_moveHistory.size(); i) { ar m_moveHistory[i].x m_moveHistory[i].y; } ar.Close(); file.Close(); }回放则是反向操作读取文件后再用一个定时器逐手显示。核心逻辑很简单void CMyGobangDlg::StartReplay(LPCTSTR filename) { CFile file(filename, CFile::modeRead); CArchive ar(file, CArchive::load); int n; ar m_playerColor m_aiColor; ar n; m_replayMoves.resize(n); for (int i 0; i n; i) ar m_replayMoves[i].x m_replayMoves[i].y; ar.Close(); file.Close(); memset(m_board, 0, sizeof(m_board)); m_playIndex 0; SetTimer(REPLAY_TIMER, 500, nullptr); // 每 500ms 走一手 }在OnTimer里按顺序把下一颗棋子填到棋盘重绘。这个回放小工具有一个额外价值你可以把 AI 的每一步和它的估值分数一起打到日志文件里然后回放时观察——哪一步 AI 选了低分点说明你的棋型分值表可能在那个场景下失真。这种用真实对局反向校准参数的方法往往比反复调静态权重高效得多。我自己的习惯是给项目留一个快捷键空格键存档F8 回放。整个序列化代码不过四五十行却让一个“能下棋”的程序变成“能复盘、能调参、能演示”的完整作品。如果你要交课程设计这也是一眼能看出的加分项。希望这些从绘制到 AI 的落地细节能帮到你少踩几个我当年踩过的坑。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →