最近一直在学习Android自定义View方面的知识,正好看到一个讲解制作五子棋小游戏的案例,遂学习一番,记录下学习过程,帮助那些有需要的人。
首先放上效果图:
下面我将带领大家一步步完成这个五子棋小游戏。
首先我们先定义一个类
WuziqiPanel
,让该类继承自View,并在类中定义一些成员变量,便于我们后面使用,而且在我们需要显示五子棋的布局文件中引入该自定义View。
WuziqiPanel.java
文件
public class WuziqiPanel extends View{
//棋盘宽度
private int mPanelWidth;
//棋盘格子的行高(声明为int会造成由于不能整除而造成的误差较大)
private float mLineHeight;
//棋盘最大行列数(其实就是棋盘横竖线的个数)
private int MAX_LINE_NUM = 10;
//定义画笔绘制棋盘格子
private Paint mPaint = new Paint();
//定义黑白棋子Bitmap
private Bitmap mWhitePiece;
private Bitmap mBlackPiece;
//棋子的缩放比例(行高的3/4)
private float pieceScaleRatio = 3 * 1.0f / 4;
//存储黑白棋子的坐标
private ArrayList mWhiteArray = new ArrayList<>();
private ArrayList mBlackArray = new ArrayList<>();
//哪方先下子
private boolean isWhiteFirst = true;
//游戏是否结束
private boolean isGameOver;
//确定赢家
private boolean isWhiteWinner = false;
//游戏结束监听
private OnGameOverListener onGameOverListener;
}
在
activity_main.xml
文件中引入该自定义View
activity_main.xml
文件
注意:自定义View的引入必须使用完整路径
这一步我们首先要书写构造函数,并且在构造函数中初始化一些设置。比如初始化画笔以及将棋子图片转为bitmap
这里我们在三个参数的构造方法中调用两个参数的构造方法,又在两个参数的构造方法中调用一个参数的构造方法。
这里简单解释一下。
一个参数的构造方法
是我们在new
出一个组件的时候调用;两个参数的构造方法
是我们在XML中使用自定义View时调用;三个参数的构造方法
是我们自定义View中使用了自定义属性的时候调用;所以我们按上面的写法就可以覆盖到这三种情况。
public WuziqiPanel(Context context) {
this(context, null);
}
public WuziqiPanel(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public WuziqiPanel(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
/**
* 初始化设置
*/
private void init() {
//初始化画笔
mPaint.setColor(0x88000000);
//设置抗锯齿
mPaint.setAntiAlias(true);
//设置防抖动
mPaint.setDither(true);
//设置为空心(画线)
mPaint.setStyle(Paint.Style.STROKE);
//初始化棋子
mWhitePiece = BitmapFactory.decodeResource(getResources(), R.drawable.icon_white_piece);
mBlackPiece = BitmapFactory.decodeResource(getResources(), R.drawable.icon_black_piece);
}
测量几乎是自定义View必须要经历的步骤,由于我们要先绘制棋盘,所以我们必须先测量出我们需要的数据。
我们在onMeasure()
方法中拿到屏幕宽高,然后在onSizeChanged()
中获得棋盘的宽度,计算出棋盘的行高。接着根据行高缩放棋子大小,使其显示大小合适。
/**
* 测量
* @param widthMeasureSpec
* @param heightMeasureSpec
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int width = Math.min(widthSize, heightSize);
//此处的逻辑判断是处理当我们自定义的View被嵌套在ScrollView中时,获得的测量模式
// 会是UNSPECIFIED
// 使得到的widthSize或者heightSize为0
if (widthMode == MeasureSpec.UNSPECIFIED){
width = heightSize;
}else if (heightMode == MeasureSpec.UNSPECIFIED){
width = widthSize;
}
//调用此方法使我们的测量结果生效
setMeasuredDimension(width, width);
}
/**
* 当宽高发生变化时回调此方法
* @param w
* @param h
* @param oldw
* @param oldh
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
//此处的参数w就是在onMeasure()方法中设置的自定义View的大小
//计算出棋盘宽度和行高
mPanelWidth = w;
mLineHeight = mPanelWidth * 1.0f / MAX_LINE_NUM;
//将棋子根据行高变化
int pieceWidth = (int) (pieceScaleRatio * mLineHeight);
mWhitePiece = Bitmap.createScaledBitmap(mWhitePiece, pieceWidth, pieceWidth, false);
mBlackPiece = Bitmap.createScaledBitmap(mBlackPiece, pieceWidth, pieceWidth, false);
}
注意:如上面注释所写,由于我们不知道我们的自定义View将会被放在什么样的布局中,所以如果我们的五子棋盘被放在ScrollView中,我们测量到的宽或者高就会有一方为0,就会使我们的测量失效,从而影响后面的绘制,所以我们必须处理这一种情况.
首先我们应该先绘制好我们要下棋的棋盘,我们此处准备横竖都画10条线来绘制我们的棋盘,其实此处的棋盘的横竖线的个数我们是可以修改的,棋子的大小也会随着棋盘格子的大小缩放,但是为了美观一些,我们此处采用横竖10条线.
大家可以先通过我下面的示意图来理解一下棋盘横竖线坐标的确定.
/**
* 绘制棋盘
* @param canvas
*/
private void drawBoard(Canvas canvas) {
int w = mPanelWidth;
float lineHeight = mLineHeight;
for (int i = 0; i < MAX_LINE_NUM; i++) {
int startX = (int) (lineHeight / 2);
int endX = (int) (w - lineHeight / 2);
int y = (int) ((0.5 + i) * lineHeight);
//画横线
canvas.drawLine(startX, y, endX, y, mPaint);
//画竖线
canvas.drawLine(y, startX, y, endX, mPaint);
}
}
通过上面的步骤棋盘就算是绘制好了.
上面我们绘制好了棋盘,接着我们就可以下棋啦,所以理所当然我们要开始处理用户的手势,开始下棋啦,所以我们要重写
onTouchEvent()
.
这一步我们要做三件事:
1 . onTouchEvent()
中 return true
拦截手势事件我们自己处理
2 . 获得用户触摸的坐标并进行处理,处理为棋盘上的整数值坐标,并存储起来.
这一步我们调用一个自定义的函数
getValidPoint()
将用户点击的点的坐标转化为整数值.也就是说用户下子的时候不必精确点击到棋盘各自的交叉点上,而是只要在这个交叉点周围就可以了,我们只需要将其取整后除以我们格子的高度(行高),
简单解释一下,如下图1、2所指的箭头所示,由于我们的棋盘上下左右边距为0.5倍的行高,当我们手指在以棋盘顶点0.5倍的行高范围内点击,只要除以行高取整,就会得到该顶点坐标.比如我点击的坐标点为(0.75,0.82),在第一个圈范围内,此时行高为,除以1后取整得到(0,0)就是棋盘的顶点,就是我们需要落子的地方。
/**
* 将用户点击的位置的Point转换为类似于(0,0)d的坐标
* @param x
* @param y
* @return
*/
private Point getValidPoint(int x, int y) {
return new Point((int) (x / mLineHeight), (int) (y / mLineHeight));
}
3 . 调用invalidate()
方法进行界面重绘,绘制出用户所下的棋子
每次调用
invalidate()
方法就会调用onDraw()
方法进行界面绘制,在该方法中先绘制棋盘,然后绘制用户所下的棋子,还要判断游戏结束.绘制棋子和判断游戏结束我会在后面的步骤中给出介绍.
4 . 将下棋权利交于另一方(白棋下完换黑棋)
这一步比较好处理,我们只需要将一个布尔变量
isWhiteFirst
取反,下一次就轮到另一种棋子下子了.
/**
* 处理用户手势操作
* @param event
* @return
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
if (isGameOver) return false;
int action = event.getAction();
//手指抬起后处理
if (action == MotionEvent.ACTION_UP){
//拦截事件自己来处理
int x = (int) event.getX();
int y = (int) event.getY();
Point point = getValidPoint(x, y);
//首先判断所点击的位置是不是已经有棋子
if (mWhiteArray.contains(point) || mBlackArray.contains(point)){
return false;
}
//白棋先下
if (isWhiteFirst){
mWhiteArray.add(point);
}else{
mBlackArray.add(point);
}
//调用重绘
invalidate();
isWhiteFirst = !isWhiteFirst;
}
return true;
}
/**
* 进行绘制工作
* @param canvas
*/
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//绘制棋盘
drawBoard(canvas);
//绘制用户已经下的所有棋子
drawPieces(canvas);
//判断游戏是否结束
checkGameOver();
}
上一步我们已经把用户所点击的要下棋子的坐标存储在了ArrayList,这一步我们就将遍历这个ArrayList将黑白棋子绘制到棋盘上.
这里我们的一个变量
pieceScaleRatio = 3/4
,表示一个棋子长宽为3/4的行高,剩余的1/4的行高平均棋子的左右各留出1/8的行高,这样棋子距离左右边框的距离为1/8行高,棋子与棋子之间的间距为2*1/8=1/4行高.
/**
* 绘制棋子
* @param canvas
*/
private void drawPieces(Canvas canvas) {
//绘制白棋子
for (int i = 0, n = mWhiteArray.size(); i < n; i++) {
Point whitePoint = mWhiteArray.get(i);
//棋子之间的间隔为1/4行高
canvas.drawBitmap(mWhitePiece,
(whitePoint.x + (1 - pieceScaleRatio) / 2) * mLineHeight,
(whitePoint.y + (1 - pieceScaleRatio) / 2) * mLineHeight, null);
}
//绘制黑棋子
for (int i = 0, n = mBlackArray.size(); i < n; i++) {
Point blackPoint = mBlackArray.get(i);
//棋子之间的间隔为1/4行高,棋子距离左右边框的距离为1/8行高
canvas.drawBitmap(mBlackPiece,
(blackPoint.x + (1 - pieceScaleRatio) / 2) * mLineHeight,
(blackPoint.y + (1 - pieceScaleRatio) / 2) * mLineHeight, null);
}
}
经过上面的步骤,我们已经可以在棋盘上落子了,下面的任务就是我们要判断游戏是否结束.
这个游戏的规则比较简单,我们只要判断在上下左右斜对角线如果存在连续5个棋子是同一色,我们就可以判定胜负了.
我们专门定义一个工具类WuziqiUtil.java
来进行判断
public class WuziqiUtil {
//每行上最大的数目
public static final int MAX_COUNT_IN_LINE = 5;
/**
* 检查是否五子连珠
* @param points
* @return
*/
public static boolean checkFiveInLine(List points) {
for (Point p: points) {
int x = p.x;
int y = p.y;
boolean win = checkHorizontal(x, y, points);
if (win) return true;
win = checkVertical(x, y, points);
if (win) return true;
win = checkLeftDiagonal(x, y, points);
if (win) return true;
win = checkRightDiagonal(x, y, points);
if (win) return true;
}
return false;
}
/**
* 判断x, y位置的棋子是否横向五个一致
* @param x
* @param y
* @param points
* @return
*/
public static boolean checkHorizontal(int x, int y, List points) {
int count = 1;
for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
if (points.contains(new Point(x - i, y))){
count ++;
}else {
break;
}
}
if (count == MAX_COUNT_IN_LINE) return true;
for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
if (points.contains(new Point(x + i, y))){
count ++;
}else {
break;
}
}
if (count == MAX_COUNT_IN_LINE) return true;
return false;
}
/**
* 判断x, y位置的棋子是否竖向五个一致
* @param x
* @param y
* @param points
* @return
*/
public static boolean checkVertical(int x, int y, List points) {
int count = 1;
for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
if (points.contains(new Point(x, y - i))){
count ++;
}else {
break;
}
}
if (count == MAX_COUNT_IN_LINE) return true;
for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
if (points.contains(new Point(x, y + i))){
count ++;
}else {
break;
}
}
if (count == MAX_COUNT_IN_LINE) return true;
return false;
}
/**
* 判断x, y位置的棋子是否左斜向上五个一致
* @param x
* @param y
* @param points
* @return
*/
public static boolean checkLeftDiagonal(int x, int y, List points) {
int count = 1;
for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
if (points.contains(new Point(x - i, y + i))){
count ++;
}else {
break;
}
}
if (count == MAX_COUNT_IN_LINE) return true;
for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
if (points.contains(new Point(x + i, y - i))){
count ++;
}else {
break;
}
}
if (count == MAX_COUNT_IN_LINE) return true;
return false;
}
/**
* 判断x, y位置的棋子是否右斜向下五个一致
* @param x
* @param y
* @param points
* @return
*/
public static boolean checkRightDiagonal(int x, int y, List points) {
int count = 1;
for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
if (points.contains(new Point(x - i, y - i))){
count ++;
}else {
break;
}
}
if (count == MAX_COUNT_IN_LINE) return true;
for (int i = 1; i < MAX_COUNT_IN_LINE; i++) {
if (points.contains(new Point(x + i, y + i))){
count ++;
}else {
break;
}
}
if (count == MAX_COUNT_IN_LINE) return true;
return false;
}
}
然后我们只要在在自定义View类中的
checkGameOver()
方法中进行调用就可以判断游戏是否结束
/**
* 检查游戏是否结束
*/
private void checkGameOver() {
//检查是否五子连珠
boolean whiteWin = WuziqiUtil.checkFiveInLine(mWhiteArray);
boolean blackWin = WuziqiUtil.checkFiveInLine(mBlackArray);
if (whiteWin || blackWin){
isGameOver = true;
isWhiteWinner = whiteWin;
//String msg = isWhiteWinner ? "白子获胜" : "黑子获胜";
//Toast.makeText(getContext(), msg, Toast.LENGTH_SHORT).show();
onGameOverListener.gameOver(isWhiteWinner);
}
}
或许已经有人发现了,我上面还声明了一个游戏结束的监听,对的,我们作为一个自定义View当然要将游戏的胜负结果通过回调函数返回给使用者,让其自行处理.
/**
* 游戏结束回调监听
*/
public interface OnGameOverListener{
void gameOver(boolean isWhiterWinner);
}
/**
* 设置游戏结束回调监听
* @param onGameOverListener
*/
public void setOnGameOverListener(OnGameOverListener onGameOverListener){
this.onGameOverListener = onGameOverListener;
}
到这里看起来我们的五子棋小游戏已经开发完成了,但是一个负责的程序员怎么可能满足于此呢,我们还要让我们的游戏更健壮.
我们想象一个场景,当我们正在玩五子棋小游戏,正到关键时刻,来电话了,这时候我们去接电话,这时候我们的小游戏就相当于处于后台,假如这时候手机内存不足,那我们在后台的小游戏就可能被内存回收了,当我们打完电话,发现棋盘上一个棋子都没啦,是不是很伤心,为了解决这个问题,我们就需要重写
onSaveInstanceState()
方法和onRestoreInstanceState()
方法来保存和恢复我们的游戏状态.
我们可以通过旋转屏幕模拟出上面提到的情况,旋转屏幕就会触发上面两个函数.
/**
* 防止内存不足活动被回收
*/
private static final String INSTANCE = "instance";
private static final String INSTANCE_GAME_OVER = "instance_game_over";
private static final String INSTANCE_WHITE_ARRAY = "instance_white_array";
private static final String INSTANCE_BLACK_ARRAY = "instance_black_array";
@Override
protected Parcelable onSaveInstanceState() {
Bundle bundle = new Bundle();
bundle.putParcelable(INSTANCE, super.onSaveInstanceState());
bundle.putBoolean(INSTANCE_GAME_OVER, isGameOver);
bundle.putParcelableArrayList(INSTANCE_WHITE_ARRAY, mWhiteArray);
bundle.putParcelableArrayList(INSTANCE_BLACK_ARRAY, mBlackArray);
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle){
Bundle bundle = (Bundle) state;
isGameOver = bundle.getBoolean(INSTANCE_GAME_OVER);
mWhiteArray = bundle.getParcelableArrayList(INSTANCE_WHITE_ARRAY);
mBlackArray = bundle.getParcelableArrayList(INSTANCE_BLACK_ARRAY);
super.onRestoreInstanceState(bundle.getParcelable(INSTANCE));
return;
}
super.onRestoreInstanceState(state);
}
好了,这样我们的游戏就健壮了不少.
一个游戏怎么可以只玩一次呢,所以我们这里还需要向使用者保留一个再来一局的方法.
/**
* 重新开始,再来一局
*/
public void restart(){
mBlackArray.clear();
mWhiteArray.clear();
isGameOver = false;
isWhiteWinner = false;
//重绘
invalidate();
}
定义已经全部完成了,现在使用就非常简单了.
activity_main.xml
文件
MainActivity.java
文件
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
WuziqiPanel panel = (WuziqiPanel) findViewById(R.id.id_wuziqi_panel);
panel.setOnGameOverListener(new WuziqiPanel.OnGameOverListener() {
@Override
public void gameOver(boolean isWhiterWinner) {
//处理胜负结果
}
});
}
}
游戏的介绍就到此为止了,希望可以帮助到需要的人.
源代码已经在Github开源,开源地址: https://github.com/codekongs/WuZiQi
欢迎大家start和fork
下集预告:五子棋小游戏之AI篇,通过算法实现人机对战。