1. 项目概述:一个看似简单却暗藏玄机的博弈游戏
最近在整理一些经典的小游戏项目时,翻到了一个非常有意思的“碰手指游戏”。这个游戏的规则简单到令人发指:两个人轮流报数,每次可以报1到3个数,谁先报到10谁就输。听起来是不是有点像简化版的“抢30”?但它的策略核心却截然不同,而且有一个反直觉的结论—— 在这个“到10即消”的规则下,实际上是后手玩家拥有必胜策略 。这个结论让很多第一次接触的朋友都感到惊讶,明明感觉先手更有主动权,怎么就后手必胜了呢?
为了把这个有趣的数学博弈和编程实践结合起来,我决定用C++完整地实现一遍这个游戏,并且把必胜策略的逻辑也内置到AI对手中。这样,无论是想理解背后的数学原理,还是想获得一个可运行、可学习的C++小游戏源码,都能得到满足。这个小项目非常适合C++初学者用来练手,它涵盖了基本的输入输出、控制流、函数封装以及简单的算法逻辑,代码清晰,没有复杂的第三方依赖,在任何一个C++开发环境里都能轻松跑起来。
2. 游戏规则与必胜策略的数学原理拆解
2.1 “到10即消”的核心规则解析
我们先把这个游戏的规则说得再明确一些,这是所有分析的起点。
- 参与方 :两名玩家,可以是人vs人,人vs电脑,或者电脑vs电脑。
- 数字序列 :一个从1开始,按顺序递增的整数序列。我们关注的是报到数字“10”的那一刻。
- 行动规则 :双方轮流进行。在自己的回合,必须且只能选择报出 1个、2个或3个 连续的数字。例如,当前数字报到了5,那么当前玩家可以报“6”、或“6,7”、或“6,7,8”。
- 胜负判定 : 谁报出数字“10”,谁就立即输掉游戏 。注意,是“报出”数字10,而不是“累计报到”10。比如,当前数字是9,轮到玩家A,如果他选择报“10”,那么他直接告负;如果他选择报“9,10”,同样也是输,因为10包含在其中。
这个规则的关键在于“到10即输”,它把10变成了一个“必败点”。我们的目标,就是避免自己踏入这个必败点,同时想方设法把对手逼进去。
2.2 逆向推导:为什么后手必胜?
很多博弈问题都可以通过“逆向归纳法”来寻找必胜策略。我们从终点(输的点)开始,倒着推哪些点是“安全”的。
- 确定必败点(N点) :首先,数字10是一个绝对的必败点(记作 N-Position)。因为谁走到10,轮到他报数,他就输了。
- 寻找必胜点(P点) :如果一个点,可以通过一次合法操作(报1-3个数)将对手送入一个必败点(N点),那么这个点就是必胜点(P-Position)。
-
逆向推导过程
:
- 数字10是N点(必败)。
- 看数字9:从9出发,可以报“10”(1个数),直接将对手送入必败点10。所以, 9是P点(必胜) 。谁走到9,谁就赢了。
- 看数字8:从8出发,可以报“9”(1个数),将对手送入必胜点9,这对我们不利。可以报“8,9”(2个数)或“8,9,10”(3个数),都会包含9或10。我们发现,无论怎么走,都会把对手送到9(P点)或者自己直接报10(N点)。所以, 8是N点(必败) 。
- 看数字7:从7出发,可以报“8”(1个数),将对手送入必败点8。所以, 7是P点(必胜) 。
- 看数字6:从6出发,可以报“7”(1个数,送对手到P点),报“6,7”(2个数,送对手到P点),报“6,7,8”(3个数,送对手到N点8)。 存在一种选择(报3个数)可以将对手送入N点 ,所以6是P点。
- 看数字5:从5出发,报“6”(送P点),“5,6”(送P点),“5,6,7”(送P点)。所有选择都指向对手的P点,所以 5是N点 。
-
依次类推:
- 4:可以报“5”(送N点),所以4是P点。
- 3:报“4”(送P点),“3,4”(送P点),“3,4,5”(送N点)。存在送N点的选择,所以3是P点。
- 2:报“3”(送P点),“2,3”(送P点),“2,3,4”(送P点)。全是P点,所以2是N点。
- 1:报“2”(送N点),所以1是P点。
- 0(游戏起点):可以看作报“1”(送P点),“1,2”(送N点),“1,2,3”(送P点)。存在送N点的选择,所以起点0是P点。
注意 :这里的“点”指的是游戏开始前或某一轮开始前,下一个将要报的数字的前一个数字。例如,当数字7刚被报出,下一个该从8开始报,我们说当前“站在”点7上。
推导结论表格 :
| 数字点 | 类型 | 说明 |
|---|---|---|
| 0 | P点 (必胜) | 游戏起点 |
| 1 | P点 | |
| 2 | N点 (必败) | |
| 3 | P点 | |
| 4 | P点 | |
| 5 | N点 | |
| 6 | P点 | |
| 7 | P点 | |
| 8 | N点 | |
| 9 | P点 | |
| 10 | N点 (终局必败) |
这个表格揭示了规律: N点(必败点)是2、5、8、10 。它们之间相差3。而10是最终的N点。
后手必胜策略 :游戏从0开始,0是P点(必胜点)。先手玩家从0开始行动,他一定会将局面从一个P点变为一个N点(因为从P点出发,所有选择都包含走向N点的可能,但先手可能不知道策略而走错)。而后手玩家的任务就是, 无论先手怎么走,都通过自己的操作,将局面重新拉回到下一个N点(2,5,8)上,直到最后将先手逼到10这个终极N点 。
例如:
- 先手报“1”(走到点1,N点?不,点1是P点,他离开了起点0这个P点,但没走到N点)。后手应该报“2,3”(2个数),走到点3?不对,这样局面是点3(P点),策略不对。正确后手策略是:先手从0报“1”后,当前数字是1,下一个该报2。后手应该报“2”(1个数),这样报出的数字是2,局面走到点2(N点)。完美。
- 先手报“1,2”(走到点2,N点)。后手发现局面已经是N点(对自己不利),此时他无法一步将局面扭转到下一个N点,因为从N点出发,所有走法都会送对手到P点。所以,如果先手第一步就走到了N点(如2),后手就失去了必胜保障,先手反而有机会赢。但先手如果不懂策略,很难第一步就精准走到N点。
因此, 在双方都完全理性、知晓必胜策略的情况下,游戏起点(0)是必胜点,所以先手玩家是必胜的。但是,我们通常假设先手玩家不了解这个策略,而后手玩家(AI)知道。那么后手AI就可以通过策略保持不败,并且一旦先手犯错,后手就能必胜。 这就是“后手必胜”说法的常见语境:面对一个不知道策略的玩家,后手的AI是必胜的。
2.3 策略算法化:如何让AI“聪明”起来
根据上面的推导,AI(后手)的核心策略可以总结为:
- 目标 :总是试图在自己行动后,使累计报出的最后一个数字是“必败点N”(即2,5,8,10)。因为10是终局,所以前期目标是2,5,8。
-
方法
:计算当前距离下一个必败点还有多少个数。设当前最后一个已报数字为
current,下一个必败点序列为[2,5,8,10]。找到第一个大于current的必败点target。计算差值diff = target - current。-
如果
diff的值在1到3之间,那么AI就报diff个数,正好走到target。 -
如果
diff大于3,说明当前离下一个必败点还远,AI可以随机报1-3个数(或者采用一种保守策略,比如总是报1个数),等待对手犯错。因为只要对手不走到必败点,局面就还在AI的控制节奏之外,但AI也没犯错误。 -
如果
diff等于0?这不可能,因为如果current已经是必败点,那应该是对手的回合,AI不会在必败点上行动。
-
如果
-
随机性
:当
diff大于3时,AI的随机行动可以增加游戏的可玩性,避免模式固定。同时,如果玩家也懂得策略,那么游戏就会进入“拼失误”的环节,谁先偏离必败点序列谁就输。
3. C++游戏实现详解与源码剖析
接下来,我们把这个策略用C++实现出来。项目结构清晰,主要包含游戏状态管理、玩家回合处理、AI逻辑和主循环。
3.1 游戏核心类的设计
我们将游戏逻辑封装在一个类中,这样状态清晰,易于管理。
// FingerTouchGame.h
#ifndef FINGERTOUCHGAME_H
#define FINGERTOUCHGAME_H
#include <vector>
#include <string>
class FingerTouchGame {
public:
// 游戏模式枚举
enum class GameMode {
PvP, // 玩家对玩家
PvC, // 玩家对电脑 (玩家先手)
CvC // 电脑对电脑 (演示用)
};
// 构造函数,初始化游戏
explicit FingerTouchGame(GameMode mode = GameMode::PvC);
// 开始游戏主循环
void StartGame();
// 获取当前最后一个数字
int GetCurrentNumber() const;
// 获取当前玩家 (1 或 2)
int GetCurrentPlayer() const;
// 判断游戏是否结束
bool IsGameOver() const;
// 获取赢家 (0: 未结束, 1: 玩家1, 2: 玩家2)
int GetWinner() const;
private:
GameMode mode_; // 游戏模式
int currentNumber_; // 当前最后一个已报出的数字,初始为0
int currentPlayer_; // 当前行动玩家 (1 或 2)
bool gameOver_; // 游戏结束标志
int winner_; // 获胜者
// 必败点序列
const std::vector<int> losingPositions_ = {2, 5, 8, 10};
// 处理玩家回合 (人类)
void ProcessHumanTurn();
// 处理电脑回合 (AI)
void ProcessComputerTurn();
// 执行报数操作,并更新状态
// 参数count: 本次要报的数字个数 (1,2,3)
// 返回值: 是否操作成功 (例如,报数后是否导致游戏结束)
bool PerformMove(int count);
// AI决策:根据策略返回应该报的数字个数 (1,2,3)
int MakeAIDecision() const;
// 显示当前游戏状态
void DisplayGameState() const;
// 显示获胜信息
void DisplayWinner() const;
// 验证输入是否合法 (1,2,3 且不超过10)
bool IsValidMove(int count) const;
};
#endif // FINGERTOUCHGAME_H
设计思路解析 :
- 状态集中管理 :所有游戏状态(当前数字、当前玩家、是否结束等)都作为类的成员变量,避免使用全局变量,提高了代码的封装性和可维护性。
-
模式支持
:通过
GameMode枚举支持三种模式,方便测试和演示。PvC模式是我们重点,其中电脑作为后手应用必胜策略。 -
核心数据
:
losingPositions_存储了计算好的必败点序列,AI决策函数会查询这个序列。 -
操作与显示分离
:
PerformMove只负责更新内部状态,DisplayGameState负责显示,符合单一职责原则。
3.2 核心逻辑的实现
让我们看看关键函数的实现,特别是AI决策和游戏主循环。
// FingerTouchGame.cpp
#include “FingerTouchGame.h”
#include <iostream>
#include <cstdlib>
#include <ctime>
#include <limits>
// 初始化随机种子
namespace {
bool srandCalled = false;
void InitRandom() {
if (!srandCalled) {
std::srand(static_cast<unsigned int>(std::time(nullptr)));
srandCalled = true;
}
}
}
FingerTouchGame::FingerTouchGame(GameMode mode)
: mode_(mode), currentNumber_(0), currentPlayer_(1), gameOver_(false), winner_(0) {
InitRandom(); // 确保随机数生成器已初始化
}
void FingerTouchGame::StartGame() {
std::cout << “\n========== 碰手指游戏 (到10即输) ==========\n”;
std::cout << “规则:两人轮流报数,每次可报1-3个连续数字。谁报到数字10谁输!\n”;
while (!IsGameOver()) {
DisplayGameState();
switch (mode_) {
case GameMode::PvP:
ProcessHumanTurn(); // 总是人类回合
break;
case GameMode::PvC:
if (currentPlayer_ == 1) {
ProcessHumanTurn(); // 玩家1是人类
} else {
ProcessComputerTurn(); // 玩家2是电脑
}
break;
case GameMode::CvC:
ProcessComputerTurn(); // 两个都是电脑
break;
}
// 切换玩家
if (!gameOver_) {
currentPlayer_ = (currentPlayer_ == 1) ? 2 : 1;
}
}
DisplayWinner();
}
// AI决策的核心函数
int FingerTouchGame::MakeAIDecision() const {
// 找到下一个必败点
int target = -1;
for (int pos : losingPositions_) {
if (pos > currentNumber_) {
target = pos;
break;
}
}
// 如果找到了下一个必败点,计算差值
if (target != -1) {
int diff = target - currentNumber_;
if (diff >= 1 && diff <= 3) {
// 完美!可以一步走到必败点
return diff;
}
}
// 如果无法一步走到必败点,或者已经过了所有必败点(理论上不会,因为到10游戏结束),则随机走
// 这里可以加入一些简单策略,比如避免直接报出9(送对手到必胜点)
// 简单实现:随机1-3
return (std::rand() % 3) + 1; // 生成1,2,3的随机数
}
void FingerTouchGame::ProcessComputerTurn() {
std::cout << “\n-> 玩家” << currentPlayer_ << “ (电脑) 正在思考...\n”;
// 可以加一个短暂延迟,模拟思考过程
// std::this_thread::sleep_for(std::chrono::milliseconds(800));
int move = MakeAIDecision();
std::cout << “电脑决定报 ” << move << “ 个数。” << std::endl;
PerformMove(move);
}
void FingerTouchGame::ProcessHumanTurn() {
int move = 0;
bool validInput = false;
while (!validInput) {
std::cout << “\n-> 玩家” << currentPlayer_ << “,请输入要报的数字个数 (1, 2, 3): “;
std::cin >> move;
// 处理非数字输入导致的cin错误状态
if (std::cin.fail()) {
std::cin.clear(); // 清除错误状态
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), ‘\n’); // 忽略错误输入行
std::cout << “输入错误!请输入一个数字。\n”;
continue;
}
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), ‘\n’); // 忽略换行符
if (IsValidMove(move)) {
validInput = true;
} else {
std::cout << “无效的输入!请输入1, 2 或 3,并且报数后不能超过10。\n”;
}
}
PerformMove(move);
}
bool FingerTouchGame::PerformMove(int count) {
// 报数
std::cout << “报数: “;
for (int i = 1; i <= count; ++i) {
std::cout << (currentNumber_ + i);
if (i < count) std::cout << “, “;
}
std::cout << std::endl;
currentNumber_ += count;
// 检查游戏是否结束
if (currentNumber_ >= 10) {
gameOver_ = true;
winner_ = (currentPlayer_ == 1) ? 2 : 1; // 当前报出10的玩家输,所以赢家是另一方
std::cout << “\n玩家” << currentPlayer_ << “ 报出了数字10!\n”;
return true;
}
return false;
}
// 其他辅助函数实现...
bool FingerTouchGame::IsValidMove(int count) const {
if (count < 1 || count > 3) return false;
if (currentNumber_ + count > 10) return false; // 报数后不能超过10
return true;
}
void FingerTouchGame::DisplayGameState() const {
std::cout << “\n——————————\n”;
std::cout << “当前数字: “ << currentNumber_ << std::endl;
std::cout << “下一个数字从 ” << (currentNumber_ + 1) << “ 开始\n”;
std::cout << “轮到玩家 ” << currentPlayer_;
if (mode_ == GameMode::PvC && currentPlayer_ == 2) {
std::cout << “ (电脑)”;
}
std::cout << “ 行动\n”;
}
代码要点解析 :
-
AI决策 (
MakeAIDecision) :这是实现必胜策略的核心。它遍历预设的必败点序列,找到第一个比当前数字大的点作为目标。计算差值diff,如果在1-3之间,就返回diff,实现最优走法。否则,随机返回1-3。这个简单的逻辑使得后手AI在面对不懂策略的玩家时几乎立于不败之地。 -
输入验证与鲁棒性
:在
ProcessHumanTurn中,我们不仅检查输入数字是否在1-3且不超过10,还处理了std::cin.fail()的情况(比如用户输入了字母)。这是编写交互式控制台程序时必须注意的细节,能防止程序因意外输入而崩溃。 -
游戏状态更新 (
PerformMove) :这个函数负责核心的状态转移。它输出报数过程,更新currentNumber_,并检查是否触发游戏结束条件(currentNumber_ >= 10)。注意,这里用的是>=10而不是==10,是一种防御性编程,防止意外逻辑错误导致数字跳过10。 -
主循环 (
StartGame) :清晰地根据游戏模式分发回合处理逻辑,并在每轮结束后切换当前玩家,直到游戏结束。
3.3 主函数与编译运行
一个简单的
main.cpp
来启动游戏:
// main.cpp
#include “FingerTouchGame.h”
#include <iostream>
int main() {
int choice = 0;
std::cout << “选择游戏模式:\n”;
std::cout << “1. 玩家 vs 电脑 (推荐,体验后手AI策略)\n”;
std::cout << “2. 玩家 vs 玩家\n”;
std::cout << “3. 电脑 vs 电脑 (演示)\n”;
std::cout << “请输入选择 (1-3): “;
std::cin >> choice;
FingerTouchGame::GameMode mode;
switch (choice) {
case 1: mode = FingerTouchGame::GameMode::PvC; break;
case 2: mode = FingerTouchGame::GameMode::PvP; break;
case 3: mode = FingerTouchGame::GameMode::CvC; break;
default:
std::cout << “无效选择,默认使用玩家vs电脑模式。\n”;
mode = FingerTouchGame::GameMode::PvC;
break;
}
FingerTouchGame game(mode);
game.StartGame();
std::cout << “\n游戏结束,谢谢游玩!\n”;
return 0;
}
编译与运行 : 这是一个标准的C++控制台项目。你可以使用任何喜欢的IDE或命令行工具编译。
-
GCC/Clang命令行
:
g++ -std=c++11 main.cpp FingerTouchGame.cpp -o finger_game -
Visual Studio
:创建一个空项目,添加这三个
.cpp文件,编译运行即可。
运行后,选择模式1(玩家vs电脑),你就可以作为先手,挑战那个拥有“后手必胜”策略的AI了。看看你能坚持多久!
4. 项目扩展思路与编程技巧探讨
实现基础版本后,我们可以从多个角度对这个项目进行扩展,这不仅能增加趣味性,也能深化对C++和博弈论的理解。
4.1 游戏规则变体与策略泛化
“到10即消”只是这类“取物游戏”或“减法游戏”的一个特例。我们可以抽象出一个更通用的模型:
- 总数字N :不是10,可以是任意正整数N(比如21点游戏)。
- 每次可取范围 :不是1-3,可以是1到k(k为任意正整数)。
- 胜负目标 :可以是“取到最后一个输”(Misère规则,如本项目),也可以是“取到最后一个赢”(Normal规则)。
对于通用模型(总数为N,每次取1~k个,取最后一个输),其必胜策略的规律是: 第一个必败点是 (k+1) 的倍数吗? 不一定。对于“取最后一个输”,其必败点序列的通项公式会更复杂一些,但依然可以通过动态规划逆向推导出来。我们可以修改程序,让用户输入N和k,然后程序自动计算并应用必胜策略。这涉及到算法从“硬编码”到“动态计算”的升级。
4.2 AI难度分级与机器学习初探
目前的AI是“完美”的(在它知道策略的情况下)。我们可以引入难度分级:
- 简单AI :完全随机走法。
- 中等AI :在基础策略上加入一定概率的“失误”,比如有20%的概率不执行最优走法,而是随机走,这样人类玩家有机会赢。
- 困难AI :即当前的完美策略AI。
- 学习型AI(挑战) :可以尝试用简单的强化学习(如Q-learning)来训练AI。状态是当前数字,动作是报1/2/3个数,奖励在输的时候为负,赢的时候为正。让AI自我对弈成千上万局,最终它也能学习到类似的必胜策略。这对于C++初学者来说是个不小的挑战,但可以作为了解AI入门的一个有趣方向。
4.3 代码优化与工程化实践
对于这个小项目,我们还可以在代码质量上做些文章:
-
使用智能指针管理资源
:虽然当前项目没有动态资源,但可以设想如果游戏状态更复杂,引入
std::unique_ptr来管理某些对象是良好的习惯。 -
将策略算法抽象为接口
:定义一个
IGameStrategy纯虚类,然后派生出RandomStrategy、PerfectStrategy、LearningStrategy等。游戏主类持有一个策略接口的指针,通过注入不同的策略实现来改变AI行为。这大大增强了代码的扩展性和可测试性。 -
加入单元测试
:使用Google Test等框架,为
IsValidMove、PerformMove、MakeAIDecision等核心函数编写测试用例,确保逻辑正确。特别是AI决策函数,可以测试其在各种局面下(当前数字为1,4,7等)是否做出了预期中的最优选择。 - 图形化界面 :用Qt、SFML或甚至控制台图形库(如ncurses)为游戏制作一个简单的图形界面,用进度条或高亮数字显示当前状态,提升用户体验。
4.4 常见的编译与运行问题排查
对于初学者,在尝试编译运行此代码时可能会遇到一些问题,这里集中列举一下:
-
头文件找不到
:确保
FingerTouchGame.h和FingerTouchGame.cpp、main.cpp在同一个目录下,或者正确配置了项目的包含路径。 -
std::this_thread::sleep_for未定义 :如果取消注释AI思考延迟的那行代码,需要包含<thread>和<chrono>头文件,并且编译器需要支持C++11及以上标准。编译时加上-std=c++11参数。 -
随机数每次一样
:我们使用了
std::srand(std::time(nullptr))来初始化随机种子。如果在极短时间内多次运行程序,time(nullptr)可能返回相同的值,导致随机序列相同。这在演示中问题不大。对于要求更高的场景,可以考虑使用C++11的<random>库。 -
输入缓冲问题
:我们在
ProcessHumanTurn中使用了std::cin.ignore(...)来清除输入缓冲区。这是为了避免之前输入残留的换行符影响后续读取。如果发现输入行为异常,可以检查这里的逻辑。 -
游戏逻辑感觉不对
:请再次核对规则“谁报到10谁输”。有时容易和“谁报到10谁赢”混淆。必胜策略是完全相反的。确保你的理解和代码中的判定条件(
currentNumber_ >= 10)匹配的是“输”的规则。
这个“碰手指游戏”项目虽小,却串联起了数学博弈、算法策略和C++面向对象编程。通过动手实现它,你不仅能获得一个有趣的游戏,更能深入理解“必胜策略”背后的逆向推导思维,以及如何将数学模型转化为可靠的代码。希望这份详细的源码和解析能给你带来启发。

479

被折叠的 条评论
为什么被折叠?



