去年我带几个学过C++基础的新手朋友做练习项目。计算器、图书管理系统这类题目,做完的感觉就是"会了,但没完全会",离真正的工程实践始终隔着一层。直到我把当年火出圈的Wordle改造成命令行版本,他们才第一次体验到"设计数据结构→实现规则→处理边界情况→打磨交互"的完整链路。
这个项目非常适合C++入门之后、想通过动手巩固知识的人。它不依赖任何第三方库,只用标准库就能写完,覆盖了类设计、容器使用、随机数、输入输出控制、字符编码处理等高频知识点。同时规则足够简单——每次猜一个词,得到三种颜色反馈,最多猜6次——实现起来却有不少值得琢磨的细节。这篇文章会从规则梳理讲到完整源码,再分享我在不同系统上编译运行踩过的坑,以及后续可以扩展的方向。
1. 为什么选Wordle作为C++练手项目:它比练习题更像真实工程
1.1 一个练手项目应该满足什么条件
我衡量一个练手项目值不值得做,主要看三点。
第一点是规则明确但不过于庞大。Wordle的规则可以在两三句话内说清楚,但实现时要处理"字母重复出现"这种边界情况,不会让人觉得无从下手,也不会简单到没有思考量。
第二点是能覆盖多种C++核心语法。如果项目只用到cout和if,做完基本没收获。Wordle需要你设计类来封装状态,需要用vector保存多轮猜测记录,需要unordered_map统计字母频次,需要随机数从词库选答案,还需要在输入输出上做精细控制。
第三点是结果直观、有反馈感。命令行程序最难的就是"反馈感",但Wordle天然有颜色反馈和逐行显示的历史记录,每次猜测都能立刻看到结果,这种即时反馈对学习动力很有帮助。
第三点我认为在练习中最容易被忽略。传统习题做完只有"编译通过",Wordle却能做出真正可以拿给别人玩、甚至骗到同事说"你在终端里做wordle?"的小工具。
1.2 这个项目覆盖的知识点清单
如果把这个项目拆分一下,每个模块对应的C++知识点大致是这样的:
- 类与封装:用一个
WordleGame类管理答案、历史记录、当前输入 - 标准容器:
std::vector存储历史结果,std::unordered_map统计字母出现次数 - 随机数:
std::mt19937和std::uniform_int_distribution从词库挑选答案 - 文件操作:从外部词库读取所有合法单词
- 宽字符处理:支持中文词库时,必须用
std::wstring和std::wcout - 程序流程控制:游戏主循环、输入校验、胜负判定
对初学者来说,这恰好串起了一整条知识链。很多人学完STL容器后不知道什么时候该用哪个,做完这个项目,vector和unordered_map的适用场景基本就清楚了。
1.3 和教材练习项目的本质差异
这里的差异值得展开说一说。教材里的练习题,通常把函数接口都给你定好了,你只需要填空。而Wordle从零开始,一切都要自己设计。比如"每一次猜测的反馈结果用什么数据结构存放"这个问题,教材不会帮你回答,你必须自己想清楚。选择vector<LetterResult>还是二维数组,取决于你要不要支持动态的猜词次数;每个格子要不要保存字符和颜色状态,取决于渲染时是否需要重绘历史记录。
另外,练习题往往有一个隐藏前提:输入总是合法的。但真实程序必须考虑"用户输入了一个不在词库里的词怎么办"。这种防御性编程思维,才是工程实践和习题之间最大的差别。
2. 动手之前先理清规则:状态机、判定逻辑与"重复字母"这个隐藏坑
2.1 从官方规则到程序状态机
写代码之前,我习惯先画一遍逻辑流程图——不是要画给谁看,而是为了逼自己把所有路径想清楚。Wordle的程序流程是这样的:
- 从词库随机选一个目标单词(答案)
- 玩家输入一个猜测
- 校验猜测是否合法(长度正确、在词库中)
- 逐字母判定颜色:
- 绿色:字母在答案中,且位置正确
- 黄色:字母在答案中,但位置不对
- 灰色:字母不在答案中
- 如果全部绿色,玩家胜利
- 如果猜测次数耗尽,显示答案,玩家失败
- 否则回到第2步
这个流程对应到代码里就是一个主循环,核心状态只有"当前行号"和"游戏是否结束"。真正的复杂点在步骤4——判定逻辑不能想当然地写。下面专门讲这个。
2.2 三个颜色反馈的判定:为什么不能直接双重循环
很多人第一反应是:对每个猜测字母,遍历答案,看它在不在、位置对不对。直接双重循环就会踩坑。
我举个例子。假设答案是APPLE,玩家输入PIPES。
直观处理方式:
- 第一个
P在答案中存在,但位置不同 → 黄色 - 第二个字母
I不在答案中 → 灰色 - 第三个字母
P呢?答案里只有一个P,但第一个P已经占用了这个名额,所以第三个P应该判灰色
如果双重循环不加控制,第三个P会因为"答案中存在P"而被标成黄色,这就不对了。Wordle的官方规则是:黄色标记不能超过答案中该字母的总次数。
正确的做法分两遍处理。第一遍先标记所有绿色匹配,同时记录答案中每个字母还剩余多少次"可匹配"机会。第二遍处理剩下的位置,只有"字母存在且剩余次数大于0"才标黄色,每次标记后把计数减一。这样每个字母的消耗都被精确追踪,不会出现上面那种"一个字母被重复计数"的问题。
这个地方是整个游戏算法的题眼,写代码时值得单独抽出一个小函数来测试。我在调试时专门写了几组用例,比如答案SPEED猜SEEKS、答案ABBEY猜BABES,逼着程序处理重复字母的边界。
2.3 键盘反馈与胜负条件
除了每行的格子反馈,Wordle还有一个键盘状态条:记录每个字母目前被判定过的最高优先级颜色,方便玩家排除字母。这里的优先级是绿色 > 黄色 > 灰色。实现时可以用一个std::map<wchar_t, MatchType>来保存,每次新判定结果产生后,如果当前字母还没有记录,或新的优先级更高,就更新。
胜负条件本身不复杂:任意一行全绿就是胜利;行数达到上限还没有全绿就是失败。但这里有一个容易被忽略的设计问题——玩家输入的词不在词库里怎么办。我做的处理是:不消耗猜测次数,直接提示"不在词库中",让玩家重新输入。这样玩家可以试探一些乱七八糟的组合而不受惩罚,更接近官方体验。
3. 模块边界与技术选型:从"能跑"到"好维护"的工程化思路
3.1 文件划分与类设计
虽然项目规模不大,我还是建议至少分成main.cpp、WordleGame.h、WordleGame.cpp三个文件。原因是强迫自己区分"声明"和"定义",感受头文件在编译流程中的作用。很多初学者在单个文件里写习惯了,到多文件工程就懵,用这个小项目过渡正好。
WordleGame类的职责我控制在三块:
- 负责一局游戏的全部状态:答案、最大猜测次数、历史记录、当前输入、键盘状态
- 核心逻辑方法:
evaluate判定颜色、isValid校验输入、render绘制面板 - 不负责的事情:词库的加载解析单独放在
loadWords函数里,和游戏类解耦
为什么把词库加载放外面?因为加载词库和游戏逻辑是两件不同的事。词库可能来自文件,也可能未来来自网络、来自数据库,不该被绑定在游戏类里。接口层面WordleGame只接收一个std::vector<std::wstring>,它对词库的来源一无所知。这就是依赖倒置的一个简单实践,虽然讲得深了没必要,但代码自然形成了这个结构。
3.2 词库文件的组织方式
词库文件我采用"每行一个单词"的纯文本格式。英文词库就放全小写的5字母单词,中文词库就放5个汉字组成的词。为什么每行一个?因为解析简单,也方便人工维护和审查。如果担心词库太大加载慢,后续可以改成二进制格式或哈希表快照,但对这个小项目来说,文本格式完全够用。
词库文件还承担了一个隐藏作用:输入校验。玩家猜的词必须在词库中就存在,这是Wordle的规则之一。所以词库既是答案池,也是合法输入的白名单。
项目里我会内置一个words.txt,如果用户没提供词库文件,就使用代码内置的一组单词作为兜底。这样保证任何环境下载下来都能运行,不会因为缺文件而劝退。
3.3 颜色渲染方案:ANSI转义序列与跨平台处理
命令行显示颜色,标准做法是ANSI转义序列。形如\033[背景色m的字符会被终端解释为颜色切换。绿色背景是\033[48;5;28m,黄色背景是\033[48;5;220m,灰色背景是\033[48;5;240m,结束恢复默认是\033[0m。
这种方案的坑在于:Windows的经典控制台(conhost)旧版本默认不解释ANSI转义,需要手动启用虚拟终端处理。还好,Windows 10以上的新终端基本都默认支持,如果遇到老环境,可以通过设置控制台模式来开启。
我用一个小的封装接口来处理:
#ifdef _WIN32 #include <windows.h> void enableVirtualTerminal() { HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); DWORD mode = 0; GetConsoleMode(hOut, &mode); SetConsoleMode(hOut, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING); } #endifmacOS和Linux终端天然支持,完全不需处理。所以跨平台代码里这个函数只对Windows有条件编译。
4. 源码拆解:核心数据结构、判定函数与游戏主循环
4.1 项目文件总览
我实际跑通的项目文件结构是:
wordle-cli/ ├── main.cpp ├── WordleGame.h ├── WordleGame.cpp ├── words.txt └── CMakeLists.txt其中main.cpp负责入口逻辑:启用控制台特性、加载词库、创建WordleGame对象并循环进行多局游戏。下面先看WordleGame.h的完整声明。
// WordleGame.h #pragma once #include <string> #include <vector> #include <map> #include <unordered_map> enum class MatchType { EXACT, // 绿色:位置和字母都正确 EXISTS, // 黄色:字母在答案中,但位置不对 NONE // 灰色:字母不在答案中 }; struct LetterResult { wchar_t ch; MatchType type; }; class WordleGame { public: WordleGame(std::vector<std::wstring> words, int maxGuesses = 6); void run(); private: std::vector<std::wstring> m_words; std::wstring m_answer; int m_maxGuesses; int m_currentRow = 0; std::wstring m_currentInput; std::vector<std::vector<LetterResult>> m_history; std::map<wchar_t, MatchType> m_keyState; std::vector<LetterResult> evaluate(const std::wstring& guess) const; void updateKeyState(const std::vector<LetterResult>& row); void render() const; bool isValidWord(const std::wstring& word) const; void resetGame(); };m_currentRow记录当前正在填写的行号,m_history保存之后所有判定过的行,m_currentInput保存玩家正在输入但还没提交的字母,m_keyState用于键盘状态条。
4.2 main函数与多局游戏循环
main.cpp里除了加载词库,还负责让玩家可以玩完一局再来一局:
// main.cpp #include <iostream> #include <fstream> #include <string> #include <vector> #include <random> #include "WordleGame.h" #ifdef _WIN32 #include <windows.h> #endif static std::vector<std::wstring> loadWordsFromFile(const std::string& path) { std::vector<std::wstring> result; std::wifstream fin(path, std::ios::binary); if (!fin) return result; fin.imbue(std::locale("en_US.UTF-8")); // 视操作系统和编译器调整 std::wstring line; while (std::getline(fin, line)) { // 去除行尾的 \r(Windows文本文件)和首尾空白 while (!line.empty() && (line.back() == L'\r' || line.back() == L' ')) line.pop_back(); if (line.size() == 5) result.push_back(line); } return result; } int main() { #ifdef _WIN32 SetConsoleOutputCP(CP_UTF8); // 确保中文正常输出 enableVirtualTerminal(); #endif auto words = loadWordsFromFile("words.txt"); if (words.empty()) { // 内置兜底词库 words = { L"APPLE", L"CRANE", L"SLATE", L"CRISP", L"TRACE" }; } std::cout << "Wordle - 命令行猜词游戏\n"; bool playAgain = true; while (playAgain) { WordleGame game(words, 6); game.run(); std::wcout << L"\n再来一局?(y/n): "; wchar_t ch; std::wcin >> ch; playAgain = (ch == L'y' || ch == L'Y'); } return 0; }这里有两个细节需要说明。第一,std::wifstream配合std::locale("en_US.UTF-8")在Windows的MSVC和Linux的GCC下表现有差异,如果读取中文词库出现乱码,优先尝试用std::locale("")或改用std::ifstream读字节流再手工转宽字符串。第二,词库兜底用大写单词,内部所有比较统一使用大写,避免大小写转换的逻辑散落各处。
4.3 evaluate函数:整个游戏的核心
判定函数是整个游戏算法的核心,也是我前面反复强调的"重复字母"坑的答案所在:
std::vector<LetterResult> WordleGame::evaluate(const std::wstring& guess) const { std::vector<LetterResult> results(guess.size()); std::unordered_map<wchar_t, int> answerCount; for (wchar_t c : m_answer) answerCount[c]++; // 第一遍:标记绿色,并消耗掉对应的计数 for (size_t i = 0; i < guess.size(); ++i) { results[i].ch = guess[i]; if (guess[i] == m_answer[i]) { results[i].type = MatchType::EXACT; answerCount[guess[i]]--; } else { results[i].type = MatchType::NONE; } } // 第二遍:标记黄色,注意不能超过剩余计数 for (size_t i = 0; i < guess.size(); ++i) { if (results[i].type == MatchType::EXACT) continue; auto it = answerCount.find(guess[i]); if (it != answerCount.end() && it->second > 0) { results[i].type = MatchType::EXISTS; it->second--; } } return results; }理解这个函数的关键在于:绿色匹配也要消耗计数。比如答案APPLE,玩家输入PIPES。第一遍时,第二个字母I不是P,第三个字母P也不是P(因为答案下标2是P,这里假设输入是PIPES,下标2是P,实际就是绿色),所以绿色只有一个P被标记,answerCount['P']从1变成0。第二遍处理下标0的第一个P时,发现计数已经是0,正确判为灰色。这样最终呈现的是:一个绿色P,没有黄色P,符合Wordle真实规则。
这个逻辑如果写反了,先处理黄色再处理绿色,或者不消耗计数,结果就会错。我建议读者把这段代码单独拿出来,用APPLE、SPEED、ABBEY这几种重复字母多的词多跑几遍用例,比记住结论要有用得多。
4.4 render函数与输入处理
渲染分为三个区域:历史记录、当前输入、昵称键盘状态。因为使用了ANSI颜色,代码里我封装了一个小的颜色设置函数:
void WordleGame::render() const { system("clear"); // Windows下改成 system("cls") std::wcout << L"\n W O R D L E\n\n"; // 绘制每一行历史记录 for (int row = 0; row < m_maxGuesses; ++row) { if (row < static_cast<int>(m_history.size())) { for (const auto& r : m_history[row]) { switch (r.type) { case MatchType::EXACT: std::wcout << L"\033[48;5;28m "; break; case MatchType::EXISTS: std::wcout << L"\033[48;5;220m "; break; case MatchType::NONE: std::wcout << L"\033[48;5;240m "; break; } std::wcout << r.ch << L" \033[0m"; } } else if (row == m_currentRow) { // 当前行:未提交的输入用默认白底显示 std::wcout << L"\033[48;5;255m\033[30m "; for (size_t i = 0; i < m_currentInput.size(); ++i) std::wcout << m_currentInput[i] << L" "; for (size_t i = m_currentInput.size(); i < 5; ++i) std::wcout << L"_ "; std::wcout << L"\033[0m"; } else { std::wcout << L" _ _ _ _ _ "; } std::wcout << L"\n\n"; } // 绘制键盘状态 std::wcout << L"键盘状态: "; std::wstring alphabet = L"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; for (wchar_t c : alphabet) { auto it = m_keyState.find(c); if (it == m_keyState.end()) std::wcout << c << L" "; else if (it->second == MatchType::EXACT) std::wcout << L"\033[48;5;28m" << c << L"\033[0m "; else if (it->second == MatchType::EXISTS) std::wcout << L"\033[48;5;220m" << c << L"\033[0m "; else std::wcout << L"\033[48;5;240m" << c << L"\033[0m "; } std::wcout << L"\n"; }clear和cls在不同系统上不一样,为了跨平台我通常会再包一层。实际项目里不推荐反复调用system,有安全和性能双重考量,但作为一个学习项目,直接这样写不用引入第三方库,是取舍后的结果。如果你要发布到生产环境,建议改用ANSI的\033[2J\033[H清屏序列,或使用ncurses库。
输入处理是另一个有意思的地方。不能直接用std::cin >> input,因为我们需要监听退格键、回车键,并且实时回显。所以在Windows下我用_getch()逐字符读取,Linux下则用termios关闭行缓冲和回显:
void WordleGame::run() { while (m_currentRow < m_maxGuesses) { render(); // 读取输入,支持退格 m_currentInput.clear(); bool confirmed = false; while (!confirmed) { wchar_t ch = readKey(); // 封装了不同平台的逐字符读取 if (ch == L'\r' || ch == L'\n') { if (m_currentInput.size() == 5) confirmed = true; } else if (ch == 8 || ch == 127) // 退格 { if (!m_currentInput.empty()) m_currentInput.pop_back(); } else { ch = towupper(ch); if (ch >= L'A' && ch <= L'Z' && m_currentInput.size() < 5) m_currentInput.push_back(ch); } render(); // 每次按键后重绘 } if (!isValidWord(m_currentInput)) { std::wcout << L"该词不在词库中,请重新输入。\n"; continue; } auto result = evaluate(m_currentInput); m_history.push_back(result); updateKeyState(result); m_currentRow++; bool allExact = true; for (const auto& r : result) if (r.type != MatchType::EXACT) allExact = false; if (allExact) { render(); std::wcout << L"恭喜!你用了 " << m_currentRow << L" 次猜中。\n"; return; } } render(); std::wcout << L"很遗憾,答案是: " << m_answer << L"\n"; }readKey()的Linux实现需要用到tcsetattr关闭ICANON和ECHO,读取后再恢复。Windows下直接用_getwch()也可以,但要注意宽字符模式下_getwch返回的是wchar_t类型。
4.5 词库加载与随机选词
构造WordleGame时,我建议从词库里随机选词作为答案:
WordleGame::WordleGame(std::vector<std::wstring> words, int maxGuesses) : m_words(std::move(words)), m_maxGuesses(maxGuesses) { resetGame(); } void WordleGame::resetGame() { static std::random_device rd; static std::mt19937 gen(rd()); if (!m_words.empty()) { std::uniform_int_distribution<size_t> dist(0, m_words.size() - 1); m_answer = m_words[dist(gen)]; } m_currentRow = 0; m_currentInput.clear(); m_history.clear(); m_keyState.clear(); }static局部变量让随机数引擎在多次重开游戏时复用,避免每次构造都重新生成随机种子导致结果重复。resetGame和构造函数分离,是为了方便以后做"同一局重开"功能。
5. 从编译到运行:踩过环境坑后的完整构建方案
5.1 Windows环境下的编译运行
在Windows下我推荐直接用Visual Studio的开发者命令行,或者装一个MinGW-w64。如果用的是MSVC:
cl /EHsc /std:c++17 main.cpp WordleGame.cpp /Fe:wordle.exe如果用的是MinGW:
g++ -std=c++17 main.cpp WordleGame.cpp -o wordle.exe编译完成后运行wordle.exe。如果终端里出现一片乱码,优先排查代码页问题。在main函数开头调用SetConsoleOutputCP(CP_UTF8)会好很多,但要注意这个函数对标准输出流和宽字符流的作用范围有限,必要时在main里先调用_setmode(_fileno(stdout), _O_U8TEXT)配合使用。
另外,Windows Terminal(新版终端)对ANSI颜色支持较好,传统conhostWindows 10旧版可能会显示←[48;5;28m这种字面量。解决办法就是前面提到的enableVirtualTerminal()。
5.2 Linux/macOS环境下编译
Linux下编译只需要一条命令:
g++ -std=c++17 main.cpp WordleGame.cpp -o wordle ./wordlemacOS的clang命令几乎一样,注意可能需要-lcurses,但本项目没有用ncurses,所以不需要额外链接参数。
有个远程服务器的坑提醒一下:如果你通过SSH连接服务器运行这个程序,system("clear")可以正常工作,但中文词库的字体渲染取决于终端软件。Windows Terminal的SSH会话基本没问题,老旧的Xterm可能显示不了中文。这种情况要么换成英文词库,要么在词库里只放ASCII字符的短词。
5.3 UTF-8词库与BOM的坑
如果你维护的词库文件是UTF-8编码,Windows记事本默认会保存成带BOM(字节序标记)的格式。std::wifstream读第一行时,BOM会被当成一个不可见字符粘在第一个单词前面,导致第一个单词的长度变成6或内容不匹配。
解决办法有两种。一是用专门的文本编辑器(VS Code、Notepad++)保存为"UTF-8无BOM"格式。二是在代码里读取前三个字节,如果发现是EF BB BF就跳过:
static void skipUtf8Bom(std::wifstream& fin) { wchar_t peek = fin.peek(); if (peek == 0xFEFF) fin.get(); // 吃掉BOM }注意peek()返回的是宽字符,在UTF-8宽字符流下BOM会被整合成一个0xFEFF宽字符。这个坑在Windows平台特别常见,我第一次做就踩了,查了半天发现第一个词永远不对。
6. 进阶扩展:把Demo变成真正耐玩的作品
6.1 统计与连续游戏
基础的六次猜词循环做完之后,第一个值得加的功能是统计系统。官方Wordle会记录玩家的猜中次数分布、连胜场次、当前连胜。实现这个功能只需要在main里维护几个变量,同时把每局结果追加到本地文件里。
我的做法是加一个简单的stats.txt,每行记录一局结果,格式为胜|败|猜中次数。启动时读取统计文件,结束后写回。这样游戏退出再重开,统计还在。
6.2 硬模式与自定义词库
官方有一个"硬模式"玩法:如果你之前获得了黄色反馈,后面的猜测必须包含那个字母;绿色反馈的字母必须保持在原位。实现思路是每次校验输入时,遍历之前所有的历史记录,把约束条件逐条检查。这个逻辑不算复杂,但写出来会逼你思考"怎样从m_history中提取约束"。
自定义词库方面,可以提供一个命令行参数指定词库文件:wordle --words mylist.txt。解析命令行参数可以用std::string argv循环判断,不需要第三方库。如果词库里混入了长度不为5的词,加载时直接过滤掉,这样对词库的宽容度更高,玩家可以放任何词进去而不怕崩溃。
6.3 后续还能往哪些方向走
做完上面这些,一个命令行Wordle就已经相当完整了。如果想继续深入,可以试试这些方向:
- 把渲染改为双缓冲或局部刷新,消除
clear带来的闪烁感 - 支持自定义每局的最大猜测次数,比如7次、8次,验证一下难度变化
- 加入计时器,记录每局用时
- 用
ncurses库重写渲染层,做到不闪屏、支持任意终端大小 - 扩展词库文件格式,支持JSON以附带单词释义
我在完成基础版本后,最推荐的下一步是"把system("clear")替换为ANSI清屏序列"。这个改动虽然简单,但会让你理解终端转义序列的运转机制,同时整个程序的刷新体验会有明显提升。
最后分享一个我自己的开发习惯:每次在终端里看到编译错误,先深呼吸,从第一条错误开始看,不要一口气往下扫。用这套流程做完这个Wordle项目,中间被我修掉的类型错误、编码错误至少七八处,每一处都让我对C++的某个细节理解更深一层。如果你在实践过程中卡住了,尽量先自己定位问题,再回头对照这篇文章的代码。自己亲手修复过的坑,记忆会深刻得多。