用C++实现基于文本界面的纸牌

2020-07-13 05:18:22

纸牌是几年前我在一台古老的Windows3.1笔记本电脑上玩的第一款电脑游戏。我从来没有真正实施过。当我说纸牌时,我实际上指的是Klondike的变体,我认为它是电脑玩家中最常见的。

这些天我喜欢C语言,因为它离机器很近,而且几乎所有人都可以随身携带,这让我有一种禅宗般的感觉。标准库不像Python、Java或JavaScript那样方便,但是我们不需要太多复杂的数据结构,所以我们可以使用自己的结构。

最初不会有图形版本,但基于终端字符的版本似乎是一个好主意,本着一次只做一件事的精神-先做一些游戏逻辑,然后再做一些用户界面,甚至可能是后来的GUI。

我不会为自己喜欢的项目做太多的前期计划,它们大多是在旅途中发展起来的。然而,在这个游戏中,计划是按照以下路线进行:

枚举{套装心脏,套装黑桃,套装俱乐部,套装钻石//♥♠♣♦};枚举{RANK_A,RANK_2,RANK_3,RANK_4,RANK_5,RANK_6,RANK_7,RANK_8,RANK_9,RANK_10,RANK_J,RANK_Q,RANK_K};类型定义结构卡{INT SUIT;INT RANK;};

两张牌是交替颜色的吗?(因为列中的卡片颜色必须在红色和黑色之间交替)。

卡可以放在底座上吗?(A♥->;2♥->;3♥->;.->;Q♥->;K♥)。

卡片可以放在底部吗?(交替且按顺序)(J♦->;10♠->;9♥->;8♣->;7♥)

int is_Black(卡片c){return C.Suit==Suit_Club||C.Suit==Suit_Spade;}int is_red(卡片c){Return C.Suit==Suit_Heart||C.Suit==Suit_Diamond;}int IS_Alternate_Color(卡片第一,卡片第二){Return IS_Black(第一)!=IS_Black(第二);}int is_in_Sequence(卡片较低,卡片较高){返回较高。排名==较低。排名+1;}int Can_Be_Placed_Bottom(卡片父项,卡片子项){return is_Alternate_color(父项,子项)&;&;is_in_Sequence(子项,父项);}int is_Same_Suit(卡片第一,卡片第二){返回第一。西装==第二件。Suit;}int Can_be_Placed_on_Foundation(卡片父项,卡片子项){return is_ame_Suit(父项,子项)&;&;is_in_Sequence(父项,子项);}。

卡C5s=make_card(Suit_Spade,RANK_5);卡C6H=MAKE_CARD(Suit_HARD,RANK_6);printf(";5s为黑色%d vs 1\n";,is_Black(C5s));printf(";5s为红色%d vs 0\n";,is_red(C5s));printf(";5s 6h为备用%d vs 0\n";);printf(";5s 6h为备用%d vs 0\n";,is_red(C5s))。

注意:如果您想要比在开发期间在main()函数中转储一些代码更正确地进行测试,那么在C中有很多单元测试框架。

让我们来定义我们的第一堆卡片--最初的52张卡片(股票)。

纸牌周围似乎还有几堆纸牌--例如,废纸牌(显露的卡片)、地基上的一堆纸牌和底部的几堆纸牌,我们称之为柱子。将桩的概念封装为具有相关功能的结构是有意义的。游戏板/游戏状态结构将包含所有这些堆。

#DEFINE CARD_COUNT 52 tyfinf struct card_node{card*value;struct card_node*next;}card_node;tyfinf struct pilt{card_node*head;int num_card;}pill;tyfinf struct Game_state{pil**pols;int pile_count;}Game_state;

堆**堆是指向堆的指针数组,也可以写为堆*堆[PILL_COUNT]。

我们可以用链表表示一堆牌的集合,或者只是假设不会有超过52张的牌堆,并使用一个数组作为后备存储和一个计数器。这样,以每堆更多的内存开销为代价。由于有一个已知的桩数:未翻转和已翻转的卡片组,4个基础,7根柱,总数为2+4+7=13个桩。在32位系统上,这最多是13*(卡的大小*)*card_count=13*4*52=2704字节开销,在PC上没有那么多,这可能是微控制器的一个因素。

另一方面,链表是一种传统的C结构,因此使用它们可能会更好。让我想想。

我们需要一套桩操作函数。Push/Pop处理卡片列表的末尾。对于处理列表开头的函数,我选择使用JavaScript命名法(Shift/UnShift)。

Java LinkedList使用addFirst、addLast、pollFirst、polllast、getFirst、getLast等名称,但它们更一致。

pileMake_pile();void ush(堆*堆,card*card);card*pop(堆*堆);card*shift(堆*堆);void unShift(堆*堆,card*card);card*peek_card_at(堆*堆,int index);card*peek(堆*堆);card*peek_last(堆*堆);void delete(堆*堆,card*card);

这些函数的实现非常简单-我们必须链接或取消链接列表中的项目。我还选择使用非侵入式列表结构,节点类型为CARD_NODE,数据类型为CARD。

介入式容器和非介入式容器之间的区别在于,介入式容器中的值类型知道它们是某个集合的一部分,这意味着链接嵌入在结构中,例如:

在非侵入式列表中,值类型不嵌入链接,因此我们引入了具有指向card`值的指针的card_node节点类型。

这会影响列表操作函数的实现,性能侵入型列表在迭代时分配较少,取消引用也较少。使用非侵入式容器可以分离值和容器数据,我喜欢在软件中分离关注点(和职责)。

我也可能受到Java/C#等平台提供的数据结构是非侵入性的影响。

为了实例化结构,我使用了make_type的约定,它使用malloc()为它们分配内存。此内存必须稍后释放,这是销毁列表节点的函数的责任:POP/SHIFT/DELETE。由于纸牌中的所有52张牌碰巧都不会消失,我们不需要删除牌本身或游戏状态-当玩家退出游戏时,它们将被释放。

我一直试图一致地跟踪枚举中的项数。我喜欢将最后一个枚举项添加为ENUM_COUNT,由于枚举值在C中为零索引,因此它可以方便地解析为以前项的数量。

当然,您也可以#定义Suite_Count 5,但是您还应该记住在枚举增长或缩小时更改定义。

了解了基本函数后,让我们编写第一个填充初始纸牌的游戏手持设备函数。

void Fill_Deck(堆*堆){FOR(int rank=0;ank<;rank_count;rank++){for(int Suit=0;Suit<;Suit_count;Suit++){PUSH(PILL,MAKE_CARD_PTR(Suit,RANK);}。

由于没有内置的列表混洗功能,我不得不使用自己的功能。它类似于Fisher-Yates Shuffle,但是我们在技术上不交换元素,而是将第一个元素插入到一个随机的位置。

void shashffle_堆(堆*堆){int shashffle_Times=堆->;num_card*10;for(int i=0;i<;Shuffle_Times;i++){//取消卡片移位并插入到随机位置int idx=rand()%堆->;num_card-1;card_ptrcard=Shift(堆);INSERT(堆,卡,idx);}}

一个流行的文本用户界面库被称为ncurses(新的curses)。我们可以通过使用它的Unicode版本ncursesw来获得对Unicode卡符号的支持,比如♥♠♣♦。这使我们能够将卡片表示为相当易读的10♠或J♥。

我们可以引入帮助器函数,例如RANK_to_charptr和Suit_to_charptr,我们将使用这些函数来打印卡片表示,并使用花色的Unicode字符代码:

const char*Suit_to_charptr(Int Suit){Switch(Suit){case Suit_Heart:return";\u2665";;case Suit_Spade:return";\u2660";;case Suit_Club:return";\u2663";;case Suit_Diamond:return";\u2666";.}}。

要在屏幕上的某个位置打印卡,我们使用MOVE(int row,int column)函数设置光标位置,然后使用printw ncurses函数进行打印,该函数的行为类似于printf。还有一些变体,比如mvprintw,它将移动光标和打印结合在一起。

现在我们需要在屏幕上布置堆积。对于纸牌玩家来说,它应该看起来很熟悉:

在这里,我还必须决定如何对成堆的卡片进行排序。我认为,第一张卡片主要是展示的那张-用于库存、废物和饲料堆放,这是有道理的。

我需要添加一个peek(堆*堆)函数来查看堆,因为这些堆只会显示最上面的卡片。

柱堆将按从上到下的顺序排列,所以最初只会显示最后一张牌,但我们可以从第一张牌画到最后一张牌。

呈现代码相当乏味,它使用字符串数组作为标题,选择任意大小(100)作为游戏终端宽度,然后遍历堆积如山并在屏幕上移动光标,直到全部布置完毕。

char*first_row_headers[]={";Stock";,";waste";,";,";Foundation 1";,";Foundation 2";,";Foundation 3";,";Foundation 4";};.//第一行标题int column_size=14;for(int i=0;i<;7;i++){Move(0,column_size*i);printw(";%s";,first_row_headers[i]);}//第一行内容移动(1,0);printw_card(peek(stock(State);move(2,0);printw_堆_size(stock(State));.//Foundations for(int f=0;f<;Foundation_count;f++){int Foundation_。Move(1,(Foundation_1_Column+f)*Column_Size);printw_card(peek(Foundation(state,f);Move(2,(Foundation_1_Column+f)*Column_Size);printw_堆_Size(Foundation(state,f));}。

在开发这一点上,显示正面朝下的牌(未显示的牌,列中的大多数牌)的等级和花色是有意义的,表示为(Q♦)。我还将显露的旗帜(正面朝上)添加到卡片结构中,因为列中可能有多张正面朝上的卡片序列。

如果我们增加一点颜色,玩家将更容易区分4个♣和4个♥(四个黑桃和四个红心)。幸运的是,Ncurses支持终端中的颜色。我用了吉姆·霍尔的教程来快速掌握基础知识。

游戏机只支持八种基本颜色-黑、红、绿、黄、蓝、品红、青、白。然后,我们必须使用init_air(索引、前景、背景)定义颜色对。您还可以将-1作为颜色传递给此函数,以使用默认值。

要在打印期间实际使用该颜色对,请使用ATTRON(COLOR_Pair(Int Pair))打开颜色属性。稍后应该通过相应的attroff()调用将其关闭。

我们可以选择使用基于文本的控件,它(在移动卡片的情况下)具有源目标的形式,因此,例如,c3c4表示从第3列中取出1张卡片,并将其放在第4列。我们还需要从库存中抽出一张卡片,因此这可能是命令s。要移动抽出的卡片(从废品中),我们将使用w。因为有时需要移动多张卡片,所以可以使用类似3c1c5-从第1列中取出3张卡片并将其放入第4列的命令来解决。由于有时需要移动多张卡片,因此可以使用类似3c1c5-从第1列和第4列中取出三张卡片的命令来解决。

有些事情,应该是不可能的。将多张牌从一列移到一个基础是没有意义的,因为它们不会按照同一花色的升序排列。将多张卡片从废品移入一列也是不允许的。从库存中抽出多张牌可能是可能的,但让我们忽略它,稍后再添加它。让我们也禁止将卡从基金会转移到其他地方。

关于如何用C语言解析用户输入,我们有多种选择。我最初想手动解析它,逐个字符并跟踪状态,或者将scanf函数与多个输入模板一起使用,例如%c%d%c%d,用于c3f1之类的东西。scanf系列函数返回特定转换的数量,因此我们可以检查返回值是否成功并级联检查。

使用scanf选项时,应该按照从最具体到最不具体的顺序准备模板,我们最终得到四种特定模式:

%dC%d c%d->;3c4 c5%dC%c%d->;c3 F1/c7 c2w%c%d->;w c4/w F3s->;s。

由于解析函数parsed_input parse_input(char*command)应该返回多个值,让我们将其封装在一个结构中:

tyfinf struct parsed_input{char source;char destination;int source_index;int target_index;int source_mount;int SUCCESS;}parsed_input;

解析函数逐个尝试模式,同时填充源/目的地,因为某些模式暗示了它们-例如,Pattern_STOCK表示源是库存堆,而目的地是未定义的。

parsed_input parse_input(char*command){parsed_input parsed;parsed。Success=1;已解析。source_mount=1;//解析器模式char*Pattern_MULTI_MOVE=";%dC%d%d";;char*Pattern_Single_Move=";c%d%c%d";;char*Pattern_Waste_Move=";w%c%d";;char*pattern_stock=";s";if(sscanf(command,Pattern_MULTI_MOVE,&;para;已解析SOURCE_AMOUNT(&A)。SOURCE_INDEX已解析(&;)。Destination_Index)==3){已解析。源=';c';已解析。Destination=';c';;}Else if(sscanf(command,pattern_Single_Move,&;parsed。SOURCE_INDEX已解析(&;)。目标,已解析(&;)。Destination_Index)==3){已解析。source=';c';;}Else IF(sscanf(command,pattern_waste_move,&;parsed。目标,已解析(&;)。Destination_Index)==2){已解析。source=';w';;}Else if(strcmp(command,pattern_stock)==0){已解析。Source=';s&39;;}其他{已解析。成功=0;}返回已解析;}。

请注意,如果我们使用箭头键控制纸牌,我们也可以重用parsed_input结构,或者在以后的图形版本中,当开发出更图形化的界面时,我们还可以有一个光标的概念,我们可以用箭头键在堆中移动。

我们使用的伪随机数生成器-rand()需要一个种子来生成数字序列。如果种子相同,则在应用程序的不同运行中,序列最终是相同的。根据srand的手册页,如果没有提供种子值,rand()函数将自动设定为值1的种子。

使用系统时钟中的值为生成器设定种子是一种常见的做法:

它还意味着,如果我们使用一个特定值,比如srand(123),我们就可以用它来重现正在发牌的特定的牌序列。

一旦我们解析了用户的命令,我们就知道他们想要将卡片从哪里移动到哪里。现在是基于源列和目标列应用规则的时候了。我们可以应用一个简单的来源栏目规则--我们可以从废物或栏目中挑选来源卡片,只有当它不是空的时候,我们才会挑选最后一张卡片。

无论是基金会还是专栏,目的地都有不同的规则,但事件的顺序是相似的。我们从目标堆中拿起最后一张牌,使用游戏逻辑比较功能,如果卡可以移动,我们将其从其源中移除,并将其推送到目标堆的末尾:

void MOVE_CARD(card*card,pill*source_pill,堆*目的堆){POP(Source_Pill);show(PEEK_LAST(Source_Pille));Push(目的地_堆,card);}.card*source_card=peek_last(Source_Pill);.card*top_Foundation_card=peek(Source_Piler);if(can_be_placed_on_foundation(*top_foundation_card,*SOURCE_CARD)){MOVE_CARD(SOURCE_CARD,SOURCE_PARD,Destination_PILD);返回MOVE_OK;}。

还需要包含在空基上放置A或在空列上放置国王的特殊规则:

IF(parsed.Destination==';f';){if(Destination_Pill->;Num_Card==0&;&;source_card->;rank==RANK_A)MOVE_CARD(SOURCE_CARD,SOURCE_PILD,Destination_PILD);.}.。IF(parsed.Destination==';c';){if(Destination_Pill->;Num_Card==0&;&;source_card->;RANK==RANK_K)MOVE_CARD(SOURCE_CARD,SOURCE_PARD,Destination_PILD);.}

我们还希望允许玩家一次移动多张牌,从一列到另一列。

要做到这一点,我们必须更改移动的逻辑-从检查源列的最下面的卡片是否适合目标列,到检查第N张卡片是否适合。

幸运的是,这只是围绕整个卡片移动逻辑的单个for循环,但有一个警告:我们不能只是从源堆中弹出最底层的卡,而是从堆中间删除可能的第N张卡,这意味着要实现另一个链表操作函数delete(堆*,卡*)。

//将卡片从堆中移除,重新链接列表void delete(堆*堆,卡*卡){if(is_空(堆)){return;}//第一个项目没有上一个节点card_node*prev=null;card_node*current;for(Current=堆->Head;Current!=null;Prev=Current,Current=Current-&>Next){if(Current-&>Next){IF(当前-&>下一个){IF(当前-&>下一个)。value==card){//如果找到第一个项目,则为特殊情况-如果(prev==null){堆->头=Current->;Next;}否则{//跳过当前项目Prev-&>;Next=Current-&>;Next;}//跳过列表中的当前项目-&>Head=Current-&>;Next;Next;//递减卡片计数器堆-&>;Num_Carders--;free。

既然这个游戏大部分都能用,我们就可以安装一个计分机制了。根据维基百科,Windows纸牌的标准评分是:

我们知道所有这些事件何时发生,因此我们可以使用一个小助手函数逐个添加分数操作:

void add_core(GAME_STATE*STATE,INT SCORE){STATE->SCORE+=SCORE;IF(STATE->;SCORE<;0){STATE-&>SCORE=0;}}。

丰富桩类型以跟踪其桩类型(柱、基础等)以保留MOVE_CARD函数中的大部分分数也很有意义:

void MOVE_CARD(GAME_STATE*STATE,卡*卡,堆*源_堆,堆*目的地_堆){.//如果(目的地_堆-&>类型==';f';){添加_分数(状态,10);}如果(源_堆->类型==';w';&;&;目的地_堆-&>类型==';c';){ADD_SCORE(STATE,5);}}。

我添加了一个不使用Unicode符号进行编译的选项,使用Makefile中定义为-DUNICODE的#ifdef Unicode。即使在使用userland Linux兼容层的Android手机上编译和运行这款游戏也是相当巧妙的。

我还在考虑将源代码拆分成多个文件。在考虑将端口移植到不同平台时,这确实是有意义的,我希望这样做!我至少会将整个ncurses接口拆分成一个单独的模块,并将其余的逻辑放在一起。

我想在开发这款游戏的同时尝试一些新东西,同时也在寻找一个有趣的项目来学习传统的VIM+GCC+GDB Linux堆栈。入门相当慢,从我通常使用的Visual Studio/VSCode/IDEA堆栈开始,这个堆栈稍微有点.。集成的。

语法着色是必须的。为了使VIM的行为更像现代IDE,我还将其配置为。

我发现保留.vimrc文件作为要点非常有用,这样我所有的机器都可以访问它。

您首先需要使用调试符号进行编译,使用GCC标志-g。这也可以在Makefile中完成,我已经创建了一个使用符号构建的调试目标。

最有用的gdb命令是p,用于打印expr的值

.