本来这题打算5.15省选考完之后晚上做的,然而15号考了一天的试,于是放16号晚上做了。
题目分析
猪国杀是一道桌游三国杀的简化版,这道题在代码涉及的知识点上难度很低,主要难度就在于如何实现,以及非常多的细节。作为一个4年TTA、3年TS、170小时STS玩家,虽然我不会玩三国杀,由于我接触过不少规则非常复杂的桌游(TTA规则A4纸30多页,字体大小也就五号不到),在学习规则的过程中经常在一些细节上出错 (专业说法叫村规) ,所以在读这道题的过程中也本能地非常注意,从而避开了那些细节上的初见杀(尽管后来调题的时间还是非常非常长,没有太体现出这个优势)。接下来具体分析一下这道题的题面。
这个规则当中可以分为以下三部分:基本流程,牌和行为逻辑。
基本流程的信息当中,这些是比较简明的:每回合抽2,且轮到谁谁再抽2;有且仅有第一只猪是主猪;一回合没有装备只能打一张杀;反猪被杀抽三张牌;主猪杀忠猪清空手牌和装备;主猪被杀或反猪全部被杀游戏立刻结束,也就是不需要再抽牌。
此外还有一条比较有争议的:每次使用牌的时候都使用最靠左的能够使用的牌。这个“能够使用”如何定义是个问题,具体要等到所有的规则全都看完才能理解(这也是读题的时候卡了我最久的一个点)。
手牌除了无懈可击都比较好理解。无懈可击这牌的描述比较抽象,分析一下要点大约是这样的:无懈可击只能防锦囊牌,锦囊牌每一次判定都要先判定是否会被无懈可击防;无懈可击可以对无懈可击使用,可以对任何人使用。所以基本是一个递归的逻辑。
这道题最复杂的部分就是行为逻辑。首先看完一遍行为逻辑可以发现,对于每一张牌打出的选择是唯一的,所以这道题是一个纯粹的模拟,不涉及决策,这是一个好消息。
概括的来说,每一名玩家都有真实身份和表现身份,攻击有明确表现的对象会使自己表现明确身份,除此之外,用AOE打到主公会被判断为类反这一不明确身份,类反会被主公攻击。类反身份可能会被明确。而且重要的是,玩家一定会跳与自己相同的身份,也就是明确身份一定是自己的真实身份;玩家也只会把定向的牌作用于明确阵营的对象(主公除外),这是这道题没有决策的关键之处。
除此之外,无懈可击用了之后会把作用对象和自己绑成一队。
现在大致看明白了整个游戏规则,回头讨论一下一开始提出的问题:什么叫做“能够使用”?有一些是题目当中明确说的,比如4点血的时候不能用桃;有一些是“不会”进行的行为,比如忠猪不会用手里的杀攻击队友。那这是否算是一种无法出牌的情况?事实上是不算的,从游戏逻辑上似乎也应该是不算的。我确实没有在读题的时候看明白这一点,我也觉得这一点题面描述说的确实不太清楚,这一点是有影响的,因为如果算的话,这是一种“可以出但不出”的行为,它的回合就应该强制结束(很多规则在描述的过程中倒是常有这种问题,比如什么“你可以…”到底是不是强制行为,我只能说是还原的很真实)。
题目看完了,该开工了。
代码
先列一个大纲。对于每一名玩家,记录以下的信息:
struct player{
bool equipped;
//是否有弩
int hp,siz,nxt,lst,id,behave;
//血量,手牌队列大小,右边、左边玩家(逆时针),身份,表现
char deck[N];
//实际手牌队列
}a[11];
然后考虑一下需要哪些函数:先实现一些基本的功能,比如杀和抽牌。弃牌只需要把这张牌改成随便一个字符就行了,不需要单独出来。当一只猪血量为空时有一个死亡阶段,这个单独实现。
剩下的就是四种锦囊牌,对决、南猪入侵、万箭齐发、无懈可击每种一个函数。最后开一个函数模拟游戏过程,便于结束的时候直接return.
然后写就完事了。列出一些我写的时候遇到的坑:
1.定向攻击类:对决和杀。注意目标不能是未表明身份的,所以讨论的时候注意判断条件是否排除了无身份情况。
2.AOE:注意及时更新手牌队列。
3.无懈可击:如果递归实现反而没有太多可说的东西,注意一下发起方、接收方、打无懈可击方三方别混了。题目当中说的自己身份不明也不能给自己无懈可击这一点,只要每一次提到目标都是判断表现身份就行了,不是什么容易出错的东西。
4.对决:目标先弃牌。
5.在表明身份后,一名玩家可以打出的牌位置可能回到左侧,所以要注意哪些情况需要重置。
6.注意i=a[i].nxt
如果不作为for
的一个条件,不要在continue
里面给忘了(这个花了我一个半小时调)。
7.主公永远是1号位,但是还是建议用a[i].id=1
判断是否为主公,尤其是我这样从0开始算的情况。
其他细节见如下完整代码:(<300行)
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
using namespace std;
const int N = 2001;
struct player{
bool equipped;
int hp,siz,nxt,lst,id,behave;
char deck[N];
}a[11];
int n,m,en,top;
char pile[N];//抽牌堆
bool ed;
void draw(int p,int num){//抽牌
while(num--){
a[p].deck[++a[p].siz] = pile[top];
if(top > 1) --top;
}
}
void dying(int fr,int to){//濒死阶段
int i;
for(i = 1;i <= a[to].siz;i++){
if(a[to].deck[i] == 'P'){
a[to].deck[i] = '0';
++a[to].hp;
return;//这个阶段只能把血量回到1
}
}
a[a[to].nxt].lst = a[to].lst;
a[a[to].lst].nxt = a[to].nxt;
if(a[to].id == 3) --en;
if(!en || a[to].id == 1){
ed = 1;
return;
}//摸牌晚于判断结束
if(a[to].id == 3) draw(fr,3);
if(a[to].id == 2 && a[fr].id == 1){
a[fr].siz = 0,a[fr].equipped = 0;
}
}
void atk(int fr,int to){//杀
int i;
for(i = 1;i <= a[to].siz;i++){
if(a[to].deck[i] == 'D'){
a[to].deck[i] = '0';
return;
}
}
--a[to].hp;
if(!a[to].hp) dying(fr,to);
}
bool parry(int fr,int to,int flag){
int i = fr,j;
do{
if(flag){
if(a[to].behave == a[i].id || (a[to].behave == 1 && a[i].id == 2) || (a[to].behave == 2 && a[i].id == 1)){
//第一次无懈可击:助攻
for(j = 1;j <= a[i].siz;j++){
if(a[i].deck[j] == 'J'){
a[i].deck[j] = '0';
a[i].behave = a[i].id;
return !parry(i,fr,0);
}
}
}
}
else{
if(((a[i].id == 1 || a[i].id == 2) && a[fr].behave == 3) || (a[i].id == 3 && (a[fr].behave == 1 || a[fr].behave == 2))){
//无懈可击套娃:打断上一次
for(j = 1;j <= a[i].siz;j++){
if(a[i].deck[j] == 'J'){
a[i].deck[j] = '0';
a[i].behave = a[i].id;
return !parry(i,fr,0);
}
}
}
}
i = a[i].nxt;
}while(i != fr);
return 0;
}
void duel(int fr,int to){
int i,l,r;
if(parry(fr,to,1)) return;
if(a[fr].id == 1 && a[to].id == 2){//这个特判要看目标的实际身份
--a[to].hp;
if(a[to].hp <= 0) dying(fr,to);
return;
}
l = 1,r = 1;
while(1){
while(r <= a[to].siz && a[to].deck[r] != 'K') ++r;
if(r > a[to].siz){
--a[to].hp;
if(a[to].hp <= 0) dying(fr,to);
return;
}
else a[to].deck[r] = '0';
//目标先弃牌
while(l <= a[fr].siz && a[fr].deck[l] != 'K') ++l;
if(l > a[fr].siz){
--a[fr].hp;
if(a[fr].hp <= 0) dying(to,fr);
return;
}
else a[fr].deck[l] = '0';
}
}
void invade(int fr){
int i = a[fr].nxt,j;
for(;i != fr;i = a[i].nxt){//一个值1h30min的for循环
if(parry(fr,i,1)) continue;
for(j = 1;j <= a[i].siz;j++){
if(a[i].deck[j] == 'K'){
a[i].deck[j] = '0';
break;
}
}
if(j > a[i].siz){//省一个变量的判断方式
--a[i].hp;
if(a[i].id == 1 && !a[fr].behave) a[fr].behave = 4;
if(!a[i].hp) dying(fr,i);
if(ed) return;
}
}
}
void shoot(int fr){
int i = a[fr].nxt,j;
for(;i != fr;i = a[i].nxt){
if(parry(fr,i,1)) continue;
for(j = 1;j <= a[i].siz;j++){
if(a[i].deck[j] == 'D'){
a[i].deck[j] = '0';
break;
}
}
if(j > a[i].siz){
--a[i].hp;
if(a[i].id == 1 && !a[fr].behave) a[fr].behave = 4;
if(a[i].hp <= 0) dying(fr,i);
if(ed) return;
}
}
}
void check(){
int i,j;
for(i = 0;i < n;i++){
printf("%d %d hp=%d\n",a[i].id,a[i].behave,a[i].hp);
for(j = 1;j <= a[i].siz;j++){
printf("%c ",a[i].deck[j]);
}
puts("");
}
puts("");
}
void solve(){
if(!en) return;
int i = 0,j,k;
char now;
bool used;
while(!ed){
used = 0;
draw(i,2);
for(j = 1;j <= a[i].siz;j++){
if(a[i].deck[j] == '0' || a[i].deck[j] == 'D') continue;
if(!a[i].hp) break;
if(a[i].deck[j] == 'P'){
if(a[i].hp < 4){
++a[i].hp;
a[i].deck[j] = '0';
}
continue;
}
if(a[i].deck[j] == 'K'){
if(used && !a[i].equipped) continue;
k = a[i].nxt;
if((a[i].id == 1 && a[k].behave != 3 && a[k].behave != 4) || (a[i].id == 2 && a[k].behave != 3) || (a[i].id == 3 && a[k].behave != 1 && a[k].behave != 2)) continue;
//这一段注意不要攻击身份不明的对象
a[i].deck[j] = '0';
atk(i,k);
a[i].behave = a[i].id;
used = 1;
if(ed) return;
continue;
}
if(a[i].deck[j] == 'F'){
if(a[i].id == 3){
a[i].deck[j] = '0';
duel(i,0);
a[i].behave = a[i].id;
if(ed) return;
j = 0;
//重新从最左的手牌开始考虑出牌,下同
}
else{
k = a[i].nxt;
while(k != i){
if((a[i].id == 1 && a[k].behave >= 3) || (a[i].id == 2 && a[k].behave == 3)){
a[i].deck[j] = '0';
duel(i,k);
a[i].behave = a[i].id;
if(ed) return;
j = 0;
break;
}
k = a[k].nxt;
}
}
continue;
}
if(a[i].deck[j] == 'N'){
a[i].deck[j] = '0';
invade(i);
if(ed) return;
j = 0;
continue;
}
if(a[i].deck[j] == 'W'){
a[i].deck[j] = '0';
shoot(i);
if(ed) return;
j = 0;
continue;
}
if(a[i].deck[j] == 'Z'){
a[i].deck[j] = '0';
a[i].equipped = 1;
j = 0;
continue;
}
}
i = a[i].nxt;
}
}
int main(){
int i,j;
char s[3];
scanf("%d %d",&n,&m);
for(i = 0;i < n;i++){
a[i].siz = a[i].hp = 4;
a[i].behave = 0;
scanf("%s",s);
if(s[0] == 'M') a[i].id = a[i].behave = 1;
if(s[0] == 'Z') a[i].id = 2;
if(s[0] == 'F') a[i].id = 3,++en;
a[i].equipped = 0;
a[i].lst = (i - 1 + n) % n,a[i].nxt = (i + 1) % n;
for(j = 1;j <= 4;j++){
scanf("%s",s);
a[i].deck[j] = s[0];
}
}
//下标从0开始便于初始化指针(其实也没啥必要)
for(i = 1;i <= m;i++){
scanf("%s",s);
pile[i] = s[0];
}
reverse(pile + 1,pile + m + 1);
top = m;
solve();
if(a[0].hp <= 0) puts("FP");
else puts("MP");
for(i = 0;i < n;i++){
if(a[i].hp <= 0) puts("DEAD");
else{
for(j = 1;j <= a[i].siz;j++){
if(a[i].deck[j] != '0') putchar(a[i].deck[j]),putchar(' ');
}
puts("");
}
}
return 0;
}
结语
我做过的桌面游戏类基本都是比较反直觉的模拟(基本每一个做过斗地主的人都跟我抱怨过把大小王拆了打带的离谱操作),因为如果做的比较真实思维含量就很爆炸。就比如这道题,如果攻击的时候考虑队友、考虑按血量顺序定向,如果可以跳相反阵营,就算不搞真实的三国杀那么多的牌也非常恐怖了(当然三国杀做电子游戏实际上程序也就是模拟而已,毕竟除非是AI否则也不需要自己写的功能去思考)。哪怕是进行了弱化,加上决策做起来还是会很恶心(比如这道题)。所以说不得不感叹机器学习到底有多大的作用。
不得不说,自从一年多之前的新汉诺塔以来,就很少有出现过这种做题的过程了。虽然被大大小小各种错误卡了很久(这题一共做了接近4h30min),做完的一瞬间也是心情非常舒畅,没有那种好不容易调完一道题只想痛骂自己或者出题人的火气,意外的很解压(再次感谢我作为一个桌游玩家的经历在做这道题的过程中给出的各方面巨大帮助)。
Thank you for reading!