Amethyst Pong 教程(四):让球移动与反弹——ECS 组件、System 与延迟生成的完整实现
发布时间:2026/9/27 23:34:26来源:尧图网络
【免费下载链接】amethystData-oriented and>项目地址https://gitcode.com/gh_mirrors/ame/amethyst点击查看免费下载本文是 AmethystRust 数据驱动游戏引擎Pong 教程系列的第四篇基于仓库中 book/src/pong-tutorial/pong-tutorial-04.md 编写。在第三篇我们已经实现了捕捉键盘输入驱动挡板移动的PaddleSystem本篇将在既有 ECS 架构之上为游戏加入一个全新的实体——球Ball先为其定义Ball组件并在世界中心生成实体再编写MoveBallsSystem基于 delta time 的帧率无关移动与BounceSystem球与挡板、上下边界的碰撞反弹最后通过状态SimpleState中的字段与update方法实现球的延迟生成解决游戏刚启动球就立刻飞离屏幕的问题。读完本篇你将掌握如何定义并注册自定义组件、如何用Time资源实现帧率无关的运动、如何用包围盒展开法做轴对齐碰撞检测以及如何用Optionf32计时器实现延迟生成逻辑。完整代码见仓库 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 两个方向的速度反弹逻辑将通过取负号翻转某个方向的分量来实现。与之对比挡板组件Paddlepong.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: HandleSpriteSheet) { // 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_ballfn 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实现MoveBallsSystemuse 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 中给出了教程片段的现代化版本逻辑完全一致只是换用了SystemBuilderimpl System for BallSystem { fn build(self) - Boxdyn 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最后一步是把新系统注册进调度器DispatcherAmethyst 用它管理系统的执行顺序与并行性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: Optionf32, sprite_sheet_handle: OptionHandleSpriteSheet, }设计要点ball_spawn_timer: Optionf32计时器。Some表示倒计时未结束每帧减 delta time减到 0.0时生成球并置为None之后不再生成。用Option表达是否还需要生成球这一状态天然避免重复生成。sprite_sheet_handle: OptionHandleSpriteSheet精灵句柄。无法在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 isnt 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写回到期后保持Noneif 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: Optionf32字段与每帧执行的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。赞分享【免费下载链接】amethystData-oriented and>项目地址https://gitcode.com/gh_mirrors/ame/amethyst点击查看免费下载相关推荐用 Amethyst 编写 Pong 教程第 4 步让球移动与反弹用 Amethyst 编写 Pong 教程第 4 步让球移动与反弹 本篇文章以仓库中 examples/pong_tutorial_04 https://liMilkdown 文档自动生成系统解析define.md 递归宏模板如何驱动全量 API 文档渲染Milkdown 文档自动生成系统解析define.md 递归宏模板如何驱动全量 API 文档渲染 本篇技术指南围绕 Milkdown 仓库中 docs/teAmethyst 实体与组件ECS完全指南从 Entity 到 Archetype 与 Tag 的实战解析Amethyst 实体与组件ECS完全指南从 Entity 到 Archetype 与 Tag 的实战解析 导读 本文围绕 Amethyst 数据驱动游戏上一篇Maestro 录制功能实操从跑通 Flow 到产出演示视频下一篇推荐开源项目Test Device Policy Control - 测试您的Android企业级管理平台创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
网站建设高端定制企业官网