SubStates:挂在别的状态名下
给街机加暂停键。第一反应是扩充 enum——Menu、Playing、Paused 三个变体平起平坐。写两行就会发现不对劲:暂停只是游戏中的一个小动作,可在三平级的世界里,Playing → Paused 和 Playing → Menu 是同一种事——都是“离开 Playing”。上一节挂在角色身上的 DespawnOnExit(GameState::Playing) 不会区分这两者:罗兰按一下暂停,勇者和史莱姆当场被清掉了。你当然可以换成 bool 资源 is_paused 来绕开,但那又回到了本章开篇的老路:没有换幕挂载点,回菜单时还得记着手工把它复位。
“暂停”真正的形状是:只在游戏中才存在的一个小状态机。Bevy 把这种从属关系做成了 SubStates:
#[derive(States, Debug, Clone, PartialEq, Eq, Hash, Default)]
enum GameState {
#[default]
Menu,
Playing,
}
/// 子状态:只在 GameState::Playing 期间存在
#[derive(SubStates, Debug, Clone, PartialEq, Eq, Hash, Default)]
#[source(GameState = GameState::Playing)]
enum IsPaused {
#[default]
Running,
Paused,
}Listing 10-8(其一):用 #[source] 声明从属——IsPaused 只在 Playing 期间存在
和 States 比只有两处不同:derive 换成 SubStates,外加一行 #[source(GameState = GameState::Playing)]——源状态等于 Playing 时我存在,否则我(连同 State<IsPaused> 资源一起)消失。#[default] 现在多了一层含义:每次“出生”都从 Running 起步。
注册用 add_sub_state,使用上和普通状态完全一致——in_state、OnEnter/OnExit、NextState 全套照用:
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_systems(OnEnter(IsPaused::Running), || {
println!(" [OnEnter(IsPaused::Running)] 机内时钟走字")
})
.add_systems(OnEnter(IsPaused::Paused), || {
println!(" [OnEnter(IsPaused::Paused)] “PAUSED”压上画面")
})
.add_systems(OnExit(IsPaused::Paused), || {
println!(" [OnExit(IsPaused::Paused)] “PAUSED”字样消失")
})
.add_systems(
Update,
(
banner,
script,
attract_screen.run_if(in_state(GameState::Menu)),
battle.run_if(in_state(IsPaused::Running)),
paused_screen.run_if(in_state(IsPaused::Paused)),
)
.chain(),
)
.run();
}Listing 10-8(其二):战斗挂的条件从 in_state(Playing) 换成了 in_state(IsPaused::Running)
注意战斗系统的条件——不再是“游戏中”,而是“游戏中且没暂停”,一个条件同时表达了两层:IsPaused::Running 成立的前提是 IsPaused 存在,而它存在就意味着 GameState::Playing。剧本让罗兰把暂停键用了个遍:
/// 剧本:开局打两回合,中途暂停取汽水,带着暂停退菜单,再开新局
fn script(
mut frame: Local<u32>,
paused: Option<Res<State<IsPaused>>>,
mut next_game: ResMut<NextState<GameState>>,
mut next_paused: ResMut<NextState<IsPaused>>,
mut exit: MessageWriter<AppExit>,
) {
*frame += 1;
match *frame {
2 => {
println!(" 罗兰投币。(叮)");
next_game.set(GameState::Playing);
}
4 => {
println!(" 老板:罗兰,你的汽水!——罗兰按下暂停去拿。");
next_paused.set(IsPaused::Paused);
}
6 => {
println!(" 小芙:门口有杂耍!罗兰顾不上恢复,直接退到待机画面。");
next_game.set(GameState::Menu);
}
7 => {
println!(
" (此刻 State<IsPaused> 资源:{})",
if paused.is_some() { "还在" } else { "已经不存在" }
);
}
8 => {
println!(" 罗兰回来了,再投一币。(叮)");
next_game.set(GameState::Playing);
}
10 => {
println!(" 老板:打烊喽。");
exit.write(AppExit::Success);
}
_ => {}
}
}Listing 10-8(其三):暂停取汽水,又带着暂停直接退到菜单
cargo run -p ch10-states --example listing-10-08—— 第 1 帧 ——
屏幕:《勇者斗史莱姆》——投币开始
—— 第 2 帧 ——
罗兰投币。(叮)
屏幕:《勇者斗史莱姆》——投币开始
[OnEnter(IsPaused::Running)] 机内时钟走字
—— 第 3 帧 ——
屏幕:勇者挥剑,史莱姆向后弹开
—— 第 4 帧 ——
老板:罗兰,你的汽水!——罗兰按下暂停去拿。
屏幕:史莱姆鼓起来,撞向勇者
[OnEnter(IsPaused::Paused)] “PAUSED”压上画面
—— 第 5 帧 ——
屏幕:PAUSED(史莱姆保持着挨打的姿势)
—— 第 6 帧 ——
小芙:门口有杂耍!罗兰顾不上恢复,直接退到待机画面。
屏幕:PAUSED(史莱姆保持着挨打的姿势)
[OnExit(IsPaused::Paused)] “PAUSED”字样消失
—— 第 7 帧 ——
(此刻 State<IsPaused> 资源:已经不存在)
屏幕:《勇者斗史莱姆》——投币开始
—— 第 8 帧 ——
罗兰回来了,再投一币。(叮)
屏幕:《勇者斗史莱姆》——投币开始
[OnEnter(IsPaused::Running)] 机内时钟走字
—— 第 9 帧 ——
屏幕:勇者挥剑,史莱姆向后弹开
—— 第 10 帧 ——
老板:打烊喽。
屏幕:史莱姆鼓起来,撞向勇者这趟剧本验证了子状态的三条契约:
- 随父而生:投币进
Playing的同一次转换里,IsPaused出现并走了自己的OnEnter(Running)(第 2、3 帧之间)。进入的顺序是先父后子——父状态的OnEnter先跑,子状态的随后。 - 随父而亡,且死得体面:第 6 帧带着暂停直接退菜单,引擎自动跑了
OnExit(IsPaused::Paused)——“PAUSED”字样是引擎收走的,不是我们写代码收的。退出的顺序反过来,先子后父:子状态先退场,父状态的OnExit殿后。第 7 帧确认State<IsPaused>资源已经不存在。 - 每次出生都是新的:第 8 帧再投币,
IsPaused重置回#[default]的Running——上一局的暂停不会阴魂不散地跟进新一局。对比一下:如果用 bool 资源实现暂停,这里就是最容易忘的那行“重置代码”。
两条边界补齐:
- 子状态在场时可以自由
set(暂停/恢复就是普通的NextState<IsPaused>写入);不在场时set无效——Menu里按暂停键,引擎只当没看见。 - 父状态在源值之内变动时(下一节会出现
Playing { demo: true } → Playing { demo: false }这种换法),子状态保持现值不重置——#[source]的右边其实是个模式,匹配上就算“仍在场”。
子状态解决了“从属”。还有一种相反的需求:不是在状态下面挂新状态,而是从已有状态里提炼视图。最后一种状态类型出场。