
简介一份基于C与Qt框架实现的单机版斗地主期末大作业适合C初学者、Qt应用开发学习者以及需要完成类似课程设计的高校学生。项目完整覆盖洗牌、发牌、出牌规则、牌型判断、胜负结算等核心游戏逻辑并提供了图形化的对战界面与人机AI对手AI会依据手牌与出牌局势进行策略决策使玩家无需联网即可体验完整对局。资源包共119个文件、约5.76MB包含12个.cpp源码、11个.h头文件、3个.ui界面文件及.pro、.qrc等工程配置文件另有90张png图片负责牌面、按钮和场景渲染。目录结构符合标准Qt工程组织方式各游戏模块独立清晰便于阅读和二次开发。目前已有98人学习下载适合作为期末答辩或Qt实战项目的参照。通过阅读gamePanel、cards、userPlayer等关键源码可以具体掌握C类设计、Qt控件布局与信号槽通信、出牌逻辑判定的实现方法以及AI自动出牌的基本思路是一份能直接运行、可拆解学习的综合实践资料。1. C 期末大作业里最常见的 Qt 单机斗地主考的不只是写界面“C 期末大作业-Qt实现单机版斗地主小游戏-2025”这个标题看起来是把扑克搬上屏幕实际上一次把 Qt、数据结构和算法全考了洗牌发牌怎么保证不重复54 张牌怎么编码才能比大小界面和逻辑怎么解耦三个玩家轮流出牌的状态怎么驱动。很多人在网上翻“c 小游戏源码”直接拷一份答辩时导师问“这副牌为什么这么编号”就答不上来。这篇笔记按落地顺序讲先把斗地主规则翻译成 C 数据模型再写牌型裁判然后做 Qt 界面最后补上调试与验证手段。适合正在做期末大作业、想在现场把每一步讲清楚的人。2. 数据模型先行牌编码、洗牌发牌和手牌排序怎么定2.1 用 053 的 int 编码还是结构体斗地主牌型判断更认哪种我刚做这个题目时第一反应是定义一个结构体struct Card { int rank; // 点数 int suit; // 花色 };写起来很直观但很快发现问题每次判断牌型都要拆字段洗牌时要给结构体数组写交换函数调试时打印一串{rank: 3, suit: 2}也费劲。期末大作业的时间有限我后来改成全用int编码一副牌就是 54 个 053 的整数大小比较、随机洗牌、存盘回放都非常省事。编码约定是这样定的051 表示普通牌rank card / 4suit card % 4其中 rank 的 012 依次对应 3、4、5、6、7、8、9、10、J、Q、K、A、252 是小王53 是大王。注意这里 rank 从 3 开始而不是从 A 开始因为斗地主里 3 最小。// 把 0~53 的牌编码换算成逻辑点数和花色 int rankOf(int card) { if (card 52) return card - 39; // 52 - 13(小王), 53 - 14(大王) return card / 4; } int suitOf(int card) { if (card 52) return 4; // 王没有花色用 4 表示 Joker return card % 4; }这两个函数是整个项目的“地基”。牌面显示、大小比较、牌型识别全部走这里后续代码里不要到处写card % 4这种裸逻辑否则换一次编码约定就要全局改。参数说明rank 最大到 14所以后面统计点数的数组至少开 15 个位置我习惯直接int cnt[15] {0}多一个位置不心疼少一个就一定越界。与显示层对应要把 rank 翻译成字符或图片名QString rankText(int card) { if (card 52) return 小王; if (card 53) return 大王; const char* names[] {3,4,5,6,7,8,9,10,J,Q,K,A,2}; return QString(names[rankOf(card)]); }这就是“逻辑编码、显示文本、牌面比较”三层分离。新手常犯的错是把显示文本当比较依据比如用字符串比较 “10” 和 “9”结果永远是 “10” 小因为字符串按字符挨个比。用 int 编码后完全避开这种问题。2.2 洗牌发牌std::shuffle 与 QRandomGenerator 的落地斗地主的牌库是 54 张无重复牌最简单的洗牌方式就是把这 54 个整数放进QVectorint然后std::shuffle打乱顺序再按“每人一张轮流发”的方式分出去。#include QRandomGenerator #include algorithm QVectorint createAndShuffleDeck() { QVectorint deck; deck.reserve(54); for (int i 0; i 54; i) deck.append(i); // 用 Qt 提供的全局随机数引擎打乱避免自己写洗牌逻辑 std::shuffle(deck.begin(), deck.end(), *QRandomGenerator::global()); return deck; }这里有个关键选择不要用qsrand(time(0))加rand()。Qt 5 之后官方就逐步弃用旧随机数接口Qt 6 里继续用会看到一堆 deprecated 警告。QRandomGenerator::global()是线程安全的全局单例种子由系统维护每次启动都不一样期末考试演示时不用担心“怎么又是同一手牌”。发牌逻辑也简单底层本质是循环取i % 3void dealCards(const QVectorint deck, QVectorint hand0, QVectorint hand1, QVectorint hand2, QVectorint dipai) { hand0.clear(); hand1.clear(); hand2.clear(); dipai.clear(); for (int i 0; i 51; i) { if (i % 3 0) hand0.append(deck[i]); else if (i % 3 1) hand1.append(deck[i]); else hand2.append(deck[i]); } for (int i 51; i 54; i) dipai.append(deck[i]); }参数说明dipai是最后 3 张底牌单机版可以直接固定归玩家所有也可以在开局界面弹一个“是否抢地主”的对话框。发完牌后必须对三家手牌都做一次排序否则后面识别顺子、连对时会乱。2.3 手牌排序用 std::sort 而不是冒泡排序手牌排序最直接的收益是 UI 展示整齐更重要的是方便牌型识别同点数的牌会相邻统计数量一目了然。期末阶段不用手写排序std::sort加一个自定义比较函数即可。void sortHand(QVectorint hand) { std::sort(hand.begin(), hand.end(), [](int a, int b) { int ra rankOf(a), rb rankOf(b); if (ra ! rb) return ra rb; // 点数从小到大 return suitOf(a) suitOf(b); // 同一张点按花色排显示更稳定 }); }注意比较函数必须严格弱排序即相同牌不能交换时返回 true否则std::sort在极端情况下会越界。这里两个参与比较的牌不可能相等因为整副牌没有重复编码所以比较安全。很多教程喜欢用冒泡排序讲“排序思路”但在 Qt 项目里手写冒泡只会增加翻车概率而且性能对 17 张牌毫无意义。按点数升序排好后底牌加入后只需要重新调用一次sortHand不用专门做插入操作。2.4 地主确认与底牌并入流程上最容易漏的一步发完牌先别急着进游戏单机斗地主还需要决定谁当地主。常见做法是随机选一个玩家或者让玩家自己选。我这里是随机地主同时把底牌并入地主手牌void setLandlord(int landlordIndex, QVectorQVectorint hands, const QVectorint dipai, QVectorint landlordHand) { // 把底牌合并给地主并重新排序 landlordHand hands[landlordIndex]; for (int c : dipai) landlordHand.append(c); sortHand(landlordHand); }这一步完成后整个牌局的数据准备才算结束。很多新手直接从洗牌跳到出牌界面底牌留在场上没人处理或者底牌显示了但没加进地主手牌导致发牌数不对。建议在GameData这样一个类里统一维护当前地主编号、三家手牌、底牌、当前出牌人编号避免把状态散落在各个 UI 控件里。3. 牌型识别与出牌合法性把裁判逻辑写成不翻车的 C 函数3.1 先列全牌型表单张、对子、三带、顺子、连对、飞机、炸弹、火箭斗地主看着复杂裁判逻辑其实是一张有限表。先把牌型枚举定义清楚enum CardType { Single, // 单张 Pair, // 对子 Triple, // 三张 TripleSingle, // 三带一 TriplePair, // 三带二 Straight, // 顺子 StraightPair, // 连对 PlaneSingle, // 飞机带单 PlanePair, // 飞机带对 Bomb, // 炸弹 Rocket, // 火箭 Invalid };牌型约束需要记牢顺子至少 5 张最多 12 张连对至少 3 个连续对子飞机带翅膀的情况更复杂。我把常见规则整理成一张表写代码时对照着填。牌型最少张数关键限制单张 / 对子1 / 2无三张 / 三带一 / 三带二3 / 4 / 5三带二不能带王炸或炸弹严格规则里可以带一对顺子5从 3 到 A不能含 2 和大小王连对6至少 3 个连续对子同样不含 2 和王飞机带单8至少 2 个连续三张翅膀数量等于三张数量飞机带对10至少 2 个连续三张翅膀为一对一对炸弹4四张同点数火箭2小王 大王3.2 用 cnt[15] 统计点数牌型判断的第一步不管什么牌型第一步都是统计每种点数出现几次。这个数组长度必须足够rank 最大值是 14所以要int cnt[15]。我写过一个版本用int cnt[13]结果大小王直接越界Qt 在调试模式下当场崩掉。// 统计每种点数出现的次数 int cnt[15] {0}; for (int c : cards) { int r rankOf(c); cnt[r]; } // 顺带统计一下各类基础牌的数量后面判断三带、飞机用 int singles 0, pairs 0, triples 0, bombs 0; for (int r 0; r 15; r) { if (cnt[r] 1) singles; else if (cnt[r] 2) pairs; else if (cnt[r] 3) triples; else if (cnt[r] 4) bombs; }参数说明这里 arrays 的下标是 rank不是花色。花色对斗地主牌型没有影响只有确定“能不能出同花色”时才会看但斗地主根本没有同花顺规则所以只需要 rank 计数就够了。3.3 识别函数从单张到飞机的一步步判断我习惯把识别函数写成一种“排除法”先处理最简单的情况再逐步处理连续型最后是特殊类型。struct PlayInfo { CardType type; int key; // 主点数用于后续压牌比较 int length; // 顺子/连对/飞机的连续长度其余牌型填 0 }; PlayInfo analyzePlay(const QVectorint cards) { PlayInfo p { Invalid, 0, 0 }; int n cards.size(); if (n 0) return p; int cnt[15] {0}; for (int c : cards) cnt[rankOf(c)]; // 火箭小王 大王 if (n 2 cnt[13] 1 cnt[14] 1) { return { Rocket, 14, 0 }; } // 单张、对子、三张、炸弹这类“整型牌” for (int r 0; r 15; r) { if (cnt[r] n) { if (n 1) return { Single, r, 0 }; if (n 2) return { Pair, r, 0 }; if (n 3) return { Triple, r, 0 }; if (n 4) return { Bomb, r, 0 }; } } // 三带一 / 三带二 if (n 4 triples 1 singles 1) return { TripleSingle, tripleRank, 0 }; if (n 5 triples 1 pairs 1) return { TriplePair, tripleRank, 0 }; // 连续型牌顺子、连对、飞机需要单独写一个函数 // 这里省略连续区间检测细节下一节展开 return p; }这里有个设计重点识别结果里只保留“主点数”。比如 3 个 Q 带一张 5key就是 Q 对应的 rank压牌时只要比较双方的主点数即可。三带一里带的牌永远参与不了比较这是规则决定的代码里也不需要记录它。完整的顺子检测要单独拆函数否则analyzePlay会膨胀到没法读。核心逻辑判断从某个开始 rank 到结束 rank 连续、长度达标、并且范围内不出现 2 和王。// 判断 [start, end] 区间内每个点数的 cnt 是否都 1 bool isConsecutive(const int cnt[15], int start, int end) { if (start 0 || start 13) return false; // rank 12 是 2不能进顺子 for (int r start; r end; r) { if (cnt[r] ! 1) return false; } return true; }注意这里的end也不能超过 12因为 2 的 rank 是 12王是 13、14。顺子最大能到 AA 的 rank 是 11所以很多实现干脆写成for (int r 0; r 12; r)无论如何都不让 2 进循环。3.4 压牌比较炸弹、火箭和同牌型谁更大出牌合法性分两层你得先是个合法牌型然后还得压过上一手。压牌比较逻辑可以抽象成这样bool canBeat(const PlayInfo last, const PlayInfo cur) { // 火箭压一切 if (cur.type Rocket) return last.type ! Rocket; if (last.type Rocket) return false; // 炸弹压除火箭外的所有牌型 if (cur.type Bomb last.type ! Bomb) return true; if (last.type Bomb cur.type ! Bomb) return false; // 同牌型才比主点数 if (cur.type ! last.type) return false; if (cur.type Single || cur.type Pair || cur.type Triple || cur.type Bomb || cur.type TripleSingle || cur.type TriplePair) { return cur.key last.key; } // 顺子、连对、飞机必须长度一致再比起点 return cur.length last.length cur.key last.key; }比较函数里最容易遗漏的是“长度一致”。比如上家出了 34567你出 45678 是 5 张顺子点数更大合法但如果你出 345678 变 6 张顺子张数都不对不合法。同理连对和飞机也必须保持长度一致。3.5 边界用例顺子不含 2、飞机翅膀怎么算写完识别逻辑后我建议立刻写几个测试用例而不是直接接 UI。这里给一份最常用的用例表直接放进main()里用qDebug()验证手牌期望结果3, 4, 5, 6, 7Straightkey73, 3, 4, 4, 5, 5StraightPairkey53, 3, 3, 4TripleSinglekey33, 3, 3, 4, 4TriplePairkey3J, Q, K, A, 2Invalid2 不能进顺子小王, 大王Rocket3, 3, 3, 3Bomb我自己翻车最狠的一次就是“JQKA2”判断一开始isConsecutive检查了开头不小于 0但结尾跑到了 rank 122导致cnt[12] 1成立后被误判成合法顺子。加上end 12条件后这个问题彻底消失。4. Qt Widgets 实现牌桌画牌、选牌、出牌、倒计时的完整链路4.1 Widgets 还是 QML期末答辩更稳的选择Qt 做界面有两条常见路线Qt Widgets 和 QML。期末大作业我强烈建议 Widgets。理由很实际Widgets 用 C 直接写信号槽机制和你在数据模型层用到的 C 知识连贯调试时断点能直接跟到鼠标点击、绘图这些动作里QML 虽然动画效果更现代但要在 JavaScript 和 C 之间来回传数据答辩时很容易被问住。Qt 界面设计如果追求精美有人会用 QML但单机斗地主的核心价值在规则算法界面只要牌面清晰、点牌顺手就够了。下面这套方案基于 Qt Widgets 加 QPainter 绘图工作量可控。4.2 从零画一张扑克牌paintEvent 里的绘制步骤每张牌是一个继承自 QWidget 的小控件宽度 80、高度 112。用paintEvent画圆角矩形和文字而不是放一张图片这样不需要额外资源文件。class CardWidget : public QWidget { Q_OBJECT public: CardWidget(int cardValue, QWidget* parent nullptr) : QWidget(parent), m_cardValue(cardValue), m_hover(false) { setFixedSize(80, 112); } void setSelected(bool sel) { m_selected sel; update(); } bool isSelected() const { return m_selected; } protected: void paintEvent(QPaintEvent*) override { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); int offset m_selected ? -20 : 0; // 选中时牌面整体上移 20 像素 QRect faceRect(0, offset, 80, 112 - offset); painter.setBrush(m_selected ? QColor(#FDF6E3) : Qt::white); painter.setPen(QPen(Qt::black, 2)); painter.drawRoundedRect(faceRect, 8, 8); // 左上角显示点数和花色 QString text rankText(m_cardValue); painter.setPen(rankColor(m_cardValue)); painter.setFont(QFont(Arial, 14, QFont::Bold)); painter.drawText(6, 20 offset, text); painter.drawText(6, 36 offset, suitSymbol(m_cardValue)); } private: int m_cardValue; bool m_selected false; };参数说明offset -20是把牌面向 y 轴负方向抬高视觉效果是“这张牌浮起来了”。update()会触发重绘不要在setSelected里直接调repaint()它会产生额外开销。花色颜色我在rankColor()里按黑红两色处理。绘制文字时“10”这个点数是两个字符drawText会自动压缩实测没问题。花色符号可以直接用♠♥♣♦四个 Unicode 字符Windows 和 Linux 下常见字体都能显示不用额外找素材。4.3 点牌选牌与出牌鼠标事件和信号槽怎么接选牌交互非常简单重写mousePressEvent点击时翻转选中状态。void CardWidget::mousePressEvent(QMouseEvent* event) { if (event-button() Qt::LeftButton) { m_selected !m_selected; update(); emit clicked(m_cardValue, m_selected); } QWidget::mousePressEvent(event); }然后在主窗口里把这些CardWidget放进一个容器QVectorCardWidget* m_cardWidgets在创建手牌时按顺序addWidget到QHBoxLayout里。出牌按钮的槽函数要做四件事收集选中的牌值、转成QVectorint、调用analyzePlay做合法性检查、通过信号发给游戏逻辑层。void MainWindow::onPlayClicked() { QVectorint selectedCards; for (CardWidget* w : m_cardWidgets) { if (w w-isSelected()) selectedCards.append(w-cardValue()); } if (selectedCards.isEmpty()) { QMessageBox::information(this, 提示, 请先选牌); return; } PlayInfo info analyzePlay(selectedCards); if (info.type Invalid) { QMessageBox::warning(this, 提示, 这不是一个合法牌型); return; } if (!canBeat(m_lastPlay, info)) { QMessageBox::warning(this, 提示, 压不过上一手); return; } // 合法把牌打出去交给状态机推进回合 emit playRequested(selectedCards); }这个流程把 UI 和规则层分得很清楚UI 只负责“选了什么牌”规则层负责“这手牌合不合法”互不干扰。后面加托管、加提示功能都不需要改选牌逻辑。抽牌后要立刻从手牌集合中移除对应的CardWidget并刷新布局。常见坑是只删了数据、没删控件导致界面越打越长。我会用deleteLater()配合layout-removeWidget()一起处理。4.4 回合推动与 AI 自动出牌QTimer 的正确姿势单机版的“轮到电脑出牌”不能靠sleep()实现。如果你在槽函数里写QThread::msleep(500)整个界面会冻住 0.5 秒看起来像卡死。正确做法是用QTimer延迟调用。// 当前轮到某个玩家出牌时启动一个一次性定时器 QTimer::singleShot(800, this, [this]() { if (m_currentPlayer 2) { // 玩家 3 是电脑 QVectorint aiCards aiChoosePlay(); if (!aiCards.isEmpty()) { emit playRequested(aiCards); } } });QTimer::singleShot是单次定时器比创建一个长期 timer 再手动 stop 少了很多状态管理。如果要做回合倒计时就再起一个 1000ms 的重复QTimer每次timeout让秒数减一减到零时自动执行“不出”或“出最小牌”。m_countdownTimer new QTimer(this); m_countdownTimer-setInterval(1000); connect(m_countdownTimer, QTimer::timeout, this, [this]() { m_secondsLeft--; ui-labelCountdown-setText(QString(%1 秒).arg(m_secondsLeft)); if (m_secondsLeft 0) onPlayClicked(); // 超时自动出牌 });4.5 桌面布局和操作提示QLayout、QPushButton、QCheckBox牌桌布局我用三个区域顶部两个 AI 玩家的出牌区中间底牌和出牌堆底部我的手牌区。手牌区必须是水平排列且能容纳 20 张牌每张 80 宽20 张就是 1600 像素建议把牌宽缩小到 70 或用QScrollArea包一层。按钮区放“出牌”“不出”“提示”三个按钮。简单提示功能的实现方式是遍历手牌组合找到第一个能压过上一手的牌型把对应的CardWidget设为选中状态。这里需要遍历性能不是问题17 张牌的组合数很少。QCheckBox* enableAI new QCheckBox(AI 托管, this); connect(enableAI, QCheckBox::toggled, this, [this](bool on) { m_isAITurn on; });这就是 Qt 里 checkbox 的常规用法用toggled信号比手动检测isChecked()更干净。期末答辩时把“AI 托管”打开三个玩家全自动跑完一局演示效果会好很多。5. 常见问题排查Qt 崩溃、乱码、假随机和出牌误判5.1 程序一启动就崩溃断点指向未知位置现象运行后窗口还没出现就闪退Qt Creator 停在“应用程序输出”里断点指向一些系统库代码。原因最常见的两个一是rankOf收到越界值比如拿到了 54二是CardWidget指针没有初始化就放进布局。解决在rankOf开头加断言Q_ASSERT_X(card 0 card 54, rankOf, card value out of range);调试模式下断言失败会直接定位到调用处。另外创建牌控件时统一用new CardWidget(deck[i], this)不要把局部变量CardWidget w(...)的地址传出去那会在函数结束时变成悬空指针。5.2 Windows 下中文变乱码现象按钮文字显示成“鍑虹墝”之类的乱码。原因Windows 上 Qt Creator 默认源码编码和编译器读取编码不一致尤其老版本 Qt 5.14.2 配合 MSVC 时常见。字符串字面量 “出牌”在源码里存成 UTF-8MSVC 默认按本地代码页 GBK 读于是解码错位。解决统一源码编码为 UTF-8并在文件头部加#pragma execution_character_set(utf-8)仅 MSVC 可用。更稳妥的方式是用QStringLiteral(出牌)宏它确保字符串在编译期以 UTF-16 存储不依赖源码编码。代码里所有中文 UI 文本都应该走这个宏。5.3 每次重开起手牌一模一样现象关掉程序再开三家起手牌完全一样感觉不是随机。原因旧代码里用了qsrand(1)或srand(0)这种固定种子甚至有人直接把QRandomGenerator global的种子写死。解决不要自己管理全局种子直接用QRandomGenerator::global()-generate()或者用std::shuffle传*QRandomGenerator::global()。如果要复现某一局用于调试可以先用固定的std::mt19937生成一次日志里把种子记下来第二天回放时再用同一个种子。5.4 顺子判断把 2 也算进去现象手牌 10、J、Q、K、A 能出但 9、10、J、Q、K、2 也被当成合法顺子。原因连续区间检测只检查了数量都等于 1没限制区间终点必须在 rank 12 以下。这个坑我在第 3 章已经踩过解决方式是统一加end 12判断。顺带一提A 的 rank 是 1110 的 rank 是 7所以 10JQKA 对应的 rank 区间是 7 到 11合法23456 里 2 的 rank 是 12被end 12拦下。写测试用例时一定要把 “10JQKA 合法”和“23456 不合法”两条都加进去。5.5 点“不出”却一直卡死界面失去响应现象轮到玩家操作时点“不出”没反应鼠标变转圈。原因在槽函数里写了循环等待或者while (!condition)自旋等待某个标志位而标志位只能由 UI 事件改变于是死锁。解决任何等待都要交给事件循环。比如等 AI 出牌用QTimer::singleShot不要写while(m_aiThinking) { QCoreApplication::processEvents(); }。processEvents在嵌套调用时容易让同一信号被触发两次造成重复出牌排查起来非常痛苦。5.6 图形界面卡顿点牌有延迟现象窗口能打开但点一张牌要顿一下才浮起来。原因paintEvent里做了不必要的 I/O比如每次重绘都去读图片文件、创建QFont和QColor对象。解决把字体、颜色、QPixmap 全部定义为成员变量或 static 变量只创建一次。另外不要在主窗口的paintEvent里遍历绘制全部牌让每个CardWidget自己画自己Qt 会按需只重绘脏区域。6. 验证技巧用日志回放整局再补两个让答辩加分的点6.1 日志回放把每手牌记成文本验收一个斗地主项目光靠肉眼点几局很难证明逻辑没问题。我习惯给整个牌局加一个日志列表struct GameLogEntry { int playerIndex; QVectorint cards; }; QVectorGameLogEntry m_log; void recordPlay(int playerIndex, const QVectorint cards) { m_log.append({playerIndex, cards}); }每局结束把日志写入文本文件QFile f(game_log.txt); if (f.open(QIODevice::WriteOnly | QIODevice::Text)) { QTextStream out(f); for (const auto e : m_log) { out 玩家 e.playerIndex :; for (int c : e.cards) out rankText(c) ; out \n; } }这样打 20 局后把日志给同学或导师看每一步都能回放比口头说“逻辑没问题”有说服力得多。我期末检查时就靠这个日志找出了 AI 连续两轮不出牌的状态推进 bug。6.2 两个进阶小方向AI 记牌和本局最佳提示如果还有余力最推荐加“AI 记牌”功能统计场上已经出过的牌中大于当前 AI 最大单张的牌还剩几张。实现不复杂维护一个bool played[54]数组每次出牌时标记即可。注意这需要一个碗扣算法。另一个方向是“提示”按钮增强把“提示”从单纯找最小牌升级成“寻找能赢下这一手的牌型”虽然只是贪心但演示效果立刻不一样。不必追求完美胜负算法期末大作业能完整跑完一局裁判逻辑和界面交互都稳定已经能拿到不错的结果。我自己的教训是永远先做通主流程再做优化。曾经在洗牌动画上花了两天结果牌型识别还没写完最后通宵赶工查越界。这个题目的核心是发牌、裁判、回合循环三件事先把它们跑通再加美术和动画。希望这篇笔记能帮你在期末前少走一段弯路。本文还有配套的精品资源点击获取