你有没有遇到过这种情况:在 Scratch 里做了一个角色移动,按下“上”和“右”键,角色斜着走,结果发现它跑得飞快,比只按一个方向键快得多?这感觉就像游戏里开了加速挂,角色“嗖”地一下就冲出去了,完全不受控制。
这不是你的错觉,也不是 Scratch 的 Bug,而是一个在游戏开发、动画制作甚至物理模拟中都非常经典的“斜向移动速度叠加”问题。很多初学者,甚至一些有经验的开发者,在第一次遇到时都会感到困惑:我只是想让角色同时响应两个方向键,怎么就“超速”了呢?
今天,我们不只解决这个具体问题,更要借这个问题,来聊聊在 Scratch(以及任何可视化编程或传统代码编程)中,一个更根本、也更重要的话题:代码架构。你会发现,解决“斜向移动过快”只是一个表面现象,其背后暴露的是我们组织代码逻辑的深层缺陷。一个好的架构,不仅能优雅地解决这类问题,更能让你的项目在面对复杂交互、状态管理、功能扩展时,依然清晰、健壮、易于维护。
我们这次尝试的,就是一种更接近“状态机”与“向量归一化”思想的架构。它可能和你习惯的“当按下按键时移动”的直白写法不太一样,但请相信我,一旦你理解了这套思路,并成功应用到你的贪吃蛇、平台跳跃或其他 Scratch 小游戏中,你会获得一种对程序控制力完全不同的感受。
1. 问题根源:为什么斜向移动会“超速”?
在深入新架构之前,我们必须先彻底搞清楚问题是怎么来的。只有理解了“病因”,才能明白“药方”的设计逻辑。
1.1 从直觉代码到问题爆发
绝大多数 Scratch 初学者实现键盘移动的代码是这样的:
当 ⚑ 被点击 重复无限次 如果 <按下 [上移 v] 键?> 那么 将 y 坐标增加 (10) 结束 如果 <按下 [下移 v] 键?> 那么 将 y 坐标增加 (-10) 结束 如果 <按下 [右移 v] 键?> 那么 将 x 坐标增加 (10) 结束 如果 <按下 [左移 v] 键?> 那么 将 x 坐标增加 (-10) 结束 结束这段代码非常符合直觉:检查每个键,如果按下了,就朝对应方向移动一段距离(比如10步)。在只按一个方向键时,一切正常。
但当我们同时按下“上”和“右”键时,发生了什么?
在一个循环内:
- 检查“上”键:按下,
y坐标增加 10。 - 检查“右”键:按下,
x坐标增加 10。
于是,角色在一次循环内,实际移动的位移是(10, 10)。如果我们用几何来表示,这是一个从原点指向(10, 10)的箭头。
现在,关键问题来了:这个箭头的长度(也就是速度的大小)是多少? 根据勾股定理:速度大小 = √(10² + 10²) = √200 ≈ 14.14
这比单独朝上或朝右移动时的速度(10)要快大约 41.4%!这就是角色“超速”感觉的来源。你的本意是让角色以速度10斜向45度移动,但代码的实际效果是让角色以约14.14的速度斜向移动。
1.2 把问题抽象化:向量叠加
我们可以把每次按键带来的移动看作一个向量(Vector)。向量有方向和大小。
- 按“上”:向量
(0, 10),大小10。 - 按“右”:向量
(10, 0),大小10。
当两个键同时按下时,代码逻辑相当于把这两个向量做了加法:(0, 10) + (10, 0) = (10, 10)。 向量加法的结果,其大小并不等于原来两个向量大小的简单相加(10+10=20),而是根据夹角计算。当两个向量垂直时(就像上下和左右),合向量的大小就是√(a² + b²)。
所以,问题的本质是:我们错误地将“移动意愿”(按键)直接线性叠加为了“移动向量”,而没有对最终合成的移动向量进行速度大小的控制。
1.3 旧架构的局限性
上述的直觉代码代表了一种常见的架构模式:“事件-响应”的直接映射。在这种架构下:
- 优势:简单明了,易于理解和上手。
- 劣势:
- 状态分散:角色的移动状态(正在向哪里移动)分散在四个独立的“如果”语句中,没有统一的管理者。
- 决策与执行耦合:“检查按键”(输入决策)和“执行移动”(输出动作)紧密耦合在同一段循环代码里。
- 难以处理复杂逻辑:当需要引入惯性、加速度、障碍物碰撞后修正方向、速度上限等复杂逻辑时,代码会迅速变得臃肿且互相干扰。
我们的“斜向超速”问题,正是这种架构在处理复合输入时暴露出的典型缺陷。要根治它,我们需要升级我们的代码组织方式。
2. 新架构核心:分离“输入”、“状态”与“执行”
好的架构的核心思想是分离关注点。对于角色移动这个系统,我们可以将其拆解为三个清晰的环节:
- 输入处理层:只负责监听键盘(或鼠标、传感器等),并将原始的按键信号,翻译成对角色“移动意愿”的抽象描述。
- 状态管理层:这是架构的核心。它接收来自输入层的“意愿”,结合当前自身的状态(如是否处于滑行、被击中僵直等),计算出角色当前应该具有的“移动向量”。
- 执行输出层:它只负责一件事:获取状态管理层计算好的“移动向量”,并忠实地将其应用到角色坐标上,让角色动起来。
这种架构很像一个工厂的流水线:输入是原料(按键),状态管理是加工车间(计算速度),输出是包装车间(移动角色)。每个环节职责单一,互不越界。
2.1 第一步:用变量建立“移动意愿”状态
在 Scratch 中,我们可以用变量来充当状态管理层的“内存”。我们不再在按键检测里直接移动角色,而是用变量来记录“用户想朝哪个方向走”。
通常,我们会为水平和垂直方向各设置一个变量:
水平方向:值可以是 -1(左)、0(不动)、1(右)。垂直方向:值可以是 -1(下)、0(不动)、1(上)。
初始化脚本:
当 ⚑ 被点击 将 [水平方向 v] 设为 (0) 将 [垂直方向 v] 设为 (0)然后,我们修改按键检测脚本,让它只更新这些状态变量:
当 ⚑ 被点击 重复无限次 如果 <按下 [右移 v] 键?> 那么 将 [水平方向 v] 设为 (1) 否则 如果 <按下 [左移 v] 键?> 那么 将 [水平方向 v] 设为 (-1) 否则 将 [水平方向 v] 设为 (0) // 两个键都没按,则停止水平移动 结束 结束 如果 <按下 [上移 v] 键?> 那么 将 [垂直方向 v] 设为 (1) 否则 如果 <按下 [下移 v] 键?> 那么 将 [垂直方向 v] 设为 (-1) 否则 将 [垂直方向 v] 设为 (0) // 两个键都没按,则停止垂直移动 结束 结束 结束注意,这里使用了“否则”结构,确保了同时按左右或上下时,后者会覆盖前者(或者你可以设计成更复杂的优先级逻辑)。现在,这段代码的唯一职责就是根据键盘输入,设置好水平方向和垂直方向这两个状态变量。它不关心角色会不会动、怎么动。
2.2 第二步:关键算法——向量归一化
现在,我们有了水平方向和垂直方向。如果直接把它们当作速度分量,我们会回到老问题:(1, 1)的速度大小是 √2 ≈ 1.414,还是比单方向的1要快。
解决方案就是向量归一化。归一化的目的,是将一个非零向量转换为方向相同,但长度为1的单位向量。
计算过程如下:
- 根据
水平方向和垂直方向,得到原始向量(dx, dy)。 - 计算这个向量的长度
长度 = √(dx² + dy²)。 - 如果长度不为0(即角色确实想移动),则计算单位向量:
单位向量x = dx / 长度单位向量y = dy / 长度 - 用我们期望的速度大小(比如10)去乘以这个单位向量,就得到了最终的速度向量:
速度x = 单位向量x * 期望速度速度y = 单位向量y * 期望速度
这个计算保证了无论(dx, dy)是(1, 0)、(0, 1)还是(1, 1)、(1, 2),最终生成的(速度x, 速度y)向量的长度都恒等于“期望速度”。斜向移动的速度就被纠正过来了。
2.3 第三步:独立的“执行”循环
最后,我们创建一个独立的循环,专门负责根据计算出的速度来移动角色。
当 ⚑ 被点击 重复无限次 // 1. 获取当前移动意愿 将 [dx v] 设为 (水平方向) 将 [dy v] 设为 (垂直方向) // 2. 计算向量长度(如果不想移动,则长度为0) 将 [长度 v] 设为 ([sqrt v] 的 ((dx) * (dx)) + ((dy) * (dy)))::operators) // 3. 如果长度>0,则进行归一化并计算实际速度 如果 <(长度) > (0)> 那么 将 [速度x v] 设为 ((dx) / (长度)) // 单位向量x分量 将 [速度y v] 设为 ((dy) / (长度)) // 单位向量y分量 // 乘以期望速度,这里假设期望速度为10 将 [速度x v] 设为 ((速度x) * (10)) 将 [速度y v] 设为 ((速度y) * (10)) // 4. 如果长度为0,则速度归零 否则 将 [速度x v] 设为 (0) 将 [速度y v] 设为 (0) 结束 // 5. 执行移动 将 x 坐标增加 (速度x) 将 y 坐标增加 (速度y) 等待 (0.016) 秒 // 约60帧/秒,用于控制循环速度,使移动更平滑 结束至此,一个将输入、状态、执行分离的新架构就搭建完成了。水平方向/垂直方向变量是输入层写给状态层的“指令单”。状态层(中间的计算部分)根据指令单和内置算法(归一化),计算出精确的速度x/速度y。执行层则毫不关心这些变量怎么来的,只负责把它们加到坐标上。
3. 新架构的威力与扩展性
解决了斜向速度问题,只是这个新架构带来的最直接的好处。它的真正价值在于,为项目未来的复杂化提供了一个清晰、稳固的框架。
3.1 轻松实现高级移动特性
现在,如果你想修改移动行为,几乎都只需要在“状态管理层”(即上面脚本的计算部分)动刀,而不会影响到输入检测和最终执行。
- 修改速度:只需改变乘以的“期望速度”值,比如从10改成5或15。
- 加入加速度/惯性:不再是直接设置
水平方向为1或-1,而是设置一个“目标水平速度”,然后让当前速度x每帧向目标值平滑接近。// 在状态计算部分加入惯性 将 [目标速度x v] 设为 ((水平方向) * (期望速度)) 将 [加速度 v] 设为 (0.5) // 加速度值 如果 <(速度x) < (目标速度x)> 那么 将 [速度x v] 设为 ((速度x) + (加速度)) 否则 如果 <(速度x) > (目标速度x)> 那么 将 [速度x v] 设为 ((速度x) - (加速度)) 结束 // 对速度y做同样处理... - 设置最大速度:在计算完速度后,可以再判断一下当前速度向量的总长度是否超过某个最大值,如果超过,就按比例缩放到最大值。
- 处理斜坡、滑冰等物理效果:可以根据角色脚下的地面类型(通过颜色侦测等),在状态层动态修改“期望速度”或加速度值。
3.2 状态管理的集中化
所有关于“角色如何运动”的逻辑,都集中在了那一段计算脚本里。这带来了巨大的可维护性优势:
- 调试方便:你可以在舞台上显示
速度x、速度y、长度等关键变量,实时观察状态变化,精准定位问题。 - 逻辑清晰:不会出现旧架构中,多个“如果”语句互相干扰、优先级混乱的情况。移动的所有规则白纸黑字写在一处。
- 易于扩展:当需要增加“冲刺”、“飞行”、“负重”等状态时,你只需要在状态层增加几个变量(如
是否冲刺、移动速度倍率),并在计算速度时考虑进去即可,无需重写输入和输出逻辑。
3.3 向更复杂的游戏架构演进
这个“输入-状态-输出”的分离思想,是许多成熟游戏引擎架构的简化版。你可以在此基础上继续演进:
- 引入“状态机”:用另一个变量(如
当前状态)来管理角色是“站立”、“行走”、“跳跃”、“攻击”等。不同状态下,对输入的处理方式和移动计算规则可以完全不同。 - 命令模式:将每一次按键操作封装成一个“命令对象”,命令对象里包含了要执行的具体行为(如“移动命令”包含方向和速度)。输入层只负责创建命令,并放入一个队列。状态层或执行层从队列中取出命令并执行。这可以实现操作回放、网络同步等高级功能。
- 组件化:将移动控制、动画播放、碰撞检测、生命值管理等拆分为独立的代码模块(在 Scratch 中可以用“自制积木”或消息广播来模拟),让角色由多个可复用的“组件”组合而成。
4. 实践建议与常见陷阱
理解了新架构的思想后,在 Scratch 中实践时,还有一些细节需要注意。
4.1 给初学者的分步实施指南
如果你正在做一个新项目,或者打算重构一个旧项目,可以按以下步骤进行:
- 建立变量:首先创建
水平方向、垂直方向、速度x、速度y这四个核心变量。将它们显示在舞台上以便调试。 - 重写输入:将原来所有直接修改坐标的按键检测代码,改为只修改
水平方向和垂直方向变量。确保逻辑正确(比如按键松开时变量归零)。 - 创建主循环:新建一个独立的、永远循环的脚本。在这个循环里,实现上述的向量归一化和速度计算逻辑,并最终用
速度x和速度y来更新角色坐标。 - 测试与调试:
- 先测试单方向移动是否正常。
- 再测试斜向移动,观察角色速度是否与单方向一致(感觉上匀速)。
- 通过显示变量,确认当按下斜向键时,
速度x和速度y的值大约是7.07(10 / √2),而不是10。
- 迭代优化:在主循环中加入帧率控制(如
等待0.016秒),使移动在不同性能的电脑上更一致。然后开始尝试添加惯性、最大速度等高级特性。
4.2 需要避开的“坑”
- 不要忘记初始化:游戏开始时,务必将所有状态变量(
水平方向、垂直方向、速度x、速度y)设为0。 - 小心除零错误:在进行归一化计算
dx / 长度时,必须确保长度 > 0。这就是为什么代码中需要如果 <(长度) > (0)>的判断。 - 理解浮点数精度:Scratch 的计算是浮点的。
速度x和速度y很可能不是整数。这是完全正常的,不要试图用“四舍五入”积木把它们变成整数,那会引入不必要的抖动。 - 广播与循环的协调:如果你使用“广播”消息来触发移动计算,要确保消息处理的速度和主循环的节奏协调,避免一帧内多次计算或计算被覆盖。
4.3 当新架构“失灵”时如何排查
即使采用了新架构,移动感觉不对了,可以按这个顺序排查:
- 检查输入层:按下按键时,
水平方向/垂直方向变量是否按预期变成了1、-1或0?确保没有其他脚本意外修改了它们。 - 检查状态层:观察主循环中计算出的
长度、速度x、速度y的值。斜向移动时,速度x和速度y的绝对值是否都小于设定的“期望速度”?它们的平方和是否接近“期望速度”的平方(允许微小浮点误差)? - 检查执行层:
速度x和速度y是否被正确地、每帧一次地加到坐标上?是否有其他脚本(如物理模拟、边界检测)在之后又修改了坐标? - 检查外部干扰:角色是否有其他“移动”相关的积木(如“在1秒内滑行到X Y”)在同时运行?是否有“重复执行直到”之类的循环在干扰主循环?
这种分层排查的思路,正是清晰架构带来的另一个好处:问题被隔离在特定的层,调试范围大大缩小。
从“按下键就动”的直觉编码,到“输入-状态-执行”的分离架构,这不仅仅是解决了一个斜向移动的速度问题。这是一种思维方式的转变:从只关心“怎么做”,到开始思考“如何组织”。
在 Scratch 这个看似简单的环境中,尝试这样的架构设计,其意义远超 Scratch 本身。它训练的是你分解问题、抽象状态、设计流程的底层能力。这些能力,在你未来接触 Python、JavaScript、C# 乃至任何复杂的软件工程时,都是相通的。
下一次,当你在 Scratch 中制作一个拥有多种技能、复杂状态的角色时,不妨先停下来,画一画它的状态图,想一想哪些是输入,哪些是内部状态,哪些是最终输出。你会发现,代码不再是纠缠在一起的线团,而是一个个各司其职、通过清晰接口连接的模块。那种对程序脉络的掌控感,才是编程路上更令人着迷的风景。