ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android自定义View打造钢琴块游戏:坐标、触摸与状态机实战

Android自定义View打造钢琴块游戏:坐标、触摸与状态机实战 简介面向Android入门学习者这份《Android Studio实现钢琴块小游戏》完整工程项目适合想通过趣味实战快速熟悉安卓开发的读者。项目以经典别踩白块玩法为核心包含Java业务源码、XML布局与资源文件、Gradle构建配置、APK安装包等涵盖点击事件、动画移动、计分与失败判定等关键逻辑可直接导入Android Studio运行和二次修改。资源压缩包共1188个文件涉及class、dex、json、java、xml及gradle等构建与源码类型整体大小约18.76MB结构完整其中APK便于真机或模拟器直接体验源码与工程文件便于对照学习和改造。目前已有1068人学习下载。通过复现这款小游戏既能提升逻辑思维和代码调试能力也能掌握从界面布局到游戏循环的安卓项目开发全流程是练手与课程设计的实用参考。1. 钢琴块游戏中的坐标、状态与手感在Android Studio里做钢琴块最容易踩的第一个坑不是逻辑而是把方块做成一堆ImageView往上挪。等到速度加到300像素每秒控件的测量和布局开始抢主线程时间画面就肉眼可见地掉帧。这个项目用自定义View的onDraw直接画黑白块每一行用一个简单对象保存列号、Y坐标和颜色状态点击、下落、结束判定全部基于矩形坐标。用Java实现的话核心类不超过几个非常适合用来理解View的坐标体系、触摸事件和游戏状态机。它适合两类人刚学完Android四大组件、想碰一碰自定义绘制的人以及想做一个小作品去面试但不想引入Unity的人。你可以从这里学会怎么用Choreographer按帧驱动动画怎么让触摸点命中一个移动的方块以及怎么在状态机里管好暂停、结束和重新开始。下面按我拆这个项目的顺序来写从方块模型开始到最终跑成能玩的版本。2. 自定义View绘制钢琴块从方块模型到下落的时序2.1 用Block对象描述黑白块在写绘制之前先定义方块的数据结构。每一行被均分成四列黑色方块只会出现在其中一列其余三个位置是白块。Block对象里保存列号、上下边距和点击状态left和right由列号计算不需要存进来。这样当屏幕宽度改变时只需要重新计算轨道宽度方块不会错位。下面是最简版本public class Block { public int column; // 0-3对应四列 public float top; // 方块顶边Y坐标 public float bottom; // 方块底边Y坐标 public boolean isBlack; // 是否黑块 public boolean clicked; // 是否已点击消除 public Block(int column, float top, float bottom, boolean isBlack) { this.column column; this.top top; this.bottom bottom; this.isBlack isBlack; this.clicked false; } }这里的column不是像素坐标而是一个逻辑索引。绘制时用column乘以轨道宽度得到leftright就是left加上轨道宽度。游戏过程中方块只沿Y方向移动X方向不变所以Y坐标用float类型避免每帧累加时的整数取整误差。clicked用来区分黑块是否已经被点过点过的黑块画成灰色并且不参与后续判定。方块高度我习惯设置为屏幕宽度的0.5倍也就是说屏幕一屏能容纳大约四行方块。列数、方块高度、初始速度和加速度这几个参数直接影响手感通常集中放在PianoView的常量区调试时只需要改一个文件。参数典型值作用COLUMN_COUNT4轨道数越多黑块越难找BLOCK_HEIGHT屏幕宽度的0.5倍控制方块高度和点击容错BASE_SPEED300 px/s初始下落速度SPEED_UP_PER_SECOND5 px/s²每秒增加的下落速度这些参数不是固定推荐而是你制作自己的版本时的起始参考。太快了新手反应不过来太慢了又测试不出边界条件我一般先慢后快测试稳定后再把BASE_SPEED提上去。2.2 方块生成与屏幕顶部管理屏幕上一开始应该同时存在几行方块之后随着方块滑出屏幕底部在顶部补充新行。新增行时每行只有一个黑块黑块列号用随机数生成但要避免和上一行的黑块列相同。这个限制看着小实际上很关键如果连续两行黑块在同一列玩家只要按住那一列就能自动连击游戏手感会变得单调。private void spawnRow(float top) { int blackColumn new Random().nextInt(COLUMN_COUNT); if (blackColumn lastBlackColumn) { blackColumn (blackColumn 1) % COLUMN_COUNT; } lastBlackColumn blackColumn; float bottom top BLOCK_HEIGHT; for (int column 0; column COLUMN_COUNT; column) { Block block new Block(column, top, bottom, column blackColumn); blockList.add(0, block); } }blockList.add(0, block)把新行插入列表头部这样列表顺序始终和屏幕视觉顺序一致索引0是屏幕最上面的一行。spawnRow里的top是这一行的统一顶坐标bottom是顶坐标加方块高度。因为行与行之间没有缝隙方块在屏幕上是连续铺满的所以不需要考虑空白间隙。在update中判断是否需要在顶部生成新行条件是列表里最顶部一行的top已经大于0说明顶部露出了一小段空白。此时生成新行放在更上面private void maybeSpawnRow() { if (blockList.isEmpty()) { spawnRow(-BLOCK_HEIGHT); return; } Block topRow blockList.get(0); if (topRow.top 0) { spawnRow(topRow.top - BLOCK_HEIGHT); } }生成的新行顶坐标是旧的顶部行top减方块高度这样新行会紧贴上一行。相同逻辑底部清理也完全靠Y坐标判断不需要维护复杂的链表结构。注意如果用add(0, …)插入那么遍历绘制和点击都要围绕这个顺序否则会出现视觉和判定错位。2.3 在onDraw中渲染方块PianoView继承自ViewonDraw里只做一件事遍历blockList根据每个方块的列号和Y坐标画一个矩形。白块用接近白底的颜色黑块用黑色已经点击过的黑块用灰色。除了填充之外还可以画一条浅色分隔线方便玩家看清每一行的边界。Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); float columnWidth getWidth() / COLUMN_COUNT; for (Block b : blockList) { float left b.column * columnWidth; float right left columnWidth; Paint paint b.clicked ? grayPaint : (b.isBlack ? blackPaint : whitePaint); canvas.drawRect(left, b.top, right, b.bottom, paint); canvas.drawLine(left, b.top, right, b.top, linePaint); } }Paint对象在构造方法里初始化不在onDraw里创建。很多第一次写自定义View的人习惯在onDraw里new Paint短时间看不出问题但帧率一高就会频繁触发GC具体表现就是玩到后面突然卡一下。另一个细节是columnWidth每次都调用getWidth()计算虽然开销不大但最好也在初始化时缓存起来。2.4 用Choreographer驱动下落钢琴块的动画不需要依赖外部游戏引擎用Choreographer回调就够。Choreographer会把回调注册到垂直同步信号上屏幕刷新一次就执行一次doFrame比Handler.postDelayed准确很多。public void startLoop() { Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { Override public void doFrame(long frameTimeNanos) { if (gameState ! GameState.RUNNING) { return; } long now SystemClock.elapsedRealtime(); float delta (now - lastFrameTime) / 1000f; lastFrameTime now; moveBlocks(delta); removeOffscreenBlocks(); maybeSpawnRow(); invalidate(); Choreographer.getInstance().postFrameCallback(this); } }); }moveBlocks里让每个方块的top和bottom加上speed * delta。delta是从上一帧到当前的真实时间差以秒为单位。如果帧率不稳定delta会变大方块移动距离相应变大从而保证速度不受帧率影响。要注意的是速度不是常量应该在每次moveBlocks时根据当前游戏时间动态计算。private void moveBlocks(float delta) { float speed BASE_SPEED elapsedTime * SPEED_UP_PER_SECOND; elapsedTime delta; for (Block b : blockList) { b.top speed * delta; b.bottom speed * delta; } }在doFrame末尾重新注册自身会让回调一直持有这个FrameCallback必须在暂停游戏时移除。如果移除不及时游戏已经切换到了后台回调还是每帧触发白白消耗电量。暂停时用Choreographer.getInstance().removeFrameCallback重新开始时再次postFrameCallback即可。3. 触控事件与黑白块命中判定别让误触毁掉手感3.1 先算出手指点在哪一列onTouchEvent里拿到的event.getX()和getY()已经是相对于PianoView左上角的坐标。如果PianoView占满全屏坐标可以直接用于碰撞检测如果有状态栏或标题栏偏移就需要先判断View的Padding。最简单的方法是让PianoView作为全屏根布局并用decorFitsSystemWindowsfalse来避免被系统栏遮挡。Override public boolean onTouchEvent(MotionEvent event) { if (gameState ! GameState.RUNNING) { return super.onTouchEvent(event); } float x event.getX(); float y event.getY(); int touchedColumn (int) (x / columnWidth); if (touchedColumn COLUMN_COUNT) { touchedColumn COLUMN_COUNT - 1; } // 找出这一列中触摸点所在的方块 for (int i blockList.size() - 1; i 0; i--) { Block b blockList.get(i); if (b.column ! touchedColumn) { continue; } if (y b.top y b.bottom) { return handleBlockPressed(b); } } return super.onTouchEvent(event); }touchedColumn用整数除法得到正常情况下取值是0到COLUMN_COUNT-1。屏幕最右边缘那一两个像素可能算出越界值所以用一次min保护。blockList从下往上遍历因为手指按到一个方块时同一列只有一个方块满足Y区间但为了保险从底部开始匹配更符合视觉直觉。handleBlockPressed里会根据b.isBlack决定加分还是结束。3.2 点击白块、点击已处理黑块与漏按的区分判定规则要分三种情况。第一种是点击黑块且未点击过这是正常得分第二种是点击白块游戏结束第三种是点击已经点击过的黑块应该忽略而不是判输。否则玩家连点同一个黑块时第二次会被误判成点白块体验极差。private boolean handleBlockPressed(Block b) { if (b.clicked) { return true; // 已经消过不再处理 } if (b.isBlack) { b.clicked true; score; updateScore(); playClickSound(); return true; } else { gameOver(); return true; } }clicked字段的作用在这里体现出来。点击过的黑块变成灰色但仍然占据原来的矩形区域玩家再次点击这个区域时程序看到clicked为true就直接忽略不会把它当作白块。如果没有这个分支快速连击时大概率会莫名触发Game Over而且是高分段才出现很难排查。漏按的判定不能放在触摸事件里因为玩家可能根本不点击。要在每帧moveBlocks之后检查哪些黑块已经滑出屏幕底部private void removeOffscreenBlocks() { IteratorBlock it blockList.iterator(); while (it.hasNext()) { Block b it.next(); if (b.bottom getHeight()) { if (b.isBlack !b.clicked) { gameOver(); return; } it.remove(); } } }这个检查有一个边界问题白块滑出屏幕不影响游戏可以直接移除并继续使用对象池黑块滑出屏幕且未点击必须立即Game Over。判断条件用bottom大于屏幕高度比用top更合理。因为top在方块还没完全露出一半时就已经在屏幕内了如果此时判定漏按玩家会以为屏幕底部还有一点可以挽救实际上已经迟了。3.3 用状态机管理暂停、结束和重新开始游戏不能只有一个布尔变量表示“是否运行”。钢琴块至少需要四个状态READY、RUNNING、PAUSED、OVER。状态机用枚举定义所有状态切换集中在PianoView或控制类中避免触摸回调里到处赋值。public enum GameState { READY, RUNNING, PAUSED, OVER }切换状态时需要注意动画循环的启动和停止。我的做法是从READY进入RUNNING时先重置方块列表、分数和速度然后startLoop从RUNNING进入OVER时stopLoop保存最高分从RUNNING进入PAUSED时stopLoop保留当前进度从PAUSED回到RUNNING时只startLoop不清空列表。当前状态事件下一状态需要做的清理READY点击屏幕RUNNING重置数据启动帧回调RUNNING点击白块 / 漏按黑块OVER停止帧回调保存最高分RUNNINGActivity onPausePAUSED停止帧回调播放暂停音PAUSEDActivity onResumeRUNNING重新注册帧回调OVER点击重新开始按钮READY清空列表恢复初始速度这里“重新开始”被设计成先回到READY再让玩家点一下屏幕进入RUNNING。这样可以直接复用首次进入游戏的初始化逻辑不需要在OVER状态再写一套reset。Activity的onPause和onResume里调用PianoView的pause和resume方法即时停止和恢复动画。不要再在Activity里直接访问PianoView的内部字段暴露startGame、pauseGame这种方法状态判断收口在PianoView内部测试时连续点击也不会进入中间状态。4. 计分、音效与绘制性能优化让项目从能玩到耐玩4.1 用SharedPreferences保存最高分最高分放到SharedPreferences里得分时实时更新内存值游戏结束时统一写入磁盘。不要在每次加一分的循环里反复写磁盘尤其是分数超过30以后onDraw和事务交替执行会造成掉帧。下面的写法是得分时先更新内存最后在gameOver时落盘private void saveBestScore() { if (score bestScore) { bestScore score; prefs.edit().putInt(best_score, bestScore).apply(); } }apply方法是异步写入会立刻更新内存中的SharedPreferences值返回时不代表已经写入磁盘。对游戏这种允许掉数据的场景完全够用。如果担心进程被杀导致分数丢失可以在每次得分时也调用apply但那样IO会变多我只在gameOver时写一次。4.2 用SoundPool播放点击音效点击黑块时的短促反馈很关键SoundPool比MediaPlayer更适合。在Android 5.0以上用Builder创建SoundPool并指定音频属性和最大同时播放数。maxStreams设4够用因为玩家的两只手最多产生几个快速点击再多播放也不会让音效更清楚。AudioAttributes attributes new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_GAME) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build(); SoundPool soundPool new SoundPool.Builder() .setMaxStreams(4) .setAudioAttributes(attributes) .build(); int clickSoundId soundPool.load(this, R.raw.click, 1); int failSoundId soundPool.load(this, R.raw.fail, 1);load方法把res/raw里的音频文件加载到内存返回一个音效ID。播放时只需调用play方法就可以在任意线程安全地触发不需要自己控制播放状态。注意SoundPool实例要放在Activity或游戏管理类中不能在触碰方法里每次创建。释放资源放在onDestroy或onPause否则退出游戏后还有音频池占用资源。如果游戏切后台再回来可以在onPause调用soundPool.autoPause()onResume调用autoResume()避免后台还在播放点击音效。4.3 减少绘制卡顿对象池和绘制对象复用当游戏运行时间变长方块不断从顶部生成、从底部移除如果每次都new Block旧对象就会被GC回收。GC在低端机上会造成几十毫秒的停顿正好体现在玩家高速点击的时候。常见做法是准备一个对象池方块滑出屏幕后不直接丢弃而是清空状态放回池中。private Block obtainBlock(int column, float top, float bottom, boolean isBlack) { if (!blockPool.isEmpty()) { Block b blockPool.remove(blockPool.size() - 1); b.column column; b.top top; b.bottom bottom; b.isBlack isBlack; b.clicked false; return b; } return new Block(column, top, bottom, isBlack); } private void recycleBlock(Block b) { blockPool.add(b); }在removeOffscreenBlocks里把滑出屏幕的方块调用recycleBlock放回池中。这个优化对钢琴块效果明显因为每生成一行就要创建4个Block玩一分钟可能创建几百个对象池化后大部分时间都在复用已有对象。另一个优化点是绘制时使用预创建的Paint不要在onDraw里设置颜色。如果颜色需要变化可以用setColor但也要注意只在状态切换时调用。优化项未优化表现优化后onDraw中new Paint每帧产生临时对象触发GC画笔预先初始化方块滑出后直接remove高分段掉帧对象池复用每帧用getWidth计算列宽多次请求布局信息缓存列宽字段4.4 难度递增参数化玩到后期不能靠玩家手速单独支撑还需要一个明确的难度曲线。最简单的方式是让下落速度随时间线性上升并在超过一定阈值后保持恒定。可以在PianoView里增加elapsedTime字段moveBlocks时动态计算当前速度。private float getCurrentSpeed() { float speed BASE_SPEED elapsedTime * SPEED_UP_PER_SECOND; return Math.min(speed, MAX_SPEED); }MAX_SPEED的作用是防止速度无限加大。当速度达到某个值之后方块一帧能移动十几个像素玩家的点击判定范围没变游戏基本上不可能继续下去。把这个最高速度设置在1000到1200 px/s之间既保证后期有挑战又不至于让判定完全失效。分数和速度的对应关系可以在调优时记在日志里方便回看手感。5. 调试与验证让游戏在真机上跑得更顺手5.1 先慢后快验证判定逻辑的调试顺序第一次跑通代码后先把BASE_SPEED调到100BLOCK_HEIGHT设大些。这样每个方块在屏幕上停留时间长点击命中率很高你可以专心验证白块误触、黑块漏按和重新开始是否正常。如果一开始就调成正常速度分不清是逻辑问题还是手速问题排错效率太低。等核心逻辑稳定后再逐步提升速度。5.2 用Logcat记录每次点击判定在handleBlockPressed里加一行日志输出当前点击的列号、方块类型和分数。这样在模拟器或真机上跑几盘能看到每次点击走了哪个分支尤其适合检查“为什么点黑块没反应”和“为什么点白块没判Game Over”这类问题。大部分情况不是逻辑错而是坐标对不上比如状态栏没有扣除。5.3 用Profiler看帧率用adb模拟连续点击打开Android Studio的Profiler在Rendering栏查看帧时间。如果出现明显掉帧优先检查onDraw里是否创建了对象其次看SoundPool是否在每次点击时被重复加载。拿到稳定帧率后再验证不同屏幕尺寸下的坐标映射。如果不想连续手点几百次可以用adb命令模拟点击adb shell input tap 200 800参数就是屏幕上的x和y坐标。把这条命令放到循环里可以模拟快速连续点击同一个位置观察游戏是否在预设条件下结束。注意input tap使用的是屏幕绝对坐标而PianoView的onTouchEvent里的坐标是相对View的只要View没有偏移两者就是一致的。这个技巧在批量化测试点击判定时很省力比一直用手点要准得多。本文还有配套的精品资源点击获取
返回列表