本文是 Amethyst(Rust 数据驱动游戏引擎)Pong 教程系列的第四篇,基于仓库中 book/src/pong-tutorial/pong-tutorial-04.md 编写。在第三篇我们已经实现了捕捉键盘输入驱动挡板移动的PaddleSystem,本篇将在既有 ECS 架构之上为游戏加入一个全新的实体——球(Ball):先为其定义Ball组件并在世界中心生成实体,再编写MoveBallsSystem(基于 delta time 的帧率无关移动)与BounceSystem(球与挡板、上下边界的碰撞反弹),最后通过状态(SimpleState)中的字段与update方法实现球的延迟生成,解决游戏刚启动球就立刻飞离屏幕的问题。读完本篇,你将掌握:如何定义并注册自定义组件、如何用Time资源实现帧率无关的运动、如何用包围盒展开法做轴对齐碰撞检测,以及如何用Option<f32>计时器实现延迟生成逻辑。完整代码见仓库 examples/pong_tutorial_04 示例。
前置准备:本教程对应的运行方式
本教程的所有阶段性代码都作为独立示例存放在仓库 examples 目录下,pong_tutorial_01至pong_tutorial_06分别对应教程每一步完成后的状态。如果你克隆了 Amethyst 仓库,可以直接运行本篇对应的示例:
cargo run -p pong_tutorial_04运行前需要先完成 Getting started 章节 的环境准备。示例运行所需的资产(精灵图)与配置(display.ron、bindings.ron)都位于 examples/pong_tutorial_04 目录中。注意仓库内示例的main.rs有一行注释说明:由于示例运行在 git 仓库上下文里,资产目录使用了app_root.join("assets/")这一与教程正文略有不同的加载方式(见 examples/pong_tutorial_04/main.rs)。
定义球的常量与 Ball 组件
在 pong.rs 中新增常量
与之前章节一样,先在 pong.rs 顶部定义本章需要的常量。教程采用硬编码常量而非外部配置文件的方式,注释中也说明了原因:当需要频繁调整数值时配置文件更有优势,这里为了保持简单直接写在代码里:
pub const BALL_VELOCITY_X: f32 = 75.0; pub const BALL_VELOCITY_Y: f32 = 50.0; pub const BALL_RADIUS: f32 = 2.0;结合仓库中完整的 pong.rs 可以看到,它们与之前定义的竞技场与挡板常量并列:
pub const ARENA_HEIGHT: f32 = 100.0; pub const ARENA_WIDTH: f32 = 100.0; pub const PADDLE_HEIGHT: f32 = 16.0; pub const PADDLE_WIDTH: f32 = 4.0;其中ARENA_WIDTH与ARENA_HEIGHT均为 100.0,球的初速度在 X 方向为 75.0、Y 方向为 50.0(每秒移动的单位数),半径仅 2.0——这些数值共同决定了球在竞技场中的运动节奏。
创建 Ball 组件
接下来定义Ball组件。在 ECS 中,组件就是纯数据:球需要速度与半径,所以组件就保存这两项:
pub struct Ball { pub velocity: [f32; 2], pub radius: f32, }仓库中的实际定义位于 examples/pong_tutorial_04/pong.rs。注意这里velocity用[f32; 2]数组分别表示 x、y 两个方向的速度,反弹逻辑将通过取负号翻转某个方向的分量来实现。与之对比,挡板组件Paddle(pong.rs)则保存side、width、height以及位置x、y,碰撞检测时会用到挡板的宽高与阵营(Side::Left/Side::Right)。
编写 initialize_ball 生成函数
参照上一篇initialize_paddles的写法,新增initialize_ball函数,在竞技场中央生成一个球实体:
/// initializes one ball in the middle-ish of the arena. fn initialize_ball(world: &mut World, sprite_sheet_handle: Handle<SpriteSheet>) { // Create the translation. let mut local_transform = Transform::default(); local_transform.set_translation_xyz(ARENA_WIDTH / 2.0, ARENA_HEIGHT / 2.0, 0.0); // Assign the sprite for the ball. The ball is the second sprite in the sheet. let sprite_render = SpriteRender::new(sprite_sheet_handle, 1); world .push((sprite_render),Ball { radius: BALL_RADIUS, velocity: [BALL_VELOCITY_X, BALL_VELOCITY_Y], },local_transform); }几个关键点:
- 初始位置:球的
Transform被设置为(ARENA_WIDTH / 2.0, ARENA_HEIGHT / 2.0, 0.0),即竞技场中心(仓库最终版做了微调,改为(ARENA_WIDTH - BALL_RADIUS) * 0.5与(ARENA_HEIGHT - BALL_RADIUS) * 0.5,见 pong.rs,效果上都是居中生成)。 - 精灵索引:
SpriteRender::new(sprite_sheet_handle, 1)中的1表示使用精灵图中的第二个精灵。上一章(绘制挡板)我们学过如何加载精灵图,精灵图布局定义在 assets/texture/pong_spritesheet.ron:纹理尺寸为 8×16,第一个精灵(索引 0)是x: 0, y: 0, width: 4, height: 16的竖长条挡板,第二个精灵(索引 1)是x: 4, y: 0, width: 4, height: 4的正方形球。 - 实体组装:通过
world.push(...)一次把渲染组件、逻辑组件与变换组件组合成实体——这正是 ECS 的核心用法:实体只是把数据(组件)聚合在一起的逻辑容器。
在 on_start 中注册并生成球
要确保代码按预期工作,需要更新on_start方法。这一步要临时注册Ball组件,并调用initialize_ball:
fn on_start(&mut self, data: StateData<'_, GameData>) { let world = data.world; // Load the spritesheet necessary to render the graphics. let sprite_sheet_handle = load_sprite_sheet(world); world.register::<Ball>(); // <- add this line temporarily initialize_ball(world, sprite_sheet_handle.clone()); // <- add this line initialize_paddles(world, sprite_sheet_handle); initialize_camera(world); }这里有两个容易踩坑的细节:
register::<Ball>()是临时的:教程强调这一行只是暂时添加。因为在 ECS 中,World需要预先知道组件类型才能为其分配存储空间;当稍后引入MoveBallsSystem、BounceSystem等系统后,系统本身会负责注册所需的组件,届时这一行就需要移除(仓库最终版 pong.rs 的on_start中已经没有它了)。sprite_sheet_handle.clone()必不可少:initialize_paddles和initialize_ball都会消费(consume)这个句柄(Handle按值传递),所以第二次调用前必须克隆一份,否则所有权会被转移、编译无法通过。Handle本身是引用计数的轻量句柄,克隆成本极低。
此时运行游戏,应该能看到两块挡板和位于屏幕中央的球。
用 MoveBallsSystem 让球动起来:delta time 与帧率无关
System 的本质
上一篇教程介绍过:System 是对"某一类组件"全体实体行为的描述,而非单个实例。它每帧运行一次,读取Ball与Transform组件,并更新变换。我们新建systems/move_balls.rs,实现MoveBallsSystem:
use amethyst::{ core::timing::Time, core::transform::Transform, ecs::{System, World}, }; use crate::pong::Ball; pub struct MoveBallsSystem; impl System for MoveBallsSystem { type SystemData = ( .read_component::<Ball>(), .write_component::<Transform>() .read_resource::<Time>(), ); fn run(&mut self, (balls, mut locals, time): Self::SystemData) { // Move every ball according to its speed, and the time passed. for (ball, local) in (&balls, &mut locals).join() { local.prepend_translation_x(ball.velocity[0] * time.delta_seconds()); local.prepend_translation_y(ball.velocity[1] * time.delta_seconds()); } } fn build(self) -> Box<(dyn ParallelRunnable + 'static)> {} }(注:教程正文的片段沿用了旧版 Specs 的SystemData/join()写法;仓库中的实际示例已升级为基于SystemBuilder的新 API,见下文"与仓库实现对照"。)
这个系统做的事:遍历所有同时拥有Ball和Transform的实体,将速度分量乘以delta_seconds后累加到平移上。join()是 ECS 中经典的"联接"操作——它同时遍历多个组件存储,只产出那些同时持有全部所需组件的实体。当前游戏只有一个球,但如果未来出现多个球,这个系统无需任何修改即可天然支持所有球实体——这正是 ECS 数据驱动范式的优势。
帧率无关运动:为什么需要 delta time
教程特别强调帧率无关(framerate independence):无论游戏跑在 60 FPS 还是 144 FPS 的机器上,球都应保持相同的移动速度。如果简单地在每帧加上固定位移,高帧率机器的球就会飞得更快,物理节奏完全失真。
解决办法就是delta time(增量时间),即自上一帧以来经过的时长,这一技巧通常被称为 delta timing。在 Amethyst 中,可以通过资源amethyst::core::timing::Time(文档见 amethyst_core::timing::Time)获取,其delta_seconds()方法返回自上一帧以来的秒数。Time是一个资源(Resource)——与组件不同,资源是全局共享的单例数据,由引擎每帧自动更新,因此系统只需通过.read_resource::<Time>()声明读取即可。
local.prepend_translation_x(ball.velocity[0] * time.delta_seconds());位移 = 速度 × 时间,这是经典的物理运动公式。无论帧率高低,每帧走过的路程都与该帧实际耗时成正比,从而保证全局速度一致。
与仓库实现对照:SystemBuilder 新 API
仓库 examples/pong_tutorial_04/systems/move_balls.rs 中给出了教程片段的现代化版本,逻辑完全一致,只是换用了SystemBuilder:
impl System for BallSystem { fn build(self) -> Box<dyn ParallelRunnable> { Box::new( SystemBuilder::new("MoveBallsSystem") .with_query(<(&Ball, &mut Transform)>::query()) .read_resource::<Time>() .read_component::<Ball>() .write_component::<Transform>() .build(move |_commands, world, time, query_balls| { for (ball, local) in query_balls.iter_mut(world) { local.prepend_translation_x( ball.velocity[0] * time.delta_time().as_secs_f32(), ); local.prepend_translation_y( ball.velocity[1] * time.delta_time().as_secs_f32(), ); } }), ) } }with_query声明查询(&Ball, &mut Transform)组合,read_resource::<Time>()声明读取Time资源,delta_time().as_secs_f32()与旧 API 的delta_seconds()等价。无论哪种写法,核心思想一致:读速度、读时间、写变换,三者结合实现帧率无关移动。
用 BounceSystem 实现碰撞反弹
球能动之后,还需要检测碰撞并反弹。新建systems/bounce.rs实现BounceSystem,职责有二:球与上下边界的碰撞、球与两块挡板的碰撞。碰撞发生时,通过翻转Ball组件速度在 x 或 y 轴上的分量实现反弹。
use amethyst::{ core::{Transform}, ecs::{System, World}, }; use crate::pong::{Ball, Paddle, Side, ARENA_HEIGHT}; pub struct BounceSystem; impl System for BounceSystem { type SystemData = ( .write_component::<Ball>() .read_component::<Paddle>(), .read_component::<Transform>(), ); fn run(&mut self, (mut balls, paddles, transforms): Self::SystemData) { // Check whether a ball collided, and bounce off accordingly. // // We also check for the velocity of the ball every time, to prevent multiple collisions // from occurring. for (ball, transform) in (&mut balls, &transforms).join() { let ball_x = transform.translation().x; let ball_y = transform.translation().y; // Bounce at the top or the bottom of the arena. if (ball_y <= ball.radius && ball.velocity[1] < 0.0) || (ball_y >= ARENA_HEIGHT - ball.radius && ball.velocity[1] > 0.0) { ball.velocity[1] = -ball.velocity[1]; } // Bounce at the paddles. for (paddle, paddle_transform) in (&paddles, &transforms).join() { let paddle_x = paddle_transform.translation().x - (paddle.width * 0.5); let paddle_y = paddle_transform.translation().y - (paddle.height * 0.5); // To determine whether the ball has collided with a paddle, we create a larger // rectangle around the current one, by subtracting the ball radius from the // lowest coordinates, and adding the ball radius to the highest ones. The ball // is then within the paddle if its center is within the larger wrapper // rectangle. if point_in_rect( ball_x, ball_y, paddle_x - ball.radius, paddle_y - ball.radius, paddle_x + paddle.width + ball.radius, paddle_y + paddle.height + ball.radius, ) { if (paddle.side == Side::Left && ball.velocity[0] < 0.0) || (paddle.side == Side::Right && ball.velocity[0] > 0.0) { ball.velocity[0] = -ball.velocity[0]; } } } } } fn build(self) -> Box<(dyn ParallelRunnable + 'static)> {} } // A point is in a box when its coordinates are smaller or equal than the top // right and larger or equal than the bottom left. fn point_in_rect(x: f32, y: f32, left: f32, bottom: f32, right: f32, top: f32) -> bool { x >= left && x <= right && y >= bottom && y <= top }上下边界反弹
if (ball_y <= ball.radius && ball.velocity[1] < 0.0) || (ball_y >= ARENA_HEIGHT - ball.radius && ball.velocity[1] > 0.0) { ball.velocity[1] = -ball.velocity[1]; }球的 y 坐标小于等于其半径时触底,大于等于ARENA_HEIGHT - ball.radius时触顶,两种情况都翻转velocity[1]。注意这里同时检查速度方向(< 0.0或> 0.0):这可以防止重复碰撞——如果球已经反弹向上,那么即使下一帧坐标仍在边界附近,由于速度方向已正确,也不会再次触发翻转,避免球"卡"在边界上来回抖动。
挡板碰撞:把球当"膨胀的矩形"检测
挡板碰撞检测的思路值得细讲。判断球(圆心(ball_x, ball_y)、半径ball.radius)是否碰到矩形挡板(左下角(paddle_x, paddle_y)、宽paddle.width、高paddle.height),等价于判断圆心是否落在一个"膨胀"过的矩形内:把挡板的四条边分别向外扩ball.radius,得到更大的包围矩形:
paddle_x - ball.radius, // left (左边界外扩) paddle_y - ball.radius, // bottom (下边界外扩) paddle_x + paddle.width + ball.radius, // right (右边界外扩) paddle_y + paddle.height + ball.radius, // top (上边界外扩)然后调用工具函数point_in_rect判断圆心是否落入该膨胀矩形:
fn point_in_rect(x: f32, y: f32, left: f32, bottom: f32, right: f32, top: f32) -> bool { x >= left && x <= right && y >= bottom && y <= top }只要 x 在[left, right]、y 在[bottom, top]闭区间内即为碰撞。这就是将圆-矩形碰撞等价转化为点-矩形包含测试的经典做法,把复杂的几何相交问题化简为几次浮点比较。下图直观展示了这一判定逻辑:
反弹时同样校验速度方向,确保只有球真正朝向挡板飞来时才翻转 x 方向速度:
if (paddle.side == Side::Left && ball.velocity[0] < 0.0) || (paddle.side == Side::Right && ball.velocity[0] > 0.0) { ball.velocity[0] = -ball.velocity[0]; }左挡板只反弹向左飞(velocity[0] < 0.0)的球,右挡板只反弹向右飞(velocity[0] > 0.0)的球,防止球"粘"在挡板上被反复翻转。
仓库最终版 systems/bounce.rs 在逻辑上完全一致(挡板位置改由Paddle组件自身的x、y字段提供,碰撞时打印"Bounce!"便于调试),并使用了world.split_for_query将球查询与挡板查询分区以避免借用冲突——这是同一碰撞逻辑在新 API 下的表达。
注册新系统:systems/mod.rs 与 Dispatcher
更新 systems/mod.rs
教程正文给出的模块组织方式是在systems/mod.rs中同时使用pub use重导出与mod声明:
pub use self::paddle::PaddleSystem; pub use self::move_balls::MoveBallsSystem; pub use self::bounce::BounceSystem; mod move_balls; mod bounce; mod paddle;仓库中的 systems/mod.rs 则采用更直接的pub mod写法:
pub mod bounce; pub mod move_balls; pub mod paddle;无论哪种组织方式,目标都是把各个系统作为模块暴露出去,供main.rs统一组装。
把系统加入 Dispatcher
最后一步是把新系统注册进调度器(Dispatcher),Amethyst 用它管理系统的执行顺序与并行性:
let game_data = DispatcherBuilder::default() // ...other systems... .with(systems::MoveBallsSystem, "ball_system", &[]) .with( systems::BounceSystem, "collision_system", &["paddle_system", "ball_system"], );这里体现了 Amethyst 系统依赖声明机制:
MoveBallsSystem命名为"ball_system",依赖列表为空(&[]),表示它不依赖其他系统;BounceSystem命名为"collision_system",依赖["paddle_system", "ball_system"],表示碰撞检测必须在挡板移动和球移动之后执行——顺序非常重要:先移动,再检测碰撞并反弹,否则会出现"穿透"或"延迟反弹"的错误观感。
仓库最终版 main.rs 通过add_system按序添加了PaddleSystem、BallSystem、BounceSystem,并依次挂载了LoaderBundle(资产加载)、TransformBundle(变换跟踪)、InputBundle(输入)与RenderingBundle(渲染)等 bundle。
运行到这里,球已经会移动并在挡板与上下边界间反弹。但很快会发现一个问题:球从左右两侧飞出屏幕后就再也回不来了,游戏直接结束——而且由于初始速度很快,可能窗口刚弹出球就已经在屏幕外了。甚至需要把BALL_VELOCITY_X调得极小才能观察到这个过程。这显然不是真正的游戏应有的表现,下一节解决它。
延迟生成球:用状态字段与 update 计时
问题与思路
球在游戏启动瞬间就生成并立刻飞离屏幕,这会造成两个问题:
- 体验问题:玩家可能还没来得及看清局面就已经丢分;
- 技术问题:操作系统与渲染器需要时间初始化窗口,球过早生成可能出现在不可见或未就绪的渲染状态下。
教程明确指出:正规游戏通常会有独立的菜单状态来过渡,我们的 Pong 直接进入对局,所以必须自行处理。解决方案是让球延迟生成——这也是一个练习"用游戏状态(State)结构体持有数据"的好机会。
新增 update 方法
SimpleState提供了多个生命周期方法,之前用过on_start(状态启动时调用一次)。现在在on_start正下方新增update方法——它每帧都会执行,返回值SimpleTrans用于表示状态转换:
impl SimpleState for MyState { fn update(&mut self, data: &mut StateData<'_, GameData>) -> SimpleTrans { Trans::None } }这里我们不需要切换状态,因此返回Trans::None。update每帧执行这一特性,正好用来倒计时:每帧从计时器中减去 delta time,归零时再生成球。
给 Pong 状态添加字段
由于update需要访问球精灵句柄,而它目前是on_start内的局部变量,必须提升为状态字段;同时需要一个计时器字段。教程的做法是用两个Option:
#[derive(Default)] pub struct Pong { ball_spawn_timer: Option<f32>, sprite_sheet_handle: Option<Handle<SpriteSheet>>, }设计要点:
ball_spawn_timer: Option<f32>:计时器。Some表示倒计时未结束,每帧减 delta time;减到<= 0.0时生成球并置为None,之后不再生成。用Option表达"是否还需要生成球"这一状态,天然避免重复生成。sprite_sheet_handle: Option<Handle<SpriteSheet>>:精灵句柄。无法在Pong的构造器里创建(需要World与资源),只能在on_start中加载后存入。#[derive(Default)]:自动实现Defaulttrait,让我们能写出Pong::default()得到空状态。
在 main.rs 使用默认状态
Application::new需要传入初始状态,改用Pong::default()即可:
let mut game = Application::new(assets_dir, Pong::default(), game_data)?;仓库 main.rs 正是这样写的。
完成计时与生成逻辑
最后重写on_start与update,完成延迟生成:
impl SimpleState for Pong { fn on_start(&mut self, data: StateData<'_, GameData>) { let world = data.world; // Wait one second before spawning the ball. self.ball_spawn_timer.replace(1.0); // Load the spritesheet necessary to render the graphics. // `spritesheet` is the layout of the sprites on the image; // `texture` is the pixel data. self.sprite_sheet_handle.replace(load_sprite_sheet(world)); initialize_paddles(world, self.sprite_sheet_handle.clone().unwrap()); initialize_camera(world); } fn update(&mut self, data: &mut StateData<'_, GameData>) -> SimpleTrans { if let Some(mut timer) = self.ball_spawn_timer.take() { // If the timer isn't expired yet, subtract the time that passed since the last update. { let time = data.world.fetch::<Time>(); timer -= time.delta_seconds(); } if timer <= 0.0 { // When timer expire, spawn the ball initialize_ball(data.world, self.sprite_sheet_handle.clone().unwrap()); } else { // If timer is not expired yet, put it back onto the state. self.ball_spawn_timer.replace(timer); } } Trans::None } }逐步拆解这段逻辑:
on_start中:self.ball_spawn_timer.replace(1.0)把计时器初始化为 1 秒;加载精灵图并存入状态字段;随后照常生成挡板与相机——注意initialize_ball已从on_start中移除,球的生成完全交给update控制。update中:用self.ball_spawn_timer.take()取出计时器(同时把字段置为None)。如果它是Some:- 先通过
data.world.fetch::<Time>()获取Time资源,减去delta_seconds(); - 若减后
timer <= 0.0,说明延迟时间已到,调用initialize_ball(data.world, self.sprite_sheet_handle.clone().unwrap())生成球; - 否则把剩余时间写回
self.ball_spawn_timer.replace(timer),等待下一帧继续倒计时。
- 先通过
take()+replace()的巧思:take()确保计时器每帧只被处理一次(避免重复扣减);未到期时replace写回,到期后保持None,if let分支不再进入,球只生成一次。
仓库最终版 pong.rs 与此完全对应,只是资源访问方式略有差异(data.resources.get::<Time>().unwrap()与delta_time().as_secs_f32()),延迟同样设为 1.0 秒。
这样,游戏启动后球会延迟约 1 秒才出现,给玩家留出准备时间,也让我们能清楚看到球生成后立刻的运动轨迹。
总结
本章为 Pong 游戏完成了三件核心工作:
- 定义并注册
Ball组件:以纯数据(速度、半径)描述球实体,通过initialize_ball在竞技场中央组装实体,精灵索引1对应精灵图中的第二个精灵; - 两个系统驱动游戏逻辑:
MoveBallsSystem读取Time资源的delta_seconds实现帧率无关移动;BounceSystem通过"矩形膨胀 + 点在矩形内"的测试同时处理上下边界与挡板碰撞,并用速度方向检查避免重复反弹,两系统按依赖关系(碰撞依赖移动)注册进DispatcherBuilder; - 延迟生成球:利用
Pong状态结构体的ball_spawn_timer: Option<f32>字段与每帧执行的update方法,实现启动后延迟 1 秒生成球,解决开局即丢球的问题。
教程正文对应的完整代码即仓库中的 pong_tutorial_04 示例(含 pong.rs、systems/move_balls.rs、systems/bounce.rs、systems/paddle.rs 与 main.rs),对照阅读可以同时看到教程讲述的经典 API 与仓库当前使用的SystemBuilder新 API 两种写法。下一篇教程将在此基础上增加"玩家失分判定"与"计分系统",敬请继续阅读 pong-tutorial-05。
【免费下载链接】amethyst
Data-oriented and>项目地址:https://gitcode.com/gh_mirrors/ame/amethyst
相关推荐
用 Amethyst 编写 Pong 教程第 4 步:让球移动与反弹
用 Amethyst 编写 Pong 教程第 4 步:让球移动与反弹 本篇文章以仓库中 examples/pong_tutorial_04 https://li
Milkdown 文档自动生成系统解析:define.md 递归宏模板如何驱动全量 API 文档渲染
Milkdown 文档自动生成系统解析:define.md 递归宏模板如何驱动全量 API 文档渲染 本篇技术指南围绕 Milkdown 仓库中 docs/te
Amethyst 实体与组件(ECS)完全指南:从 Entity 到 Archetype 与 Tag 的实战解析
Amethyst 实体与组件(ECS)完全指南:从 Entity 到 Archetype 与 Tag 的实战解析 导读 本文围绕 Amethyst 数据驱动游戏