夜战:伤害飘字
第二幕《夜战》里阿燕有一场单挑水匪的武戏。老雷的要求朴素:“看客得看清每一招的分量。”——也就是游戏里人见人爱的伤害飘字:一击命中,数字从落点蹦出来,往上飘,淡出,消失;会心一击换大字、换金色、带个“会心!”的小签。
这是本章的总装战,零件却大半是旧的:挨打的事件走第 8 章的 Event + Observer,节拍走第 15 章的状态机套路,飘字本身是 Text2d + TextFont + TextColor + Text2dShadow,连击牌是 TextSpan + Text2dWriter,淡出是 Alpha trait(第 15 章调色间)。完整代码:
rust
//! Listing 16-11:《夜战》练功房——伤害飘字
//! 阿燕按节拍劈木人桩:每记一击触发一个 Event(第 8 章),Observer 当场
//! 铸一条会上飘、会淡出的 Text2d;会心一击换大字号、金色与"会心!"小签。
//! 连击牌用 Text2dWriter 改 TextSpan 的数字。
use bevy::prelude::*;
use bevy::sprite::{Anchor, Text2dShadow};
use bevy::text::TextBounds;
/// 桥板地面的高度(演员与木桩都站在它上面)
const FLOOR: f32 = -180.0;
/// 木人桩的站位
const DUMMY_X: f32 = 210.0;
// ANCHOR: events
/// 一记劈中:伤害多少、是不是会心
#[derive(Event)]
struct StrikeLanded {
damage: u32,
crit: bool,
}
/// 歇手:一轮打完,连击归零
#[derive(Event)]
struct BreatherTaken;
// ANCHOR_END: events
// ANCHOR: floating
/// 飘字的飞行参数:往上飘多快、活多久
#[derive(Component)]
struct FloatingText {
rise: f32,
life: Timer,
}
// ANCHOR_END: floating
/// 阿燕的练功节拍:预备 → 抬剑 → 劈落,六剑一歇
#[derive(Component)]
enum DrillPhase {
Ready { until: f32 },
Raise { until: f32 },
Slash { until: f32 },
Rest { until: f32 },
}
/// 连击计数
#[derive(Resource, Default)]
struct Combo {
count: u32,
best: u32,
}
/// 标记:阿燕 / 连击牌的文本根 / 木人桩
#[derive(Component)]
struct Ayan;
#[derive(Component)]
struct ComboBoard;
#[derive(Component)]
struct Dummy {
wobble: Timer,
}
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(ImagePlugin::default_nearest()))
.insert_resource(ClearColor(Color::srgb(0.10, 0.10, 0.14)))
.init_resource::<Combo>()
.add_systems(Startup, setup)
.add_systems(Update, (drill, float_and_fade, wobble_dummy))
.add_observer(on_strike)
.add_observer(on_breather)
.run();
}
fn setup(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
commands.spawn(Camera2d);
let regular = asset_server.load("fonts/book-sans-sc-regular.otf");
let bold = asset_server.load("fonts/book-sans-sc-bold.otf");
// —— 练功房:桥板地面 + 木人桩 ——
commands.spawn((
Sprite {
image: asset_server.load("props/dock-plank.png"),
custom_size: Some(Vec2::new(1400.0, 56.0)),
image_mode: SpriteImageMode::Tiled {
tile_x: true,
tile_y: true,
stretch_value: 4.0,
},
..default()
},
Transform::from_xyz(0.0, FLOOR - 28.0, 0.0),
));
commands.spawn((
Dummy {
wobble: Timer::from_seconds(0.25, TimerMode::Once),
},
Sprite::from_image(asset_server.load("props/wooden-dummy.png")),
Anchor::BOTTOM_CENTER,
Transform::from_xyz(DUMMY_X, FLOOR, 2.0).with_scale(Vec3::splat(4.0)),
));
// —— 阿燕:四帧练剑图集,从预备式起拍 ——
let drill_sheet = asset_server.load("actors/ayan-drill-sheet.png");
let drill_layout = layouts.add(TextureAtlasLayout::from_grid(
UVec2::new(32, 40),
4,
1,
None,
None,
));
commands.spawn((
Ayan,
Sprite::from_atlas_image(
drill_sheet,
TextureAtlas {
layout: drill_layout,
index: 0,
},
),
Anchor::BOTTOM_CENTER,
Transform::from_xyz(120.0, FLOOR, 3.0).with_scale(Vec3::splat(4.0)),
DrillPhase::Ready { until: 1.2 },
));
// ANCHOR: combo_board
// —— 连击牌:根管"连击 ×",两个 TextSpan 各管数字与最高纪录 ——
commands.spawn((
ComboBoard,
Text2d::new("连击 ×"),
TextFont {
font: FontSource::Handle(bold.clone()),
font_size: FontSize::Px(30.0),
..default()
},
TextColor(Color::srgb(0.91, 0.72, 0.29)),
Anchor::CENTER_LEFT,
Transform::from_xyz(-600.0, 300.0, 5.0),
children![
(
TextSpan::new("0"),
TextFont {
font: FontSource::Handle(bold.clone()),
font_size: FontSize::Px(30.0),
..default()
},
TextColor::WHITE,
),
(
TextSpan::new(" 最高 ×0"),
TextFont {
font: FontSource::Handle(regular.clone()),
font_size: FontSize::Px(20.0),
..default()
},
TextColor(Color::srgb(0.55, 0.57, 0.62)),
),
],
));
// ANCHOR_END: combo_board
// —— 台口字幕框:本场的题板 ——
commands.spawn((
Sprite {
image: asset_server.load("props/scroll-panel.png"),
custom_size: Some(Vec2::new(700.0, 84.0)),
image_mode: SpriteImageMode::Sliced(TextureSlicer {
border: BorderRect::all(12.0),
max_corner_scale: 4.0,
..default()
}),
..default()
},
Transform::from_xyz(0.0, -310.0, 4.0),
children![(
Text2d::new("第二幕《夜战》排练——木人桩上见真章"),
TextFont {
font: FontSource::Handle(regular.clone()),
font_size: FontSize::Px(26.0),
..default()
},
TextColor(Color::srgb(0.24, 0.16, 0.08)),
TextBounds::new_horizontal(640.0),
Transform::from_translation(Vec3::Z),
)],
));
println!("老雷:《夜战》排练。小棠的木人桩,秋白的飘字——都上。");
println!("场记:连击牌挂好,看招吧。");
}
// ANCHOR: drill
/// 练功节拍机:预备 → 抬剑 → 劈落(触发 StrikeLanded)→ 六剑一歇
fn drill(
time: Res<Time>,
mut ayan: Single<(&mut DrillPhase, &mut Sprite), With<Ayan>>,
mut swings: Local<u32>,
mut commands: Commands,
) {
let now = time.elapsed_secs();
let (phase, sprite) = &mut *ayan;
let frame = match **phase {
DrillPhase::Ready { until } => {
if now >= until {
**phase = DrillPhase::Raise { until: now + 0.18 };
2
} else if (now * 2.5) as u32 % 2 == 0 {
0
} else {
1
}
}
DrillPhase::Raise { until } => {
if now >= until {
// 劈落即命中:第 n 剑的伤害走一条固定数列,每四剑出一记会心
*swings += 1;
let n = *swings;
let base = 14 + (n * 7) % 9;
let crit = n % 4 == 3;
commands.trigger(StrikeLanded {
damage: if crit { base * 2 } else { base },
crit,
});
**phase = DrillPhase::Slash { until: now + 0.22 };
3
} else {
2
}
}
DrillPhase::Slash { until } => {
if now >= until {
**phase = if *swings % 6 == 0 {
commands.trigger(BreatherTaken);
DrillPhase::Rest { until: now + 2.2 }
} else {
DrillPhase::Ready { until: now + 0.9 }
};
}
3
}
DrillPhase::Rest { until } => {
if now >= until {
**phase = DrillPhase::Ready { until: now + 0.9 };
}
0
}
};
sprite.texture_atlas.as_mut().unwrap().index = frame;
}
// ANCHOR_END: drill
// ANCHOR: on_strike
/// 每一记命中:当场铸一条飘字,更新连击牌,再让木桩晃两下
fn on_strike(
strike: On<StrikeLanded>,
mut commands: Commands,
asset_server: Res<AssetServer>,
mut combo: ResMut<Combo>,
board: Single<Entity, With<ComboBoard>>,
mut writer: Text2dWriter,
mut dummy: Single<&mut Dummy>,
mut said: Local<bool>,
) {
let bold = asset_server.load("fonts/book-sans-sc-bold.otf");
// 连击牌:数字与最高纪录各是一个 TextSpan,按序号改字符串
combo.count += 1;
combo.best = combo.best.max(combo.count);
*writer.text(*board, 1) = combo.count.to_string();
*writer.text(*board, 2) = format!(" 最高 ×{}", combo.best);
// 飘字落点:木桩头顶偏一点,免得叠成一摞
let jitter = (combo.count * 53 % 61) as f32 - 30.0;
let spot = Vec3::new(DUMMY_X + jitter, FLOOR + 210.0, 6.0);
if strike.crit {
// 会心:大一号、金色、带"会心!"小签
commands.spawn((
Text2d::new(strike.damage.to_string()),
TextFont {
font: FontSource::Handle(bold.clone()),
font_size: FontSize::Px(46.0),
..default()
},
TextColor(Color::srgb(0.95, 0.78, 0.22)),
Text2dShadow::default(),
Transform::from_translation(spot),
FloatingText {
rise: 130.0,
life: Timer::from_seconds(1.1, TimerMode::Once),
},
children![(
TextSpan::new(" 会心!"),
TextFont {
font: FontSource::Handle(bold),
font_size: FontSize::Px(28.0),
..default()
},
TextColor(Color::srgb(0.86, 0.32, 0.28)),
)],
));
if !*said {
*said = true;
println!("场记:会心!双倍,记上。");
}
} else {
commands.spawn((
Text2d::new(strike.damage.to_string()),
TextFont {
font: FontSource::Handle(bold),
font_size: FontSize::Px(30.0),
..default()
},
TextColor(Color::srgb(0.92, 0.93, 0.96)),
Text2dShadow::default(),
Transform::from_translation(spot),
FloatingText {
rise: 110.0,
life: Timer::from_seconds(0.9, TimerMode::Once),
},
));
}
dummy.wobble.reset();
}
// ANCHOR_END: on_strike
/// 歇手:连击归零(最高纪录留着)
fn on_breather(
_breather: On<BreatherTaken>,
mut combo: ResMut<Combo>,
board: Single<Entity, With<ComboBoard>>,
mut writer: Text2dWriter,
mut said: Local<bool>,
) {
combo.count = 0;
*writer.text(*board, 1) = "0".to_string();
if !*said {
*said = true;
println!("阿燕:喘口气,连击重头数。");
}
}
// ANCHOR: float_and_fade
/// 飘字的一生:匀速上飘,按寿命比例淡出,到点谢幕。
/// 字色淡出用 Text2dWriter 扫整块文本——根和"会心!"小签一起变透明;
/// 阴影是另一个组件,别忘了一起淡,否则后半程只剩一团黑影
fn float_and_fade(
time: Res<Time>,
mut floats: Query<(Entity, &mut Transform, &mut FloatingText, &mut Text2dShadow)>,
mut writer: Text2dWriter,
mut commands: Commands,
) {
for (entity, mut transform, mut float, mut shadow) in &mut floats {
float.life.tick(time.delta());
transform.translation.y += float.rise * time.delta_secs();
let alpha = 1.0 - float.life.fraction();
writer.for_each_color(entity, |mut color| color.set_alpha(alpha));
shadow.color.set_alpha(alpha * 0.8);
if float.life.is_finished() {
commands.entity(entity).despawn();
}
}
}
// ANCHOR_END: float_and_fade
/// 挨了打的木桩晃两下(纯装饰)
fn wobble_dummy(time: Res<Time>, mut dummy: Single<(&mut Dummy, &mut Transform)>) {
let (dummy, transform) = &mut *dummy;
dummy.wobble.tick(time.delta());
let strength = 1.0 - dummy.wobble.fraction();
transform.rotation =
Quat::from_rotation_z(0.05 * strength * (time.elapsed_secs() * 40.0).sin());
}Listing 16-11:完整示例——《夜战》练功房(src/main.rs)
console
cargo run -p ch16-texttext
老雷:《夜战》排练。小棠的木人桩,秋白的飘字——都上。
场记:连击牌挂好,看招吧。
场记:会心!双倍,记上。
阿燕:喘口气,连击重头数。
Figure 16-12:《夜战》练功房——飘字上浮淡出,会心换装,连击牌实时改数
几处接线值得回头看:
- 打与飘是解耦的。
drill节拍机只负责“劈中了,伤害 17,会心”这件事实,commands.trigger(StrikeLanded { .. })一喊就收工;飘字怎么铸、连击牌怎么改,全在 Observeron_strike里。明天想加打击音效、屏幕震动,再挂两个 Observer 就是——第 8 章承诺过的扩展方式,这里兑现。 - 飘字是“铸完就不管”的实体。出生时配齐
Text2d全家与FloatingText(飞行参数 + 寿命 Timer),此后归float_and_fade统一调度:上飘、淡出、到点despawn。会心飘字的“会心!”小签是个TextSpan子实体——销毁根时子实体随之级联销毁(第 9 章),不用单独收尸。 - 淡出要淡两样东西。字色经
Text2dWriter::for_each_color整块扫一遍——根的数字和小签一起变透明;但Text2dShadow的影子色是独立组件,不在 writer 的管辖里,得单独拿&mut Text2dShadow调。第一版漏了影子,飘字后半生成了一团上浮的黑影——比缺字形还瘆人。 - 连击牌的两个 span 按序号直改:
writer.text(*board, 1)是数字、(*board, 2)是最高纪录——0 号是根“连击 ×”,后面按树序排队。比起爬Children再逐个get_mut,writer 省去了全部中间环节。 - 会心字号是档位不是滑杆:普通 30、会心 46,全场只有这两档(加上字幕 26、连击牌 30/20),字形图集数量恒定——16.3 节的性能账,这里是实践。
试两把:把 on_strike 里会心分支的 font_size: 46.0 连同 TextColor 一起删掉,会心瞬间泯然众“字”——飘字的打击感几乎全部来自字号与颜色的反差,动画反而是配角。再把 float_and_fade 里的 shadow.color.set_alpha(...) 注释掉,亲眼看一回“黑影飘”的事故现场。
小结
Text2d把字当 2D 道具:一个装着String的组件,required components 自动补齐TextFont/TextColor/TextLayout/TextBounds/Anchor/Transform全家;和 Sprite 同一套世界观,能摆能转能钉锚点- 默认字模只有 95 个 ASCII 字形,缺字形渲染成豆腐块(
.notdef),全程零报错零警告;Bevy 不读系统字体——中文必须自带字体资产,load进来挂上TextFont::font。字体只认.ttf/.otf(.ttc不行);发布游戏用 OFL 等开源字体并按惯例子集化,子集漏字会静默空格 font_size是光栅化精度,Transform::scale是放大照片——前者锐后者糊;每种“字体 × 字号”组合各占一张字形图集,字号用档位别用连续值;行高LineHeight默认 1.2 倍字号;FontSmoothing::None出像素阶梯- 排版三件套:
TextBounds划地界(默认无界;只裁完全出界的字形,别当严格裁切框用)、LineBreak定换行规矩(中文用默认WordBoundary即可,长英文词才需要AnyCharacter系)、Justify管行间对齐(单行无界时不生效);Anchor钉整块,给了宽度时对齐的是 bounds 的框 - 改组件就是改字幕:
Text2d可DerefMut成String,变更检测自动触发重排——但每改一次重排一块,大段高频文本三思 - 富文本是一棵树:根
Text2d+ 子TextSpan,排版按块、妆容按段;每个 span 自己配TextFont(不继承,忘配中文段单独变豆腐);装饰有Underline/Strikethrough(±配色组件)、TextBackgroundColor、整块的Text2dShadow;按序号读写用Text2dReader/Text2dWriter - 场景字
Text2d,界面字Text:同一台排版引擎、同一套样式组件,定位一个靠Transform+Anchor、一个靠Node(第 28 章)
练习
- 找豆腐:把提词器(Listing 16-8)的台词末尾加上“——玥儿记”。“玥”字不在 GB2312 里,跑起来看看缺字形长什么样;然后重跑
py -3.11 scripts/make_ch16_assets.py——脚本会扫描本章源码的用字,自动把“玥”收进子集。体会一遍“子集漏字—发现—补字”的完整工序。 - 省料的飘字:把
main.rs会心飘字改成font_size: 30.0配Transform::from_translation(spot).with_scale(Vec3::splat(1.5))。画质糊了多少?再想想这么改省下了什么(提示:30 号字形图集本来就有),什么场合值得用画质换图集。 - 给 Justify 一个框:16.4 节说单行字的
Justify不生效。给 Listing 16-7 上排的每块文本加一个TextBounds::new_horizontal(220.0),再把三种Justify各跑一遍——行有了参照的框,单行也开始听话。对照 Figure 16-8 找不同。 - 木桩的委屈:给木人桩头顶挂一个九宫格小气泡(
scroll-panel.png裁小一点),里面一行Text2d写“今日挨打:0 下”,在on_strike里用Text2dWriter每击加一——综合字幕框、富文本与 writer 三节的手艺。 - 会转的影子:
Text2dShadow的offset暗示光从哪边来。写一个系统让字幕框那行字的影子 offset 绕着字转圈(Vec2::from_angle(t).rotate(Vec2::new(4.0, 0.0))一类),看“光源”绕场一周的效果——顺带记住影子是独立组件,不归 writer 管。
下一章,玩家终于要摸到手柄了——键盘、鼠标、触摸、Gamepad,输入这件事在 Bevy 里同样是几个资源和事件的组合拳。阿燕的剑,该交到你手上了。