街机厅的一天
全章零件组装:三层状态各司其职——GameState 管“谁在玩”(菜单/演示局/真人局),IsPaused 子状态管“暂停”(只在游戏中存在),InAction 计算状态管“画面上是否正在过招”(推导而来,负责搭台拆台)。一天的营业从待机字幕开始:
rust
//! 第 10 章综合示例:灰岩镇街机厅的一天
//! 三层状态各司其职:GameState 管"谁在玩"(菜单/演示局/真人局),
//! IsPaused 子状态管"暂停"(只在游戏中存在),
//! InAction 计算状态管"画面上是否正在过招"(推导而来,负责搭台拆台)
use bevy::app::ScheduleRunnerPlugin;
use bevy::prelude::*;
use bevy::state::app::StatesPlugin;
use std::time::Duration;
// ANCHOR: states
/// 源状态:待机,或一局游戏(演示局 / 真人局)
#[derive(States, Debug, Clone, PartialEq, Eq, Hash, Default)]
enum GameState {
#[default]
Menu,
Playing {
demo: bool,
},
}
/// 子状态:暂停——只要在游戏中就存在,不管是演示还是真人
#[derive(SubStates, Debug, Clone, PartialEq, Eq, Hash, Default)]
#[source(GameState = GameState::Playing { .. })]
enum IsPaused {
#[default]
Running,
Paused,
}
/// 计算状态:画面上正在过招
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct InAction;
impl ComputedStates for InAction {
type SourceStates = GameState;
const ALLOW_SAME_STATE_TRANSITIONS: bool = false;
fn compute(source: GameState) -> Option<Self> {
matches!(source, GameState::Playing { .. }).then_some(InAction)
}
}
// ANCHOR_END: states
/// 标记:一局游戏里的登场角色
#[derive(Component)]
struct Actor;
/// 本局得分
#[derive(Resource, Default)]
struct Score(u32);
// ANCHOR: main
fn main() {
App::new()
.add_plugins(MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(
Duration::from_millis(100),
)))
.add_plugins(StatesPlugin)
.init_state::<GameState>()
.add_sub_state::<IsPaused>()
.add_computed_state::<InAction>()
.init_resource::<Score>()
// 搭台拆台跟着 InAction 走:demo ↔ 真人切换不折腾舞台
.add_systems(OnEnter(InAction), setup_stage)
.add_systems(OnExit(InAction), || {
println!(" [OnExit(InAction)] 风扇停转,机台歇了")
})
.add_systems(OnEnter(IsPaused::Paused), || {
println!(" [OnEnter(Paused)] “PAUSED”压上画面")
})
.add_systems(OnExit(IsPaused::Paused), || {
println!(" [OnExit(Paused)] “PAUSED”字样消失")
})
.add_systems(
Update,
(
banner,
script,
attract_screen.run_if(in_state(GameState::Menu)),
battle.run_if(in_state(IsPaused::Running)),
scoring.run_if(
in_state(GameState::Playing { demo: false })
.and(in_state(IsPaused::Running)),
),
paused_screen.run_if(in_state(IsPaused::Paused)),
)
.chain(),
)
.run();
println!("(run() 返回,街机厅的一天结束了)");
}
// ANCHOR_END: main
fn banner(mut frame: Local<u32>) {
*frame += 1;
println!("—— 第 {} 帧 ——", *frame);
}
// ANCHOR: setup
/// 开台:机台运转,角色登场;离开 InAction 时自动清场
fn setup_stage(mut commands: Commands) {
println!(" [OnEnter(InAction)] 风扇转起来,勇者与史莱姆登场");
commands.spawn((Actor, Name::new("勇者"), DespawnOnExit(InAction)));
commands.spawn((Actor, Name::new("史莱姆"), DespawnOnExit(InAction)));
}
// ANCHOR_END: setup
// ANCHOR: script
/// 剧本:演示局揽客 → 罗兰接管 → 汽水暂停 → 通关散场
fn script(
mut frame: Local<u32>,
mut next_game: ResMut<NextState<GameState>>,
mut next_paused: ResMut<NextState<IsPaused>>,
actors: Query<&Name, With<Actor>>,
mut exit: MessageWriter<AppExit>,
) {
*frame += 1;
match *frame {
2 => {
println!(" 机器等得无聊,自顾自开了一局演示揽客。");
next_game.set(GameState::Playing { demo: true });
}
4 => {
println!(" 罗兰看得手痒:让我来!(叮——切到真人局)");
next_game.set(GameState::Playing { demo: false });
}
6 => {
println!(" 老板:汽水好了!——罗兰按下暂停。");
next_paused.set(IsPaused::Paused);
}
8 => {
println!(" 罗兰一抹嘴:继续!");
next_paused.set(IsPaused::Running);
}
10 => {
println!(" 屏幕:通关!罗兰心满意足,机器退回待机画面。");
next_game.set(GameState::Menu);
}
11 => {
println!(" 老板清点:场上还剩 {} 个角色。打烊喽。", actors.iter().count());
exit.write(AppExit::Success);
}
_ => {}
}
}
// ANCHOR_END: script
/// 待机画面
fn attract_screen() {
println!(" 屏幕:《勇者斗史莱姆》——投币开始");
}
/// 战斗画面:演示局与真人局共用,台词不同
fn battle(state: Res<State<GameState>>) {
match state.get() {
GameState::Playing { demo: true } => {
println!(" 屏幕:(演示局)勇者与史莱姆你来我往");
}
_ => println!(" 屏幕:罗兰操刀,勇者大杀四方"),
}
}
/// 记分牌:真人局且未暂停才计分
fn scoring(mut score: ResMut<Score>) {
score.0 += 100;
println!(" 记分牌:+100,共 {} 分", score.0);
}
/// 暂停画面
fn paused_screen() {
println!(" 屏幕:PAUSED(史莱姆保持着挨打的姿势)");
}Listing 10-11:完整示例——街机厅的一天(src/main.rs)
console
cargo run -p ch10-statestext
—— 第 1 帧 ——
屏幕:《勇者斗史莱姆》——投币开始
—— 第 2 帧 ——
机器等得无聊,自顾自开了一局演示揽客。
屏幕:《勇者斗史莱姆》——投币开始
[OnEnter(InAction)] 风扇转起来,勇者与史莱姆登场
—— 第 3 帧 ——
屏幕:(演示局)勇者与史莱姆你来我往
—— 第 4 帧 ——
罗兰看得手痒:让我来!(叮——切到真人局)
屏幕:(演示局)勇者与史莱姆你来我往
—— 第 5 帧 ——
屏幕:罗兰操刀,勇者大杀四方
记分牌:+100,共 100 分
—— 第 6 帧 ——
老板:汽水好了!——罗兰按下暂停。
屏幕:罗兰操刀,勇者大杀四方
记分牌:+100,共 200 分
[OnEnter(Paused)] “PAUSED”压上画面
—— 第 7 帧 ——
屏幕:PAUSED(史莱姆保持着挨打的姿势)
—— 第 8 帧 ——
罗兰一抹嘴:继续!
屏幕:PAUSED(史莱姆保持着挨打的姿势)
[OnExit(Paused)] “PAUSED”字样消失
—— 第 9 帧 ——
屏幕:罗兰操刀,勇者大杀四方
记分牌:+100,共 300 分
—— 第 10 帧 ——
屏幕:通关!罗兰心满意足,机器退回待机画面。
屏幕:罗兰操刀,勇者大杀四方
记分牌:+100,共 400 分
[OnExit(InAction)] 风扇停转,机台歇了
—— 第 11 帧 ——
老板清点:场上还剩 0 个角色。打烊喽。
屏幕:《勇者斗史莱姆》——投币开始
(run() 返回,街机厅的一天结束了)三处值得回头多看一眼:
- 舞台跟着推导态走,而不是跟着源状态。角色在
OnEnter(InAction)登场、挂DespawnOnExit(InAction)退场。于是第 4 帧演示局切真人局——源状态换了值——风扇没重启、角色没重生(ALLOW_SAME_STATE_TRANSITIONS = false把同值推导挡掉了);直到第 10 帧真正回菜单,清场才发生,第 11 帧老板数到 0。要是把这两处都挂在GameState::Playing { demo: true }上,罗兰接管的瞬间就会看到角色被清掉再生成一遍。“哪一层状态变了要折腾,哪一层不用”,正是分层建模的全部意义。 - 计分牌的条件是两层状态的与:
in_state(Playing { demo: false }).and(in_state(IsPaused::Running))——真人局且没暂停。第 3 帧(演示)和第 7 帧(暂停)它都沉默,两种沉默来自两个不同的状态机,但写在同一行run_if里。第 6 章的条件组合在状态机上原样可用。 - 暂停往返没有惊动任何别人。第 6~8 帧
IsPaused来回切,GameState与InAction全程不动——没有OnExit(InAction)、没有清场、记分不清零。子状态把“小动作”锁在了自己那一层。
这台状态机还会再开机
第 20 章的 Breakout 用的就是本章这套骨架:Menu / Playing / GameOver 三态加暂停子状态,菜单 UI 与局内实体各挂各的 DespawnOnExit。届时屏幕上真的会有画面,而状态机的部分你已经全部见过。
小结
- 状态描述 App 的全局阶段:
#[derive(States)]的 enum,经init_state注册后以两个资源存在——State<S>读当前值,NextState<S>写切换申请。StatesPlugin是前提(DefaultPlugins自带,MinimalPlugins没有) - 切换是延迟的:
set只填申请单,下一帧帧首的StateTransition调度(PreUpdate之后)统一受理——同一帧内全世界看到一致的状态 - 逐帧启停用
run_if(in_state(s));换幕瞬间用三个转换调度:OnExit(旧)→OnTransition→OnEnter(新),每次转换各跑一遍;init_state还会在PreStartup之前触发一次进入默认态的启动转换 - 同值转换:
set同值照样重跑OnExit/OnEnter,set_if_neq防抖(写(*next).set_if_neq,绕开ResMut的同名方法) - 状态作用域实体:spawn 时挂
DespawnOnExit(s)/DespawnOnEnter(s),转换期间自动 despawn(含子树);与“OnEnter搭台”构成标准搭配;同值转换不清场 - SubStates 随父而生灭:
#[source(父 = 模式)],匹配则在场(每次出生重置为#[default]),不匹配则连资源一起消失;在场时可自由set - ComputedStates 由
compute单向推导:None即不存在;没有NextState(不实现FreelyMutableState,编译期拦截);ALLOW_SAME_STATE_TRANSITIONS = false让“结果没变”不折腾;源可以是元组、可以级联 - 转换同时广播
StateTransitionEvent<S>消息,调试工具log_transitions基于它实现
练习
- 修好重开键:给 Listing 10-5 的街机加一个真正的“重开本关”——要求重开时连击清零、但角色不闪烁重生。提示:单靠同值转换做不到干净的重开(10-3 节末尾说过它不清场);试试给
Playing带上一个局数字段Playing { round: u32 },重开就是round + 1。 - 演示局也该歇手:在 Listing 10-11 里,演示局打到一半罗兰还没来,老板想直接按暂停去吃饭。预测:暂停对演示局有效吗?
IsPaused的#[source]模式匹配的是哪些值?改剧本验证你的预测。 - 第四种沉默:Listing 10-11 的记分牌在演示局和暂停时都沉默。再给它加一种沉默——通关后的
Menu里分数应该清零重来吗?分别用“OnExit(InAction)里清零”和“把Score资源换成挂DespawnOnExit(InAction)的实体组件”两种方案实现,体会资源与状态作用域实体的取舍。 - 亲眼看一次默认值的代价:删掉 Listing 10-9 里的
ALLOW_SAME_STATE_TRANSITIONS那行(回到默认true),预测第 4 帧会多出哪两行输出,运行对答案。
下一章收起剧场,掀开引擎盖:Query 和 Commands 之下还有一层更直接的世界访问——World、EntityRef/EntityMut、独占系统。读懂它们,官方源码和高级示例就不再是天书。