拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Scratch游戏引擎框架实战:帧循环、AABB碰撞、对象池与摄像机

1. 为什么要给Scratch装上游戏引擎的骨架三个多月前我在一个少儿编程社群里看到有人问Scratch能不能做出《空洞骑士》那样的横版动作游戏底下的回答清一色是能做个雏形但手感很差。这个回答本身没错但它暴露了一个更根本的问题——大多数人用Scratch做游戏做的是一个游戏而不是一套能做游戏的系统。这两者的差别就像用锤子钉了一颗钉子和造一台能自动打钉子的机器。Scratch本身是一个面向教育的可视化编程环境它的设计目标是让初学者理解顺序、循环、条件、变量、消息这些编程概念。它自带舞台、角色、造型、声音、克隆体、画笔这些能力拼起来确实能做出游戏但当你真正想做一个稍微像样点的项目时就会撞上一堵墙没有帧的概念、没有实体管理、没有碰撞层、没有摄像机、没有对象池、没有状态机——什么都没有。你每做一个新游戏就要把上一套脏逻辑扔掉重写一遍。我们决定做的事情就是把这堵墙拆掉。具体来说我们花了大约三个月严格算下来是十一个周末加若干深夜在Scratch现有能力之上搭出了一套可以复用的轻量级游戏引擎框架它不改变Scratch的运行环境不依赖任何外部插件完全用积木块和少量扩展能力实现。它能提供稳定的帧循环、可复用的实体系统、手写AABB碰撞、可移动摄像机、瓦片地图渲染、简易状态机和对象池。做出来的三个Demo里有一个横版卷轴跑酷、一个俯视角弹幕射击、一个简化版平台跳跃都在低配笔记本上跑到了稳定的帧率。这套东西适合谁如果你只是想让小孩完成一节编程课那它太复杂了。但如果你是一个想在Scratch里做出手感接近正经游戏的开发者或者是一个想教学生项目架构的编程老师又或者你单纯好奇可视化编程的性能天花板到底在哪那接下来的内容应该能让你少走至少两个月的弯路。下面我把这套框架的拆解思路、关键实现、踩过的坑原原本本讲一遍。很多结论是我们在具体调参和反复推翻重做之后才得到的文档里不会写搜索也基本搜不到。2. 动手之前先想清楚Scratch到底缺了引擎的哪几块拼图很多人做Scratch游戏的流程是这样的新建角色写一堆当绿旗被点击和当按下空格键用广播在角色之间传消息跑起来能玩但加两个敌人就开始卡加三个关卡就乱成一团。这不是开发者水平问题而是Scratch的默认工作方式决定了它天然不是为系统化游戏设计的。我们把一个标准游戏引擎应该具备的能力列出来对照Scratch的现状能清楚看到缺口在哪。引擎能力正经引擎的提供方式Scratch的默认状态我们的补法帧循环引擎主循环固定每秒60次逻辑更新无帧概念循环速度随脚本复杂度变化用等待0秒计时器差分模拟固定步长实体管理Entity/Component系统统一管理每个角色各自为政克隆体散落克隆体池化全局实体表碰撞检测内置物理引擎支持多种碰撞体只有碰到角色/颜色这类布尔判断手写AABB分轴移动穿透修正摄像机变换视图矩阵世界坐标转屏幕坐标舞台固定无法滚动全局世界坐标反向平移所有渲染层瓦片地图内置Tilemap组件只能一个个角色摆或用画笔硬画画笔绘制数据驱动视口裁剪状态机内置动画状态机无靠变量和广播硬拼状态表消息驱动的简易FSM资源管理异步加载引用计数造型声音全部常驻分场景切换显式释放这张表里最要命的是前两项。没有帧循环就没有游戏逻辑的稳定时间基准。在Scratch里一个重复执行循环跑多快取决于里面有多少积木、有多少克隆体、舞台上有多少角色在同时响应消息。做动画还能凑合做物理和AI就是灾难——同一个跳跃逻辑在敌人多的时候会跳得更高因为帧间隔被拉长了。我们试过的最朴素方案是在每个角色里各自写重复执行移动10步结果就是敌人和主角的运动速度完全脱节主角像在冰面上滑敌人像在泥里走。后来改成用计时器做全局节拍才把这个基础问题解决掉。第二个大缺口是实体管理。Scratch的克隆体很方便但它的生命周期管理全靠开发者自觉什么时候克隆、什么时候删除、删除后数据怎么回收全没有约定。我们最初做弹幕射击屏幕上两百个子弹克隆体每次发射都新建、命中都删除跑了两分钟帧率掉到个位数而且内存明显上涨——Scratch虽然不暴露内存数据但你能从表现上感觉到它在变慢。后来引入对象池把子弹做成藏着复用才把这个问题按住。第三个容易忽视的是渲染层与逻辑层的耦合。在Scratch里角色的位置既是逻辑坐标也是渲染坐标摄像机一移动所有东西都要跟着动。我们一开始的做法是给每个角色都加位置减去摄像机偏移结果每个克隆体每帧都要多算四五步性能直接吃掉一大块。正确做法是让渲染层统一处理偏移逻辑层始终用世界坐标两者只在最终显示时做一次转换。把这三块想清楚之后整个引擎的骨架才立得起来。很多人一上来就写具体游戏逻辑写到一半发现架构撑不住只能推倒重来这就是我们前一个月反复返工的根源。2.1 为什么不用现成扩展而选择纯积木实现有人会问Scratch不是有扩展吗加个物理扩展、加个渲染扩展不就行了我们认真评估过这条路最后放弃了原因有三个。第一大多数第三方Scratch扩展依赖外部加载换台电脑、换个网络环境就可能失效做出来的项目不具备可移植性发给别人别人打不开这在教学场景里是硬伤。第二扩展的能力往往过于黑盒你调不了它的内部参数遇到手感问题只能干瞪眼而游戏开发恰恰是细节决定手感。第三纯积木实现虽然性能上限低一点但它的每一行逻辑都是透明的学生能看懂、能改、能学到东西——这恰恰是Scratch这个平台的核心价值。所以我们定下的原则是所有核心系统必须用原生积木实现画笔和音乐等内置扩展可以用但不引入任何外部依赖。这条原则后来被证明是对的因为整套框架可以直接分享给任何一个装了Scratch的人。2.2 全局坐标系的统一是第一件必须做的事在写任何一行游戏逻辑之前我们先定了一套全局坐标系。Scratch舞台是480x360中心为原点x范围-240到240y范围-180到180。这个尺寸做小游戏够用但做卷轴游戏就太窄了所以我们的世界坐标允许超出舞台范围摄像机负责决定当前看哪一块。具体约定是世界坐标以左上角为原点或者以中心为原点都可以但全项目必须统一。我们选的是以舞台中心为世界原点这样和Scratch原生坐标一致减少了转换成本。所有实体的位置、速度、碰撞盒都以世界坐标存储只有渲染时才减去摄像机偏移。摄像机的偏移量本身也是一个世界坐标点代表当前屏幕中心在世界中的位置。这个约定看起来简单但它带来的好处是巨大的当你写AI时你不需要关心摄像机在哪当你写摄像机跟随逻辑时你不需要关心实体怎么渲染。职责清晰了bug就少了。我们踩过的最大的一个坑是在项目中期临时改了坐标系结果所有碰撞检测的符号全反了排查了整整一个下午才发现是转换层没统一。提示坐标系统一这件事一定要在项目启动第一天就定死并且写进项目注释。中途改坐标系的代价远比你想象的大。3. 用克隆体池化和全局帧调度撑起引擎的主循环引擎的骨架说到底就两件事谁在什么时候更新以及更新的时候操作谁。前者是帧调度后者是实体管理。这两块如果一开始就不设计好后面所有功能都会长在烂地基上。3.1 固定步长帧循环在Scratch里的可行实现正经引擎的帧循环一般是这样的记录上一帧时间算出时间差delta用delta去驱动物理和动画同时用累加器保证逻辑以固定步长比如每秒60次更新。Scratch没有delta但有个计时器积木它返回的是自项目启动以来的秒数精度足够。我们的做法是在舞台或一个专门的主控角色里跑一个重复执行循环每次循环开头读取当前计时器减去上一帧记录的计时器得到delta。然后判断delta是否大于等于目标步长1/30秒或1/60秒如果够了就触发一次逻辑帧把所有实体的更新脚本跑一遍同时把累加器清掉。伪代码大致是这样当绿旗被点击 将 [上一帧时间 v] 设为 (计时器) 将 [累加器 v] 设为 0 重复执行 将 [当前时间 v] 设为 (计时器) 将 [delta v] 设为 ((当前时间) - (上一帧时间)) 将 [上一帧时间 v] 设为 (当前时间) 将 [累加器 v] 增加 (delta) 如果 (累加器) (目标步长) 那么 广播 [逻辑帧 v] 并等待 将 [累加器 v] 设为 0 结束 结束这里有一个关键点广播并等待是保证一帧内所有实体更新完成的核心。如果用普通的广播消息发出后主循环会立刻继续跑导致这一帧的逻辑和下一帧的计时重叠实体的更新顺序也不可控。用广播并等待主循环会阻塞到所有接收者处理完毕这就形成了一个天然的同步屏障。实测下来这套调度在只有少量实体时能稳定跑到30帧以上在画布元素较多时会掉到20帧左右但至少时间基准是稳的物理表现是一致的。这比跑多快算多快强太多。3.2 克隆体池化为什么不能每次需要就新建对象池这个概念在正经引擎里很常见但在Scratch里几乎没人提。原因大概是因为初学者觉得克隆体反正能用管它呢。但只要你做过弹幕游戏就知道新建和删除克隆体的开销远比复用大得多。我们做过一个粗略对比在一个场景里连续发射子弹用每次新建、命中删除的方式屏幕上维持100个子弹时就已经明显感觉到输入延迟改成对象池之后同样屏幕数量下操作流畅感有肉眼可见的提升。虽然Scratch不给我们帧率数字但从操作响应上能明显感知到差别。对象池的实现思路很朴素在游戏开始时预生成一批克隆体让它们进入休眠状态隐藏标记空闲需要时从池里取一个激活不需要时归还而不是删除。克隆体本身用一个局部变量记录自己的状态比如状态0表示空闲1表示激活。再配合一个全局列表记录哪些克隆体编号是空闲的取用和归还就变成了对列表的增删操作。代码结构大致是当作为克隆体启动时 将 [我的状态 v] 设为 0 隐藏 重复执行 如果 (我的状态) 1 那么 // 执行激活状态下的逻辑 结束 结束这套结构的好处是克隆体的脚本主循环一直在跑但只在激活时执行重逻辑空闲时几乎不消耗什么。缺点是所有克隆体都在持续运行循环数量太多时依然会有开销所以池的大小要控制好。3.3 全局实体表让谁存在这件事变得可查对象池解决了复用问题但还有一个问题当某个实体需要找到另一个实体时怎么办比如子弹要判断是否打到敌人如果每个子弹都去遍历所有敌人那就是O(n²)的开销屏幕上几十个敌人加几百个子弹直接卡死。我们用一个全局列表来维护当前所有激活实体的引用每个实体激活时把自己的唯一ID写入列表休眠时移除。需要查找时只遍历这个列表而不是遍历所有克隆体。为了进一步优化我们还按类型分了几个列表比如敌人列表、子弹列表、障碍列表这样子弹只需要遍历敌人列表范围大大缩小。这里有个坑要提醒列表的增删操作在Scratch里是有成本的。如果每帧都全量重建列表性能会崩。我们的做法是只在实体状态发生变化时激活/休眠才更新列表而不是每帧重建。这个改动把我们的弹幕Demo从卡顿救回了可玩。4. 手写AABB碰撞在积木块里复现物理引擎的基本功Scratch原生的碰到角色和碰到颜色判断很好用但它有两个致命问题一是判断精度依赖造型透明边缘也会算进去导致碰撞判定比看起来更胖二是碰到颜色在画笔绘制的地图上会非常慢因为每次判断都要对像素做检测。做平台跳跃类游戏时这两个问题会同时爆发。所以我们决定手写一套基于AABB轴对齐包围盒的碰撞系统。AABB的意思是每个实体用一个矩形来描述它的碰撞范围矩形的边和世界坐标轴平行。判断两个实体是否碰撞只需要比较它们在x轴和y轴上的投影是否重叠也就是常说的区间相交判断。4.1 AABB碰撞判断的具体实现一个实体有位置世界坐标、宽、高那么它的碰撞盒就是左边界 x - 宽/2右边界 x 宽/2下边界 y - 高/2上边界 y 高/2两个实体A和B发生重叠的条件是A的右边界大于B的左边界A的左边界小于B的右边界同时A的上边界大于B的下边界A的下边界小于B的上边界。这四个条件同时成立才算碰撞。用积木表达就是如果 (A右边) (B左边) 且 (A左边) (B右边) 且 (A上边) (B下边) 且 (A下边) (B上边) 那么 // 发生碰撞 结束这套判断用积木拼出来大概七八个积木块单次判断非常快。实测下来一个子弹和一百个敌人做碰撞检测在30帧的预算里完全撑得住。但AABB有个天然缺陷它只能处理矩形处理不了圆形和旋转后的形状。对于大多数2D游戏来说矩形碰撞盒其实够了主角和敌人都可以用略小于造型的矩形来近似手感反而更精准。如果你要做圆形碰撞可以把圆形也近似成矩形或者额外写一个圆与圆的距离判断这里就不展开了。4.2 分轴移动解决卡墙和穿墙的关键有了碰撞判断还不够真正难的是碰撞响应。初学者常犯的错误是先算出新位置再判断是否碰撞如果碰撞就完全不动。这种做法会导致一个经典问题——角色斜着撞墙时会卡住不动因为x和y方向的移动被一起否决了。正确做法是分轴移动先只在x方向移动判断碰撞如果碰撞就把x方向的移动撤销或者贴边再在y方向移动同样处理。这样角色撞到竖直的墙时x方向被挡住但y方向还能自由移动就能沿着墙滑行手感自然得多。我们的实现逻辑是这样的// 更新x 将 [新x v] 设为 ((x) (vx)) 如果 新位置碰撞 那么 如果 (vx) 0 那么 将 [x v] 设为 (墙的左边减去半宽) 否则 将 [x v] 设为 (墙的右边加上半宽) 将 [vx v] 设为 0 否则 将 [x v] 设为 (新x) 结束 // 更新y同理这段逻辑把贴边处理得很干净角色不会嵌进墙里也不会在墙上抖动。我们把这段代码封装成一个自定义积木块任何实体只要调用它就能获得稳定的碰撞响应。4.3 穿透检测与速度上限AABB有一个经典的失效场景高速物体穿透薄墙。如果子弹每帧移动20像素而墙只有10像素厚那么子弹可能这一帧在墙左边下一帧就在墙右边中间的碰撞根本没被检测到。这在弹幕游戏里非常明显。解决办法有两个。一是限制最大速度让每帧位移不超过最薄障碍物的厚度这是个简单粗暴但有效的方案我们把它作为默认约束。二是做扫掠检测也就是把这一帧的移动路径当作一条线段和障碍物求交取最早的碰撞点。扫掠检测更精确但实现复杂我们只在必要的高速实体上使用简化版把移动拆成若干子步每步不超过墙厚的一半逐步推进。这样虽然多几次判断但避免了穿透。实际上对于大多数Scratch游戏限制速度上限就够了因为游戏节奏本来就不会特别快。我们在跑酷Demo里把主角最大水平速度定在每帧12像素障碍物最薄处16像素就再也没有出现过穿透。5. 摄像机、瓦片地图与视口裁剪让世界比舞台大一百倍Scratch舞台只有480x360做卷轴游戏时世界可能有几千像素宽。如果只是把所有角色都放在世界里然后让它们的位置减去摄像机偏移性能会随着世界变大而崩塌因为屏幕外的角色也在每帧更新和渲染。5.1 摄像机跟随的平滑处理摄像机本质上就是一个当前视野中心的世界坐标。最简单的实现是让摄像机直接等于主角的位置但这样会有个问题主角一动屏幕就跟着猛动玩久了会晕。所以真正好用的摄像机需要平滑跟随也就是摄像机位置缓慢逼近主角位置而不是瞬间到位。我们的做法是每帧执行将 [摄像机x v] 设为 ((摄像机x) ((主角x) - (摄像机x)) * 0.1)这个0.1叫跟随系数值越大跟随越紧值越小越懒。我们试过0.05到0.3之间的多个值最后发现0.1到0.15之间最舒服。太紧会晕太松会感觉角色在屏幕上飘。还有一个细节是死区当主角在屏幕中央附近小范围移动时摄像机完全不动只有超出死区才跟随。这能让画面更稳定。死区的实现是在判断是否跟随之前先算主角与摄像机的距离小于阈值就不更新摄像机。5.2 瓦片地图用画笔画出整张地图Scratch的画笔扩展是我们在整个项目里唯一重度依赖的非核心能力。瓦片地图的思路是把地图数据存在一个列表里每个元素代表一个格子的类型0为空1为地面2为墙等渲染时遍历这个列表根据摄像机位置决定哪些格子可见然后用画笔在对应位置画矩形。这样做的好处是一张几百格的地图只需要渲染屏幕上可见的十几格性能开销和地图大小无关。我们用这一套渲染了一张宽6400像素、高720像素的地图在屏幕上只画可见区域帧率几乎没受影响。具体实现上每一格的大小我们定为40x40像素这样480宽的舞台正好显示12列。渲染时先遍历可见列和可见行对每个格子判断类型用落笔移动到位置加画矩形的方式绘制。为了让画笔不闪烁每一帧先全部擦除再重新画一遍这在Scratch里是标准做法。这里有个优化点画笔的移动和落笔次数才是性能瓶颈不是绘制面积。所以能一次画完的矩形不要拆成多次画能合并的线条不要分开落笔。我们把地面格子做了行合并优化同一行的连续地面只画一个长条绘制次数减少了一半以上。5.3 视口裁剪屏幕外的东西一律不更新视口裁剪的核心思想是只在屏幕可见范围内的实体才执行重逻辑。对于屏幕外的敌人我们只让它保持一个冻结状态不执行AI、不做碰撞、不更新动画。这样即使世界里有上百个敌人真正消耗性能的也只有屏幕上那十几个。实现上每个实体每帧先检查自己的世界坐标是否在摄像机视野的扩展范围内我们额外加了100像素的缓冲防止实体刚进屏幕时还没反应过来。如果在范围内就标记为激活执行完整逻辑否则标记为休眠只维持基本状态。这个改动对性能的提升非常明显。我们的横版跑酷Demo里整张地图上散落了大约80个敌人和道具不做裁剪时帧率肉眼可见地掉做完裁剪后和只放10个敌人时的表现几乎没差别。6. 三个月里真正让我们卡住的几个坑前面讲的都是正确做法但这些东西不是一开始就想明白的。下面这几个坑每一个都让我们花了不少时间去定位和修复写出来希望能帮你省点时间。6.1 广播风暴消息太多反而拖慢主循环我们最初设计实体通信时大量使用了广播。主角受伤广播一次敌人死亡广播一次子弹命中再广播一次。结果在一个有50个敌人的场景里每帧都有几十次广播主循环被拖得很慢。排查过程挺笨的我们先怀疑是克隆体太多把克隆体减半后没改善又怀疑是碰撞检测太慢把碰撞频率降低后还是没改善。最后我们做了个实验把所有广播注释掉用直接改变量代替帧率立刻回来了。这才定位到问题在广播上。Scratch的广播是一个全局事件每个广播都要遍历所有脚本判断是否有匹配的接收器响应这个开销随着脚本数量增加而增加。所以后来我们定了个规矩只有在一对多且低频的场景才用广播比如游戏开始、游戏结束、关卡切换高频的实体间通信一律用全局变量或列表。6.2 克隆体上限数量不是无限的Scratch对克隆体数量是有上限的不同版本数字略有差异但通常在300左右。我们做弹幕游戏时曾经同时存在200多个克隆体一开始还能跑但后来随着池子越开越大突然有一天所有克隆体都不响应了表现是所有子弹停在原地不动。这个问题的隐蔽之处在于它不会报错只是悄悄地失效。我们排查了很久最后是把克隆体数量统计打印出来才发现已经逼近上限。解决办法就是严格控制池的大小并且定期回收长时间空闲的克隆体。我们的经验值是任何时刻激活的克隆体不超过150个池的总量不超过250个这个范围比较安全。6.3 浮点数精度数值比较的坑Scratch里的数字都是双精度浮点数这意味着一系列加减乘除之后一个本该等于0的值可能是0.0000001。我们在做碰撞贴边时用了如果x等于墙的左边这样的判断结果偶尔会失效。修复方法很简单所有浮点数比较都改用绝对值小于某个极小值的形式比如把x 0改成|x| 0.001。这个改动虽然看起来啰嗦但避免了很多偶发的定位偏差。后来我们把这条写进了项目规范。6.4 计时器精度不同设备不一样我们用的计时器差分来做帧调度这在大多数设备上没问题但在一些性能较弱的设备上计时器本身的更新频率可能不够高导致delta算出来偏大或偏小。表现出来就是同样的游戏在不同电脑上手感有差异。我们的应对是给delta加一个上限比如单帧delta不超过0.1秒超过就按0.1算。这样即使某一帧卡了一下物理也不会因为delta过大而瞬移。这个处理叫delta钳制是正经引擎里很常见的做法。7. 用这套框架跑出来的三个Demo与实际表现光讲架构容易空说说我们实际用它做出来的东西以及每个项目暴露出的问题。第一个是横版卷轴跑酷。它用到了摄像机跟随、瓦片地图、AABB碰撞、对象池金币和敌人。整体跑下来在主角速度较快时偶尔会有轻微的视觉抖动后来发现是摄像机跟随系数太紧导致的调松之后就好了。这个项目的经验是跑酷类游戏的镜头必须有预判也就是摄像机要略微朝主角运动方向偏移一点否则玩家看不到前方的障碍。第二个是俯视角弹幕射击。它用到了对象池子弹、全局实体表、视口裁剪。这个项目暴露的最大问题是碰撞检测的频率一开始我们每帧对所有子弹和所有敌人做全量碰撞帧率直接崩了。后来加了空间分区——把世界切成网格每个格子维护自己的实体列表子弹只和所在格子及相邻格子的敌人做检测性能马上就回来了。这也是正经引擎里空间换时间的经典思路。第三个是简化版平台跳跃。它用到了状态机、分轴移动、穿透检测。这个项目的教训是状态管理一定要集中我们一开始把角色的各种状态散落在多个脚本里导致跳跃中受伤这种组合状态处理起来一团乱。后来改成一张状态表驱动每个状态定义进入、更新、退出三个动作逻辑就清晰多了。三个Demo加起来大概用了整套框架八成的能力剩下两成是我们在做的过程中发现其实不需要而砍掉的比如复杂的光照和粒子系统——在Scratch里做这些性价比太低不如把精力放在手感和内容上。8. 想复现这套思路的话几个我个人的实操建议如果你打算自己动手我建议不要一上来就搭全套框架那样很容易在细节里迷失。更靠谱的路径是从一个小项目出发遇到问题再抽象。比如先做一个只有主角和地面碰撞的测试场景把帧循环和AABB跑通然后再加一个敌人把实体表和状态管理加进来再加一张大点的地图把摄像机和瓦片地图加上。每一步都只解决一个具体问题抽象自然就出来了。关于性能调优我个人的体会是Scratch里真正的瓶颈通常不是计算而是渲染和对象数量。你写再多的数学运算只要不涉及画笔和克隆体的大规模操作基本都不会卡。反过来屏幕上多几十个角色或者画笔每帧画上千个矩形立刻就能感觉到。所以优化优先级应该是先减少同时激活的对象数量再优化渲染次数最后才轮到优化计算逻辑。还有一个很容易被忽视的点是脚本的组织方式。Scratch项目一复杂脚本区就会变成一团毛线。我们的做法是每个角色只保留一个主入口脚本其他逻辑全部封装成自定义积木按功能命名比如更新物理渲染自身处理输入。这样不管项目多大看代码的时候都能顺着入口一步步读下去改起来也不容易漏。最后说个我踩过的坑不要过早追求通用。我们中途曾经想把碰撞系统做成完全通用的支持各种形状和任意旋转结果越做越复杂最后连最简单的矩形碰撞都跑不稳。后来全部砍掉回到只支持AABB反而什么项目都能做了。在Scratch这种表达能力有限的环境里够用的抽象远比完美的抽象有价值。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门