编程对来说就像呼吸一样自然,即使在休息的时候,大脑也总是不自觉地在编写代码。妻子希望能休息一下,但似乎已经对编程上瘾了,她接受了这样的,而也无法改变。好吧,至少找到了一种休息方式——编写游戏代码。
非常喜欢像《Cluedo》这样的逻辑推理游戏。这里,将介绍一个小型的控制台版本游戏以及背后的设计思路,使用C++语言实现。这个游戏利用了C++11的特性,包括lambda表达式、std::random等。
虽然《Cluedo》有一套特定的卡片(6个嫌疑人,6种武器,9个房间),但代码可以包含任意数量的类别,每个类别也可以包含任意数量的对象。函数DefaultCards
用于创建《Cluedo》的初始设置:
void DefaultCards() {
cards.clear();
cards[0].resize(6);
cards[0][0] = L"Mustard";
cards[0][1] = L"Orchid";
...
cards[1].resize(6);
cards[1][0] = L"Candlestick";
cards[1][1] = L"Dagger";
...
cards[2].resize(9);
cards[2][0] = L"Ballroom";
cards[2][1] = L"Billiard Room";
cards[2][2] = L"Conservatory";
...
}
发牌者从每个类别中随机抽取一张卡片作为“有罪卡片”,然后将剩余的卡片分发给玩家(默认6名)。函数Inform
通知每个玩家游戏设置(玩家数量、发牌类型和他们自己的卡片)。结构体PLAYERCARD
包含卡片信息(类别索引,项目索引):
struct PLAYERCARD {
size_t cat = 0;
size_t idx = 0;
};
使用std::map
存储正向信息,即玩家确定知道属于哪里的卡片。正向信息可以来自:
- 初始发牌(玩家收到的卡片)
- 游戏中向玩家展示的卡片
- 其他卡片的推导(见下文)
使用std::map
存储负向信息,即玩家确定知道不属于哪里的卡片。负向信息可以来自:
- 玩家的不回应。例如,如果玩家1建议“Mustard”,“Dagger”和“Ballroom”,玩家2没有回应,玩家1就知道玩家2不能持有这些卡片中的任何一张。
- 当一个玩家的所有卡片都已知时,那么该玩家就不能持有其他任何卡片。
- 因为一张卡片的负向信息可以匹配多个玩家,所以第二种类型的映射是std::unordered_set
。这允许在集合中插入重复项,它们会自动被消除。
当玩家2对玩家1的建议做出回应时,玩家1看到了那张卡片。其他玩家看不到这张卡片,但他们知道玩家2有玩家1建议的这些卡片中的一张。这个向量为每个玩家保持可能性。如果一个玩家已知有“Mustard”,“Dagger”或“Conservatory”卡片,后来“Dagger”和“Conservatory”被排除,那么可以推断出该玩家有“Mustard”卡片。
玩家从每个类别中建议一张卡片。建议函数的第一个版本是随机选择的。第二个版本只选择不是正向的卡片。当前版本只选择有最多负向的卡片。
std::vector Suggest() {
std::vector o;
size_t cats = TypeDeal.size();
for (size_t i = 0; i < cats; i++) {
struct CARDANDNEG {
PLAYERCARD p;
size_t nneg = 0;
bool operator<(const CARDANDNEG& can) {
if (nneg < can.nneg)
return true;
return false;
}
};
std::vector picks;
for (size_t idx = 0; idx < TypeDeal[i].size(); idx++) {
PLAYERCARD t(i, idx);
if (Positives.find(t) == Positives.end()) {
CARDANDNEG can;
can.p = t;
picks.push_back(can);
}
}
if (picks.empty()) {
PLAYERCARD t(i, 0);
CARDANDNEG can;
can.p = t;
picks.push_back(can);
}
if (picks.size() == 1) {
PLAYERCARD pc = picks[0].p;
o.push_back(pc);
continue;
}
for (size_t ii = 0; ii < picks.size(); ii++) {
auto& p = picks[ii];
if (Negatives.find(p.p) == Negatives.end())
continue;
p.nneg = Negatives[p.nneg].size();
}
std::sort(picks.begin(), picks.end());
auto lastneg = picks[picks.size() - 1].nneg;
while (picks.size() > 1) {
if (picks[0].nneg == lastneg)
break;
picks.erase(picks.begin());
}
std::uniform_int_distribution<> distr(0, (int)(picks.size() - 1));
int rt = distr(random_engine);
PLAYERCARD pc = picks[rt].p;
o.push_back(pc);
}
return o;
}
下一个想法是建议那张如果被发现,将更有助于解决可能性的卡片。
当有零张或一张卡片可以展示时,没有选择。当有超过一张卡片可以回应时,第一个算法版本是随机选择的。当前版本选择过去展示最多的卡片(即,如果展示了Revolver卡片,并且在下一次有机会再次展示它,它将再次被选中)。
std::optional Respond([[maybe_unused]] size_t pl, std::vector& pcs) {
size_t CanRespond = 0;
for (auto& req : pcs) {
for (auto& my : mycards) {
if (req.cat == my.cat && req.idx == my.idx)
CanRespond++;
}
}
if (CanRespond == 0)
return {};
if (CanRespond == 1) {
for (auto& req : pcs) {
for (auto& my : mycards) {
if (req.cat == my.cat && req.idx == my.idx) {
mycardsshown[my]++;
return my;
}
}
}
}
PLAYERCARD Should;
size_t sm = (size_t)-1;
for (auto& req : pcs) {
for (auto& my : mycards) {
if (req.cat == my.cat && req.idx == my.idx) {
size_t count = mycardsshown[my];
if (count < sm || sm == (size_t)-1) {
sm = count;
Should = req;
}
}
}
}
mycardsshown[Should]++;
return Should;
}
在每个玩家得到回应后,被要求提供有罪卡片。步骤包括: - 计算最大玩家卡片上的负向卡片,例如,如果一个玩家的卡片都是已知的正向,那么所有其他卡片就不能属于这张卡片。 - 解决可能性。如果一个玩家已知有“Mustard”,“Dagger”或“Conservatory”卡片,当前的负向表明“Dagger”和“Conservatory”被排除,那么可以推断出该玩家有“Mustard”卡片。 - 对于每个类别: - 如果一张卡片因为所有负向而被强制(例如,六个负向/六个玩家),那么这就是一张有罪卡片。 - 如果5/6的卡片对一个玩家有正向,那么第六张卡片就是有罪卡片。 - 如果有更多潜在的有罪卡片,函数立即返回一个概率分数,但不建议有罪卡片。
std::optional> AskGuilty();
未来版本的指控阶段应该考虑到另一个玩家可能在下一轮之前解决谜题的机会。因此,如果罪恶卡片的概率不是1,但它相对较大(比如0.3),那么未来的版本可能会冒险进行指控阶段,理由是玩家无论如何也不会有另一个机会。
让玩家跟踪并估计其他玩家解决谜题的可能性。如果有可能一个玩家会在下一轮解决谜题,当前玩家必须建议罪恶卡片,即使概率很低。