ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android贪吃蛇实战:双时钟游戏循环与TileView网格绘制

Android贪吃蛇实战:双时钟游戏循环与TileView网格绘制

简介:这份面向计算机专业毕业设计阶段的贪吃蛇项目文档,以Android平台手机游戏开发为主线,适合正在准备毕设选题、需要参考完整论文结构与实现思路的本科或高职学生。内容从Java语言基础与Android系统特点切入,延伸到Drawable、Canvas、Thread等核心类与控件的使用方法,并结合游戏循环、蛇身移动、食物生成、边界与自身碰撞检测、触摸事件响应等环节,说明如何实现一个可运行的单机版贪吃蛇;同时涉及动画过渡与多分辨率屏幕适配等实践细节。整包仅含1个doc文档,约301KB,篇幅紧凑,便于通读与摘取章节框架。目前已有503人学习,可用作毕设写作模板与技术梳理参考,帮助读者快速厘清论文摘要、关键词、技术方案与总结的组织方式,并据此对照自己的开发进度查漏补缺。

1. 贪吃蛇的难点从来不是规则:为什么第一版总会卡成幻灯片

贪吃蛇的规则两句话就说完了:吃到食物变长,撞墙撞自己就结束。可把它搬到 Android 上,绝大多数人的第一版会出现同一个现象:蛇走得一顿一顿,方向键按下去要等半拍才生效,玩到后期蛇变长之后更明显。问题基本不会出在规则上,而是出在一个更底层的地方:游戏里其实有两个互不相干的时钟。一个是你自己的逻辑时钟,蛇每隔多少毫秒挪一格;另一个是平台给的绘制时钟,View 什么时候真正被重绘。把这两个时钟合成一个,是这类作品最容易失败的地方。搞懂它们怎么拆开、又怎么对齐,比背下 Canvas、Drawable、Thread 的 API 有用得多。

2. 游戏循环:为什么while(true)会 ANR,而后台线程配postInvalidate就正常

2.1 Android 的单线程 UI 模型:所有绘制和触摸都在一条线程上排队

Android 的 View 体系是单线程模型。触摸事件分发、onMeasure、onLayout、onDraw、Activity 的生命周期回调,全部排在同一条主线程的消息队列里,一个接一个执行。这意味着:只要主线程里有一个不返回的函数,后面所有的消息都送不进来。

初学者最容易写出的版本是把这个循环直接塞进onDraw或者onCreate:

// 错误示范:主线程里死循环推进游戏 public void startGame() { while (true) { update(); // 改坐标、判碰撞 invalidate(); // 请求重绘 // 没有任何让出 CPU 的地方 } }

invalidate()在这里其实没用。它只是给 View 打了一个“脏”标记,真正调用onDraw要等主线程回到消息循环、处理到那条重绘消息才会发生。而现在主线程卡在while里永远回不去,重绘消息永远排不到,屏幕一帧都不会更新。同时系统的输入事件也进不来,超过几秒收不到输入响应,就会弹出“应用无响应”。更有隐蔽的一种变体是在循环里调用Thread.sleep(100),看起来让出了 CPU:屏幕是不卡死了,但invalidate()依然只能在主线程执行,而主线程现在正在执行你这个sleep,一趟下来照样卡顿。真正的问题在于:你在本该处理消息的线程上,抢走了本该属于消息循环的时间片。

2.2 拆成两个时钟:后台线程决定“什么时候走一步”,绘制系统决定“什么时候画一帧”

正确的做法是把职责拆开。后台线程只负责推进逻辑:改蛇的坐标、判碰撞、算分数,这些都是纯数据运算,跑在哪条线程都行。算完之后,它不去直接画,而是投递一个“请重绘”的请求,然后继续按自己的节奏走下一步。绘制由平台的渲染管线在下一个 VSYNC 信号到来时统一调度,往往 16.6 毫秒一帧,和你的逻辑频率完全解耦。

public class SnakeView extends TileView implements Runnable { private Thread mThread; // 逻辑时钟的载体 private volatile boolean mRunning; // 让后台线程能看到停止信号 private long mDelay = 600; // 当前每走一格所需的毫秒数 @Override public void run() { while (mRunning) { long frameStart = SystemClock.uptimeMillis(); update(); // 1. 推进一帧逻辑,不碰任何 UI 对象 postInvalidate(); // 2. 从任意线程投递重绘请求,立即返回 long cost = SystemClock.uptimeMillis() - frameStart; long sleepTime = mDelay - cost; // 3. 扣掉本帧耗时,保持节奏稳定 try { if (sleepTime > 0) { Thread.sleep(sleepTime); } } catch (InterruptedException e) { mRunning = false; // 被打断就干净退出,不吃掉异常 } } } }

这段代码有三处值得琢磨。第一,postInvalidate()而不是invalidate():后者要求调用方必须是 UI 线程,在后台线程调用会直接抛异常;前者内部持有一个主线程的 Handler,会把请求转发过去。第二,计时用SystemClock.uptimeMillis()而不是System.currentTimeMillis():后者是墙上时钟,用户手动改系统时间、或者网络对时,都会让它跳变,表现出来就是蛇忽然狂奔一段或者停住不动。uptimeMillis从开机算起且不含深睡时间,只往前走。第三,mDelay - cost这个减法很关键:如果本帧逻辑算得慢,比如蛇很长、碰撞检测遍历了上百节,那么睡眠时间就该相应缩短,否则每一帧都会累积一点误差,几十帧之后节奏就漂了。当sleepTime算出来是负数时直接跳过睡眠,宁可掉一帧也不让任务堆积。最后mRunning加了volatile,因为它是被主线程写、后台线程读的,没有这个修饰符,后台线程可能永远读到缓存里的旧值,退出时线程停不下来。

2.3 线程还是 Handler:两种刷新模型的取舍

同样的效果,也可以不用额外的线程,改用 Handler 延迟消息自己给自己发消息:

private final Handler mRefreshHandler = new Handler(Looper.getMainLooper()) { @Override public void handleMessage(Message msg) { SnakeView.this.update(); SnakeView.this.invalidate(); sendEmptyMessageDelayed(0, mDelay); // 下一拍 } }; public void stopRefresh() { mRefreshHandler.removeCallbacksAndMessages(null); // 一句就停干净 }

两种方案的差别在于:一条是常驻线程,逻辑跑在主线程之外,主线程再忙也不影响逻辑推进;另一条复用主线程,好处是省一条线程、停止和重启极简单,代价是逻辑耗时直接吃掉 UI 线程的时间预算,一旦update()变重就会掉帧。贪吃蛇这种量级,两条都能跑;但如果哪天想让蛇支持几百节的超长模式,或者加上寻路 AI,后台线程方案会更稳。

维度Thread + sleep + postInvalidateHandler 延迟消息自循环
线程开销多一条常驻线程复用主线程,无额外线程
节奏稳定性可扣耗时补偿,但受线程调度影响受消息队列中其他消息插队影响
生命周期管理必须手动置标志位并 interruptremoveCallbacksAndMessages(null)即可
逻辑耗时承受力不占主线程,较重的逻辑也无所谓逻辑跑在主线程,重了会掉帧
适合场景逻辑较重、帧率要求稳定逻辑轻、希望代码路径简单

2.4 速度曲线:mDelay从 600 降到 100,为什么必须有个下限

难度递增的实现方式很朴素:每吃到一个食物,就把mDelay减一点。但这里有两层约束容易被忽略。

private static final long SPEED_INIT = 600; // 开局每格 600ms private static final long SPEED_MIN = 100; // 速度上限,再快人手跟不上 private static final long SPEED_STEP = 8; // 每吃一个食物减少 8ms private void onAppleEaten() { mScore++; mDelay = Math.max(SPEED_MIN, SPEED_INIT - mScore * SPEED_STEP); }

第一层是人的反应窗口。一帧逻辑推一格,mDelay越小,玩家从看到蛇头位置到按下方向键的时间就越短。低于 100 毫秒时,大部分人的操作已经变成盲按了,再快只会让游戏从“挑战”变成“随机”。第二层是渲染管线的节流:postInvalidate()只是请求,真正绘制受 VSYNC 限制,一帧大约 16.6 毫秒。当mDelay逼近甚至小于这个值时,逻辑推进速度会超过屏幕刷新速度,出现连续几帧的逻辑变化被合并成一次绘制,视觉上就是蛇在“瞬移”。所以速度下限不只是体验问题,也是物理上限问题:这条链路能表现出的最快移动,由屏幕刷新率兜底。知道这一点,就明白为什么在 120Hz 屏上调同样的参数,观感会和 60Hz 屏不一样。

3. 网格坐标系:TileView为什么要把屏幕切成格子

3.1 用行列而不是像素:逻辑层和分辨率的解耦

TileView干的事情很简单:把整个 View 的可绘制区域切成mXTileCount列 ×mYTileCount行的等大方块,所有游戏对象的位置都用整数对 (列, 行) 表示,从不出现像素坐标。这个设计带来三个直接收益。

逻辑与分辨率彻底解耦。同一套蛇的坐标数据,在低密度屏和高密度屏上都是同一个数组,不需要任何分支判断。碰撞检测退化成整数相等比较,不存在浮点误差问题:两节蛇的坐标要么完全一样,要么完全不一样,不会出现“差 0.3 像素算不算撞上”这种折磨人的边界情况。缩放和平移只在绘制那一层做一次,逻辑层完全无感。

坐标对象本身也要注意一个 Java 层面的老问题:

public class Coordinate { public final int x; public final int y; public Coordinate(int x, int y) { this.x = x; this.y = y; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Coordinate)) return false; Coordinate c = (Coordinate) o; return x == c.x && y == c.y; } @Override public int hashCode() { return x * 31 + y; // 必须和 equals 保持一致 } }

只重写equals不重写hashCode,是 Java 里代价很高的经验教训。碰撞检测如果哪天改成用HashSet<Coordinate>判重,缺了hashCode就会全部判不中,因为默认实现基于对象地址。字段声明成final也有讲究:蛇的坐标一旦创建就不应该被改,需要新位置就 new 一个。否则一旦把同一节蛇的对象引用塞进集合又改了它的值,集合内部的桶位置就失效了。

3.2onSizeChanged:格子大小、居中偏移必须在这里一次算清

View 在构造函数执行时还拿不到自己的真实尺寸,getWidth()返回 0。所有跟尺寸有关的量只能在onSizeChanged里算,这个方法在布局流程结束、尺寸确定后被调用。

@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); // 取长宽两个方向能容纳的较小值,保证格子是正方形且不越界 int tileSize = Math.min(w / mXTileCount, h / mYTileCount); mTileSize = Math.max(tileSize, 1); // 防止极端尺寸下算出 0,后面做除数会崩 // 剩余空间均分成左右、上下两条边 mXOffset = (w - mXTileCount * mTileSize) / 2; mYOffset = (h - mYTileCount * mTileSize) / 2; clearTiles(); // 尺寸变了,旧的瓦片状态全部作废 }

取min而不是分别算宽高两个尺寸,是为了让格子保持正方形。如果横向用w / cols、纵向用h / rows,在宽高比不是整除关系的屏幕上会得到长方形格子,蛇看起来会被拉扁。取min之后剩下的余数,一半放左边一半放右边,游戏区域就自动居中了。Math.max(tileSize, 1)这一句看着多余,但只要有人把行列数设得比屏幕像素还大,就会算出 0,后面x * mTileSize全是 0,所有东西叠在原点,排查起来极其费劲。最后clearTiles()不能省:尺寸变化意味着所有瓦片索引对应的物理位置都变了,不清理会画出一堆错位的残影。

3.3onDraw的三步顺序:setBounds漏一次就错位

绘制阶段要做的事,本质上就是把“哪个格子放哪张图”翻译成像素矩形,然后交给画布。

public void setTile(int x, int y, int index) { if (x < 0 || x >= mXTileCount || y < 0 || y >= mYTileCount) return; mTiles[y * mXTileCount + x] = index; // 一维数组存二维坐标 } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); for (int y = 0; y < mYTileCount; y++) { for (int x = 0; x < mXTileCount; x++) { int index = mTiles[y * mXTileCount + x]; if (index < 0) continue; // 空格子直接跳过 Drawable d = mTileGrid[index]; int left = mXOffset + x * mTileSize; int top = mYOffset + y * mTileSize; d.setBounds(left, top, left + mTileSize, top + mTileSize); d.draw(canvas); } } }

用一维数组加y * mXTileCount + x寻址,是老式瓦片地图的经典手法:少一层对象引用,遍历时缓存局部性更好,在低端设备上能省出可观的绘制时间。Drawable和裸Bitmap的区别在于,Drawable是“可绘制物”,它自带bounds(画在哪块矩形里)、level、state三个属性,同一个实例可以被画到无数个不同的位置。但这也意味着它的bounds是有状态的:如果某次忘记调用setBounds,它会沿用上一次的矩形,表现出来的现象就是某几节蛇身忽然跑到屏幕别的地方,或者大小不对。每帧重设bounds这一步看着啰嗦,但是必须做。另外,资源图放在不同密度的目录下时,Drawable会按屏幕密度自动做缩放适配,裸Bitmap不会,这是优先选Drawable的实际理由。

4. 蛇的数据结构:为什么“移动”只需要两次操作

4.1 头部插入、尾部删除:一次移动的代价是 O(n) 的一次搬移

新手容易把蛇的移动理解成“每一节都往前挪一格”。实际上真正的实现只需要两步:在头部插入一个新坐标,把尾部那个坐标删掉。中间那些节的位置完全不变,它们只是“被后面的节追上了”。

private static final int NORTH = 0, SOUTH = 1, EAST = 2, WEST = 3; private static final int[] DX = {0, 0, 1, -1}; // 与方向常量一一对应 private static final int[] DY = {-1, 1, 0, 0}; // 注意屏幕坐标系 y 向下为正 private void moveSnake() { mDirection = mNextDirection; // 本帧生效的方向 Coordinate head = mSnakeTrail.get(0); int nx = head.x + DX[mDirection]; int ny = head.y + DY[mDirection]; mSnakeTrail.add(0, new Coordinate(nx, ny)); // 头插 boolean eaten = (nx == mApple.x && ny == mApple.y); if (eaten) { mScore++; // 不删尾,长度自然 +1 resetApple(); onAppleEaten(); } else { mSnakeTrail.remove(mSnakeTrail.size() - 1); // 删尾,长度不变 } }

方向用常量加查表,而不是在switch里写四套nx/ny计算,好处是后面加方向、做 AI 寻路、写单元测试都只需要操作数组下标。ArrayList.add(0, x)是 O(n) 的数组搬移,蛇很长时每帧都要搬一次,理论上有开销;但贪吃蛇的节数通常不超过几百,这个量级在现代设备上完全感知不到。真要做超长蛇,换成双端队列或者环形缓冲区就行,接口几乎不用改。

DY里 NORTH 是 -1,是因为屏幕坐标系的 y 轴向下为正。这个约定和数学坐标系相反,是方向判断写反的头号原因,后面排查部分还会提到。

4.2mNextDirection:为什么不能让按键回调直接改当前方向

按键事件发生在主线程,逻辑推进发生在后台线程。如果按键回调里直接写mDirection = dir,会撞上一个很隐蔽的时序问题:玩家在一帧之内快速按了“上”又按“下”,如果直接改当前方向,那么这一帧的蛇头位置是按“上”算出来的,但碰撞检测时读到的mDirection已经是“下”了,方向常量一错,整个判断逻辑全乱。

正确做法是用两个变量分离“已经生效的方向”和“玩家最新的意图”:

private void handleDirectionKey(int keyCode) { int dir = mapKeyToDirection(keyCode); if (dir < 0) return; // 反向保护:基于已经生效的 mDirection 判断,而不是 mNextDirection if (mDirection == NORTH && dir == SOUTH) return; if (mDirection == SOUTH && dir == NORTH) return; if (mDirection == EAST && dir == WEST) return; if (mDirection == WEST && dir == EAST) return; mNextDirection = dir; }

反向保护必须基于mDirection。因为mNextDirection是“待生效”的值,玩家在一帧内连按两个方向时它会被后一次覆盖;如果拿它来判断反向,会出现“按了左、又按了上,程序认为当前在往左走,于是允许向上”——这本身没错,但如果顺序反过来,判断就可能出现漏判。基于已经生效的方向,能保证一个物理上连续的身体不会瞬间折返撞到自己。

顺带一提,mDirection和mNextDirection都是被两条线程读写的int。int的读写本身是原子的,不会读到“半截值”,但可见性没有保证:主线程改了mNextDirection,后台线程可能还在看旧值。要么加volatile,要么更彻底一点,把按键事件打包成消息投递到逻辑线程自己的队列里,让所有状态只有一个写入者。

4.3 碰撞检测:三处边界和“尾巴豁免”

碰撞检测看起来是最简单的部分,实际上 off-by-one 错误最容易出现在这里。

private boolean isDead(Coordinate newHead) { // 1. 撞墙 if (newHead.x < 0 || newHead.x >= mXTileCount) return true; if (newHead.y < 0 || newHead.y >= mYTileCount) return true; // 2. 撞自己:从下标 1 开始,跳过新蛇头本身 // 如果本帧不吃食物,尾巴会在同一帧消失,所以最后一节可以豁免 int limit = mSnakeTrail.size() - 1; for (int i = 1; i < limit; i++) { if (newHead.equals(mSnakeTrail.get(i))) return true; } return false; }

三个细节。第一,遍历从i = 1开始。下标 0 是新的蛇头,拿它和自己比,等于开局第一步就判死。第二,循环上界减一,是因为这一帧如果不吃食物,尾部那一节会在同一帧被删掉;蛇头正好走到尾巴当前所在的格子时,实际上尾巴已经让开了,不该算碰撞。第三,判定顺序必须是“先判墙和自身,再判食物”。如果反过来先处理吃食物、蛇变长、尾巴保留,再去做自身碰撞检测,那么原本应该豁免的尾部位置就变成了真实障碍,会出现“明明能吃到食物却莫名game over”的现象。这个顺序如果不明确写出来,代码读起来是看不出问题的,只有实际玩到那一步才会暴露。

5. 排查清单:真机上才会暴露的五个坑

5.1 蛇走得一顿一顿,滑动越久越明显

现象:模拟器上还凑合,装到真机上玩到蛇变长之后,能明显感到卡顿,偶尔整帧停一下。

原因:onDraw里每帧都在new Paint()、new Rect()、拼字符串调drawText,一秒钟几十次分配,累积起来触发垃圾回收。早些年设备上的回收是整段暂停的,一次能有几十毫秒,表现出来就是一帧卡死。另一个常见原因是mDelay用了固定值但没扣本帧耗时,节奏逐渐漂移。

解决:所有Paint、Rect、Paint相关的对象全部提到成员变量里,只创建一次;onDraw里禁止任何形式的对象分配和字符串拼接;用SystemClock.uptimeMillis()做耗时补偿。判断依据不要靠感觉,用性能分析工具看一眼onDraw的耗时占比,比猜快得多。

5.2 旋转屏幕或者切后台回来,分数清零、蛇重新开始

现象:横竖屏切换一下,游戏从头开始;按 Home 键回来,蛇的位置重置了。

原因:屏幕配置变化时,Activity 默认会被销毁重建,View 里的mSnakeTrail、mScore、mApple这些成员变量全部随之消失。游戏状态存在 View 对象里,就等于把状态存在了随时会被丢弃的地方。

解决:轻度做法是在清单文件里声明configChanges,让旋转不重建;但这只解决旋转,切后台被系统回收依然会丢。稳妥做法是把游戏状态抽到一个独立的数据对象里,通过 ViewModel 或者onSaveInstanceState保存,Activity 重建后再把它注回新的 View。这个改动同时也是走向“引擎与视图分离”的第一步。

5.3 手指滑动方向判反,或者要滑很长一段才转向

现象:往上滑,蛇往下走;或者小幅度滑动没反应,必须滑过大半个屏幕。

原因:屏幕坐标系 y 轴向下为正,dy > 0代表向下滑,对应的是 SOUTH,很多人习惯性当成向上。另一个原因是判断逻辑先比 X 再比 Y,斜着滑动时被判成水平方向;也可能是没有最小滑动阈值,手指按下去的轻微位移就被当成一次转向。

private float mDownX, mDownY; @Override public boolean onTouchEvent(MotionEvent e) { if (e.getAction() == MotionEvent.ACTION_DOWN) { mDownX = e.getX(); mDownY = e.getY(); return true; } if (e.getAction() != MotionEvent.ACTION_UP) return true; float dx = e.getX() - mDownX; float dy = e.getY() - mDownY; float threshold = 20 * getResources().getDisplayMetrics().density; if (Math.abs(dx) < threshold && Math.abs(dy) < threshold) return true; // 抖动,忽略 if (Math.abs(dx) > Math.abs(dy)) { handleDirectionKey(dx > 0 ? EAST : WEST); // 右滑为东 } else { handleDirectionKey(dy > 0 ? SOUTH : NORTH); // y 向下为正,dy>0 是南 } return true; }

阈值用 dp 换算而不是写死像素,否则高密度屏上 20 像素还不到一个指甲盖,低密度屏上又宽得离谱。

5.4 换一台手机格子就错位,或者边缘被切掉一半

现象:在一台设备上跑得好好的,换到另一台后发现四周黑边一大片,或者最右一列蛇身只画出一半。

原因:格子尺寸写成了固定像素;或者onMeasure没有处理测量模式为“不约束”的情况,此时父容器给的期望尺寸不可用,算出来的宽高是 0;再或者onSizeChanged里先算了居中偏移、后用新的格子尺寸,顺序反了就会得到负数偏移。

解决:格子尺寸一律用min(w / cols, h / rows)动态算,余数对称分成两条边;onMeasure里当尺寸不受约束时给一个默认值兜底;onSizeChanged里严格按“先算格子大小、再算偏移、最后清空瓦片”的顺序执行。

5.5 退出游戏后设备发热,后台线程还在跑

现象:按返回键退出,过一阵看电量掉得很快;或者重新进游戏,发现蛇的速度变成原来的两倍。

原因:Thread里的while (mRunning)没有在任何生命周期回调里被置为 false,View 已经不可见了,线程还在每 600 毫秒醒来算一次、投递一次重绘,白白耗电。更麻烦的是重进游戏又起了一条新线程,两条线程同时改同一个蛇的数组,出现“速度快一倍”甚至数据结构错乱。

解决:在onPause里停、onResume里起,在onDetachedFromWindow里把运行标志置 false 并调用interrupt();catch (InterruptedException)里一定要退出循环,不能吞掉异常继续跑。用 Handler 方案的,退出时调用removeCallbacksAndMessages(null)一句就能清理干净。启动线程前先判一下mThread == null || !mThread.isAlive(),避免重复创建。

6. 设计取舍:把游戏逻辑塞进 View 里,这笔账怎么算

这份作品里最值得回头看的一个决定,是把整个游戏逻辑都写进了SnakeView。写的时候确实快:线程、绘制、数据都在一个类里,改一个变量不用跨文件找。但代价在验收阶段会一并到来——想单测一下碰撞检测,发现必须 new 一个 View,而它又依赖 Context;想在屏幕旋转后恢复状态,发现状态散落在七八个成员变量里,没有任何一处能整体取出。常见的实现思路是把SnakeEngine抽成纯数据类:只持有蛇的坐标数组、食物位置、分数、方向,只暴露tick()和几个查询方法,不导入任何android.view下的类。这样一来,一局游戏可以用纯 JVM 单元测试跑完:喂 1000 组随机方向输入,断言蛇的长度始终等于初始长度加分数、任意两节坐标不重合、蛇头坐标永远在规定范围内。这套断言能在几秒内跑完,比在真机上手动点一百局有效得多。

第二个取舍是全屏重绘还是局部重绘。网格游戏理论上可以只重画变化的那两三个格子:蛇尾离开的、蛇头进入的、食物所在的那块。但瓦片之间是有层叠关系的,蛇头可能盖在食物上,尾巴离开的位置下面可能还有别的图层,局部重绘要把这些关系理清楚,代码复杂度直接翻倍。在格子数量只有几百个的规模下,全屏重绘的实际开销完全够用,这时候选简单方案是对的。真到了需要优化的规模,正确的方向也不是改局部重绘,而是把整个棋盘预渲染成一张位图,每帧只贴这一张。

第三个取舍是“定长跳动”还是“帧间插值”。贪吃蛇是网格游戏,一帧跳一格,视觉上那种一顿一顿的节奏反而是它该有的手感。如果要做平滑移动,就得把逻辑坐标和渲染坐标分开存:逻辑上蛇头还在第 3 格,渲染时按本帧进度百分比画在第 3 格和第 4 格之间。这个改动会让绘制层多一层插值计算,也会让碰撞判定必须基于逻辑坐标而非渲染坐标,稍不注意就会出现“看着没撞上却死了”的争议。我的习惯是:先确认这个手感是不是玩法的一部分,再决定要不要动它。

从那以后我每次写这类带循环的界面,都强制自己先画一遍“谁在什么线程、以什么频率、改哪几个变量”的时序草图,再动键盘;退出路径和启动路径成对写,先写停止再写启动。吃过一次后台线程没停、两份逻辑同时改同一个数组的亏之后,这个习惯就再没丢过。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进