一、设计目标
三、设计方案
1.游戏模式
2.游戏过程
3.游戏设计
四、硬件配置
1.TFT-LCD液晶屏模块
(1)工作原理
(2)硬件连接
(3)配置步骤
(4)相关库函数
2.触摸屏模块
(1)工作原理
(2)硬件连接
(3)模块初始化
(4)相关库函数
3.LED灯
(1)工作原理
(2)硬件连接
(3)模块初始化
蜂鸣器-toc" style="margin-left:40px;">4.蜂鸣器
(1)工作原理
(2)硬件连接
(3)模块初始化
5.RNG模块
(1)工作原理
(2)模块初始化
(3)相关库函数
6.定时器模块
(1)工作原理
(2)配置步骤
(3)模块初始化
7.独立看门狗模块
(1)工作原理
(2)配置步骤
(3)模块初始化
(4)相关库函数
四、软件设计
1.定时器中断服务程序
2.主程序
3.游戏主程序
三、五子棋算法
(1)算法1
(2)算法2
五、实现效果
基于ALIENTEK 探索者 STM32F407开发板,设计一款五子棋游戏。
通过LCD液晶屏显示游戏画面,通过触摸屏幕进行游戏模式选择、落子、暂停游戏、重新开始游戏、悔棋等操作。
编写五子棋算法,实现人机对战功能。
五子棋游戏分为PVE(Player VS Environment的简称,即人机对战)、PVP(Player VS Player的简称,即人人对战)两个模式。在PVE模式中,玩家持黑子先下,电脑玩家持白子后下。在PVP模式中,两玩家分别持黑白子,先下者为黑子。
在进入游戏时,首先显示主菜单。玩家在PVE模式和PVP模式中进行选择。其中,PVE模式又分为简单、一般和困难三个模式。选择模式后即可进入游戏。在游戏中,点击棋盘相应空子位置即可落子,落子时蜂鸣器短暂鸣叫,提示已落子。可以通过LED灯和屏幕下方提示,获悉当前落子玩家。若DS0亮起,则到左手玩家(即黑子)落子。若DS1亮起,则到右手玩家(即白子)落子。可以通过点击左上角UNDO键进行悔棋操作,回到上一次落子前状态。可以通过上方HELP键由电脑帮助落子,该电脑下棋水平等同于PVE模式中的困难模式。也可以通过右上角PAUSE键暂停游戏,暂停时会显示暂停对话框,可以选择重新开始游戏、退出游戏或者关闭对话框。
当有一方五子连珠时,蜂鸣器会有节奏长鸣,然后弹出游戏结束对话框。此时也可以选择重新开始游戏或者退出游戏。
五子棋游戏棋盘为15*15,共有225个落子位置,可使用15*15的数组来存储落子情况。数组初始全为0,按落子顺序,对每枚棋子编号,从1开始,将编号存储在数组对应位置。按黑子先下原则,所有单数编号的棋子均为黑子,所有双数编号的棋子均为白子。上一编号为双数,则下一落子为黑子玩家,反之亦然。而悔棋即将数组中存储上一编号的位置置零,重新落子。
围绕上述方案,在五子棋游戏中,使用STM32F407开发板相关硬件,实现以下功能:
(1)完成LCD液晶屏驱动程序的设计,使用LCD显示五子棋游戏内容。
(2)完成触摸屏驱动程序的设计,使用触摸屏进行游戏控制。
(3)使用定时器、LED和蜂鸣器进行游戏相关提示。
(4)使用独立看门狗来防止程序跑飞。
本部分会详细叙述上述硬件资源的相关工作原理、硬件电路的连接、配置步骤和相关库函数,具体如下所示:
本设计采用4.3寸LCD屏幕显示游戏内容,屏幕像素数为800*480,可以调用相关硬件函数绘制点、线、圆或矩阵。
TFT-LCD 即薄膜晶体管液晶显示器。TFT-LCD 与无源 TN-LCD、STN-LCD 的简单矩阵不同,它在液晶显示屏的每一个像素上都设置有一个薄膜晶体管(TFT),可有效地克服非选通时的串扰,使显示液晶屏的静态特性与扫描线数无关,因此大大提高了图像质量。
其模块原理图如图1所示。
图 1 TFTLCD 4.3寸原理图
STM32F4使用FSMC来驱动TFT-LCD。FSMC是灵活的静态存储控制器,能够与同步或异步存储器和16位PC存储器卡连接。FSMC可以驱动LCD的主要原因是因为FSMC的读写时序和LCD的读写时序相似,于是把LCD当成一个外部存储器来用。利用FSMC在相应的地址读或写相关数值时,STM32F4的FSMC会在硬件上自动完成时序上的控制,所以只要设置好读写相关时序寄存器后,FSMC就可以完成时序上的控制。
下面是TFTLCD 模块与 ALIETEK 探索者STM32F4 开发板的连接,探索者 STM32F4 开发板底板的 LCD 接口和 ALIENTEK TFTLCD 模块直接可以对插,连接关系如图2所示。
图 2 TFTLCD接口
在硬件上,TFTLCD 模块与探索者 STM32F4 开发板的 IO 口对应关系如下:
LCD_BL(背光控制)对应 PB0;
LCD_CS 对应 PG12 即 FSMC_NE4;
LCD _RS 对应 PF12 即 FSMC_A6;
LCD _WR 对应 PD5 即 FSMC_NWE;
LCD _RD 对应 PD4 即 FSMC_NOE;
LCD _D[15:0]则直接连接在 FSMC_D15~FSMC_D0;
初始化函数为 LCD_Init,该函数先初始化 STM32 与TFTLCD 连接的 IO 口,并配置 FSMC 控制器,然后读取 LCD 控制器的型号,根据控制 IC 的型号执行不同的初始化代码,其步骤如下:
① 使能 PD,PE,PF,PG 时钟
RCC_AHB1PeriphClockCmd();
使能 FSMC 时钟,
RCC_AHB3PeriphClockCmd();
② GPIO 初始化:
GPIO_Init()
③ 设置引脚复用映射。
④ FSMC 初始化:
FSMC_NORSRAMInit()
⑤ FSMC 使能:
FSMC_NORSRAMCmd()
⑥ 不同的 LCD 驱动器的初始化代码。
void LCD_Clear(u16 Color); //清屏
void LCD_DrawPoint(u16 x,u16 y); //画点
void LCD_Fast_DrawPoint(u16 x,u16 y,u16 color); //快速画点
void LCD_Draw_Circle(u16 x0,u16 y0,u8 r); //画圆
void LCD_DrawLine(u16 x1, u16 y1, u16 x2, u16 y2); //画线
void LCD_DrawRectangle(u16 x1, u16 y1, u16 x2, u16 y2); //画矩形
void LCD_Fill(u16 sx,u16 sy,u16 ex,u16 ey,u16 color); //填充单色
void LCD_Color_Fill(u16 sx,u16 sy,u16 ex,u16 ey,u16 *color); //填充指定颜色
void LCD_ShowChar(u16 x,u16 y,u8 num,u8 size,u8 mode); //显示一个字符
void LCD_ShowNum(u16 x,u16 y,u32 num,u8 len,u8 size); //显示一个数字
void LCD_ShowxNum(u16 x,u16 y,u32 num,u8 len,u8 size,u8 mode); //显示 数字
void LCD_ShowString(u16 x,u16 y,u16 width,u16 height,u8 size,u8 *p); //显示一个字符串,12/16字体
本设计使用的是4.3寸的投射式电容触摸屏,用来进行游戏相关触摸操作。
投射式电容触摸屏采用纵横两列电极组成感应矩阵,来感应触摸。以两个交叉的电极矩阵,即:X 轴电极和 Y 轴电极,来检测每一格感应单元的电容变化。
图 3 投射式电容屏电极矩阵
电容触摸屏一般都需要一个驱动 IC 来检测电容触摸,且一般是通过 IIC 接口输出触摸数据的。ALIENTEK 4.3寸 TFTLCD 模块使用 GT9147 作为驱动 IC,采用 17*10 的驱动结构(10 个感应通道,17 个驱动通道)。
TFTLCD 模块的触摸屏(电阻触摸屏)总共有 5 跟线与 STM32F4 连接,连接电路图如4图所示。
图 4 触摸屏与STM32F4的连接图
触摸屏初始化函数:TP_Init,该函数根据 LCD 的 ID(即 lcddev.id)判别是电阻屏还是电容屏,执行不同的初始化,该函数代码如下:
/触摸屏初始化
//返回值:0,没有进行校准 1,进行过校准
u8 TP_Init(void)
{
if(lcddev.id==0X5510) //电容触摸屏
{
if(GT9147_Init()==0) //是 GT9147?
{
tp_dev.scan=GT9147_Scan; //扫描函数指向 GT9147 触摸屏扫描
}else
{
OTT2001A_Init();
tp_dev.scan=OTT2001A_Scan;//扫描函数指向 OTT2001A 触摸屏扫描
}
tp_dev.touchtype|=0X80; //电容屏
tp_dev.touchtype|=lcddev.dir&0X01;//横屏还是竖屏
return 0;
} else if(lcddev.id==0X1963)
{
FT5206_Init();
tp_dev.scan=FT5206_Scan; //扫描函数指向 GT9147 触摸屏扫描
tp_dev.touchtype|=0X80; //电容屏
tp_dev.touchtype|=lcddev.dir&0X01;//横屏还是竖屏
return 0;
} else
{
RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB|RCC_AHB1Periph_GPIOC|
RCC_AHB1Periph_GPIOF, ENABLE);//使能 GPIOB,C,F 时钟
//GPIOB1,2 初始化设置
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_2;//PB1/2 设置为上拉输入
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN;//输入模式
GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;//推挽输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;//100MHz
GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;//上拉
GPIO_Init(GPIOB, &GPIO_InitStructure);//初始化
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;//PB0 设置为推挽输出
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;//输出模式
GPIO_Init(GPIOB, &GPIO_InitStructure);//初始化
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13;//PC13 设置为推挽输出
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;//输出模式
GPIO_Init(GPIOC, &GPIO_InitStructure);//初始化
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11;//PF11 设置推挽输出
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;//输出模式
GPIO_Init(GPIOF, &GPIO_InitStructure);//初始化
TP_Read_XY(&tp_dev.x[0],&tp_dev.y[0]);//第一次读取初始化
AT24CXX_Init(); //初始化 24CXX
if(TP_Get_Adjdata()) return 0;//已经校准
else //未校准?
{
LCD_Clear(WHITE);//清屏
TP_Adjust(); //屏幕校准
TP_Save_Adjdata();
}
TP_Get_Adjdata();
}
return 1;
}
tp_dev.scan(0);//触摸屏扫描函数
本设计使用LED灯来提示玩家落子,DS0和DS1分别对应一位玩家。
发光二极管简称为LED。发光二极管是由一个PN结组成,也具有单向导电性。当给发光二极管加上正向电压后,从P区注入到N区的空穴和由N区注入到P区的电子,在PN结附近数微米内分别与N区的电子和P区的空穴复合,产生自发辐射的荧光。
在STM32F4中,使用GPIO的IO口驱动LED。
图 5 LED硬件连接
//初始化PF9和PF10为输出口.并使能这两个口的时钟
//LED IO初始化
void LED_Init(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOF, ENABLE);//使能GPIOF时钟
//GPIOF9,F10初始化设置
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9 | GPIO_Pin_10;//LED0和LED1对应IO
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;//普通输出模式
GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;//推挽输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;//100MHz
GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;//上拉
GPIO_Init(GPIOF, &GPIO_InitStructure);//初始化GPIO
GPIO_SetBits(GPIOF,GPIO_Pin_9 | GPIO_Pin_10);//GPIOF9,F10设置高,灯灭
}
本设计采用蜂鸣器的短鸣提示玩家落子,有节奏长鸣提示玩家游戏结束。
蜂鸣器是一种一体化结构的电子讯响器,采用直流电压供电,广泛应用于计算机、打印机、复印机、报警器、电子玩具、汽车电子设备、电话机、定时器等电子产品中作发声器件。蜂鸣器主要分为压电式蜂鸣器和电磁式蜂鸣器两种类型。探索者 STM32F4 开发板板载的蜂鸣器是电磁式的有源蜂鸣器,如图6所示。
图 6 有源蜂鸣器
在STM32F4中,使用GPIO的IO口驱动蜂鸣器。
图 7 硬件连接
/初始化 PF8 为输出口
//BEEP IO 初始化
void BEEP_Init(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOF, ENABLE);//使能 GPIOF 时钟
//初始化蜂鸣器对应引脚 GPIOF8
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;//普通输出模式
GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;//推挽输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;//100MHz
GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_DOWN;//下拉
GPIO_Init(GPIOF, &GPIO_InitStructure);//初始化 GPIO
GPIO_ResetBits(GPIOF,GPIO_Pin_8); //蜂鸣器对应引脚 GPIOF8 拉低,
}
本设计在五子棋算法中,会使用随机数来选择随机选择分值相同的落子点,需要使用到RNG模块来生成随机数。
STM32F4 自带了硬件随机数发生器(RNG),RNG 处理器是一个以连续模拟噪声为基础的
随机数发生器,在主机读数时提供一个 32 位的随机数。STM32F4 的随机数发生器框图如图8所示。
图 8 随机数发生器(RNG)框图
STM32F4 的随机数发生器(RNG)采用模拟电路实现。此电路产生馈入线性反馈移位寄存
器 (RNG_LFSR) 的种子,用于生成 32 位随机数。
该模拟电路由几个环形振荡器组成,振荡器的输出进行异或运算以产生种子。RNG_LFSR由专用时钟 (PLL48CLK) 按恒定频率提供时钟信息,因此随机数质量与 HCLK 频率无关。当将大量种子引入 RNG_LFSR 后,RNG_LFSR 的内容会传入数据寄存器 (RNG_DR)。
同时,系统会监视模拟种子和专用时钟 PLL48CLK,当种子上出现异常序列,或 PLL48CLK时钟频率过低时,可以由 RNG_SR 寄存器的对应位读取到,如果设置了中断,则在检测到错误时,还可以产生中断。
//初始化 RNG
//返回值:0,成功;1,失败
u8 RNG_Init(void)
{
u16 retry=0;
RCC_AHB2PeriphClockCmd(RCC_AHB2Periph_RNG, ENABLE); //开启 RNG 时钟
RNG_Cmd(ENABLE); //使能 RNG
while(RNG_GetFlagStatus(RNG_FLAG_DRDY)==RESET&&retry<10000)//等待就绪
{
retry++; delay_us(100);
}
if(retry>=10000) return 1;//随机数产生器工作不正常
return 0;
}
//得到随机数
//返回值:获取到的随机数
u32 RNG_Get_RandomNum(void);
//生成[min,max]范围的随机数
int RNG_Get_RandomRange(int min,int max);
本设计通过定时器产生100ms的脉冲,用来控制LED的闪烁频率。
STM32F4 的通用定时器包含一个 16 位或 32 位自动重载计数器(CNT),该计数器由可编程预分频器(PSC)驱动。STM32F4 的通用定时器可以被用于:测量输入信号的脉冲长度(输入捕获)或者产生输出波形(输出比较和 PWM)等。 使用定时器预分频器和 RCC 时钟控制器预分频器,脉冲长度和波形周期可以在几个微秒到几个毫秒间调整。
①使能定时器时钟。
RCC_APB1PeriphClockCmd();
②初始化定时器,配置ARR、PSC、CR1。
TIM_TimeBaselnit();
③开启定时器中断,配置NVIC。
TIM_ITConfig();NVIC_Init();
④使能定时器。
TIM_Cmd();
⑤编写中断服务函数。
TlMx_IRQHandler();
//通用定时器 3 中断初始化
//arr:自动重装值。 psc:时钟预分频数
//定时器溢出时间计算方法:Tout=((arr+1)*(psc+1))/Ft us.
//Ft=定时器工作频率,单位:Mhz
//这里使用的是定时器 3!
void TIM3_Int_Init(u16 arr,u16 psc)
{
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
NVIC_InitTypeDef NVIC_InitStructure;
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3,ENABLE); //①使能 TIM3 时钟
TIM_TimeBaseInitStructure.TIM_Period = arr; //自动重装载值
TIM_TimeBaseInitStructure.TIM_Prescaler=psc; //定时器分频
TIM_TimeBaseInitStructure.TIM_CounterMode=TIM_CounterMode_Up; //向上计数模式
TIM_TimeBaseInitStructure.TIM_ClockDivision=TIM_CKD_DIV1;
TIM_TimeBaseInit(TIM3,&TIM_TimeBaseInitStructure);// ②初始化定时器 TIM3
TIM_ITConfig(TIM3,TIM_IT_Update,ENABLE); //③允许定时器 3 更新中断
NVIC_InitStructure.NVIC_IRQChannel=TIM3_IRQn; //定时器 3 中断
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority=0x01; //抢占优先级 1
NVIC_InitStructure.NVIC_IRQChannelSubPriority=0x03; //响应优先级 3
NVIC_InitStructure.NVIC_IRQChannelCmd=ENABLE;
NVIC_Init(&NVIC_InitStructure);// ④初始化 NVIC
TIM_Cmd(TIM3,ENABLE); //⑤使能定时器 3
}
本设计用独立看门狗来防止程序跑飞,在主程序和游戏主程序的主循环中进行喂狗。
在由单片机构成的徽型计算机系统中,由于单片机的工作常常会受到来自外界电磁场的干扰,造成程序的跑飞,而陷入死循环,程序的正常运行被打断,由单片机控制的系统无法继续工作,会造成整个系统的陷入停滞状态,发生不可预料的后果,所以出于对单片机运行状态进行实时监测的考虑,便产生了一种专门用于监测单片机程序运行状态的模块或者芯片,俗称着门狗”(watch dog)。
在键值寄存器( IwDG_KR)中写入0xcccc(启动),开始启用独立看门狗。此时计数器开始从其复位值OxFFF递减,当计数器值计数到尾值Ox000时会产生一个复位信号(IwDG_RESET)。
无论何时,只要往键值寄存器IwDG_KR中写入OxAAAA(俗称喂狗),自动重装载寄存器wDG_RLR的值就会重新加载到计数器,从而避免看门狗复位。
如果程序异常,就无法正常喂狗,则导致系统复位。
①取消寄存器写保护:
IWDG _WriteAccessCmd();
②设置独立看门狗的预分频系数,确定时钟:
IWDG _SetPrescaler();
③设置看门狗重装载值,确定溢出时间:
IWDG _SetReload();
Tout=(4×2^prerxrlr)/32
④使能(启动)看门狗
IWDG _Enable();
⑤应用程序喂狗:
IWDG_ReloadCounter();
void lwDG_lnit(u8 prer,u16 rlr)
{
IWDG_WriteAccessCmd(lwDG_writeAccess_Enable);//关写保护
IWDG_SetPrescaler(prer);//分频系数
IWDG_SetReload(rlr)//重载值
IWDG_Enable();//启用(使能)看门狗,写OxccCC到KR
IWDG_ReloadCounter();//重载(喂狗),写OxAAAA到KR
}
void IWDG_Feed(void);//喂狗
本部分围绕程序主要代码,介绍具体的实现过程。
定时器中断服务程序负责控制LED闪烁提示当前落子玩家,控制灯闪烁频率为1hz。
extern int Game_status,ChessIdx;
int cnt=0;
//定时器3中断服务程序
void TIM3_IRQHandler(void)
{
if(TIM3->SR&0X0001)
{
if(cnt==9)
{
cnt=0;
if(Game_status==0)
{
//游戏中
if(ChessIdx%2==0)
{
LED0=!LED0;
LED1=1;
}
else
{
LED1=!LED1;
LED0=1;
}
}
else
{
if(LED0||LED1)
{
LED0=1;LED1=1;
}
}
}
else cnt++;
}
TIM3->SR&=~(1<<0);//清除中断标志位
}
主程序代码主要负责初始化各个模块,并进行主循环。在主循环中,控制LCD液晶屏显示主菜单或对话框,并扫描触摸屏,获取用户的操作,完成主菜单选择。
下面是主程序相关的变量和函数。
int Menu_UI=0,Menu_status=0; //菜单UI标志,菜单状态标志
extern int Game_PVEDiff; //电脑难度
void DrawMenuUI(void) //绘制菜单UI
void DrawSelectDiffDialog(void) //绘制难度选择对话框
下面是主程序的代码。
int main(void)
{
Stm32_Clock_Init(336,8,2,7);//设置时钟,168Mhz
uart_init(84,115200); //初始化串口波特率为115200
delay_init(168); //延时初始化
LED_Init(); //初始化LED
LCD_Init(); //LCD初始化
KEY_Init(); //按键初始
BEEP_Init(); //初始化蜂鸣器
IWDG_Init(4,2000); //溢出时间4秒
TIM3_Int_Init(1000-1,8400-1);//初始化定时器,中断间隔100ms
while(RNG_Init()); //随机数初始化
tp_dev.init(); //触摸屏初始化
u16 x,y; //触摸像素的横纵坐标
while(1)
{
delay_ms(50);
tp_dev.scan(0);//扫描触摸屏
if(tp_dev.sta&TP_PRES_DOWN)
{
//获取坐标值
x = tp_dev.x[0],y = tp_dev.y[0];
//主菜单模式
if(Menu_status==0)
{
if (x > 62 && x < 418&&y > 518 && y < 593)
{
//PVP
GameStart(0); //以PVP方式开始游戏
Menu_UI=0; //菜单UI未绘制
}
if (x > 62 && x < 418&&y > 618 && y < 693)
{
//PVE
Menu_status=1;//进入对话框状态
DrawSelectDiffDialog();//绘制对话框
Menu_UI=0; //菜单UI未绘制
}
}
//对话框模式
if(Menu_status==1)
{
int PVE_ready=0;//是否选择难度
if (x > 62 && x < 418&&y > 318 && y < 393)
{
//难度简单
Game_PVEDiff=1;
PVE_ready=1;
}
if (x > 62 && x < 418&&y > 418 && y < 493)
{
//难度一般
Game_PVEDiff=2;
PVE_ready=1;
}
if (x > 62 && x < 418&&y > 518 && y < 593)
{
//难度困难
Game_PVEDiff=3;
PVE_ready=1;
}
if(PVE_ready)
{
//已经选择难度
GameStart(1);//以PVE方式开始游戏
Menu_status=0;
}
}
}
if(Menu_status==0&&!Menu_UI)
{
//在主菜单状态且主菜单未绘制
DrawMenuUI();Menu_UI=1;
}
//喂狗
IWDG_Feed();
}
}
主程序的主循环用Menu_status标志区分了两个状态。当Menu_status为0时,是主菜单状态,用以选择模式;当Menu_status为1时,是对话框状态,用于选择PVE模式的难度。当选择了PVP模式后,直接进入游戏,而当选择PVE模式后,会进入对话框状态,进一步选择PVE模式难度。
定义了Menu_UI标志用以标志主菜单是否绘制,当绘制对话框或进入游戏后,Menu_UI为0,当绘制主菜单后,Menu_UI为1。每次主循环判断当在主菜单状态且主菜单未绘制,会绘制主菜单。
主循环中通过触摸屏扫描函数,获取触摸屏状态。若触摸屏已经被触摸过,则读取触摸位置的横纵坐标进行判断。当触摸位置在相关操作区域内,则进行相应操作。例如,按下PVE键,则会进入对话框状态,进一步选择游戏难度。
下面是游戏主程序相关宏定义、变量和函数。
首先,定义了棋盘位置、大小、颜色相关的宏定义,用以绘制棋盘和落子时判断棋子位置。
//定义棋盘起始坐标
#define Board_LeftTop_X 16
#define Board_LeftTop_Y 136
#define Board_RightBottom_X 464
#define Board_RightBottom_Y 584
//棋盘大小
#define CHESSBOARD_SIZE (Board_RightBottom_X-Board_LeftTop_X)
//网格大小
#define CHESS_SIZE (CHESSBOARD_SIZE / 14)
//棋盘颜色
#define CHESSBOARD_BGCOLOR BROWN
接着,定义了游戏相关的宏定义和变量。其中,ChessBoard[][]数组用于存储棋子状态,而ChessIdx存储下一落子的棋子编号。Game_status存储游戏当前状态,共有5种状态,初始时为未开始。Game_mode存储当前游戏模式。Game_PVEDiff存储游戏难度。ChessX与ChessY存储落子位置。
//游戏状态
#define INACTIVE -1 //未开始
#define NORMAL 0 //正常
#define PING 1 //平局
#define FIVE 2 //五子连珠
#define PAUSE 3 //暂停
//游戏模式
#define PVP 0
#define PVE 1
u8 ChessBoard[15][15]; //棋盘状态
u8 ChessIdx=0; //棋子编号
int Game_status=INACTIVE; //游戏状态
int Game_mode=PVP; //游戏模式
int Game_PVEDiff; //游戏难度
int ChessX,ChessY; //落子位置(ChessX,ChessY)
最后,是其他相关函数声明。
int GetNextChess(void);//获取下一落子函数
u16 GetNextChessColor();//获取下一落子颜色函数
void DrawGameUI(void); //绘制游戏UI函数
void DrawDialog(int s);//绘制对话框函数
void DrawWaitingDialog(int t);//绘制等待对话框函数
void DrawChessBoard(void); //绘制棋盘函数
void DrawPoint(int x, int y);//绘制棋盘点函数
void DrawChess(int x, int y,int num);//绘制棋子函数
void DrawChessCircle(int x, int y);//绘制棋子提示圆函数
void DrawNextChess(void);//绘制下一棋子提示函数
void DrawFiveChess(int x, int y, int dx, int dy);//绘制五子连珠提示函数
void Sound(u16 frq);//蜂鸣器发声函数
void PlayMusic(void);//蜂鸣器奏乐函数
void ChessDownBeep(void);//落子提示函数
void UndoTheLastChess(void);//悔棋函数
//获取某一方向上同色棋子数,不可跳空
int GetLineChessNum1(int ChessColor, int x, int y, int dx, int dy);
//获取某一方向上同色棋子数,可跳空
int GetLineChessNum2(int ChessColor, int x, int y, int dx, int dy);
int IsFiveChessContant(int x, int y);//判断是否五子连珠函数
int IsGameOver(int x,int y);//判断是否游戏结束函数
int SearchLine(int ChessColor, int x, int y, int dx, int dy);//搜索棋线
int GetScore(int i, int j, int color);//获取某点分数
int BoardSearch(int *xChess, int *yChess, int ChessColor);//棋盘搜索函数
void FindBestChessLocation1(int *xChess, int *yChess);//获取电脑落子位置1
void FindBestChessLocation2(int *xChess, int *yChess);//获取电脑落子位置2
void ChessDown();//落子函数
void GameInit(void);//游戏初始化函数
void GameStart(int mod);//游戏开始函数,也就是游戏主程序
下面是游戏主程序。
/游戏开始
void GameStart(int mod)
{
Game_mode=mod;
LCD_Clear(WHITE);
DrawWaitingDialog(500);
GameInit();
tp_dev.scan(0);
u16 x, y;
while(1)
{
if(Game_status==NORMAL&&Game_mode==PVE
&&GetNextChess()==WHITECHESS)
{
//电脑走白棋
if(ChessBoard[ChessX][ChessY]>0)
DrawChess(ChessX,ChessY,ChessIdx);
if(Game_PVEDiff==1||Game_PVEDiff==2)
FindBestChessLocation1(&ChessX, &ChessY);
if(Game_PVEDiff==3)
FindBestChessLocation2(&ChessX, &ChessY);
ChessDown();
continue;
}
delay_ms(10);
tp_dev.scan(0);
if(tp_dev.sta&TP_PRES_DOWN)
{
//获取坐标值
x = tp_dev.x[0];y = tp_dev.y[0];
switch(Game_status)
{
case NORMAL:
//棋盘区域
if (x > Board_LeftTop_X-10 && x < Board_RightBottom_X+10
&&y > Board_LeftTop_Y-10 && y < Board_RightBottom_Y+20)
{
int xx=(x-Board_LeftTop_X)/CHESS_SIZE,
yy=(y-Board_LeftTop_Y)/CHESS_SIZE;
if(ChessBoard[xx][yy]>0) break;
DrawChess(ChessX,ChessY,ChessIdx);
ChessX=xx;ChessY=yy;
printf("ATTACK:%d DEFFENSE:%d",
GetScore(ChessX,ChessY,GetNextChess()),
GetScore(ChessX,ChessY,!GetNextChess()));
ChessDown();
}
//悔棋按钮
if (x > 0 && x < 50 &&y > 0 && y < 40)
{
DrawWaitingDialog(500);
UndoTheLastChess();
if(Game_mode==PVE)
UndoTheLastChess();
DrawChessBoard();
DrawNextChess();
}
//帮助按钮
if(x > 200 && x < 280 &&y > 0 && y < 40)
{
if(ChessBoard[ChessX][ChessY]>0)
DrawChess(ChessX,ChessY,ChessIdx);
FindBestChessLocation2(&ChessX, &ChessY);
ChessDown();
}
//暂停按钮
if (x > 430 && x < 480 &&y > 0 && y < 40)
{
DrawDialog(Game_status);
Game_status=PAUSE;
}
break;
case PAUSE:case PING:case FIVE:
if(Game_status==PAUSE&& (x > 400 && x < 438) &&
(y > 330 && y < 380))
{
//取消暂停
DrawWaitingDialog(500);
DrawGameUI();
Game_status=NORMAL;
}
if (x > 46 && x < 240 &&y > 450 && y < 514)
{
//重新开始
DrawWaitingDialog(500);
GameInit();
}
if (x > 240 && x < 434 &&y > 450 && y < 514)
{
//退出
Game_status=INACTIVE;
DrawWaitingDialog(500);
return;
}
break;
}
tp_dev.scan(0);
}
//喂狗
IWDG_Feed();
}
}
游戏的主程序为GameStart()函数,由主程序调用后开始游戏。GameStart()有一个参数mode,当mode为0时,进行PVP游戏,当mode为1时,进行PVE游戏。
当游戏开始时,主程序会执行游戏初始化函数GameInit(),用以初始化15*15数组,重置游戏状态并绘制游戏UI。
//游戏初始化
void GameInit(void)
{
Game_status=0;//游戏状态归0
ChessIdx=0;//编号归0
ChessX=-1;ChessY=-1;
memset(ChessBoard, 0, sizeof(ChessBoard));
DrawGameUI();//绘制游戏UI
};
接着,游戏会等待玩家落子或者电脑玩家自动落子。轮到某位玩家落子时,相应的LED灯和屏幕都会有相应提示。
当玩家触摸到棋盘中未落子位置时,就会运行落子函数。而在PVE模式时,轮到白子落子时,电脑玩家会自动落子。每次落子后,都会运行IsGameOver()函数判断游戏是否结束。而当玩家触摸到功能键时,则会进入相应的游戏状态,并调用相应功能的函数。
本设计的五子棋算法的大致思路是对当前棋盘上所有的空子位置依次进行评分,得到一个分值矩阵,根据分值矩阵选取最优落子位置。但是因为实际周围棋子颜色的不同,判断的角度又分为两种:一种是以黑子的角度,另一种是以白子的角度。以黑子的角度判断周围黑子分布,可以得到一个分值矩阵,而以白子的角度又可以得到一个新的分值矩阵。以持黑子玩家的视角,黑子的分值矩阵可以用于进攻,而白子的分值矩阵可以用于防守。所以,需要综合以上两个分值矩阵来判断最优落子位置。
而对空子位置位置评分的依据,是空子位置周围棋子的分布情况。这里首先对所有算法所涉及的一些五子棋棋型命名,未涉及的棋型不予展示。
如下,是活棋的4种情况。
图 9 活棋
如下,是跳棋的3种情况。
图 10 跳棋
如下,是死棋的3种情况。
图 11 死棋
结合上述棋型,便能对空子位置附近的棋型进行研判,并赋予相应分值。
以下述情况为例,图中虚线位置为空子位置。若虚线位置下黑子,则无法成任一棋型,故黑子为0分。若虚线位置下白子,则能成两个死四棋型。在这种情况下,白子成五子的概率极大,所以对应白子的分值为30000分。
图 12 死四*2
其他情形不一一展示,具体分值如下。
五连*1 |
50000 |
活四*1 |
30000 |
死四*2 |
30000 |
死四(或死跳四)*1+活三(或跳三)*1 |
20000 |
活三*2 |
20000 |
跳三*2 |
20000 |
活三*1+跳三*1 |
20000 |
跳四*1 |
10000 |
死四*1+活二*2 |
5000 |
活三*1+死三*1 |
1000 |
死四*1 |
500 |
活三*1 |
200 |
活二*2 |
100 |
死三*2 |
50 |
活二*1 |
10 |
死三*1 |
5 |
表 1 棋型分值
以上分值,算法只取最高值。因此,便能写出估值函数。
//获取(i,j)点下子的分数
int GetScore(int i, int j, int ChessColor)
{
int DeadFour=0, DeadThree=0, DeadTwo=0,WinFive=0, AliveFour=0,
AliveThree=0, AliveTwo=0,JumpThree=0,JumpFour=0,DeadJumpFour=0;
int LineStatus[4];//4条棋线状态,即棋型
LineStatus[0] = SearchLine(ChessColor, i, j, 0, 1); //上下
LineStatus[1] = SearchLine(ChessColor, i, j, 1, 0); //左右
LineStatus[2] = SearchLine(ChessColor, i, j, 1, 1); //右上对角线
LineStatus[3] = SearchLine(ChessColor, i, j, 1, -1); //右下对角线
//统计各种情况的数目
for (int n = 0; n < 4; n++)
{
switch (LineStatus[n])
{
case 11://死跳四
DeadJumpFour++;break;
case 10://跳四
JumpFour++;break;
case 9://跳三
JumpThree++;break;
case 8://死四
DeadFour++;break;
case 7://死三
DeadThree++;break;
case 6://死二
DeadTwo++;break;
case 5://五连
WinFive = 1;break;
case 4://活四
AliveFour = 1;break;
case 3://活三
AliveThree++;break;
case 2://活二
AliveTwo++;break;
default:
break;
}
}
if(WinFive) return 50000;
if(AliveFour) return 30000;
if(DeadFour>=2) return 30000;
if((DeadFour||DeadJumpFour)&&(AliveThree||JumpThree)) return 20000;
if(AliveThree>=2) return 20000;
if(JumpThree>=2) return 20000;
if(JumpThree&&AliveThree) return 20000;
if(JumpFour) return 10000;
if(DeadFour&&(AliveTwo>=2)) return 5000;
if(AliveThree&&DeadThree) return 1000;
if(DeadFour==1) return 500;
if(AliveThree==1) return 200;
if(AliveTwo>=2) return 100;
if(DeadThree>=2) return 50;
if(AliveTwo==1) return 10;
if(DeadThree==1) return 5;
return 1;
}
下面便是对两个分值矩阵的处理的不同方法所产生的的不同算法。
分别以黑子和白子的视角,得到两个分值矩阵,取两个分值矩阵中的最大点为最佳落子位置。具体算法如下。
//棋盘搜索
int BoardSearch(int *xChess, int *yChess, int ChessColor)
{
if(ChessColor!=BLACKCHESS&&ChessColor!=WHITECHESS) return 0;
int score,MaxScore = 0;
//计算整个棋盘
for(int x=0;x<15;x++)
for(int y=0;y<15;y++)
if(ChessBoard[x][y]==0)
{
score=GetScore(x,y,ChessColor);
//判断
if(score>=50000)
{
*xChess = x;
*yChess = y;
return score;
}
if(score>MaxScore)
{
MaxScore = score;
*xChess = x;
*yChess = y;
}
else if(score==MaxScore)
{
int randrom=RNG_Get_RandomRange(0, 9);
if(randrom%2)
{
MaxScore = score;
*xChess = x;
*yChess = y;
}
}
}
return MaxScore;
}
//xChess,yChess:棋子坐标
void FindBestChessLocation1(int *xChess, int *yChess)
{
int WhiteChessScore, BlackChessScore;
int wi, wj, bi, bj;
WhiteChessScore = BoardSearch(&wi, &wj, WHITECHESS);
BlackChessScore = BoardSearch(&bi, &bj, BLACKCHESS);
//白棋有利,电脑进攻
if((Game_PVEDiff==1?1.5:1)*WhiteChessScore > BlackChessScore)
{
*xChess = wi;
*yChess = wj;
}
else if((Game_PVEDiff==1?1.5:1)*WhiteChessScore <= BlackChessScore)
//黑棋有利,电脑防守
{
*xChess = bi;
*yChess = bj;
}
printf("(%d,%d) DEFFENSE:%d ATTACK:%d",*xChess,*yChess,
GetScore(*xChess,*yChess,BLACKCHESS),
GetScore(*xChess,*yChess,WHITECHESS));
}
这种算法虽然实现了一定程度的下棋水平,但在部分情况的落子选择不佳,水平有限。分析该算法的问题在于只片面地局限于进攻或者防守的最有利处,而忽略了两者的结合。在部分空子处落子,即可以进攻又可以防守,攻守兼备,收益大于在片面地进攻或防守的最有利处落子,所以这种算法最终实现的下棋水平并不高,故作为PVE模式的一般水平。再对其中白棋的进攻分值做加权处理,降低算法的下棋水平,作为PVE模式的简单水平。
基于上一算法的问题,考虑将两个分值矩阵做加权和,合并为一个分值矩阵,再取新的分值矩阵的分值最大点为最佳落子点。按黑子先下原则,黑子有优势,故黑子分数的权重为1.2,而白子为1.0。具体函数如下。
void FindBestChessLocation2(int *xChess, int *yChess)
{
int score,MaxScore = 0,tb,tw;
//计算整个棋盘
for(int x=0;x<15;x++)
for(int y=0;y<15;y++)
if(ChessBoard[x][y]==0)
{
int blackScore=GetScore(x,y,BLACKCHESS),
whiteScore=GetScore(x,y,WHITECHESS);
score=1.2*blackScore+whiteScore;
//判断
if(whiteScore>=50000)
{
*xChess = x;
*yChess = y;
printf("x:%d y:%d Score:%d",x,y,score);
return;
}
if(score>MaxScore)
{
MaxScore = score;
*xChess = x;
*yChess = y;
}
else if(score==MaxScore)
{
int randrom=RNG_Get_RandomRange(0, 9);
if(randrom%2)
{
MaxScore = score;
*xChess = x;
*yChess = y;
}
}
}
printf("x:%d y:%d Score:%d DEFFENSE:%d ATTACK:%d",*xChess,*yChess,MaxScore,
GetScore(*xChess,*yChess,BLACKCHESS),
GetScore(*xChess,*yChess,WHITECHESS));
}
此算法最终实现的下棋水平极高,故作为PVE模式的困难水平。
下面截取了几张实际运行时的情况。
图 13 主菜单
图 14 棋盘
图 15 落子
图 16 游戏结束
图 17 选择难度