首页 > 代码库 > 是男人就下100层【第五层】——2048游戏

是男人就下100层【第五层】——2048游戏

前言:

在“阳光小强”的实战系列博文《是男人就下100层》的上一层我们一起从零开始完成了我们自己的贪吃蛇游戏——CrazySnake,可能很多朋友还不过瘾,那么我们今天就来玩一玩最近一直比较火的2048游戏,让大家再过一把瘾。由于“阳光小强”目前并没有从事Android的游戏开发工作,所以这些游戏的实现并不需要很专业的游戏开发知识,如果你有Android的基础就可以一起来参与进来共同完成这个游戏。有些朋友可能就会说“这些小游戏,会不会有点简单,整天搞这些对自己没有帮助“,有这种理解的无非两种人,一种是真真经验丰富的大牛,还有一种是好高骛远喜欢仰望星空的朋友,我觉得经典就是对我们有很大很长远指导意义的东西,这些游戏虽然小(100行可以写出贪吃蛇),但是里面蕴含的编程技巧和算法对初学者是非常有用的。孔子说过”温故而知新“,学习的过程就分为”学“和”习“,研究前人的代码就是一种学,思考加改良后就是习。假若有一天你能触类旁通的说这些小游戏我都能很快的实现并且和有所变化和改进,你就会发现你已经不知不觉的提高了许多。在前面三篇CrazySnake中”阳光小强“用自己的方式和风格完成了经典的贪吃蛇游戏,这一篇同样我们也要用自己的方式来一步步思考并实现我们不一样的2048.

一、游戏介绍

《2048》是一款单人在线和移动端游戏,由19岁的意大利人Gabriele Cirulli于2014年3月开发。游戏任务是在一个网格上滑动小方块来进行组合,直到形成一个带有有数字2048的方块。为什么会出现这款游戏呢?作者开发这个游戏是为了测试自己是否有能力从零开始创造一款游戏,但游戏飙升的人气(不到1周内有400万访客)完全出乎他的预料。现在2048被称为网络上“最上瘾的东西”,由于该游戏为开源软件,所以现在市场上有很多改进版本和变种。

游戏使用方向键让方块上下左右移动。如果两个带有相同数字的方块在移动中碰撞,则它们会合并为一个方块,且所带数字变为两者之和。每次移动时,会有一个值为2或者4的新方块出现。当值为2048的方块出现时,游戏即胜利,游戏因此叫做2048.【游戏介绍来源维基百科】

二、实现游戏布局

游戏布局我就仿照了市面上一款2048游戏的界面,布局如下图:

详细的布局结构如下,外面的包裹均为LinearLayout.这只是一种布局方式,还可以使用RelativeLayout布局(这样创建的对象更少)

布局文件activity_main.xml如下:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:orientation="vertical"
    android:background="#ffffff"
    android:padding="20dip">
    <LinearLayout 
        android:layout_width="match_parent"
        android:layout_height="100dip"
        android:orientation="horizontal">
    <TextView 
        android:layout_width="0dip"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:background="@drawable/text_yellow_bg"
        android:textColor="#ffffff"
        android:gravity="center"
        android:text="2048"
        android:textStyle="bold"
        android:textSize="30dip"/>
    <LinearLayout 
        android:layout_width="0dip"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:orientation="vertical"
        android:layout_marginLeft="15dip">
        <LinearLayout 
            android:layout_width="match_parent"
            android:layout_height="0dip"
            android:layout_weight="3"
            android:orientation="vertical"
            android:background="@drawable/text_grey_bg"
            android:gravity="center_vertical">
	        <TextView 
	            android:layout_width="match_parent"
	            android:layout_height="wrap_content"
	            android:gravity="center"
	            android:textColor="#E3D4C1"
	            android:textSize="16sp"
	            android:text="分数"/>
	         <TextView 
	            android:layout_width="match_parent"
	            android:layout_height="wrap_content"
	            android:gravity="center"
	            android:textColor="#ffffff"
	            android:textSize="20dip"
	            android:text="400"/>
        </LinearLayout>
        <TextView 
            android:layout_marginTop="15dip"
            android:layout_width="match_parent"
            android:layout_height="0dip"
            android:layout_weight="2"
            android:background="@drawable/text_red_bg"
            android:textColor="#ffffff"
            android:textSize="20dip"
            android:gravity="center"
            android:text="设置"/>
    </LinearLayout>
    <LinearLayout 
        android:layout_width="0dip"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:orientation="vertical"
        android:layout_marginLeft="15dip">
        <LinearLayout 
            android:layout_width="match_parent"
            android:layout_height="0dip"
            android:layout_weight="3"
            android:orientation="vertical"
            android:background="@drawable/text_grey_bg"
            android:gravity="center_vertical">
	        <TextView 
	            android:layout_width="match_parent"
	            android:layout_height="wrap_content"
	            android:gravity="center"
	            android:textColor="#E3D4C1"
	            android:textSize="16sp"
	            android:text="最高分数"/>
	         <TextView 
	            android:layout_width="match_parent"
	            android:layout_height="wrap_content"
	            android:gravity="center"
	            android:textColor="#ffffff"
	            android:textSize="20dip"
	            android:text="400"/>
        </LinearLayout>
        <TextView 
            android:layout_marginTop="15dip"
            android:layout_width="match_parent"
            android:layout_height="0dip"
            android:layout_weight="2"
            android:background="@drawable/text_red_bg"
            android:textColor="#ffffff"
            android:textSize="20dip"
            android:gravity="center"
            android:text="分享"/>
    </LinearLayout>
    </LinearLayout>
	<com.example.my2048.My2048View 
	    android:layout_marginTop="20dip"
	    android:layout_width="match_parent"
	    android:layout_height="wrap_content"
	    android:background="@drawable/my2048view_bg"/>
</LinearLayout>

三、自定义视图My2048View


	@Override
	protected void onSizeChanged(int w, int h, int oldw, int oldh) {
		super.onSizeChanged(w, h, oldw, oldh);
		this.mViewWidth = w;
		this.mViewHeight = h;
		cellSpace = ((float)mViewWidth - (TOTAL_COL + 1) * SPACE) / TOTAL_COL;
		textPaint.setTextSize(cellSpace / 3);
	}
在View的onSizeChanged的方法中获取View的宽度和高度,并根据宽度计算出每个小格子的长度(宽度和高度),其中TOTAL_COL=4表示四列,SPACE表示间隔宽度。最后一行的textPaint.setTextSize(cellSpace / 3)是设置文字画笔的字体大小(现在不明白没关系,一会就会明白)。相关定义如下:
	private static final int TOTAL_ROW = 4; //行
	private static final int TOTAL_COL = 4; //列
	private static final int SPACE = 15;  //行和列之间的间隙
	private int mViewWidth;  //View的宽度
	private int mViewHeight;  //View的高度
	private float cellSpace;   //每个格子的大小
下面就开始在onDraw方法中绘制小方格
	@Override
	protected void onDraw(Canvas canvas) {
		super.onDraw(canvas);
		String showNum;
		for(int i=0; i<TOTAL_ROW; i++){
			for(int j=0; j<TOTAL_COL; j++){
				pointX = SPACE * (j + 1) + j * cellSpace;
				pointY = SPACE * (i + 1) + i * cellSpace;
				//绘制背景
				rectf.set(pointX, pointY, pointX + cellSpace, pointY + cellSpace);
				paint.setColor(Color.rgb(204, 192, 178));
				canvas.drawRect(rectf, paint);
			}
		}
	}
pintX和pointY是计算绘制方块的起始位置,如下图红点位置分别为方块1、2、3的起始位置。
绘制好方块接下来的目标是绘制上面的数字和每个数字所对应颜色的方块,我们先来观察一下这个游戏中的数字有什么规律,2、4、8......2048可以这样表示2^1、2^2、2^3.......2^11.同时我们可以将没有数字的方块看成数字为1的方块也就是2^0.所以我们的数据范围就是从2^0到2^11.我们定义12中颜色
	private int[] colors = {
			Color.rgb(204, 192, 178), //1
			Color.rgb(253, 235, 213),  //2
			Color.rgb(252, 224, 174),  //4
			Color.rgb(255, 95, 95),   //8
			Color.rgb(255, 68, 68), //16
			Color.rgb(248, 58, 58), //32
			Color.rgb(240, 49, 49), //64
			Color.rgb(233, 39, 39),  //128
			Color.rgb(226, 29, 29),  //256
			Color.rgb(219, 19, 19),  //562
			Color.rgb(211, 10, 10),  //1024
			Color.rgb(204, 0, 0)   //2048
			};
找这么多颜色还真不容易,我就索性将Android的设计规范中红色的后9个颜色取到了这里(这里大家可以自己变成喜欢的颜色)
http://www.apkbus.com/design/style/color.html

通过上面对数据范围的分析,其实我们可以用0到11这12个连续数字来表示1、2、4、8、16.......2048这些数字,最后通过Math的pow函数计算出来即可。我们先来模拟一些数据来绘制出来看看效果。
	/**
	 * 模拟测试数据
	 */
	private void initData(){
		for(int i=0; i<TOTAL_ROW; i++){
			for(int j=0; j<TOTAL_COL; j++){
				int a =  (i+1) * (j+1);
				if(a < 12){
					datas[i][j] = a;
				}else{
					datas[i][j] = 0;
				}
			}
		}
	}
上面的datas就是我们模拟的整型数据数组,我们再改写onDraw方法将这些数据绘制出来。
	private float pointX;
	private float pointY;
	@Override
	protected void onDraw(Canvas canvas) {
		super.onDraw(canvas);
		String showNum;
		for(int i=0; i<TOTAL_ROW; i++){
			for(int j=0; j<TOTAL_COL; j++){
				pointX = SPACE * (j + 1) + j * cellSpace;
				pointY = SPACE * (i + 1) + i * cellSpace;
				//绘制背景
				rectf.set(pointX, pointY, pointX + cellSpace, pointY + cellSpace);
				paint.setColor(colors[datas[i][j]]);
				canvas.drawRect(rectf, paint);
				
				if(datas[i][j] != 0){
					//绘制数字
					if(datas[i][j] == 1 || datas[i][j] == 2){
						textPaint.setColor(Color.rgb(0, 0, 0));
					}else{
						textPaint.setColor(Color.rgb(255, 255, 255));
					}
					showNum = (int)Math.pow(2, datas[i][j]) + "";
					canvas.drawText(showNum, pointX + (cellSpace - textPaint.measureText(showNum)) / 2,
							pointY + (cellSpace + textPaint.measureText(showNum, 0, 1)) / 2, textPaint);
				}
			}
		}
	}
上面对随机数字2、4的字体颜色和其他的字体颜色进行了区分,运行效果如下:
看来没有什么太大问题,我们接下来实现随机产生一个数字2或4来绘制到网格中,要随机产生一个数字2或4比较简单,使用(random.nextInt(2) + 1) * 2 即可实现,其实和上面的分析相同,这里我们仅仅需要随机产生1和2,所以就是random.nextInt (2) + 1.先看随机产生代码:
	/**
	 * 随机的产生1或者2
	 */
	private void randomOneOrTwo(){
		int row = random.nextInt(TOTAL_ROW);
		int col = random.nextInt(TOTAL_COL);
		
		//判断在该位置是否已存在数据
		if(datas[row][col] != 0){
			randomOneOrTwo();
		}else{
			datas[row][col] = random.nextInt(2) + 1;
		}
	}
上面代码用到了递归,为什么要使用递归呢?这是因为我们在放置这个随机产生的数字的时候需要随机产生一个x和y方向的坐标值,如果(x,y)坐标处已经存在数据则需要重新获取,直到该(x,y)处没有数据。

接下来我们来实现让这个小方块可以随着我们的手势上下左右移动到边缘,重写onTouchEvent方法如下。
	private enum Directory{
		LEFT,
		RIGHT,
		BOTTOM,
		TOP
	}
	private float mDownX;
	private float mDownY;
	@Override
	public boolean onTouchEvent(MotionEvent event) {
		switch (event.getAction()) {
		case MotionEvent.ACTION_DOWN:
			mDownX = event.getX();
			mDownY = event.getY();
			return true;
		case MotionEvent.ACTION_MOVE:
			float disX = event.getX() - mDownX;
			float disY = event.getY() - mDownY;
			if(Math.abs(disX) > touchSlop || Math.abs(disY) > touchSlop){
				System.out.println("isMove");
				isMoved = true;
				if(Math.abs(disX) > Math.abs(disY)){
					if(disX > 0){
						currentDirectory = Directory.RIGHT;
					}else{
						currentDirectory = Directory.LEFT;
					}
				}else{
					if(disY > 0){
						currentDirectory = Directory.BOTTOM;
					}else{
						currentDirectory = Directory.TOP;
					}
				}
			}
			return true;
		case MotionEvent.ACTION_UP:
			if(isMoved == true){
				changeState();
				randomOneOrTwo();
				invalidate();
				isMoved = false;
			}
		}
		return super.onTouchEvent(event);
	}
	private void changeState(){
		switch (currentDirectory) {
		case TOP:
			toTop();
			break;
		case BOTTOM:
			toBottom();
			break;
		case LEFT:
			toLeft();
			break;
		case RIGHT:
			toRight();
			break;
		}
	}
上面再手移动的时候去判断移动距离,如果大于tochSlop(16dip)后则认为滑动有效,再通过距离的正负值判断移动方向。有些朋友可能就会疑惑,我这这里为什么要先定义四个方向然后在ACTION_UP中取改变状态呢,而不是直接在判断方向后去执行相应方向的移动代码呢?重写过onTouchEvent方法的朋友可能会知道,我们返回true表示我们拦截到了事件,这样我们手指如果一直在滑动,则会不断的进入ACTION_MOVE中,其实我们只需要在手指抬起后进行移动和其他操作。终于到了最核心的部分了,toTop、toBottom、tooLeft、toRight方法中就是这个游戏最大的谜团,下面我们一起进入这个谜团来慢慢解开它。首先我们来看toLeft()方法
	private void toLeft(){
		int temp;
		//向左移动
		for(int i=0; i<TOTAL_ROW; i++){
			for(int j=0; j<TOTAL_COL; j++){
				for(int k=0; k<TOTAL_COL - j -1; k++){
					if(datas[i][k] == 0){
						temp = datas[i][k];
						datas[i][k] = datas[i][k+1];
						datas[i][k+1] = temp;
					}
				}
			}
		}
		//合并数字
		for(int i=0; i<TOTAL_ROW; i++){
			for(int j=0; j<TOTAL_COL; j++){
				for(int k=0; k<TOTAL_COL - j -1; k++){
					if(datas[i][k] !=0 && datas[i][k] == datas[i][k+1]){
						datas[i][k] = datas[i][k] + 1;
						datas[i][k+1] = 0;
					}
				}
			}
		}
	}
我在这个方法中共进行了两大步操作,一个是整体向左移动,并将值为0的方格向右移动,第二个操作是合并相邻的相同数字。
上面我们用的是冒泡法,每次循环判断当前位置是否为零,如果为零则左右交换,如此交换,直到将该零移动到最右边,最后将移动排列后的数组再进行合并,并实现相邻元素比较合并。有的朋友可以就会有疑问了,上面为什么要重复的写两个三层循环呢?为什么不把移动和合并操作放置到同一个循环结构内呢?其实我也试图这样去优化代码,降低代码的复杂程度,但是这两个之间有一个先后问题,也就是说将上面的两个三层循环(合并和移动)换个位置,则就达不到我们的目的,这个合并是在移动整理的条件下进行的,所以就会使用两个三层循环。
这样我们就基本实现了我们自己的2048游戏,游戏中的规则和消除以及移动方式大家可以自己去设置,说不定某天就能创造出自己风格的2048或者4092,游戏中还有得分、最高纪录、设置等功能,感兴趣的朋友可以自己实现并改进后放到Android市场上。别急~~我们先试玩一下,保证没有问题了再放到Android市场上,不然被怕是要被别人骂的哦。

四、试玩并修改

通过我的试玩发现了如下几个问题:
1、当格子放满后就会奔溃自动退出,这个问题的原因很简单,我们没有做游戏结束判断。
	/**
	 * 随机的产生1或者2
	 */
	private void randomOneOrTwo() {
		if(count >= TOTAL_COL * TOTAL_ROW){
			currentState = State.FAILL;
			return;
		}
		int row = random.nextInt(TOTAL_ROW);
		int col = random.nextInt(TOTAL_COL);

		// 判断在该位置是否已存在数据
		if (datas[row][col] != 0) {
			randomOneOrTwo();
		} else {
			datas[row][col] = random.nextInt(2) + 1;
			count++;
		}
	}
将randomOneOrTwo函数修改如上,定义了一个记录当前格子使用情况的变量count,没当增加一个格子后就会增加1.同样在消除格子的时候就要减少1.
		// 合并数字
		for (int i = 0; i < TOTAL_ROW; i++) {
			for (int j = 0; j < TOTAL_COL; j++) {
				for (int k = 0; k < TOTAL_COL - j - 1; k++) {
					if (datas[i][k] != 0 && datas[i][k] == datas[i][k + 1]) {
						datas[i][k] = datas[i][k] + 1;
						datas[i][k + 1] = 0;
						count--;
					}
				}
			}
		}
并且在状态改变的时候绘制“游戏结束”文字进行提醒
		if(currentState == State.FAILL){
			textPaint.setColor(Color.rgb(255, 255, 255));
			canvas.drawText("游戏结束", (mViewWidth - textPaint.measureText("游戏结束")) / 2, mViewHeight / 2, textPaint);
		}
这样做貌似还不够,那么我们又如何重新开始呢?在onTouchEvent方法中进行如下判断,并修改结束时的显示(在底部添加一个重新开始按钮)
		case MotionEvent.ACTION_DOWN:
			mDownX = event.getX();
			mDownY = event.getY();
			if(currentState == State.FAILL){
				if(mDownY < mViewHeight && mDownY > mViewHeight - cellSpace){
					currentState = State.RUNNING;
					initData();
					invalidate();
				}
			}
			return true;
		if(currentState == State.FAILL){
			rectf.set(0 , mViewHeight - cellSpace, mViewWidth, mViewHeight);
			paint.setColor(colors[5]);
			canvas.drawRect(rectf, paint);
			textPaint.setColor(Color.rgb(255, 255, 255));
			canvas.drawText("游戏结束", (mViewWidth - textPaint.measureText("游戏结束")) / 2, mViewHeight / 2, textPaint);
			canvas.drawText("重新开始", (mViewWidth - textPaint.measureText("游戏结束")) / 2, 
					mViewHeight - textPaint.measureText("游戏结束", 0, 1), textPaint);
		}

2、没有记录分数感觉玩起来超没劲,下面我们就在合并方格的地方添加了如下代码,用来增加分数。
		// 合并数字
		for (int i = 0; i < TOTAL_ROW; i++) {
			for (int j = 0; j < TOTAL_COL; j++) {
				for (int k = 0; k < TOTAL_COL - j - 1; k++) {
					if (datas[i][k] != 0 && datas[i][k] == datas[i][k + 1]) {
						datas[i][k] = datas[i][k] + 1;
						datas[i][k + 1] = 0;
						score = score + (int)Math.pow(2, datas[i][k]);
						count--;
					}
				}
			}
		}
	/**
	 * 随机的产生1或者2
	 */
	private void randomOneOrTwo() {
		if(count >= TOTAL_COL * TOTAL_ROW){
			int maxScore = sharedPreference.getInt("maxScore", 0);
				if(score > maxScore){
				Editor edit = sharedPreference.edit();
				edit.putInt("maxScore", score);
				edit.commit();
			}
			gameChangeListener.onChangedGameOver(score, maxScore);
			currentState = State.FAILL;
			return;
		}
		int row = random.nextInt(TOTAL_ROW);
		int col = random.nextInt(TOTAL_COL);

		// 判断在该位置是否已存在数据
		if (datas[row][col] != 0) {
			randomOneOrTwo();
		} else {
			datas[row][col] = random.nextInt(2) + 1;
			count++;
		}
	}
上面将最高记录保持在了SharedPreference中,每次结束时和当前分数进行判断,如果游戏过程中突然退出我们也要考虑到记录当前最高记录值,代码如下:
	@Override
	protected void onVisibilityChanged(View changedView, int visibility) {
		super.onVisibilityChanged(changedView, visibility);
		if(visibility != View.VISIBLE){
			int maxScore = sharedPreference.getInt("maxScore", 0);
			if(score > maxScore){
				Editor edit = sharedPreference.edit();
				edit.putInt("maxScore", score);
				edit.commit();
			}
		}
	}
那么现在我们如何将这些结果显示到MainActivity的TextView上面呢?我们在My2048View中定义一个接口如下:
	public interface GameChangeListener{
		public void onChangedGameOver(int score, int maxScore);
		public void onChangedScore(int score);
	}
并提供接口的注册方法:
	public void setOnGameChangeListener(GameChangeListener gameChangeListener){
		this.gameChangeListener = gameChangeListener;
		gameChangeListener.onChangedGameOver(score, sharedPreference.getInt("maxScore", 0));
		gameChangeListener.onChangedScore(score);
	}
在我们结束游戏或者消除方块加分的时候进行回调显示,MainActivity中的代码如下:
		my2048View = (My2048View) findViewById(R.id.my2048view);
		my2048View.setOnGameChangeListener(new GameChangeListener() {
			
			@Override
			public void onChangedScore(int score) {
				scoreText.setText(score + "");
			}
			
			@Override
			public void onChangedGameOver(int score, int maxScore) {
				scoreText.setText(score + "");
				maxScoreText.setText(maxScore + "");
			}
		});

除过上面的两个不足外,还存在消除时没有动画(会突然消掉),这个到影响不大,后面再完善吧(时间不早了)。

五、源代码下载及说明

该项目的代码我托管到了CSDN的CODE上面,下载地址:git@code.csdn.net:lxq_xsyu/my2048.git
说明:这是阳光小强熬夜赶出来的代码,如果上面的思路或者实现过程有什么问题或者疑问,请在博客下面回复,我们来共同完善。另外阳光小强的这篇博客参加了CSDN博客大赛的决赛,如果你觉得对你有所帮助请投出您宝贵的一票,投票地址:http://vote.blog.csdn.net/article/details?articleid=37863693