Timer 与 Stopwatch:冷却、补给与掐表
新戏码《连珠箭》排上日程:空格一按,阿燕甩手一支袖箭。老雷提了两条规矩——出手之后手上要缓 0.8 秒才能再出(玩过游戏的都认识它:冷却);箭匣容量三支,每 2.5 秒自动补一支。
两条规矩都是“跟一段时长较劲”。拿上一节的钟硬写不是不行:出手时抄下 elapsed_secs(),每帧用减法看够不够 0.8——能跑,但状态散落、重置别扭,补箭那条还得自己处理“一帧跨过两个 2.5 秒”的边角。Bevy 把这套活计做成了现成的家什:Timer(定时器——朝着设定时长走、走完报“到点”的值类型)。它有两个关键脾气:
- 它不挂在任何调度上。
Timer只是个普通的值(拿来做组件字段、资源字段都行),自己不会走——每帧得用tick(delta)喂它时间。不喂不走,喂谁的时间就过谁的日子; - 走法分两种,构造时用
TimerMode指定:Once只走一程,到头就停在终点,等你reset()从头再来——冷却、延时一次性事件用它;Repeating走到头自动回绕重走——心跳、补给、刷怪节拍用它。
Figure 18-3:Timer 的一生——Once 停在终点等 reset,Repeating 回绕不停表
箭匣账本
冷却和补箭一只手数得过来,装进一个资源:
/// 箭匣账本:两只定时器、一格库存、一本战绩
#[derive(Resource)]
struct Quiver {
cooldown: Timer, // 出手后的硬直:只走一程(Once)
refill: Timer, // 补箭的节拍:循环走(Repeating)
darts: u32,
thrown: u32,
hits: u32,
}
impl Default for Quiver {
fn default() -> Self {
let mut cooldown = Timer::from_seconds(0.8, TimerMode::Once);
cooldown.finish(); // 开场手是稳的:把冷却直接拨到走完
Self {
cooldown,
refill: Timer::from_seconds(2.5, TimerMode::Repeating),
darts: 3,
thrown: 0,
hits: 0,
}
}
}Listing 18-4(其一):两只定时器——冷却只走一程,补箭循环走(examples/listing-18-04.rs)
留意 Default 里那句 cooldown.finish():新建的 Timer 从零起步,照规矩要等 0.8 秒才能出第一箭——开场就让玩家干等是糟糕的手感,finish() 把表直接拨到终点,开场手就是稳的。
喂表集中在一个系统里,每帧各喂一口 delta()(注意是 Duration 版——tick 吃的是精确时长,不是 f32):
/// 拨表:两只定时器每帧各喂一次 delta;补箭只在“循环走完”的那一帧动手
fn tick_quiver(time: Res<Time>, mut quiver: ResMut<Quiver>) {
quiver.cooldown.tick(time.delta());
if quiver.refill.tick(time.delta()).just_finished() && quiver.darts < 3 {
quiver.darts += 1;
println!("场记:补一支——匣 {}/3。", quiver.darts);
}
}Listing 18-4(其二):拨表——tick 返回 &Self,链一句 just_finished() 正好
补箭那行是 Repeating 的标准用法:just_finished() 只在“这次 tick 恰好走完一轮”时答真——每 2.5 秒恰好一次,循环表自动回绕,不用任何手工归零。出手侧则是 Once 的标准三问:
/// 出手三问:按了吗、缓过劲了吗、匣里还有吗
fn throw(
keyboard: Res<ButtonInput<KeyCode>>,
mut quiver: ResMut<Quiver>,
mut commands: Commands,
) {
if !keyboard.just_pressed(KeyCode::Space) {
return;
}
if !quiver.cooldown.is_finished() {
println!(
"阿燕:手上还没缓过劲——还差 {:.1} 秒。",
quiver.cooldown.remaining_secs()
);
return;
}
if quiver.darts == 0 {
println!("场记:匣空了——等补箭。");
return;
}
quiver.darts -= 1;
quiver.thrown += 1;
quiver.cooldown.reset(); // 冷却从头再走一程
commands.spawn((
Dart,
Sprite::from_color(Color::srgb(0.92, 0.96, 1.0), Vec2::new(30.0, 6.0)),
Transform::from_xyz(AYAN_X + 70.0, FLOOR + 110.0, 4.0),
));
if quiver.thrown == 1 {
println!("阿燕:看箭。");
}
}Listing 18-4(其三):出手三问——按了吗、缓过劲了吗(is_finished)、匣里还有吗
is_finished() 答的是状态(“到点了吗”),适合冷却这种“到点之后一直可用”的判断;拒绝时顺手用 remaining_secs() 报出还差多少,台词都是现成的。出手成功就 reset(),冷却从头再走一程。还剩一个 HUD:把冷却进度画成一根条,fraction()(已走完的比例,0 到 1)直接当横向缩放用:
/// 冷却条直接画 fraction:出手瞬间归零,0.8 秒长回满格
fn hud(
quiver: Res<Quiver>,
mut fill: Single<&mut Transform, With<CooldownFill>>,
mut pips: Query<(&DartPip, &mut Visibility)>,
) {
fill.scale.x = quiver.cooldown.fraction();
for (pip, mut visibility) in &mut pips {
*visibility = if pip.0 < quiver.darts {
Visibility::Visible
} else {
Visibility::Hidden
};
}
}Listing 18-4(其四):冷却条——fraction() 出手瞬间归零,0.8 秒长回满格
cargo run -p ch18-time --example listing-18-04每隔 0.4 秒点一下空格——快于冷却、慢于补箭,两本账一起转起来:
老雷:练《连珠箭》——空格出手。手上要缓劲,匣里要等补。
阿燕:看箭。
阿燕:手上还没缓过劲——还差 0.4 秒。
阿燕:手上还没缓过劲——还差 0.4 秒。
阿燕:手上还没缓过劲——还差 0.4 秒。
场记:补一支——匣 1/3。
场记:补一支——匣 2/3。每两下里总有一下撞在冷却上吃闭门羹;三支射空、住手之后,补箭的循环表每 2.5 秒准时喊一声。点得再急一点,等冷却缓过来而匣子还空着,吃的就是场记那句“匣空了——等补箭”。

Figure 18-4:冷却条直播 fraction()——出手归零,0.8 秒长回满格
三个值得记下的边角:
- 一帧跨多轮怎么办?
Repeating表在一次tick里跨过两轮,just_finished()仍只答一次真——该补两支箭就会漏一支。精确计数问times_finished_this_tick():这次 tick 走完了几轮,答几(Figure 18-3 底部那条警示)。本例 2.5 秒的节拍对 16 毫秒的帧绰绰有余,不用管;写“每 0.05 秒结算一次”的密集节拍时就得换它; - 喂哪只钟的 delta,是个设计决定。本例喂
Res<Time>(戏台钟),于是中场暂停时冷却、补箭全体冻结——对玩法计时这正是想要的:没人愿意见到“暂停回蓝”的赖招。反过来,“跳过开场动画前的强制三秒”这类戏外计时就该喂Time<Real>的 delta; run_if里的定时器。翻开上一节 listing-18-03.rs 的注册行,读数牌挂着run_if(on_real_timer(Duration::from_millis(100)))——每秒只刷十次的节流阀,内脏就是一只循环Timer加just_finished(),bevy::time::common_conditions替你包好了。on_timer是戏台钟版(会被暂停冻住),on_real_timer是怀表版。低频轮询、节流打印,一句条件搞定,不必每次都手搓系统。
Stopwatch:正着数的表
Timer 朝着终点倒着数,还有一种需求是正着数、没有终点:这一招蓄了多久的力、这一局打了多长时间。家什叫 Stopwatch(秒表——只会累计你喂给它的时间的值类型),不在 prelude 里,要 use bevy::time::Stopwatch 显式请进来。
拿它重做第 17 章的桩功:左 Shift 运劲、松手收势——当时“这口劲运了几秒”是场记口头估的,这回上表:
/// 运劲的账:一只秒表
#[derive(Component)]
struct Charge {
watch: Stopwatch,
}
/// 按住才喂时间,松手报数归零——表走不走,全看你喂不喂
fn practice(
time: Res<Time>,
keyboard: Res<ButtonInput<KeyCode>>,
mut ayan: Single<(&mut Charge, &mut Sprite)>,
) {
let (charge, sprite) = &mut *ayan;
if keyboard.pressed(KeyCode::ShiftLeft) {
charge.watch.tick(time.delta());
}
if keyboard.just_released(KeyCode::ShiftLeft) {
println!("场记:收势——这口劲运了 {:.1} 秒。", charge.watch.elapsed_secs());
charge.watch.reset();
}
// 劲越足,戏服越金(两秒到顶)
let heat = (charge.watch.elapsed_secs() / 2.0).min(1.0);
sprite.color = Color::WHITE.mix(&Color::srgb(1.0, 0.84, 0.40), heat);
}Listing 18-5:掐表——按住才喂时间,松手报数归零(examples/listing-18-05.rs)
cargo run -p ch18-time --example listing-18-05老雷:今儿练桩功——左 Shift 运劲,松手收势,场记掐表。
场记:收势——这口劲运了 1.0 秒。
场记:收势——这口劲运了 2.3 秒。精髓在第一行判断:表走不走,全看你喂不喂。按住时每帧喂一口 delta,松手就断粮——“只统计按住的时长”这个需求没有写任何状态机,喂表的条件就是状态本身。Stopwatch 还有 pause()/unpause()(喂了也不走,适合“表在别人手里 tick、你想临时冻结”的场合)和 reset(),方法表和 Timer 一脉相承——事实上 Timer 内部就揣着一只 Stopwatch。
家什备齐,该会鼓师了。他那面鼓从第 6 章起就在敲——FixedUpdate 每秒 64 拍,但拍子和帧到底怎么对上,账本一直没掀开。