定义状态与切换
一台街机只有两种营业状态:待机画面滚字幕,或者一局游戏正打着。先把这件事告诉引擎。
状态是一个 enum
/// 街机的两个阶段:待机画面,或一局游戏中
#[derive(States, Debug, Clone, PartialEq, Eq, Hash, Default)]
enum GameState {
#[default]
Menu,
Playing,
}Listing 10-1(其一):用 derive 把一个 enum 变成状态机
#[derive(States)] 把普通 enum 升格为状态类型。它对 derive 列表里的同伴有要求:Clone + PartialEq + Eq + Hash + Debug 一个不能少——引擎要比较、要哈希、要在日志里打印它。Default 则回答“开机时处在哪一格”:标了 #[default] 的 Menu 就是起点。
和第 5 章的 Resource 一样,状态描述的是全局唯一的事实——整个 App 此刻要么在菜单、要么在游戏中,不存在“一半实体在菜单”。实际上它就是用 Resource 实现的,马上你会见到。
注册三件套
fn main() {
App::new()
.add_plugins(MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(
Duration::from_millis(100),
)))
.add_plugins(StatesPlugin)
.init_state::<GameState>()
.add_systems(
Update,
(
banner,
script,
attract_screen.run_if(in_state(GameState::Menu)),
battle.run_if(in_state(GameState::Playing)),
)
.chain(),
)
.run();
}Listing 10-1(其二):StatesPlugin + init_state + in_state
三处新面孔,从上往下:
StatesPlugin——状态机的发动机。它把一个名叫StateTransition的调度挂进 Main 调度全家(第 6 章那张表里“排在PreUpdate之后”的那位),切换状态的实际动作全在里面发生。DefaultPlugins自带它;但本书的纯逻辑示例用的是MinimalPlugins,没有它,得手动加一行。它不在 prelude 里,注意文件头上的use bevy::state::app::StatesPlugin;。init_state::<GameState>()——开机上电。它做两件事:把两个资源插进世界——State<GameState>(当前值,只读)和NextState<GameState>(切换申请单,可写);再以Default为起点,触发一次进入Menu的“启动转换”(下一节细说)。in_state(GameState::Menu)——第 6 章run_if预告过的那位。它就是一个普通的运行条件:当前状态等于给定值才放行。待机字幕挂Menu,战斗挂Playing,启停逻辑从此写在调度层,系统函数体里一个if都不用写。
切换:写申请单
剧本系统负责投币:
/// 剧本:第 3 帧罗兰投币,第 6 帧打烊
fn script(
mut frame: Local<u32>,
mut next: ResMut<NextState<GameState>>,
mut exit: MessageWriter<AppExit>,
) {
*frame += 1;
match *frame {
3 => {
println!(" 罗兰:守了一路商队,也轮到我冒险一回。(投入硬币,叮)");
next.set(GameState::Playing);
}
6 => {
println!(" 老板:打烊喽。(拉闸)");
exit.write(AppExit::Success);
}
_ => {}
}
}Listing 10-1(其三):NextState::set——申请切换
ResMut<NextState<GameState>> 就是那张申请单,next.set(GameState::Playing) 把它从默认的“无变动”填成“去 Playing”。注意这是申请而不是立刻执行——具体什么时候生效,输出会告诉我们。运行:
cargo run -p ch10-states --example listing-10-01—— 第 1 帧 ——
屏幕:《勇者斗史莱姆》——投币开始
—— 第 2 帧 ——
屏幕:最高纪录 9999 分,保持者“老蔫儿”
—— 第 3 帧 ——
罗兰:守了一路商队,也轮到我冒险一回。(投入硬币,叮)
屏幕:《勇者斗史莱姆》——投币开始
—— 第 4 帧 ——
屏幕:勇者挥剑!史莱姆 HP 剩 10
—— 第 5 帧 ——
屏幕:勇者挥剑!史莱姆倒下——通关!
—— 第 6 帧 ——
老板:打烊喽。(拉闸)前两帧字幕轮播,第 4、5 帧打史莱姆,状态机如约工作。但第 3 帧值得盯一眼:罗兰投了币,画面却还在滚字幕——battle 没接班,attract_screen 也没下班。硬币明明进去了,怎么没反应?
按下不表,先看一个更响亮的失败。
忘了 StatesPlugin 会怎样
把发动机那行删掉:
fn main() {
App::new()
.add_plugins(MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(
Duration::from_millis(100),
)))
// 忘了 .add_plugins(StatesPlugin)
.init_state::<GameState>()
.run();
}Listing 10-2:行不通——没有 StatesPlugin,init_state 直接 panic
cargo run -p ch10-states --example listing-10-02thread 'main' (26112) panicked at C:\Users\94887\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\bevy_state-0.18.1\src\app.rs:96:67:
The `StateTransition` schedule is missing. Did you forget to add StatesPlugin or DefaultPlugins before calling init_state?
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace报错把话说得很全:init_state 要把状态机装进 StateTransition 调度,而那个调度由 StatesPlugin 创建——插件还没加,调度不存在,当场 panic。顺序也有讲究:add_plugins(StatesPlugin) 必须写在 init_state 之前。用 DefaultPlugins 的程序不会遇到这个问题,但哪天你写无窗口的服务端或测试,撞上这条 panic 就知道是怎么回事了。
切换不在本帧生效
回到第 3 帧的疑案。这次让投币系统自己作证——set 完立刻回头读一眼 State 资源:
/// 投币口:第 2 帧投币,并立刻回头看一眼状态资源
fn coin_slot(
mut frame: Local<u32>,
state: Res<State<GameState>>,
mut next: ResMut<NextState<GameState>>,
mut exit: MessageWriter<AppExit>,
) {
*frame += 1;
match *frame {
2 => {
next.set(GameState::Playing);
println!(
" 罗兰投币。又凑近看了一眼:画面是 {:?}——硬币进去了,怎么没反应?",
state.get()
);
}
4 => {
exit.write(AppExit::Success);
}
_ => {}
}
}Listing 10-3(其一):set 之后立刻读,State 还是旧值
屏幕系统每帧报告自己看到的状态。State<GameState> 用 .get() 取值:
/// 屏幕:每帧报告自己处在哪个状态
fn screen(state: Res<State<GameState>>) {
match state.get() {
GameState::Menu => println!(" 屏幕:待机画面(state = Menu)"),
GameState::Playing => println!(" 屏幕:战斗画面(state = Playing)"),
}
}Listing 10-3(其二):读 State 资源
cargo run -p ch10-states --example listing-10-03—— 第 1 帧 ——
屏幕:待机画面(state = Menu)
—— 第 2 帧 ——
罗兰投币。又凑近看了一眼:画面是 Menu——硬币进去了,怎么没反应?
屏幕:待机画面(state = Menu)
—— 第 3 帧 ——
屏幕:战斗画面(state = Playing)
—— 第 4 帧 ——
屏幕:战斗画面(state = Playing)第 2 帧,set 已经调用,可无论是投币系统自己还是排在它后面的屏幕系统,读到的都是 Menu;第 3 帧屏幕才切到战斗画面。对账:
NextState::set只是填申请单,State资源纹丝不动。真正搬闸刀的是StateTransition调度——它检查申请单,有变动才改写State。StateTransition排在PreUpdate之后、Update之前(第 6 章的地图)。你在Update里set,本帧的StateTransition早就跑完了,申请要等下一帧帧首才被受理。所以投币帧全世界看到的都是Menu,没有“前半帧菜单、后半帧战斗”的撕裂帧。- 这和第 3 章
Commands的延迟是同一族设计:修改先排队,在固定的时机统一落地,换来的是帧内视图的一致。
几条随手要用的细则:
- 想指定起点而不用
Default:insert_state(GameState::Playing)替代init_state,比如做关卡编辑器直接进游戏态调试。 - 一帧内多次
set,最后一次赢——申请单只有一格,后写的覆盖先写的。 - 状态机可以有多台。
GameState管阶段、AudioState管静音,各自init_state、互不相干;本章后半会看到它们之间还能建立从属关系。
“申请下一帧才生效”解释了第 3 帧的惯性。但切换的那个瞬间——字幕收起、锣声敲响的换幕时刻——程序还没有任何代码在管。下一节给换幕时刻装上挂载点。