尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++实战:从零实现21点游戏,掌握面向对象与游戏逻辑设计

C++实战:从零实现21点游戏,掌握面向对象与游戏逻辑设计 1. 项目概述与核心价值最近在整理自己的C学习笔记时翻到了一个几年前写的21点扑克牌游戏项目。这个项目虽然不大但麻雀虽小五脏俱全几乎涵盖了从基础语法到面向对象设计、再到游戏逻辑实现的多个核心知识点。很多朋友在学完C基础后常常会陷入一个迷茫期语法都懂了但不知道如何把它们组合起来做出一个像样的、能跑起来的程序。这个21点游戏项目恰好就是一个绝佳的练手靶子。它不像大型游戏引擎那样复杂但又比教科书上的“计算器”或“学生管理系统”更有趣更能激发编程的乐趣。今天我就把这个项目的完整实现思路、关键代码以及我踩过的那些坑系统地梳理一遍希望能给正在寻找C实战项目的你提供一个清晰、可复现的路线图。21点也叫Blackjack是一个经典的卡牌游戏。其核心规则简单玩家和庄家电脑比拼手中牌的点数总和谁更接近21点且不超过谁就赢。Ace可以算作1点或11点J、Q、K算作10点其余牌按面值计算。我们的目标就是用C在控制台里模拟这个游戏的全部过程包括发牌、要牌、停牌、计算胜负以及多局游戏的筹码管理。这不仅仅是一个游戏更是一个综合性的编程练习能让你深刻理解类与对象、标准库容器、随机数生成、输入输出控制以及程序状态管理。2. 整体架构设计与核心类规划在动手写第一行代码之前花点时间做顶层设计至关重要。我最初就是吃了“边写边想”的亏导致项目中期结构混乱差点推倒重来。对于21点游戏我们可以从现实世界的实体抽象出几个核心的类这是面向对象思想的直接应用。2.1 核心类职责划分我的设计主要包含四个核心类Card单张扑克牌、Deck一副牌堆负责洗牌和发牌、Hand一手牌代表玩家或庄家当前持有的牌、Game游戏主控类管理游戏流程和状态。有些更复杂的实现可能会把Player和Dealer也单独成类但对于我们这个练手项目用Hand类同时表示玩家和庄家的手牌并在Game类中区分它们的逻辑已经足够清晰且易于管理。Card类这是最基本的构建块。它需要两个属性点数Rank和花色Suit。点数我们通常用枚举enum来定义比如ACE,TWO,THREE, ...,KING。花色也是枚举如CLUBS,DIAMONDS,HEARTS,SPADES。除了构造函数和获取属性的方法外一个关键方法是GetValue()它返回这张牌在当前牌局中的点数。这里就要处理Ace的特殊逻辑Ace的点数不是固定的它可能在计算手牌总值时被动态地视为1点或11点。因此Card::GetValue()通常只返回一个基础值Ace返回1花牌返回10数字牌返回面值而将Ace的11点逻辑放在Hand类中统一处理。Deck类代表一副或多副标准52张扑克牌。它内部使用一个std::vectorCard来存储所有的牌。关键操作包括初始化Initialize用双重循环生成所有花色和点数的牌放入vector。洗牌Shuffle利用C11的random库生成高质量的随机数对vector中的牌进行随机重排。这里切忌使用老旧的rand()和srand(time(NULL))它们在多副牌、多次游戏场景下随机性不足且容易预测。我会使用std::mt19937梅森旋转算法作为随机数引擎。发牌DealCard从牌堆vector的末尾取出一张牌并返回同时将其从vector中移除。如果牌堆快发完了可以设计一个RefillAndShuffle方法重新初始化并洗牌模拟现实中用完一副牌后启用新牌并洗牌的过程。Hand类代表一个玩家或庄家所持有的一组牌。它内部用一个std::vectorCard来存储。核心方法包括添加牌AddCard接收一张Card对象加入手牌vector。计算总值GetTotalValue这是算法核心。需要遍历所有牌累加每张牌的GetValue()。同时需要智能处理Ace因为Ace的基础值是1我们先把它当作1来累加。然后检查手牌中是否有Ace并且当前总值加上10即把某个Ace当作11后不超过21点。如果满足条件我们就加上这10点。这样就能动态地、最优地利用Ace的两种点数。清空手牌Clear用于新一局开始前清空当前手牌。判断是否爆牌IsBusted检查总值是否超过21。判断是否为“黑杰克”IsBlackjack检查是否恰好只有两张牌且总点数为21即一张Ace和一张10点牌。这是21点中的特殊胜利情况赔率通常更高。Game类这是游戏的大脑控制整个流程。它需要包含以下成员游戏组件Deck对象牌堆、Hand对象玩家手牌、Hand对象庄家手牌。游戏状态玩家筹码数、当前下注额、游戏是否结束等。核心流程方法Play()主循环、PlaceBet()下注、DealInitialCards()发初始牌、PlayerTurn()玩家回合、DealerTurn()庄家回合、SettleRound()结算回合。输入输出负责与用户交互显示游戏状态获取玩家指令要牌、停牌、下注等。设计心得在初期我曾试图把玩家和庄家的回合逻辑完全解耦写成了两个独立的类。但后来发现他们的行为要牌、停牌虽然类似但决策逻辑完全不同玩家基于输入庄家基于固定规则强行抽象反而增加了复杂度。最终我选择在Game类中用两个独立的方法PlayerTurn和DealerTurn来实现代码更直观。这让我明白面向对象不是为抽象而抽象清晰和易于维护才是首要目标。2.2 项目文件结构规划一个清晰的文件结构能让项目管理和后续阅读轻松很多。我建议这样组织BlackjackProject/ ├── src/ │ ├── Card.cpp │ ├── Card.h │ ├── Deck.cpp │ ├── Deck.h │ ├── Hand.cpp │ ├── Hand.h │ ├── Game.cpp │ └── Game.h ├── main.cpp └── CMakeLists.txt (或 .sln/.vcxproj 等工程文件)头文件.h中声明类和函数源文件.cpp中实现。main.cpp非常简单通常只包含#include Game.h和main()函数在main()中创建一个Game对象并调用其Play()方法。3. 核心模块实现与关键技术点有了清晰的架构我们就可以逐个击破每个模块的实现。这里我会重点讲解那些容易出错或者有技巧性的部分。3.1 Card类的实现枚举与基础值Card.h头文件#ifndef CARD_H #define CARD_H #include string // 使用枚举类enum class比传统enum更安全能避免隐式类型转换和命名污染 enum class Rank { ACE 1, TWO, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, TEN, JACK, QUEEN, KING // J, Q, K 我们会在GetValue()里处理为10点 }; enum class Suit { CLUBS, DIAMONDS, HEARTS, SPADES }; class Card { public: Card(Rank rank, Suit suit); // 获取牌的点数基础值Ace为1花牌为10 int GetValue() const; // 获取牌的字符串表示用于显示如 Ace of Spades std::string ToString() const; // 获取花色和点数的枚举值如果需要 Rank GetRank() const { return m_rank; } Suit GetSuit() const { return m_suit; } private: Rank m_rank; Suit m_suit; }; #endif // CARD_HCard.cpp源文件#include Card.h #include map #include string // 用于将枚举值转换为可读字符串的辅助映射 namespace { const std::mapRank, std::string rankStrings { {Rank::ACE, Ace}, {Rank::TWO, 2}, {Rank::THREE, 3}, {Rank::FOUR, 4}, {Rank::FIVE, 5}, {Rank::SIX, 6}, {Rank::SEVEN, 7}, {Rank::EIGHT, 8}, {Rank::NINE, 9}, {Rank::TEN, 10}, {Rank::JACK, Jack}, {Rank::QUEEN, Queen}, {Rank::KING, King} }; const std::mapSuit, std::string suitStrings { {Suit::CLUBS, Clubs}, {Suit::DIAMONDS, Diamonds}, {Suit::HEARTS, Hearts}, {Suit::SPADES, Spades} }; } Card::Card(Rank rank, Suit suit) : m_rank(rank), m_suit(suit) {} int Card::GetValue() const { // Ace在基础计算中视为1点其11点的逻辑在Hand类中处理 if (m_rank Rank::ACE) { return 1; } // 花牌J, Q, K视为10点 else if (m_rank Rank::JACK) { return 10; } else { // 静态转换因为枚举底层是整型且我们确保了数字牌从2开始连续 return static_castint(m_rank); } } std::string Card::ToString() const { return rankStrings.at(m_rank) of suitStrings.at(m_suit); }这里的关键点在于GetValue()方法。它让Card只负责提供“基础点数”将Ace的灵活点数策略推迟到Hand的总值计算中。这种职责分离让代码更清晰。3.2 Deck类的实现现代C随机洗牌Deck.h:#ifndef DECK_H #define DECK_H #include vector #include Card.h class Deck { public: Deck(); // 初始化牌堆可以指定牌副数默认1副 void Initialize(int numberOfDecks 1); // 洗牌 void Shuffle(); // 发一张牌如果牌堆空则先重新填充并洗牌 Card DealCard(); // 获取剩余牌数 size_t CardsRemaining() const { return m_cards.size(); } private: std::vectorCard m_cards; }; #endif // DECK_HDeck.cpp:#include Deck.h #include random // 用于 std::shuffle 和随机数引擎 #include algorithm // 用于 std::shuffle #include chrono // 用于生成随机种子 Deck::Deck() { Initialize(); } void Deck::Initialize(int numberOfDecks) { m_cards.clear(); m_cards.reserve(numberOfDecks * 52); // 预分配空间提高效率 for (int deck 0; deck numberOfDecks; deck) { // 遍历所有花色和点数生成一副完整的牌 for (int s static_castint(Suit::CLUBS); s static_castint(Suit::SPADES); s) { for (int r static_castint(Rank::ACE); r static_castint(Rank::KING); r) { m_cards.emplace_back(static_castRank(r), static_castSuit(s)); } } } // 初始化后立即洗牌 Shuffle(); } void Deck::Shuffle() { // 使用高质量随机数引擎梅森旋转算法 // 使用系统时钟作为种子确保每次运行洗牌结果不同 static std::mt19937 rng(static_castunsigned int( std::chrono::steady_clock::now().time_since_epoch().count() )); // 使用 std::shuffle 算法配合上面的随机数引擎 std::shuffle(m_cards.begin(), m_cards.end(), rng); } Card Deck::DealCard() { if (m_cards.empty()) { // 在实际赌场中一副牌用完会启用新牌并洗牌。 // 这里我们简单处理重新初始化并洗牌。 Initialize(); // 或者可以抛出异常但为了游戏流畅性我们选择静默重置。 } Card card m_cards.back(); // 从牌堆末尾取牌 m_cards.pop_back(); // 移除已发的牌 return card; }避坑指南关于随机数。千万不要用rand() % 52这种方式来模拟洗牌或抽牌它的随机性差且分布不均匀。std::shuffle配合std::mt19937是C11后的标准做法。另外注意随机数引擎如mt19937的初始化。如果它在函数内部被定义为局部变量且每次洗牌都重新创建那么如果游戏循环很快可能因为种子相同时间戳精度不足而导致多次洗牌结果雷同。我在这里将其定义为static让它在整个程序生命周期内只初始化一次既保证了随机性又避免了重复构造的开销。3.3 Hand类的实现智能计算牌面总值Hand.h:#ifndef HAND_H #define HAND_H #include vector #include Card.h class Hand { public: Hand(); // 添加一张牌到手牌 void AddCard(const Card card); // 计算手牌总值并智能处理Ace int GetTotalValue() const; // 清空手牌 void Clear(); // 判断是否爆牌超过21点 bool IsBusted() const; // 判断是否为“黑杰克”初始两张牌即21点 bool IsBlackjack() const; // 获取手牌中的所有牌用于显示 const std::vectorCard GetCards() const { return m_cards; } // 显示手牌控制台友好格式 void Display() const; private: std::vectorCard m_cards; }; #endif // HAND_HHand.cpp:#include Hand.h #include iostream Hand::Hand() { m_cards.reserve(10); // 一手牌很少会超过10张预分配空间 } void Hand::AddCard(const Card card) { m_cards.push_back(card); } int Hand::GetTotalValue() const { int totalValue 0; int aceCount 0; // 第一遍遍历累加基础值并统计Ace的数量 for (const auto card : m_cards) { totalValue card.GetValue(); // Ace在这里被计为1 if (card.GetRank() Rank::ACE) { aceCount; } } // 第二遍处理尝试将Ace当作11点 // 只要当前总值加上10即把一个Ace从1点变成11点净增10点后不超过21我们就加 // 注意我们只关心是否“可以”加而不是“加多少个”。因为最多只能有一个Ace被算作11点否则必爆。 for (int i 0; i aceCount; i) { if (totalValue 10 21) { totalValue 10; // 将一个Ace视为11点因为基础值1已加所以再加10即可 } else { break; // 如果加了会爆就不再尝试将后续的Ace视为11点 } } return totalValue; } void Hand::Clear() { m_cards.clear(); } bool Hand::IsBusted() const { return GetTotalValue() 21; } bool Hand::IsBlackjack() const { // 黑杰克条件恰好有两张牌且总点数为21 return (m_cards.size() 2) (GetTotalValue() 21); } void Hand::Display() const { if (m_cards.empty()) { std::cout [Empty Hand]; return; } for (const auto card : m_cards) { std::cout card.ToString() ; } std::cout \nTotal Value: GetTotalValue() std::endl; }GetTotalValue()是这个类的灵魂。它的算法非常经典先按Ace1点计算总和并数Ace的个数然后循环尝试为每个Ace加上10点即将其视为11点只要加上后不超过21点就加。这个循环巧妙地处理了多个Ace的情况例如 A, A, 9 的总值是 11911然后尝试加10变成21再加10就会爆所以停在21。这是实现21点规则最简洁、高效的方法之一。3.4 Game类的实现游戏流程与状态机Game类是粘合剂它将所有组件串联起来并管理游戏状态。由于代码较长我分块解析核心方法。游戏状态与初始化 (Game.h):#ifndef GAME_H #define GAME_H #include Deck.h #include Hand.h #include string class Game { public: Game(int initialChips 100); // 初始筹码 // 主游戏循环 void Play(); private: // 私有辅助方法 void DisplayGameState(bool showDealerFirstCard false) const; void PlaceBet(); void DealInitialCards(); void PlayerTurn(); void DealerTurn(); void SettleRound(); bool AskPlayAgain() const; // 游戏组件与状态 Deck m_deck; Hand m_playerHand; Hand m_dealerHand; int m_playerChips; int m_currentBet; bool m_gameOver; }; #endif // GAME_H主游戏循环 (Game.cpp中的Play方法):void Game::Play() { std::cout Welcome to Console Blackjack!\n; std::cout You start with m_playerChips chips.\n\n; while (m_playerChips 0 !m_gameOver) { // 每一轮开始前重置状态 m_playerHand.Clear(); m_dealerHand.Clear(); m_currentBet 0; // 步骤1: 下注 PlaceBet(); if (m_gameOver) break; // 玩家可能选择退出 // 步骤2: 发初始牌各两张 DealInitialCards(); DisplayGameState(false); // 不显示庄家的第一张牌 // 步骤3: 检查玩家是否直接拿到黑杰克 if (m_playerHand.IsBlackjack()) { std::cout \n*** Blackjack! You got a natural 21! ***\n; // 庄家也需要检查是否也是黑杰克 if (m_dealerHand.IsBlackjack()) { std::cout But dealer also has Blackjack. Push (tie).\n; m_playerChips m_currentBet; // 返还下注 } else { std::cout You win 1.5 times your bet!\n; m_playerChips static_castint(m_currentBet * 2.5); // 赢1.5倍加上本金共2.5倍 } // 本轮结束询问是否继续 if (!AskPlayAgain()) { m_gameOver true; } continue; // 跳过后续回合进入下一轮 } // 步骤4: 玩家回合 PlayerTurn(); if (m_playerHand.IsBusted()) { std::cout \n*** You busted! Dealer wins. ***\n; // 筹码已在PlaceBet时扣除这里无需再操作 if (!AskPlayAgain()) { m_gameOver true; } continue; } // 步骤5: 庄家回合 DealerTurn(); // 步骤6: 结算 SettleRound(); // 步骤7: 询问是否继续 if (!AskPlayAgain()) { m_gameOver true; } } if (m_playerChips 0) { std::cout \nYouve run out of chips! Game Over.\n; } else { std::cout \nYou left the table with m_playerChips chips. Thanks for playing!\n; } }主循环清晰地勾勒出了一局21点的完整流程。这里有一个细节黑杰克Blackjack的结算优先级最高需要在玩家要牌前判断。如果玩家初始两张牌就是21点则立即结算除非庄家也是黑杰克则平局。下注与发牌:void Game::PlaceBet() { while (true) { std::cout \nYou have m_playerChips chips. Place your bet (0 to quit): ; int bet; if (!(std::cin bet)) { // 处理非数字输入 std::cin.clear(); std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); std::cout Invalid input. Please enter a number.\n; continue; } if (bet 0) { m_gameOver true; break; } if (bet 0) { std::cout Bet cannot be negative.\n; } else if (bet m_playerChips) { std::cout You dont have enough chips. Maximum bet is m_playerChips .\n; } else { m_currentBet bet; m_playerChips - bet; // 下注后立即扣除筹码 std::cout Bet placed: bet chips.\n; break; } } } void Game::DealInitialCards() { // 玩家和庄家交替发牌模拟真实场景玩家一张庄家一张玩家第二张庄家第二张 m_playerHand.AddCard(m_deck.DealCard()); m_dealerHand.AddCard(m_deck.DealCard()); m_playerHand.AddCard(m_deck.DealCard()); m_dealerHand.AddCard(m_deck.DealCard()); }输入处理心得PlaceBet()函数中的std::cin处理是新手常踩的坑。如果用户输入了非数字如字母std::cin会进入错误状态后续所有输入操作都会失败。必须用std::cin.clear()清除错误状态并用std::cin.ignore(...)清空输入缓冲区中的无效字符否则程序会陷入无限循环。这是一个非常实用的防御性编程技巧。玩家与庄家回合:void Game::PlayerTurn() { std::cout \n--- Your Turn ---\n; char choice; while (true) { std::cout \nYour hand: ; m_playerHand.Display(); std::cout Dealer shows: ; // 只显示庄家的第一张牌和一张面朝下的牌 if (!m_dealerHand.GetCards().empty()) { std::cout m_dealerHand.GetCards()[0].ToString() and [Hidden Card]\n; } std::cout \n(H)it or (S)tand? ; std::cin choice; choice std::toupper(choice); // 清理输入缓冲区防止残留字符影响下一次输入 std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); if (choice H) { Card newCard m_deck.DealCard(); m_playerHand.AddCard(newCard); std::cout You drew: newCard.ToString() std::endl; if (m_playerHand.IsBusted()) { std::cout Your hand: ; m_playerHand.Display(); std::cout *** Busted! ***\n; break; } } else if (choice S) { std::cout You stand.\n; break; } else { std::cout Invalid choice. Please enter H or S.\n; } } } void Game::DealerTurn() { std::cout \n--- Dealers Turn ---\n; std::cout Dealers full hand: ; m_dealerHand.Display(); // 庄家固定规则点数小于17必须拿牌大于等于17必须停牌 while (m_dealerHand.GetTotalValue() 17) { std::cout Dealer hits.\n; Card newCard m_deck.DealCard(); m_dealerHand.AddCard(newCard); std::cout Dealer drew: newCard.ToString() std::endl; std::cout Dealers hand: ; m_dealerHand.Display(); if (m_dealerHand.IsBusted()) { std::cout *** Dealer busted! ***\n; break; } } if (!m_dealerHand.IsBusted()) { std::cout Dealer stands.\n; } }玩家回合的核心是一个循环不断询问“要牌”还是“停牌”。庄家回合则完全自动化遵循赌场标准规则软17点规则通常要求庄家在Ace算作11点总值为17时也拿牌我们这里简化了采用硬17规则。注意在玩家选择后使用std::cin.ignore(...)清空缓冲区这能避免因用户多输入字符导致的下一次循环直接读取错误。结算逻辑:void Game::SettleRound() { std::cout \n--- Settlement ---\n; std::cout Your final hand: ; m_playerHand.Display(); std::cout Dealers final hand: ; m_dealerHand.Display(); int playerValue m_playerHand.GetTotalValue(); int dealerValue m_dealerHand.GetTotalValue(); if (m_dealerHand.IsBusted()) { // 庄家爆牌玩家赢除非玩家也爆了但玩家爆牌已在PlayerTurn处理并返回 std::cout Dealer busted. You win!\n; m_playerChips m_currentBet * 2; // 赢回本金和等额奖金 } else if (playerValue dealerValue) { std::cout You win!\n; m_playerChips m_currentBet * 2; } else if (playerValue dealerValue) { std::cout Dealer wins.\n; // 筹码已在PlaceBet时扣除庄家赢则玩家失去下注 } else { // 平局 std::cout Push (tie). Your bet is returned.\n; m_playerChips m_currentBet; // 返还下注 } std::cout Your chip total is now: m_playerChips std::endl; }结算逻辑是游戏规则的最后体现。注意平局Push时玩家拿回自己的下注不输不赢。庄家爆牌是玩家获胜的一种特殊情况。4. 编译、运行与功能扩展4.1 编译与运行环境配置这个项目是纯C控制台程序不依赖任何图形库因此编译非常 straightforward。你可以使用任何你熟悉的IDE或编译器。使用GCC/Clang命令行编译:# 假设所有.cpp文件都在当前目录 g -stdc11 -o blackjack main.cpp Card.cpp Deck.cpp Hand.cpp Game.cpp # 运行 ./blackjack使用CMake (推荐便于跨平台和管理):创建一个CMakeLists.txt文件cmake_minimum_required(VERSION 3.10) project(Blackjack) set(CMAKE_CXX_STANDARD 11) add_executable(blackjack main.cpp src/Card.cpp src/Deck.cpp src/Hand.cpp src/Game.cpp ) target_include_directories(blackjack PRIVATE src)然后在项目根目录执行mkdir build cd build cmake .. make ./blackjack在Visual Studio中创建一个新的空C项目将所有.h和.cpp文件添加进去确保项目属性中C语言标准设置为C11或更高然后编译运行即可。4.2 常见问题与调试技巧在开发过程中我遇到了几个典型问题这里分享排查思路Ace点数计算错误症状是手牌有Ace时总值计算不符合预期比如 A, 8 算成了19而不是19或9。排查在Hand::GetTotalValue()方法中插入调试输出打印每次循环后的totalValue和aceCount。确保你是在所有基础值累加完后再处理Ace的11点逻辑并且card.GetValue()对于Ace返回的是1。洗牌结果每次都一样如果使用srand(time(0))和rand()并且在快速循环中创建新牌堆可能因为time(0)返回值在短时间内相同而导致洗牌结果重复。解决切换到C11的random库并使用std::chrono高精度时钟作为种子或者将随机数引擎定义为静态变量如我之前的代码所示。输入流混乱导致无限循环在玩家输入选择后程序跳过下一次输入直接崩溃或循环。原因std::cin choice;只读取了一个字符如果用户输入了“hello”然后回车choice得到 ‘h’但 “ello\n” 还留在缓冲区。下次循环到std::cin choice;时会直接读取 ‘e’而不是等待新输入。解决在每次读取后使用std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n);清空缓冲区直到换行符。内存访问错误如vector下标越界在Deck::DealCard()中如果m_cards为空调用back()和pop_back()是未定义行为。解决添加空检查如我代码中所示为空时重新初始化牌堆或抛出异常。4.3 功能扩展与优化建议基础版本完成后你可以尝试添加更多功能来挑战自己这能极大提升你的工程能力多副牌修改Deck::Initialize接受一个参数指定牌副数。这能更真实地模拟赌场并降低玩家通过记牌策略获得的优势。你需要相应地调整牌堆大小和洗牌逻辑。分牌Split当玩家初始两张牌点数相同时允许其分牌将一手牌分成两手独立游戏并需额外下注。这需要修改Hand和Game类Game类可能需要管理一个Hand的向量来表示玩家的多手牌结算逻辑会复杂很多。加倍下注Double Down玩家在拿到初始两张牌后可以选择加倍下注然后只能再拿一张牌。这需要在PlayerTurn中增加选项 ‘D’并调整下注和发牌逻辑。保险Insurance当庄家明牌是Ace时玩家可以购买保险以防庄家是黑杰克。这是一个独立的投注选项。游戏存档/读档将玩家的筹码数、游戏状态如果需要保存到文件。这涉及到文件I/O操作fstream和序列化简单的数据结构。图形界面使用如 SFML、SDL2 或甚至 Qt 来为游戏添加图形界面用图片显示扑克牌。这将把你从控制台编程引入到图形和事件驱动编程的新领域。简单的AI玩家实现一个基于基本策略例如硬点数表、软点数表的AI来替代人类玩家自动做出要牌/停牌决策。你可以对比AI和庄家的长期胜率。实现这些扩展功能时最大的挑战是保持代码的清晰和可维护性。我的建议是每添加一个新功能都先仔细思考它应该如何融入现有的类结构和游戏流程状态机中必要时进行适度的重构。例如加入“分牌”可能意味着Game类需要从管理单个m_playerHand变为管理一个std::vectorHand m_playerHands游戏主循环也会变成一个嵌套循环依次处理每一手牌。虽然复杂但这个过程对理解软件设计模式大有裨益。这个21点项目虽然基础但它像一块优质的敲门砖帮你把C的语法知识串联成解决实际问题的能力。从数据抽象、算法实现到用户交互和状态管理它提供了一个完整的微型软件生命周期体验。当你成功运行起第一个版本看着控制台里发牌、要牌、结算那种成就感是看多少遍教科书都无法比拟的。更重要的是在调试那些诡异的bug和设计扩展功能的过程中所积累的经验才是你编程水平提升的真正阶梯。
返回列表