1. 项目概述与核心价值
最近在整理大学时期的项目代码,翻出来一个当年在吉林大学软件学院课程设计里做的“大富翁游戏”,用C++纯手工打造,并且有意识地应用了多种经典的软件设计模式。这个项目不是简单的“能跑就行”,而是我们几个同学花了小半个学期,在理解设计模式精髓后,刻意将其融入游戏逻辑的一次实践。现在回头看,虽然图形界面用的是最原始的字符控制台,但整个架构的设计思路,对理解面向对象设计和设计模式的实际应用,依然非常有价值。尤其是对于正在学习C++、面临课程设计或者想深入理解设计模式如何落地到具体项目的朋友来说,这个项目的完整代码和设计思路,或许能提供一个非常直观的参考案例。
简单来说,这是一个在Windows/Linux控制台下运行的回合制大富翁游戏。玩家通过掷骰子在地图上移动,可以购买土地、建造房屋、收取过路费,并有机会触发“命运”和“机会”卡牌事件。游戏的核心乐趣和商业逻辑与大富翁经典玩法一致。而我们项目的重点和难点,在于如何用良好的C++面向对象编程(OOP)思想来组织代码,并运用设计模式解决游戏开发中常见的“变化”问题,比如如何优雅地添加新的地块类型、新的卡牌事件,如何管理游戏中的各种状态等。最终,我们实现了包括单例模式、工厂模式、观察者模式、状态模式、策略模式等在内的多种模式,让代码的扩展性和维护性得到了质的提升。下面,我就把这个项目的设计思路、关键实现、踩过的坑以及完整的代码结构分享出来,希望能帮你少走弯路。
2. 整体架构与设计模式选型思路
做课程设计或者个人项目,最怕的就是一开始代码写得飞起,后期加功能时却发现“牵一发而动全身”,改起来痛苦不堪。我们在大富翁项目立项时,就定下了一个核心目标:用设计模式构建一个易于扩展的游戏框架。这意味着,未来如果想增加一种新的特殊地块(比如“火车站”),或者一种新的卡片效果,应该只需要添加新的类,修改极少的配置代码,而不需要去改动那些已经稳定运行的核心逻辑。
2.1 核心模块划分
基于上述目标,我们将游戏系统拆分为以下几个核心模块,每个模块都承担明确的职责:
- 游戏引擎核心:这是游戏的主循环和调度中心。它负责初始化游戏、管理当前玩家回合、处理掷骰子、移动、结算等一系列流程。这里我们主要运用了状态模式来管理游戏的不同阶段(如掷骰子阶段、购买阶段、建筑阶段等),使得状态转换清晰明了。
- 地图与地块系统:地图由一系列“地块”对象组成。我们为不同类型的地块(普通地产、起点、监狱、机会、命运等)设计了统一的基类接口,然后派生出具体的子类。这里大量使用了工厂模式来创建地块对象,使得地图的配置可以从文件(如
map.txt)中读取,程序只需要根据一个标识符(如P代表普通地产)调用工厂即可创建对应的对象,极大提升了地图编辑的灵活性。 - 玩家系统:玩家类保存现金、资产、当前位置、是否在监狱中等状态。玩家与地图、卡牌系统的交互,主要通过引擎核心来协调。
- 卡牌系统:“机会”和“命运”卡牌是游戏随机性的重要来源。我们将每张卡牌的效果封装成一个独立的“命令”对象。这里使用了命令模式,将卡牌触发的事件(如“获得200元”、“移动到指定位置”)封装起来,卡牌队列管理器只需要执行这些命令对象,无需关心具体逻辑。添加新卡牌就是添加一个新的命令类。
- 用户界面:为了专注于逻辑,我们采用了最简单的控制台文本交互。输入使用
cin,输出使用cout和cerr。虽然简陋,但完全够用,并且能将所有精力放在架构设计上。 - 工具与管理器:例如,一个全局的单例模式的“游戏管理器”,用于方便地访问游戏中的共享资源,如随机数生成器、日志器等。还有观察者模式,用于实现一些简单的消息通知,比如当玩家资产变化时,自动更新显示。
2.2 为何选择这些设计模式?
- 工厂模式用于创建地块:地图需要多种地块。如果在地图初始化代码里写满
new Land(...),new Chance(...),那么增加一种新地块就要修改这段初始化代码,违反了“开闭原则”。工厂模式将对象的创建逻辑封装起来,客户端(地图加载器)只依赖一个抽象的创建接口,具体创建什么对象由工厂类根据输入参数决定。这样,新增地块类型只需扩展工厂类,地图加载代码完全不用动。 - 状态模式管理游戏流程:游戏流程有明确的状态:等待掷骰子、等待选择购买/建造、等待处理卡牌效果等。如果用一堆
if-else或者switch-case来维护当前状态并决定下一个动作,代码会非常臃肿且难以维护。状态模式将每个状态封装成一个类,游戏引擎只持有当前状态对象的引用。当需要切换状态时(比如掷完骰子),当前状态对象自己决定下一个状态是什么,并负责切换。这使得状态转移逻辑分散到各个状态类中,结构清晰,添加新状态也很方便。 - 命令模式封装卡牌逻辑:卡牌效果五花八门,“前进3步”和“缴纳所得税50%”是截然不同的操作。如果用一个
Card类,里面用一个巨大的switch来执行不同效果,同样是维护灾难。命令模式把每个操作(如MoveCommand,PayCommand)封装成独立对象,卡牌只保存一个命令对象的引用。执行卡牌时,就是调用这个命令对象的execute()方法。要加新效果?新建一个命令类就行。 - 单例模式管理全局资源:像随机数生成器、游戏配置参数,整个游戏只需要一个实例,并且很多地方都需要访问。使用单例模式可以确保全局唯一访问点,避免重复创建和传递引用的麻烦。但要注意,单例不能滥用,我们只在确有必要时使用。
- 观察者模式实现松耦合通知:比如,玩家的现金显示需要实时更新。我们可以在玩家类中保存一个UI显示对象的引用,每次现金变化就手动调用显示更新。但这将玩家和UI紧耦合在一起。使用观察者模式,玩家作为“被观察者”,现金显示组件作为“观察者”注册到玩家那里。玩家现金变化时,只需通知所有观察者“我变了”,观察者自己决定如何更新(更新显示、播放音效等)。这样,玩家类完全不需要知道谁在关心它的现金变化。
3. 核心模块实现细节与代码解析
接下来,我们深入到几个最关键模块的C++实现细节中。我会贴出部分核心代码,并解释其设计意图和注意事项。
3.1 地图与地块系统的工厂模式实现
首先,定义所有地块的基类Block。
// Block.h - 地块基类 #ifndef BLOCK_H #define BLOCK_H #include <string> #include <memory> class Player; // 前向声明 class Block { public: enum class Type { LAND, START, JAIL, CHANCE, FATE, TAX, FREE_PARKING }; Block(int id, const std::string& name, Type type); virtual ~Block() = default; int getId() const { return id_; } std::string getName() const { return name_; } Type getType() const { return type_; } // 关键虚函数:当玩家停留在此地块时触发的动作 virtual void onPlayerStay(std::shared_ptr<Player> player) = 0; // 虚函数:当玩家经过此地块时触发的动作(如起点经过给钱) virtual void onPlayerPass(std::shared_ptr<Player> player); protected: int id_; std::string name_; Type type_; }; #endif // BLOCK_H然后,我们实现几个具体的地块。以普通地产LandBlock为例:
// LandBlock.h #ifndef LANDBLOCK_H #define LANDBLOCK_H #include "Block.h" class LandBlock : public Block { public: LandBlock(int id, const std::string& name, int price, int baseRent); virtual ~LandBlock() = default; void onPlayerStay(std::shared_ptr<Player> player) override; int getPrice() const { return price_; } int getBaseRent() const { return baseRent_; } // ... 其他方法,如获取所有者、设置所有者、计算当前租金(考虑房屋等级)等 private: int price_; int baseRent_; std::weak_ptr<Player> owner_; // 使用weak_ptr避免循环引用 int houseLevel_{0}; };现在,最关键的部分来了:地块工厂BlockFactory。它的职责是根据一个类型标识符(比如从配置文件中读取的字符),创建对应的地块对象。
// BlockFactory.h #ifndef BLOCKFACTORY_H #define BLOCKFACTORY_H #include <memory> #include <string> #include <unordered_map> #include <functional> #include "Block.h" class BlockFactory { public: using Creator = std::function<std::unique_ptr<Block>(int id, const std::string& name)>; // 获取工厂单例(这里用了单例模式管理工厂) static BlockFactory& getInstance(); // 注册地块创建器 void registerCreator(const std::string& typeKey, Creator creator); // 根据类型标识符创建地块 std::unique_ptr<Block> createBlock(const std::string& typeKey, int id, const std::string& name, const std::string& extraArgs = ""); // 禁止拷贝和赋值 BlockFactory(const BlockFactory&) = delete; BlockFactory& operator=(const BlockFactory&) = delete; private: BlockFactory(); void registerDefaultCreators(); // 内部方法,注册默认的地块创建器 std::unordered_map<std::string, Creator> creators_; }; #endif // BLOCKFACTORY_H// BlockFactory.cpp #include "BlockFactory.h" #include "LandBlock.h" #include "StartBlock.h" #include "ChanceBlock.h" // ... 其他地块头文件 BlockFactory::BlockFactory() { registerDefaultCreators(); } BlockFactory& BlockFactory::getInstance() { static BlockFactory instance; // 局部静态变量实现单例 return instance; } void BlockFactory::registerDefaultCreators() { // 注册普通地产:配置文件里用 "L" 表示,后面可能跟价格和基础租金,如 "L:60:2" registerCreator("L", [](int id, const std::string& name, const std::string& args) -> std::unique_ptr<Block> { // 解析args,例如"60:2" size_t colon1 = args.find(':'); size_t colon2 = args.rfind(':'); if (colon1 == std::string::npos) { // 参数错误,返回默认或nullptr return nullptr; } int price = std::stoi(args.substr(0, colon1)); int rent = std::stoi(args.substr(colon1 + 1)); return std::make_unique<LandBlock>(id, name, price, rent); }); // 注册起点:用 "S" 表示 registerCreator("S", [](int id, const std::string& name, const std::string& args) -> std::unique_ptr<Block> { return std::make_unique<StartBlock>(id, name); }); // 注册机会:用 "C" 表示 registerCreator("C", [](int id, const std::string& name, const std::string& args) -> std::unique_ptr<Block> { return std::make_unique<ChanceBlock>(id, name); }); // ... 注册其他地块类型 } std::unique_ptr<Block> BlockFactory::createBlock(const std::string& typeKey, int id, const std::string& name, const std::string& extraArgs) { auto it = creators_.find(typeKey); if (it != creators_.end() && it->second) { return it->second(id, name, extraArgs); } // 处理未知类型,可以返回一个默认地块或抛出异常 std::cerr << "Warning: Unknown block type key: " << typeKey << ". Creating a default placeholder block." << std::endl; // 返回一个简单的、无特殊功能的占位地块 return std::make_unique<Block>(id, name, Block::Type::LAND); // 注意:这里需要Block有合适的构造函数 }设计要点与踩坑记录:
- 使用
std::function和Lambda:工厂的创建器使用std::function存储,配合Lambda表达式,使得注册新地块类型的代码非常集中和简洁,都在registerDefaultCreators一个函数里完成。 - 参数传递:我们设计
createBlock函数时,除了typeKey,还提供了一个extraArgs字符串。这是因为像普通地产L,需要价格和租金这两个额外参数。我们在Lambda里解析这个字符串。这是一种简单的设计。更复杂的方案可以定义一个配置结构体,或者使用更强大的配置文件格式(如JSON、XML)。 - 错误处理:工厂在找不到对应的创建器时,不能简单地返回
nullptr,因为这可能导致程序在后续访问时崩溃。我们这里选择输出警告并返回一个无害的“占位符”地块,保证游戏能继续运行(尽管可能不符合预期)。在生产环境中,可能需要更严格的错误处理,比如抛出异常。 - 单例工厂:将工厂设计为单例是合理的,因为整个游戏只需要一个统一的地块创建入口。使用“局部静态变量”是实现单例线程安全(C++11以后)且简单高效的方法。
3.2 游戏流程的状态模式实现
游戏主循环不应该是一锅粥的if-else。我们使用状态模式来梳理流程。首先定义状态接口GameState。
// GameState.h #ifndef GAMESTATE_H #define GAMESTATE_H #include <memory> class GameEngine; // 前向声明,状态需要操作引擎 class GameState { public: virtual ~GameState() = default; // 处理当前状态下的输入/事件 virtual void handle(GameEngine* engine) = 0; // 进入该状态时执行的操作 virtual void enter(GameEngine* engine) {} // 离开该状态时执行的操作 virtual void exit(GameEngine* engine) {} }; #endif // GAMESTATE_H然后,实现几个具体的状态。例如,PlayerTurnStartState(玩家回合开始状态):
// PlayerTurnStartState.h #ifndef PLAYERTURNSTARTSTATE_H #define PLAYERTURNSTARTSTATE_H #include "GameState.h" class PlayerTurnStartState : public GameState { public: void handle(GameEngine* engine) override; void enter(GameEngine* engine) override; }; #endif // PLAYERTURNSTARTSTATE_H// PlayerTurnStartState.cpp #include "PlayerTurnStartState.h" #include "GameEngine.h" #include "Player.h" #include "Dice.h" #include <iostream> void PlayerTurnStartState::enter(GameEngine* engine) { auto currentPlayer = engine->getCurrentPlayer(); std::cout << "\n--- " << currentPlayer->getName() << "'s Turn ---" << std::endl; std::cout << "Cash: $" << currentPlayer->getCash() << std::endl; // 显示玩家资产等信息... } void PlayerTurnStartState::handle(GameEngine* engine) { std::cout << "Press Enter to roll the dice..."; std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); // 清空输入缓冲区 std::cin.get(); Dice& dice = Dice::getInstance(); // 假设骰子也是单例 int steps = dice.roll(); std::cout << "You rolled a " << steps << "!" << std::endl; // 状态转换:掷骰子后,进入移动状态 engine->movePlayer(steps); engine->setState(std::make_unique<PlayerMovingState>()); // 切换到移动状态 }再看PlayerMovingState(玩家移动状态):
// PlayerMovingState.cpp #include "PlayerMovingState.h" #include "GameEngine.h" #include "Player.h" #include "Map.h" #include <iostream> void PlayerMovingState::handle(GameEngine* engine) { auto player = engine->getCurrentPlayer(); auto& map = engine->getMap(); // 获取玩家即将到达的地块 int newPos = (player->getPosition() + engine->getPendingSteps()) % map.getBlockCount(); auto targetBlock = map.getBlock(newPos); std::cout << "You moved to: " << targetBlock->getName() << std::endl; // 触发“经过”事件(比如经过起点) // 这里需要处理从地图尾部移动到头部时,经过起点的逻辑 int oldPos = player->getPosition(); if (newPos < oldPos) { // 说明绕了一圈,经过了起点 map.getBlock(0)->onPlayerPass(player); // 假设0号地块是起点 } player->setPosition(newPos); // 触发“停留”事件 targetBlock->onPlayerStay(player); // 状态转换:移动并结算后,进入回合结束或购买决策状态 // 这里需要判断,比如如果触发了进监狱,可能直接结束回合 if (player->isInJail()) { std::cout << "You are in jail!" << std::endl; engine->setState(std::make_unique<PlayerTurnEndState>()); } else if (/* 地块可购买且玩家有钱且... */) { engine->setState(std::make_unique<PlayerBuyDecisionState>()); } else { engine->setState(std::make_unique<PlayerTurnEndState>()); } }设计要点与踩坑记录:
- 状态类持有引擎指针:每个状态类都需要能够操作游戏引擎(如获取当前玩家、地图、切换状态等)。我们通过
GameEngine*指针来实现。确保状态类不负责具体的游戏数据管理,只负责流程控制。 - 状态转换的决策点:状态转换的逻辑写在当前状态的
handle方法里。例如,PlayerTurnStartState处理完掷骰子后,自己决定下一个状态是PlayerMovingState。这样,流程逻辑就分散到了各个状态类中,非常清晰。如果要增加一个新状态(比如“拍卖状态”),只需要修改相关状态类的转换逻辑即可。 - 使用
std::unique_ptr管理状态:GameEngine持有一个std::unique_ptr<GameState>来表示当前状态。切换状态时,直接setState(std::make_unique<NewState>())。这保证了状态对象的唯一所有权和自动内存管理。 enter和exit钩子:这两个虚函数不是必须的,但提供了很好的扩展点。比如在enter里显示提示信息,在exit里清理临时数据。
3.3 卡牌系统的命令模式实现
命令模式将“请求”封装成对象。我们定义一个抽象的CardCommand接口。
// CardCommand.h #ifndef CARDCOMMAND_H #define CARDCOMMAND_H #include <memory> class Player; class GameEngine; class CardCommand { public: virtual ~CardCommand() = default; virtual void execute(std::shared_ptr<Player> target, GameEngine* engine) = 0; virtual std::string getDescription() const = 0; };然后实现具体的命令。例如,MoveStepsCommand(移动步数命令):
// MoveStepsCommand.h #ifndef MOVESTEPSCOMMAND_H #define MOVESTEPSCOMMAND_H #include "CardCommand.h" class MoveStepsCommand : public CardCommand { public: MoveStepsCommand(int steps); void execute(std::shared_ptr<Player> target, GameEngine* engine) override; std::string getDescription() const override; private: int steps_; };// MoveStepsCommand.cpp #include "MoveStepsCommand.h" #include "Player.h" #include "GameEngine.h" #include <iostream> MoveStepsCommand::MoveStepsCommand(int steps) : steps_(steps) {} void MoveStepsCommand::execute(std::shared_ptr<Player> target, GameEngine* engine) { std::cout << "Card Effect: Move " << steps_ << " steps." << std::endl; // 这里需要小心!直接移动玩家可能会干扰当前的状态机。 // 更好的方式是,让命令产生一个“效果”,由引擎在合适的时机处理。 // 我们修改一下设计:命令不直接操作,而是向引擎提交一个“延迟动作”。 engine->submitPendingMove(target, steps_); } std::string MoveStepsCommand::getDescription() const { return "Move forward " + std::to_string(steps_) + " steps."; }再比如GainMoneyCommand(获得金钱命令):
// GainMoneyCommand.cpp #include "GainMoneyCommand.h" #include "Player.h" #include <iostream> GainMoneyCommand::GainMoneyCommand(int amount) : amount_(amount) {} void GainMoneyCommand::execute(std::shared_ptr<Player> target, GameEngine* engine) { std::cout << "Card Effect: Gain $" << amount_ << "." << std::endl; target->gainCash(amount_); // 获得金钱是即时效果,可以直接执行 } std::string GainMoneyCommand::getDescription() const { return "Gain $" + std::to_string(amount_) + "."; }卡牌类Card就非常简单了,它只包含一个命令对象和描述。
// Card.h #ifndef CARD_H #define CARD_H #include <memory> #include <string> #include "CardCommand.h" class Card { public: Card(std::unique_ptr<CardCommand> command, const std::string& description); void execute(std::shared_ptr<Player> target, GameEngine* engine); std::string getDescription() const; private: std::unique_ptr<CardCommand> command_; std::string description_; };卡牌堆CardDeck负责管理一组卡牌,并提供抽卡功能。
// CardDeck.h #ifndef CARDDECK_H #define CARDDECK_H #include <vector> #include <memory> #include "Card.h" class CardDeck { public: CardDeck(); void shuffle(); std::shared_ptr<Card> drawCard(); // 抽一张牌 void returnCard(std::shared_ptr<Card> card); // 将牌放回牌堆底部 private: std::vector<std::shared_ptr<Card>> cards_; size_t currentIndex_{0}; };设计要点与踩坑记录:
- 命令的副作用:这是命令模式在此场景下最需要注意的地方。有些命令(如
GainMoneyCommand)可以立即执行。但有些命令(如MoveStepsCommand)会改变玩家位置,进而可能触发新的地块事件,这可能会与当前游戏状态机产生冲突。我们的解决方案是:让命令产生“意图”而非直接执行。MoveStepsCommand的execute方法不再直接移动玩家,而是调用engine->submitPendingMove(...),将移动请求提交给引擎。引擎会在当前状态处理完毕后的合适时机(例如,在PlayerTurnEndState之前),统一处理这些 pending 的动作。这保证了游戏流程的确定性。 - 卡牌的重用:
CardDeck在抽卡后,通常会将卡牌放入弃牌堆,当抽牌堆空时再洗回。我们这里简化了,使用returnCard将牌放回底部。更完整的实现需要两个向量:drawPile和discardPile。 - 命令的创建:和地块一样,卡牌命令也可以使用工厂模式来创建,从配置文件中读取。例如,配置文件一行可能是
MOVE:3或GAIN:200,然后由一个CommandFactory来解析并创建对应的CardCommand对象。这能让卡牌系统的配置完全数据化。
4. 项目构建、运行与配置详解
4.1 开发环境与工具链
这个项目是标准的C++项目,对跨平台支持友好。我们主要在以下环境开发和测试:
- 编译器:
g++(MinGW-w64 或 Linux GCC) 或clang++。确保支持 C++11 或更高标准(我们使用了std::unique_ptr,std::shared_ptr,std::function等特性)。 - 构建工具:强烈推荐使用CMake。它可以帮助你轻松管理依赖、生成跨平台的构建文件(如 Makefile 或 Visual Studio 项目)。
- 代码编辑器:
VSCode、CLion、Visual Studio均可。VSCode配置 C++ 环境需要安装C/C++扩展,并配置好c_cpp_properties.json,tasks.json,launch.json来支持编译和调试。 - 版本控制:使用
Git。项目初期就应该建立仓库,养成良好的提交习惯。
4.2 使用CMake构建项目
项目根目录下的CMakeLists.txt是关键。一个简化版本如下:
cmake_minimum_required(VERSION 3.10) project(MonopolyDesignPatterns VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 将源代码文件分组 set(GAME_ENGINE_SOURCES src/main.cpp src/GameEngine.cpp src/Player.cpp src/Map.cpp src/Dice.cpp # ... 其他核心类cpp文件 ) set(BLOCK_SOURCES src/Block.cpp src/LandBlock.cpp src/StartBlock.cpp src/ChanceBlock.cpp src/BlockFactory.cpp # ... 其他地块cpp文件 ) set(STATE_SOURCES src/GameState.cpp src/PlayerTurnStartState.cpp src/PlayerMovingState.cpp # ... 其他状态cpp文件 ) set(COMMAND_SOURCES src/CardCommand.cpp src/MoveStepsCommand.cpp src/GainMoneyCommand.cpp src/Card.cpp src/CardDeck.cpp # ... 其他命令和卡牌cpp文件 ) # 将所有源文件合并 set(ALL_SOURCES ${GAME_ENGINE_SOURCES} ${BLOCK_SOURCES} ${STATE_SOURCES} ${COMMAND_SOURCES} ) # 创建可执行文件 add_executable(monopoly_dp ${ALL_SOURCES}) # 包含头文件目录 target_include_directories(monopoly_dp PRIVATE include) # 在Windows下,如果是Visual Studio,可能需要设置子系统为控制台 if(WIN32 AND MSVC) set_target_properties(monopoly_dp PROPERTIES LINK_FLAGS "/SUBSYSTEM:CONSOLE") endif()构建步骤:
- 在项目根目录打开终端。
mkdir build && cd build(创建一个构建目录,保持源码干净)。cmake ..(生成构建文件。如果想指定生成器,如cmake -G "MinGW Makefiles" ..)。cmake --build .(编译项目。在Linux/macOS下也可以直接make)。- 编译完成后,在
build目录(或build/Debug)下会生成可执行文件monopoly_dp(或monopoly_dp.exe)。
4.3 配置文件与数据驱动
为了让游戏内容可配置,我们将地图和卡牌数据放在外部文件中。
地图配置文件
map.txt:# 格式: 地块ID, 类型标识符, 地块名称, 额外参数 0,S,Start,GO 1,L,Mediterranean Avenue,60:2 2,C,Chance Chest, 3,L,Baltic Avenue,60:4 4,T,Income Tax,200:10% # 所得税,200或10%取高 5,R,Reading Railroad,200 # 铁路,特殊地块 ...游戏启动时,
Map类读取这个文件,逐行解析,并调用BlockFactory::createBlock(typeKey, id, name, extraArgs)来创建地块对象。卡牌配置文件
chance.txt和fate.txt:# 格式: 命令类型, 参数, 描述文本 MOVE,3,Advance to Go. Collect $200. # 此命令需要特殊处理,因为“前进到Go”是固定位置 GAIN,50,Bank pays you dividend of $50. PAY_EACH,-50,Pay poor tax of $50. # 负数表示支付 MOVE_TO,5,Take a trip to Reading Railroad. ...CardDeck初始化时,读取对应文件,利用一个CommandFactory(其实现类似BlockFactory)创建出具体的CardCommand对象,然后组装成Card,加入牌堆。
这样做的好处:你想修改游戏规则、调整地图、增加新卡牌,完全不需要重新编译代码!只需修改文本文件即可。这是设计模式带来的强大扩展性的直接体现。
5. 常见问题、调试技巧与扩展方向
5.1 编译与运行常见问题
undefined reference to ...链接错误:- 原因:这是最常见的问题。说明你的
.cpp源文件没有加入到编译列表中,或者对应的.o文件没有被链接。 - 解决:检查
CMakeLists.txt中的set(ALL_SOURCES ...)部分,确保所有你实现的.cpp文件都列在了里面。特别是新添加的类,一定要把它的.cpp文件加进去。
- 原因:这是最常见的问题。说明你的
‘unique_ptr’ in namespace ‘std’ did not name a template type:- 原因:编译器不支持 C++11 或未启用 C++11 标准。
- 解决:在
CMakeLists.txt中确保设置了set(CMAKE_CXX_STANDARD 11)。如果使用 g++ 命令行,添加-std=c++11标志。
控制台输出中文乱码:
- 原因:Windows 控制台默认编码是 GBK,而源代码文件是 UTF-8。
- 解决(Windows):
- 方案一:将源代码文件保存为带 BOM 的 UTF-8 或 GBK 编码(不推荐,不利于跨平台)。
- 方案二:在代码中输出中文时,使用宽字符
std::wstring和std::wcout(改动较大)。 - 推荐方案三:在程序启动时,设置控制台代码页。在
main()函数开头添加:#ifdef _WIN32 #include <windows.h> #endif int main() { #ifdef _WIN32 SetConsoleOutputCP(CP_UTF8); // 设置控制台输出为UTF-8 #endif // ... 游戏逻辑 } - 方案四:在 VSCode 的集成终端中运行,它通常能更好地处理 UTF-8。
程序崩溃,错误信息不明显:
- 解决:使用调试器。在 VSCode 中,配置好
launch.json,在关键代码处设置断点。在 CLion 或 Visual Studio 中直接使用内置调试器。这是定位指针错误、数组越界等问题的最有效方法。
- 解决:使用调试器。在 VSCode 中,配置好
5.2 设计模式应用中的典型“坑”
循环引用导致内存泄漏:在地块系统中,
LandBlock拥有一个owner_,类型是std::weak_ptr<Player>。为什么不用std::shared_ptr<Player>?因为Player类可能也会持有其拥有的地产列表(std::vector<std::shared_ptr<LandBlock>>)。如果双方都用shared_ptr,就会形成循环引用,导致对象永远无法被释放。std::weak_ptr是一种“弱引用”,它不会增加对象的引用计数,打破了循环,是处理这类问题的标准做法。状态机状态爆炸:如果游戏逻辑非常复杂,状态类可能会变得很多。这时候需要审视状态划分是否合理。有时,可以将一些简单的、短暂的状态用枚举或标志位在同一个状态类内部处理,而不是为每一个细微步骤都创建一个新的状态类。保持状态机的简洁和可理解性更重要。
工厂模式中参数解析的复杂性:我们用了简单的冒号分隔字符串来传递参数。当地块或命令的参数变得复杂时(比如一个命令需要多个不同类型的参数),这种格式会很难维护。进阶方案:使用结构体来封装参数,或者直接使用 JSON、YAML 等配置文件格式,配合像
nlohmann/json这样的库来解析,可读性和可维护性会好很多。单例模式的滥用与测试困难:单例模式让全局访问变得方便,但也隐藏了依赖关系,使得单元测试变得困难(因为你很难模拟一个单例)。在我们的项目中,
Dice(骰子)和BlockFactory使用单例是合理的,因为它们是无状态的或确实是全局唯一的工具。但对于有状态的“游戏管理器”,如果未来需要做网络版或AI测试,单例可能会成为障碍。需要权衡利弊。
5.3 项目扩展方向
这个基础框架为扩展留下了很多空间:
图形化界面:这是最直观的扩展。你可以用
Qt、SFML或Dear ImGui等库替换掉控制台输出。模型(我们的核心逻辑)和视图(GUI)是分离的,你只需要在现有状态类和命令类的相应位置,将std::cout替换为向GUI发送消息或调用更新函数即可。设计模式保证了业务逻辑的独立性,使得更换UI相对容易。网络对战:将游戏引擎改造成服务器,玩家客户端通过网络连接。状态模式在这里依然有用,服务器端维护游戏状态机,接收客户端指令(如“掷骰子”、“购买”),驱动状态转换,并将结果广播给所有客户端。命令模式可以用来序列化和反序列化网络消息。
AI玩家:实现一个简单的AI并不难。AI在每个状态(如
PlayerBuyDecisionState)需要做出决策。你可以为AI实现一套策略,例如:总是购买无人拥有的土地、当现金低于阈值时不购买、根据预期收益决定是否建造房屋等。AI的决策可以看作是在当前状态下,对游戏引擎的一个“自动”输入。更复杂的经济系统:引入银行贷款、股票市场、道具系统等。这些可以作为新的“命令”加入到卡牌系统,或者作为新的“地块”类型(如“股票交易所”地块),通过扩展工厂和命令模式轻松集成。
数据持久化:实现存档/读档功能。你需要为每个可序列化的类(如
Player,LandBlock,Map)实现toJson()和fromJson()方法,将游戏状态保存到文件。设计模式让对象关系清晰,序列化工作也会更有条理。
回过头看,这个项目最大的收获不是做出了一个多好玩的游戏,而是在实践中真切地体会到了设计模式如何让代码应对变化。当你需要加一个新功能时,不再是抱着头在一堆 spaghetti code 里寻找该改哪里,而是思考“我应该新增一个类,并在这里注册一下”。这种开发体验,对于培养良好的软件工程思维至关重要。代码我整理后放在了GitHub上,你可以直接下载、编译、运行,更可以随意修改和扩展。希望这个来自吉大软院的课程设计项目,能成为你学习C++和设计模式的一个有价值的踏脚石。