
在上一篇入门教程中,我们使用 Rust 和 Bevy 构建了一个具备基础状态的桌面宠物。今天,让我们深入探讨如何打造一个真正“好玩”的交互体验!本文将深入剖析以下核心特性:
- 状态机驱动的动画系统 - 让宠物行为更自然
- 连击检测与隐藏彩蛋 - 增加发现的乐趣
- 反应速度小游戏 - 互动游戏化体验
- 成就解锁系统 - 激励持续互动
- 事件驱动架构 - 解耦的系统设计
状态机驱动的动画系统
为什么需要状态机?
入门篇中的动画切换是“被动”的 —— 仅根据饥饿、快乐、能量值来决定。但实际应用中,用户需要能主动触发动画(如跳舞、睡觉、喂食),这就需要一个有限状态机(FSM)来管理系统行为,避免状态混乱。
状态机设计
// src/animation/types.rs
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum UserAction {
Dance, // 跳舞 - 行1 (帧 4-7)
Sleep, // 睡觉 - 行2 (帧 8-11)
Talk, // 聊天 - 行3 (帧 12-15)
Feed, // 喂食 - 行4 (帧 16-19)
Pet, // 抚摸 - 行5 (帧 20-23)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum ActionState {
#[default]
Idle, // 空闲状态
Acting(UserAction), // 正在执行某个动作
}
#[derive(Resource)]
pub struct ActionStateMachine {
pub state: ActionState,
timer: Timer, // 动作持续时间
}
核心思想:
Idle 是默认状态,宠物处于待机动画。
Acting(action) 表示正在执行某个动作,由定时器控制持续时间。
- 动作结束后自动返回
Idle,整个过程由状态机清晰管理。
状态转移实现
impl ActionStateMachine {
/// 触发一个新动作
pub fn trigger(&mut self, action: UserAction) {
self.state = ActionState::Acting(action);
self.timer.reset(); // 重置计时器
}
/// 检查是否处于空闲状态
pub fn is_idle(&self) -> bool {
matches!(self.state, ActionState::Idle)
}
}
动画帧映射
精灵图采用4列×6行的网格布局,每行对应一个动作。状态机状态的改变直接驱动动画帧的切换。
pub fn update_action_animation(
mut query: Query<&mut AnimationIndices>,
state_machine: Res<ActionStateMachine>,
) {
for mut indices in &mut query {
let (first, last) = match state_machine.state {
ActionState::Idle => (0, 3),
ActionState::Acting(UserAction::Dance) => (4, 7),
ActionState::Acting(UserAction::Sleep) => (8, 11),
ActionState::Acting(UserAction::Talk) => (12, 15),
ActionState::Acting(UserAction::Feed) => (16, 19),
ActionState::Acting(UserAction::Pet) => (20, 23),
};
indices.first = first;
indices.last = last;
}
}
动作效果应用
动作执行期间,除了播放动画,我们还可以实时更新宠物的数值状态,让互动更有反馈感。
pub fn tick_action_state_machine(
time: Res<Time>,
mut state_machine: ResMut<ActionStateMachine>,
mut pet_query: Query<&mut Pet>,
) {
let Some(action) = state_machine.current_action() else {
return;
};
state_machine.timer.tick(time.delta());
for mut pet in &mut pet_query {
match action {
UserAction::Dance => {
pet.happiness = (pet.happiness + 15.0 * time.delta_secs()).min(100.0);
pet.energy = (pet.energy - 5.0 * time.delta_secs()).max(0.0);
}
UserAction::Sleep => {
pet.energy = (pet.energy + 25.0 * time.delta_secs()).min(100.0);
}
UserAction::Feed => {
pet.hunger = (pet.hunger + 30.0 * time.delta_secs()).min(100.0);
}
// ... 其他动作
}
}
// 动作结束,返回空闲
if state_machine.timer.just_finished() {
state_machine.state = ActionState::Idle;
}
}
连击检测与隐藏彩蛋
设计思路
“彩蛋”是增加趣味性和用户探索欲的经典手段。我们设计一个三连击彩蛋:
- 用户在 0.65 秒内连续点击宠物 3 次。
- 触发隐藏的“跳舞”动画。
- 显示特殊提示信息,给予玩家正反馈。
连击追踪器
// src/fun/state.rs
#[derive(Resource, Default)]
pub struct ComboTracker {
streak: u8, // 当前连击数
last_click_secs: Option<f64>, // 上次点击时间
}
impl ComboTracker {
const COMBO_WINDOW_SECS: f64 = 0.65; // 连击时间窗口
pub fn register_click(&mut self, now_secs: f64) -> bool {
// 检查是否在时间窗口内
let within_window = self
.last_click_secs
.is_some_and(|last| now_secs - last <= Self::COMBO_WINDOW_SECS);
if within_window {
self.streak = self.streak.saturating_add(1);
} else {
self.streak = 1; // 重置连击
}
self.last_click_secs = Some(now_secs);
// 达成三连击
if self.streak >= 3 {
self.streak = 0;
self.last_click_secs = None;
return true; // 触发彩蛋!
}
false
}
}
触发彩蛋
在点击处理逻辑中集成连击检测,当检测到彩蛋时,触发特殊动作并发送事件通知其他系统。
// src/ui/menu.rs
pub fn handle_pet_click(
// ...
mut combo_tracker: ResMut<ComboTracker>,
mut fun_events: MessageWriter<FunEvent>,
) {
// ... 点击检测逻辑
// 检测连击
if combo_tracker.register_click(time.elapsed_secs_f64()) {
action_state_machine.trigger(UserAction::Dance); // 触发跳舞
*menu_state = MenuState::Hidden;
fun_events.write(FunEvent::ComboTriggered); // 发送事件
return;
}
// 正常点击切换菜单
// ...
}
反应速度小游戏
游戏设计
在桌面宠物中内置一个简单的反应力测试小游戏,可以大大增强可玩性:
- 按 R 键 开始游戏。
- 等待随机时间(0.8~2.0秒)。
- 出现 “GO!” 提示后,尽快按 空格键。
- 显示反应时间,记录最佳成绩。
状态流转
Idle → Waiting → Go → Cooldown → Idle
↓ ↓
(过早按键) (超时)
↓ ↓
Cooldown → Idle
状态定义
pub(crate) enum ReactionState {
Idle, // 等待开始
Waiting { timer: Timer }, // 等待 GO
Go { elapsed_ms: f32, timeout: Timer }, // 等待玩家反应
Cooldown { timer: Timer }, // 冷却期
}
核心逻辑
// 开始游戏
fn try_start_reaction_game(
keys: &ButtonInput<KeyCode>,
time: &Time,
reaction_game: &mut ReactionGame,
action_state_machine: &ActionStateMachine,
fun_events: &mut MessageWriter<FunEvent>,
) {
if !keys.just_pressed(KeyCode::KeyR)
|| reaction_game.is_active()
|| !action_state_machine.is_idle()
{
return;
}
// 随机延迟(使用正弦函数增加随机性)
let delay = 0.8 + (time.elapsed_secs() * 3.7).sin().abs() * 1.2;
reaction_game.state = ReactionState::Waiting {
timer: Timer::from_seconds(delay, TimerMode::Once),
};
fun_events.write(FunEvent::Notify("Reaction game: wait for GO...".into()));
}
// 处理 GO 状态
fn evaluate_go_state(
elapsed_ms: &mut f32,
timeout: &mut Timer,
keys: &ButtonInput<KeyCode>,
time: &Time,
action_state_machine: &mut ActionStateMachine,
fun_events: &mut MessageWriter<FunEvent>,
) -> Option<ReactionState> {
// 玩家按下空格
if pressed_space(keys) {
let reaction_ms = finalize_reaction_ms(*elapsed_ms);
action_state_machine.trigger(UserAction::Dance); // 胜利舞蹈!
emit_reaction_success(fun_events, reaction_ms);
return Some(cooldown_state(1.5));
}
// 计时
*elapsed_ms += time.delta_secs() * 1000.0;
// 超时
if timeout.tick(time.delta()).just_finished() {
emit_reaction_failure(fun_events, "Missed! Press R to retry");
return Some(cooldown_state(1.2));
}
None
}
成就解锁系统
成就定义
成就系统是维持用户长期兴趣的关键。我们定义几个简单的成就作为示例。
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum Achievement {
FirstFeed, // 首次喂食
PetLover, // 抚摸 10 次
ComboStarter, // 触发连击彩蛋
ReflexAce, // 反应时间 ≤ 350ms
}
进度追踪
使用一个全局资源来追踪玩家的各项进度和已解锁的成就。
#[derive(Resource, Default)]
pub struct FunProgress {
feed_count: u32,
pet_count: u32,
combo_count: u32,
reaction_runs: u32,
best_reaction_ms: Option<u32>,
unlocked: HashSet<Achievement>, // 已解锁的成就
}
解锁检测
在适当的时候(如每次相关事件发生后)检查进度,并尝试解锁新成就。
fn update_achievements(progress: &mut FunProgress, hud: &mut FunHudMessage) {
try_unlock(progress, hud, Achievement::FirstFeed, progress.feed_count >= 1);
try_unlock(progress, hud, Achievement::PetLover, progress.pet_count >= 10);
try_unlock(progress, hud, Achievement::ComboStarter, progress.combo_count >= 1);
try_unlock(progress, hud, Achievement::ReflexAce,
progress.best_reaction_ms.is_some_and(|ms| ms <= 350));
}
fn try_unlock(
progress: &mut FunProgress,
hud: &mut FunHudMessage,
achievement: Achievement,
condition: bool,
) {
// 成就未解锁且满足条件
if condition && progress.unlocked.insert(achievement) {
show_hud(hud, achievement.title(), 2.6);
}
}
事件驱动架构
为什么使用事件?
当系统之间需要通信时,直接函数调用会导致代码高度耦合,难以维护和扩展。事件驱动架构让系统间实现松耦合,例如:
用户点击 → UI系统 → 发送事件 → 游戏系统响应
↓
成就系统检测
事件定义
首先,我们需要定义系统中可能发生的各种事件。
// src/fun/events.rs
#[derive(Message, Debug, Clone)]
pub enum FunEvent {
ActionTriggered(UserAction), // 动作被触发
ComboTriggered, // 连击彩蛋触发
ReactionFinished { // 反应游戏结束
success: bool,
reaction_ms: Option<u32>,
},
Notify(String), // 通知消息
}
事件注册与处理
在 Bevy 的 App 中注册事件类型,并添加处理这些事件的系统。任何系统都可以发送事件,而专门的事件处理系统会集中响应并更新游戏状态。
// main.rs
fn main() {
App::new()
.add_message::<FunEvent>() // 注册事件类型
.add_systems(Update, process_fun_events)
.run();
}
// 处理所有事件
pub fn process_fun_events(
mut events: MessageReader<FunEvent>,
mut progress: ResMut<FunProgress>,
mut hud: ResMut<FunHudMessage>,
) {
for event in events.read() {
handle_fun_event(event, &mut progress, &mut hud);
update_achievements(&mut progress, &mut hud);
}
}
UI 组件设计模式
组件标记
在复杂的 UI 中,使用 Marker Component 模式可以让系统精准地查询到特定的 UI 元素,避免遍历所有实体,这是优化 后端 & 架构 中查询效率的常用技巧。
// src/ui/components.rs
#[derive(Component)]
pub struct ActionMenuContainer; // 菜单容器
#[derive(Component)]
pub struct DanceButton; // 跳舞按钮
#[derive(Component)]
pub struct StatusValueHeart; // 心情数值
#[derive(Component)]
pub struct FunToastText; // 提示文字
UI 搭建
创建 UI 时,为相应的实体插入这些标记组件。
// src/ui/setup.rs
fn spawn_action_menu(commands: &mut Commands, asset_server: &AssetServer) {
commands
.spawn(Node { /* 布局配置 */ })
.insert(BackgroundColor(Color::srgba(0.1, 0.12, 0.2, 0.95)))
.insert(ActionMenuContainer) // 标记组件
.insert(Visibility::Hidden) // 默认隐藏
.with_children(|menu| {
// 添加按钮...
});
}
查询与更新
在后续的系统里,我们可以使用 With<T> 过滤器进行高效且意图明确的查询。
// 查询特定按钮的交互状态
pub fn handle_menu_actions(
dance_query: Query<&Interaction, With<DanceButton>>,
sleep_query: Query<&Interaction, With<SleepButton>>,
// ...
) {
if dance_query.iter().any(|i| *i == Interaction::Pressed) {
// 处理跳舞按钮点击
}
}
// 更新状态显示
pub fn update_status_display(
pet_query: Query<&Pet>,
mut heart_text: Query<&mut Text, With<StatusValueHeart>>,
) {
for pet in &pet_query {
for mut text in &mut heart_text {
**text = format!("{:.0}", pet.happiness);
}
}
}
项目架构总结
一个清晰的项目结构是维护性的基石。本项目的模块划分如下:
src/
├── main.rs # 应用入口,系统集成
├── pet.rs # 宠物状态组件
├── window.rs # 窗口配置
├── animation/ # 动画模块
│ ├── mod.rs
│ ├── types.rs # 状态机、动作类型
│ └── systems.rs # 动画更新、状态转移
├── fun/ # 游戏玩法模块
│ ├── mod.rs
│ ├── events.rs # 事件定义
│ ├── state.rs # 连击、反应游戏、成就
│ └── systems.rs # 游戏逻辑处理
└── ui/ # 界面模块
├── mod.rs
├── components.rs # UI 组件标记
├── resources.rs # 资源定义
├── setup.rs # UI 初始化
├── menu.rs # 菜单交互
└── status.rs # 状态显示
设计亮点:
| 模式 |
应用场景 |
优势 |
| ECS 架构 |
整体设计 |
数据与逻辑分离,易于扩展 |
| 状态机 |
动画控制 |
行为可控,状态明确 |
| 事件驱动 |
系统通信 |
松耦合,易维护 |
| Marker Component |
UI 元素 |
精准查询,代码清晰 |
| Resource |
全局状态 |
单例数据,系统共享 |
性能优化建议
对于一个小型桌面应用,性能通常不是瓶颈,但养成良好的习惯总是有益的。
1. 动画帧缓存
精灵图加载后会被 GPU 缓存,无需额外优化。
2. 减少系统查询
使用 With<T> 或 Without<T> 过滤器减少查询时遍历的组件数量,这是 Rust 游戏开发中常见的优化点。
// 只查询有 DanceButton 组件的实体
Query<&Interaction, With<DanceButton>>
3. 条件系统
使用 run_if 让系统只在条件满足时运行,避免不必要的计算。
.add_systems(
Update,
update_reaction_game.run_if(resource_changed::<ReactionGame>),
)
结语
通过这篇进阶教程,我们不仅为桌面宠物添加了丰富的互动功能,更重要的是实践了一系列在 开源实战 和游戏开发中通用的核心设计模式:
- 如何使用状态机管理复杂的行为逻辑。
- 如何设计彩蛋和成就系统来提升用户粘性。
- 如何实现一个状态清晰的小游戏。
- 如何利用事件驱动架构解耦系统,提升代码可维护性。
- 如何使用标记组件来高效管理 UI。
这些思想和模式完全可以迁移到更复杂的项目中去。希望这个用 Rust 和 Bevy 构建的桌面宠物项目,能为你自己的创意应用或游戏开发提供一些有趣的灵感。欢迎在 云栈社区 分享你的实现和想法!
🐰 愿这只小机器人陪伴你的编程之旅,带来更多创造乐趣!