最近在学自定义View,无意中看到鸿洋大神以前写过的2048,看起来很不错,所以自己在他的基础上做一个加强版的2048。先看图:
功能除了正常的2048外,还支持数字与图片无缝切换而没有任何影响,此外,图片不是嵌在自定义View里面的,而是开发者自己在调用时再自己添加的,如:在MainActivity里面添加图片,缺点是Activity被销毁后再进入是重新开始的,不过这只是做一个demo而已,就不讲究这么多了。其实想要开发者改变更多的样式而不用改自定义View内部的关键在于对外暴露的方法的多少,如你可以在自定义View里面写4行4列,也可以暴露一个改变行列数的方法,结果其实没差,只是说这样会减少对自定义View内部的直接操作。
下面这两张图是对应的,切换只需按一下按钮。
下面开始挑战2048:
一共两个自定义View:一个容器GameLayout,一个小方格GameItem。容器主要监听整体变化如数的变化,逻辑处理、小方格的位置等等,具体画小方格的颜色、图片、数字还是由小方块自己画,而调用的时候是对GameLayout进行操作。
写自定义View的第一步:分析有什么属性。
一、容器GameLayout,很明显,必须要知道有多少行多少列,小方格的间距,这是靠上下左右滑动的当然就有检测用户滑动的手势,玩的过程肯定要计分啦...
接着开始实现
1、可以用一个数组来存放小方格,数组的大小由行数决定,之后数字变化了都会对这个数组进行操作,保证每时每刻位置和数字都是对的;
/**
* 测量Layout的宽和高,以及设置Item的宽和高,这里忽略wrap_content 以宽、高之中的最小值绘制正方形
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 获得正方形的边长
int length = Math.min(getMeasuredHeight(), getMeasuredWidth());
// 获得Item的宽度
int childWidth = (length - mPadding * 2 - mMargin * (mColumn - 1)) / mColumn;
if (!once) {
if (mItems == null) {
mItems = new GameItem[mColumn * mColumn];
}
// 放置Item
for (int i = 0; i < mItems.length; i++) {
GameItem item = new GameItem(getContext());
mItems[i] = item;
item.setId(i + 1);
RelativeLayout.LayoutParams lp = new LayoutParams(childWidth, childWidth);
// 设置横向边距,不是最后一列
if ((i + 1) % mColumn != 0) {
lp.rightMargin = mMargin;
}
// 如果不是第一列
if (i % mColumn != 0) {
lp.addRule(RelativeLayout.RIGHT_OF, mItems[i - 1].getId());
}
// 如果不是第一行,设置纵向边距,非最后一行
if ((i + 1) > mColumn) {
lp.topMargin = mMargin;
lp.addRule(RelativeLayout.BELOW, mItems[i - mColumn].getId());
}
addView(item, lp);
}
//生成数字
generateNum();
}
once = true;
setMeasuredDimension(length, length);
}
2、对于手势,为了简单方便,我们枚举四个方向,自己写一个类继承GestureDetector.SimpleOnGestureListener,在里面判断向那边滑动,注释写的很清楚就不多说了,对于里面的action方法,它会根据你向哪边滑动做出响应的处理,如对小方格移动、数字的合并等等;
/**
* 运动方向的枚举
*/
private enum ACTION {
LEFT, RIGHT, UP, DOWM
}
/**
* 根据坐标变化判断手势
*/
class MyGestureDetector extends GestureDetector.SimpleOnGestureListener {
// 设置最小滑动距离
final int FLING_MIN_DISTANCE = 50;
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
// 得到在X轴移动的距离
float x = e2.getX() - e1.getX();
// 得到在Y轴移动的距离
float y = e2.getY() - e1.getY();
if (x > FLING_MIN_DISTANCE && Math.abs(velocityX) > Math.abs(velocityY)) {
// 向右滑
action(ACTION.RIGHT);
} else if (x < -FLING_MIN_DISTANCE && Math.abs(velocityX) > Math.abs(velocityY)) {
// 向左滑
action(ACTION.LEFT);
} else if (y > FLING_MIN_DISTANCE && Math.abs(velocityX) < Math.abs(velocityY)) {
// 向下滑
action(ACTION.DOWM);
} else if (y < -FLING_MIN_DISTANCE && Math.abs(velocityX) < Math.abs(velocityY)) {
// 向上滑
action(ACTION.UP);
}
return true;
}
}
3、不从界面,单纯从逻辑考虑,当用户向某一方向移动时,其实就是不断遍历再判断,表的遍历需要两重for循环,根据方向从方向的最前面开始,一个一个判断是不是0(0表示空白),从而判断能不能移动,然后判断是否能合并以及设置合并后的值,之后在值为0的空白小方格中随机选一块产生2或4,当然,到最后无法产生随机数就说明游戏结束了,逻辑差不多就这样吧。
/**
* 根据用户运动,整体进行移动合并值等
*/
private void action(ACTION action) {
// 行|列
for (int i = 0; i < mColumn; i++) {
List row = new ArrayList<>();
// 行|列
//记录不为0的数字
for (int j = 0; j < mColumn; j++) {
// 得到下标
int index = getIndexByAction(action, i, j);
GameItem item = mItems[index];
// 记录不为0的数字
if (item.getNumber() != 0) {
row.add(item);
}
}
//判断是否发生移动
for (int j = 0; j < mColumn && j < row.size(); j++) {
int index = getIndexByAction(action, i, j);
GameItem item = mItems[index];
if (item.getNumber() != row.get(j).getNumber()) {
isMoveHappen = true;
}
}
// 合并相同的
mergeItem(row);
// 设置合并后的值
for (int j = 0; j < mColumn; j++) {
int index = getIndexByAction(action, i, j);
if (row.size() > j) {
mItems[index].setNumber(row.get(j).getNumber());
} else {
mItems[index].setNumber(0);
}
}
}
//生成数字
generateNum();
}
二、接下来轮到小方格了,他应该设什么属性呢?你可能会想到边长吧,其实边长是可以不用考虑的,因为容器的边长确定了,行数确定了,内边距也确定了,小方格的边长也就确定了,这也符合自定义View的原则之一,能又其他属性算出来的就直接算出来而不重复设。它的属性应该有类型(是图片还是数字)、数字、图片、背景色。
1、默认类型是数字,可以用setType方法改变模式;
/**
* 设置类型
* @param type 0为数字, 1为图片
*/
public void setType(int type) {
this.type = type;
invalidate();
}
2、通过setNumber方法改变内容,改变时又会根据不同的数字选取不同的颜色(这些颜色是我自己一个一个试的,感觉还可以,还有就是我比较喜欢蓝色的,所以你会看到demo运行后基本上界面都是蓝色的),同理,图片也是根据这个来变化的。
/**
* 得到图片id数组,并转换成Bitmap类型
*
* @param iamges
*/
public void setImages(int[] Images) {
this.mImages = Images;
if (mBitmaps == null) {
mBitmaps = new Bitmap[mImages.length];
for (int i = 0; i < mImages.length; i++) {
// 将图片id转化成Bitmap
mBitmaps[i] = BitmapFactory.decodeResource(getResources(), mImages[i]);
}
}
invalidate();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (type == TYPE_NUMBER) {
String bgColor = null;
switch (mNumber) {
case 0:
bgColor = "#616ba1";
break;
case 2:
bgColor = "#bfc8f7";
break;
case 4:
bgColor = "#b0bbf7";
break;
case 8:
bgColor = "#9facf5";
break;
case 16:
bgColor = "#909ff4";
break;
case 32:
bgColor = "#8394f2";
break;
case 64:
bgColor = "#788bf4";
break;
case 128:
bgColor = "#6f83f2";
break;
case 256:
bgColor = "#6379f2";
break;
case 512:
bgColor = "#5971f4";
break;
case 1024:
bgColor = "#4f69f2";
break;
case 2048:
bgColor = "#3F51B5";
break;
default:
bgColor = "#8899f5";
break;
}
// 用对应的颜色充满整个小方格
mPaint.setColor(Color.parseColor(bgColor));
mPaint.setStyle(Paint.Style.FILL);
canvas.drawRect(0, 0, getWidth(), getHeight(), mPaint);
// 如果有数字就画出来
if (mNumber != 0) {
mPaint.setColor(Color.BLACK);
float x = (getWidth() - mBound.width()) / 2;
float y = getHeight() / 2 + mBound.height() / 2;
canvas.drawText(mNumber + "", x, y, mPaint);
}
} else {
int index = -1;
// 将数字转换成图片下标
switch (mNumber) {
case 2:
index = 0;
break;
case 4:
index = 1;
break;
case 8:
index = 2;
break;
case 16:
index = 3;
break;
case 32:
index = 4;
break;
case 64:
index = 5;
break;
case 128:
index = 6;
break;
case 256:
index = 7;
break;
case 512:
index = 8;
break;
case 1024:
index = 9;
break;
case 2048:
index = 10;
break;
}
// 如果没有图片,则直接用颜色充满整个小方格
if (mNumber == 0) {
mPaint.setColor(Color.parseColor("#616ba1"));
mPaint.setStyle(Paint.Style.FILL);
canvas.drawRect(0, 0, getWidth(), getHeight(), mPaint);
}
// 如果有图片就画出来
if (mNumber != 0)
canvas.drawBitmap(mBitmaps[index], null, new Rect(0, 0, getWidth(), getHeight()), null);
}
}
三、接下来就是使用了,其实很简单,加入xml后,在Activity 中找到控件,设置各种监听和处理
Activity也只是简答的判断逻辑
package com.talentclass.numberimage2048;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.content.SharedPreferences;
import android.preference.Preference;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
/**
* 程序入口
*
* @author talentClass
*/
public class MainActivity extends AppCompatActivity implements GameLayout.Game2048Listener {
public static final String SCORE = "score";
/**
* 模式:false为数字,true为图片
*/
private boolean bType;
private TextView tvScore, tvMaxScore; // 当前分数、最高分
private Button btnType, btnRestart; // 设置类型、重新开始
private GameLayout mGameLayout; // 自定义View容器
// 放置图片的数组
private int[] mImages = {R.mipmap.image1, R.mipmap.image2, R.mipmap.image3, R.mipmap.image4, R.mipmap.image5, R.mipmap.image6,
R.mipmap.image7, R.mipmap.image8, R.mipmap.image9, R.mipmap.image10, R.mipmap.image11};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// 初始化界面
init();
}
/**
* 初始化界面
*/
private void init() {
tvScore = (TextView) findViewById(R.id.id_score);
tvMaxScore = (TextView) findViewById(R.id.id_max_score);
btnType = (Button) findViewById(R.id.id_type);
btnRestart = (Button) findViewById(R.id.id_restart);
mGameLayout = (GameLayout) findViewById(R.id.id_game2048);
mGameLayout.setOnGame2048Listener(this);
btnType.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if(bType){// 如果当前是图片模式,则此时按钮显示数字模式,所以点下去后,按钮显示图片模式
bType = false;
btnType.setText("图片模式");
// 设置类型为数字模式
mGameLayout.setType(GameItem.TYPE_NUMBER);
}else {// 如果当前是数字模式,则按钮显示图片模式,所以点下去后,按钮显示数字模式
bType = true;
btnType.setText("数字模式");
// 先把图片放进去,然后再设置类型为图片模式
mGameLayout.setImage(mImages);
mGameLayout.setType(GameItem.TYPE_IMAGE);
}
}
});
btnRestart.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
saveScore(tvScore.getText().toString());
// 重新开始
mGameLayout.restart();
}
});
tvMaxScore.setText(getScore());
}
/**
* 获取最高分
*
* @return
*/
private String getScore() {
return getSharedPreferences(SCORE, MODE_PRIVATE).getString(SCORE, "0");
}
/**
* 根据得分判断是否保存到最高分
*
* @param score
*/
private void saveScore(String score) {
// 先转换成int类型比较大小
int now = Integer.parseInt(tvScore.getText().toString());
int max = Integer.parseInt(tvMaxScore.getText().toString());
// 如果超过最高分
if (now > max) {
tvMaxScore.setText(score);
// 保存起来,下次启动再拿出来
SharedPreferences.Editor editor = getSharedPreferences(SCORE, MODE_PRIVATE).edit();
editor.putString(SCORE, score);
editor.commit();
}
}
@Override
public void onBackPressed() {
// 推出前先保存分数
saveScore(tvMaxScore.getText().toString());
super.onBackPressed();
}
@Override
public void onScoreChange(int score) {
tvScore.setText(score + "");
}
@Override
public void onGameOver() {
new AlertDialog.Builder(this).setTitle("游戏结束")
.setMessage("你的得分是:" + tvScore.getText())
.setPositiveButton("再来一次", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
saveScore(tvScore.getText().toString());
mGameLayout.restart();
}
})
.setNegativeButton("不玩了", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
// 保存分数后直接退出应用
saveScore(tvScore.getText().toString());
finish();
}
}).show();
}
}
其实源代码我注释也写的很详细,大家可以下载,相信一看就懂的。