---------------------------------------------------------------------------------------------------------------------------------
该篇博文参加了CSDN博客大赛,投票地址:
---------------------------------------------------------------------------------------------------------------------------------
前言:
在“阳光小强”的实战系列博文《是男人就下100层》的上一层我们一起从零开始完成了我们自己的贪吃蛇游戏——CrazySnake,可能很多朋友还不过瘾,那么我们今天就来玩一玩最近一直比较火的2048游戏,让大家再过一把瘾。由于“阳光小强”目前并没有从事Android的游戏开发工作,所以这些游戏的实现并不需要很专业的游戏开发知识,如果你有Android的基础就可以一起来参与进来共同完成这个游戏。有些朋友可能就会说“这些小游戏,会不会有点简单,整天搞这些对自己没有帮助“,我觉得经典就是对我们有很大很长远指导意义的东西,这些游戏虽然小(100行可以写出贪吃蛇),但是里面蕴含的编程技巧和算法对初学者是非常有用的。孔子说过”温故而知新“,学习的过程就分为”学“和”习“,研究前人的代码就是一种学,思考加改良后就是习。假若有一天你能触类旁通的说这些小游戏我都能很快的实现并且和有所变化和改进,你就会发现你已经不知不觉的提高了许多。在前面三篇CrazySnake中”阳光小强“用自己的方式和风格完成了经典的贪吃蛇游戏,这一篇同样我们也要用自己的方式来一步步思考并实现我们不一样的2048.
一、游戏介绍
《2048》是一款单人在线和移动端游戏,由19岁的意大利人Gabriele Cirulli于2014年3月开发。游戏任务是在一个网格上滑动小方块来进行组合,直到形成一个带有有数字2048的方块。为什么会出现这款游戏呢?作者开发这个游戏是为了测试自己是否有能力从零开始创造一款游戏,但游戏飙升的人气(不到1周内有400万访客)完全出乎他的预料。现在2048被称为网络上“最上瘾的东西”,由于该游戏为开源软件,所以现在市场上有很多改进版本和变种。
游戏使用方向键让方块上下左右移动。如果两个带有相同数字的方块在移动中碰撞,则它们会合并为一个方块,且所带数字变为两者之和。每次移动时,会有一个值为2或者4的新方块出现。当值为2048的方块出现时,游戏即胜利,游戏因此叫做2048。
二、最终效果展示
经过几天的摸索这款游戏终于可以告一段落了,最终的结果如下:
1、实现了2048游戏的所有功能。
2、新增了 更换皮肤功能(有三款皮肤任你选择)。
3、 声音开关(让你上班时也能偷着玩)。
4、新增了游戏介绍模块(各个皮肤的顺序一目了然)。
5、必不可少的 分享功能,和你的朋友一块来摇滚吧!
6、添加了有米广告(这个是阳光小强第一次这样搞,其实对app中插广告我也是蛮愤怒的,这一次大家就忍耐一下,这样可以从头到尾的来演示一遍如何将一款app发布到Android市场上,另一方面则完全是因为第一次的好奇心)。
7、添加了 版本自动更新功能,保证新版本和功能及时更新到你的手机。
8、随时随地不受阻挠的换肤和最酷的双杀到 五杀的音效(lol游戏中的音效)。
9、最后再混淆了代码发布到市场上
下载地址1:
下载地址2:
全部源代码请看下一篇博文(博文篇幅过长,分成两篇了):
三、实现游戏布局
游戏布局我就仿照了市面上一款2048游戏的界面,布局如下图:
详细的布局结构如下,外面的包裹均为LinearLayout.里面的显示部分使用TextView,这只是一种布局方式,其实还可以使用RelativeLayout布局(这样创建的对象更少),这里为了速度就使用LinearLayout包裹实现了。下面的自定义View主要实现onDraw(用来绘制)和onTouchEvent(主要用来控制)方法。
布局文件activity_main.xml如下:
四、自定义视图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; ipintX和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上面的datas就是我们模拟的整型数据数组,我们再改写onDraw方法将这些数据绘制出来。
private float pointX; private float pointY; @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); String showNum; for(int i=0; i上面对随机数字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; } }
private void toLeft(){ int temp; //向左移动 for(int i=0; i我在这个方法中共进行了两大步操作,一个是整体向左移动,并将值为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); }
// 合并数字 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工具下载)
说明:这是阳光小强熬夜赶出来的代码,如果上面的思路或者实现过程有什么问题或者疑问,请在博客下面回复,我们来共同完善。另外阳光小强的这篇博客参加了CSDN博客大赛的决赛,如果你觉得对你有所帮助请投出您宝贵的一票, 投票地址:
-------------------------------------------------------------------------------------------------------------------------------------
7月17日修改:
修改说明:就在阳光小强发出博客的当天就有很多朋友对代码提出了自己意见,阳光小强在这里非常感谢,这些朋友提出的问题我已经抽时间修改,并且添加了消除过程中的动画效果,如果后续还存在什么问题请予以指出,我会及时修改,现将运行效果和代码贴出:
最新apk文件下载: (此版本apk已更新,请向下看,下载最新apk)
修改后的代码请看下面链接:
-------------------------------------------------------------------------------------------------------------------------------------
7月18日凌晨修改
修改说明:昨天下午下班后走在路上阳光小强就正式的体验了一把咱们的2048游戏,玩了之后感觉不爽,回来后又在安卓市场上下载了别人做的2048游戏,结果一对比发现了新的问题,比如消除的方向,有时候如2024这样排列向右滑动会消除两次变为0008,在某个方向没有可消除的后还能继续产生新的数字(2或4)等问题。修改后的apk文件已经达到游戏的要求,不防下载下来玩一下,我将最新apk文件和代码贴出到下面。通过两次修改我们的游戏越来越健壮了,阳光小强也从中也收获了不少的知识,你有没有一些收获呢?如果有收获请记得给阳光小强投票哦~( 投票地址: )
最新apk文件下载地址: (此版本已经更新,请再向下看,下载最新apk文件)
修改后的源代码查看链接:
-------------------------------------------------------------------------------------------------------------------------------------
7月18日下午修改
今天中午吃饭的时候有一局玩的挺好,结果数据没有保存下来,顿时我就火了,于是回来就对对记录保存bug进行了修改,新增了切屏保存数据等内容,经过初步测试没有什么问题,玩了玩之后还是不够过瘾,阳光小强自从来到北京这边,住的地方网速老差了,好久没玩LOL了,何不把LOL的元素集成进来,这样就可以每天在手机上看到自己喜欢的英雄了,呵呵,有想法就干。
最新apk文件下载地址: (最终版已经出炉,最终版在文章第二个大标题下面可看到下载链接)
/** * 保存数据和状态 * @return */ public Bundle saveDataAndState(){ Bundle map = new Bundle(); map.putIntArray("row1", datas[0]); map.putIntArray("row2", datas[1]); map.putIntArray("row3", datas[2]); map.putIntArray("row4", datas[3]); map.putSerializable("currentDirectory", currentDirectory); return map; } /** * 取出保存的数据和状态 * @param bundle */ public void restoreDataAndState(Bundle bundle){ datas[0] = bundle.getIntArray("row1"); datas[1] = bundle.getIntArray("row2"); datas[2] = bundle.getIntArray("row3"); datas[3] = bundle.getIntArray("row4"); currentDirectory = (Directory) bundle.getSerializable("currentDirectory"); }在MainActivity中添加了状态保存代码,如下:(MainActivity.java)
package com.example.my2048;import android.app.Activity;import android.os.Bundle;import android.widget.TextView;import android.widget.Toast;import com.example.my2048.My2048View.GameChangeListener;public class MainActivity extends Activity { private TextView scoreText; private TextView maxScoreText; private My2048View my2048View; private static final String DATA_NAME = "my2048Data"; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); scoreText = (TextView) findViewById(R.id.score); maxScoreText = (TextView) findViewById(R.id.maxScore); 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 + ""); } }); if(savedInstanceState != null){ Toast.makeText(this, "saveInstanceNotNull", 2000).show(); Bundle map = savedInstanceState.getBundle(DATA_NAME); if(map != null){ Toast.makeText(this, "mapNotNull", 2000).show(); my2048View.restoreDataAndState(map); } } } @Override protected void onPause() { super.onPause(); my2048View.saveMaxScore(); } @Override protected void onSaveInstanceState(Bundle outState) { outState.putBundle(DATA_NAME, my2048View.saveDataAndState()); }}最新源代码查看地址: