Skip to content

夜战:伤害飘字

第二幕《夜战》里阿燕有一场单挑水匪的武戏。老雷的要求朴素:“看客得看清每一招的分量。”——也就是游戏里人见人爱的伤害飘字:一击命中,数字从落点蹦出来,往上飘,淡出,消失;会心一击换大字、换金色、带个“会心!”的小签。

这是本章的总装战,零件却大半是旧的:挨打的事件走第 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-text
text
老雷:《夜战》排练。小棠的木人桩,秋白的飘字——都上。
场记:连击牌挂好,看招吧。
场记:会心!双倍,记上。
阿燕:喘口气,连击重头数。

动图:阿燕反复抬剑劈向木人桩,白色伤害数字从桩头一次次蹦出上飘淡出,第四剑出现金色大号数字带红色会心小签,左上角连击牌随击数增长,歇手时归零

Figure 16-12:《夜战》练功房——飘字上浮淡出,会心换装,连击牌实时改数

几处接线值得回头看:

  • 打与飘是解耦的drill 节拍机只负责“劈中了,伤害 17,会心”这件事实,commands.trigger(StrikeLanded { .. }) 一喊就收工;飘字怎么铸、连击牌怎么改,全在 Observer on_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 自动补齐 TextFontTextColorTextLayoutTextBoundsAnchorTransform 全家;和 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 的框
  • 改组件就是改字幕Text2dDerefMutString,变更检测自动触发重排——但每改一次重排一块,大段高频文本三思
  • 富文本是一棵树:根 Text2d + 子 TextSpan,排版按块、妆容按段;每个 span 自己配 TextFont(不继承,忘配中文段单独变豆腐);装饰有 UnderlineStrikethrough(±配色组件)、TextBackgroundColor、整块的 Text2dShadow;按序号读写用 Text2dReaderText2dWriter
  • 场景字 Text2d,界面字 Text:同一台排版引擎、同一套样式组件,定位一个靠 Transform+Anchor、一个靠 Node(第 28 章)

练习

  1. 找豆腐:把提词器(Listing 16-8)的台词末尾加上“——玥儿记”。“玥”字不在 GB2312 里,跑起来看看缺字形长什么样;然后重跑 py -3.11 scripts/make_ch16_assets.py——脚本会扫描本章源码的用字,自动把“玥”收进子集。体会一遍“子集漏字—发现—补字”的完整工序。
  2. 省料的飘字:把 main.rs 会心飘字改成 font_size: 30.0Transform::from_translation(spot).with_scale(Vec3::splat(1.5))。画质糊了多少?再想想这么改省下了什么(提示:30 号字形图集本来就有),什么场合值得用画质换图集。
  3. 给 Justify 一个框:16.4 节说单行字的 Justify 不生效。给 Listing 16-7 上排的每块文本加一个 TextBounds::new_horizontal(220.0),再把三种 Justify 各跑一遍——行有了参照的框,单行也开始听话。对照 Figure 16-8 找不同。
  4. 木桩的委屈:给木人桩头顶挂一个九宫格小气泡(scroll-panel.png 裁小一点),里面一行 Text2d 写“今日挨打:0 下”,在 on_strike 里用 Text2dWriter 每击加一——综合字幕框、富文本与 writer 三节的手艺。
  5. 会转的影子Text2dShadowoffset 暗示光从哪边来。写一个系统让字幕框那行字的影子 offset 绕着字转圈(Vec2::from_angle(t).rotate(Vec2::new(4.0, 0.0)) 一类),看“光源”绕场一周的效果——顺带记住影子是独立组件,不归 writer 管。

下一章,玩家终于要摸到手柄了——键盘、鼠标、触摸、Gamepad,输入这件事在 Bevy 里同样是几个资源和事件的组合拳。阿燕的剑,该交到你手上了。