Skip to content

开机大吉

全章零件总装。开机日的完整流程:嵌入的场记板撑起加载画面,八件家当(四张幕布、三件道具、一本剧本)整单装货,金条爬满、亮足时辰才开机;开机后装台、按剧本连排;秋白的热重载通道全程待命。

rust
//! 《长风渡》开机日——Asset 系统总装:
//! 嵌入的场记板撑起加载画面,整本家当装货看进度,全齐开机,
//! 剧本是自定义资产,编剧现场改词热重载。

use bevy::asset::{embedded_asset, io::Reader, load_embedded_asset};
use bevy::asset::{AssetLoader, LoadContext, UntypedHandle};
use bevy::prelude::*;
use thiserror::Error;

// ====================================================== 剧本资产与装载器

#[derive(Asset, TypePath, Debug)]
struct Script {
    title: String,
    lines: Vec<ScriptLine>,
}

#[derive(Debug)]
struct ScriptLine {
    speaker: String,
    text: String,
}

#[derive(Debug, Error)]
enum ScriptLoaderError {
    #[error("读不了剧本文件:{0}")]
    Io(#[from] std::io::Error),
    #[error("剧本不是 UTF-8 文本:{0}")]
    NotText(#[from] std::string::FromUtf8Error),
    #[error("第 {line} 行不是“角色:台词”的格式:{content:?}")]
    BadLine { line: usize, content: String },
}

#[derive(Default, TypePath)]
struct ScriptLoader;

impl AssetLoader for ScriptLoader {
    type Asset = Script;
    type Settings = ();
    type Error = ScriptLoaderError;

    async fn load(
        &self,
        reader: &mut dyn Reader,
        _settings: &(),
        _load_context: &mut LoadContext<'_>,
    ) -> Result<Script, ScriptLoaderError> {
        let mut bytes = Vec::new();
        reader.read_to_end(&mut bytes).await?;
        let text = String::from_utf8(bytes)?;
        let mut title = String::from("无题");
        let mut lines = Vec::new();
        for (number, raw) in text.lines().enumerate() {
            let raw = raw.trim();
            if raw.is_empty() || raw.starts_with('#') {
                continue;
            }
            let Some((head, tail)) = raw.split_once(':') else {
                return Err(ScriptLoaderError::BadLine {
                    line: number + 1,
                    content: raw.to_string(),
                });
            };
            if head == "幕名" {
                title = tail.to_string();
            } else {
                lines.push(ScriptLine {
                    speaker: head.to_string(),
                    text: tail.to_string(),
                });
            }
        }
        Ok(Script { title, lines })
    }

    fn extensions(&self) -> &[&str] {
        &["script"]
    }
}

// ====================================================== 片场的资产打包成 Plugin

// ANCHOR: plugin
/// 青蝉影视城的资产底座:自定义资产上户口 + 场记板缝进二进制
struct StudioAssetsPlugin;

impl Plugin for StudioAssetsPlugin {
    fn build(&self, app: &mut App) {
        app.init_asset::<Script>()
            .init_asset_loader::<ScriptLoader>();
        // main.rs 在 src/ 下,宏用默认前缀即可;文件在 src/embedded/clapper.png
        embedded_asset!(app, "embedded/clapper.png");
    }
}
// ANCHOR_END: plugin

// ====================================================== 状态与资源

#[derive(States, Debug, Clone, PartialEq, Eq, Hash, Default)]
enum SetupPhase {
    #[default]
    Loading,
    Rolling,
}

/// 整本戏的家当清单(不记类型的提货单)
#[derive(Resource)]
struct PropManifest {
    handles: Vec<UntypedHandle>,
}

#[derive(Resource)]
struct Rehearsal {
    script: Handle<Script>,
    cursor: usize,
    next_at: f32,
}

#[derive(Component)]
struct BarFill;

#[derive(Component)]
struct Clapper;

fn main() {
    App::new()
        .add_plugins((DefaultPlugins, StudioAssetsPlugin))
        .insert_resource(ClearColor(Color::srgb(0.06, 0.06, 0.08)))
        .init_state::<SetupPhase>()
        .add_systems(Startup, open_the_warehouse)
        .add_systems(
            Update,
            (track_progress, wiggle_clapper).run_if(in_state(SetupPhase::Loading)),
        )
        .add_systems(OnEnter(SetupPhase::Rolling), build_the_set)
        .add_systems(
            Update,
            (recite, watch_rewrites).run_if(in_state(SetupPhase::Rolling)),
        )
        .run();
}

// ====================================================== Loading:装货与进度

// ANCHOR: open
/// 开机日清早:开出整本戏的提货单,摆好加载画面
fn open_the_warehouse(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn(Camera2d);

    // 八件家当:四张幕布、三件道具、一本剧本——类型不同,单子混开
    let mut handles: Vec<UntypedHandle> = [
        "backdrops/night-crossing.png",
        "backdrops/bamboo-sea.png",
        "backdrops/old-road.png",
        "backdrops/ferry-dock.png",
        "props/qingshuang-sword.png",
        "props/lantern.png",
        "props/changfeng-banner.png",
    ]
    .iter()
    .map(|path| asset_server.load::<Image>(*path).untyped())
    .collect();

    let script: Handle<Script> = asset_server.load("scripts/opening.script");
    handles.push(script.clone().untyped());
    commands.insert_resource(Rehearsal { script, cursor: 0, next_at: 0.0 });

    println!("老顾:《长风渡》开机日,家当 {} 件,单子全开出去了。", handles.len());
    commands.insert_resource(PropManifest { handles });

    // 加载画面三件套:场记板(嵌入资产,不占清单)、底槽、金条
    commands.spawn((
        Clapper,
        Sprite::from_image(load_embedded_asset!(&*asset_server, "embedded/clapper.png")),
        Transform::from_xyz(0.0, 120.0, 0.0),
        DespawnOnExit(SetupPhase::Loading),
    ));
    commands.spawn((
        Sprite::from_color(Color::srgb(0.16, 0.16, 0.19), Vec2::new(560.0, 26.0)),
        DespawnOnExit(SetupPhase::Loading),
    ));
    commands.spawn((
        BarFill,
        Sprite::from_color(Color::srgb(0.93, 0.74, 0.29), Vec2::new(0.0, 16.0)),
        Transform::from_xyz(-280.0, 0.0, 1.0),
        DespawnOnExit(SetupPhase::Loading),
    ));
}
// ANCHOR_END: open

// ANCHOR: track
/// 清点到货,推进金条;八件全齐,状态切到 Rolling
fn track_progress(
    manifest: Res<PropManifest>,
    asset_server: Res<AssetServer>,
    fill: Single<(&mut Sprite, &mut Transform), With<BarFill>>,
    mut next: ResMut<NextState<SetupPhase>>,
    time: Res<Time>,
    mut last_done: Local<usize>,
) {
    let total = manifest.handles.len();
    let done = manifest
        .handles
        .iter()
        .filter(|handle| asset_server.is_loaded_with_dependencies(handle.id()))
        .count();

    let frac = done as f32 / total as f32;
    let (mut sprite, mut transform) = fill.into_inner();
    sprite.custom_size = Some(Vec2::new(560.0 * frac, 16.0));
    transform.translation.x = -280.0 + 280.0 * frac;

    if done != *last_done {
        println!("老顾:到货 {done}/{total}。");
        *last_done = done;
    }
    // 全齐也不抢拍:加载画面至少亮足 1.6 秒——快机器上几帧装完,
    // 不加这道闸,观众连场记板都没看清就开机了
    if done == total && time.elapsed_secs() > 1.6 {
        println!("老雷:全齐。装台——开机!");
        next.set(SetupPhase::Rolling);
    }
}
// ANCHOR_END: track

/// 等货的工夫,场记板小幅摇晃——画面没死机
fn wiggle_clapper(clapper: Single<&mut Transform, With<Clapper>>, time: Res<Time>) {
    let mut transform = clapper.into_inner();
    transform.rotation = Quat::from_rotation_z((time.elapsed_secs() * 6.0).sin() * 0.08);
}

// ====================================================== Rolling:装台与连排

// ANCHOR: build
/// 装台:夜渡幕布、三件道具、两位演员——全部资产此刻都已在架
fn build_the_set(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn((
        Sprite {
            image: asset_server.load("backdrops/night-crossing.png"),
            custom_size: Some(Vec2::new(1280.0, 720.0)),
            ..default()
        },
        Transform::from_xyz(0.0, 0.0, -10.0),
    ));
    // 灯笼挂高处,长风旗立右侧
    commands.spawn((
        Sprite::from_image(asset_server.load("props/lantern.png")),
        Transform::from_xyz(-420.0, 150.0, 0.0),
    ));
    commands.spawn((
        Sprite::from_image(asset_server.load("props/changfeng-banner.png")),
        Transform::from_xyz(430.0, -100.0, 0.0),
    ));
    // 阿燕(红衣)持剑站渡口,梢公(蓑衣)候在一旁
    commands.spawn((
        Sprite::from_color(Color::srgb(0.82, 0.21, 0.2), Vec2::new(34.0, 52.0)),
        Transform::from_xyz(-60.0, -190.0, 0.0),
    ));
    commands.spawn((
        Sprite::from_image(asset_server.load("props/qingshuang-sword.png")),
        Transform::from_xyz(-10.0, -180.0, 1.0),
    ));
    commands.spawn((
        Sprite::from_color(Color::srgb(0.45, 0.4, 0.3), Vec2::new(34.0, 46.0)),
        Transform::from_xyz(160.0, -200.0, 0.0),
    ));
    println!("场务:台装好了,各就各位。");
}
// ANCHOR_END: build

/// 连排:每两秒过一句词;词念尽,等编剧
fn recite(mut rehearsal: ResMut<Rehearsal>, scripts: Res<Assets<Script>>, time: Res<Time>) {
    let Some(script) = scripts.get(&rehearsal.script) else {
        return;
    };
    if rehearsal.cursor == 0 && rehearsal.next_at == 0.0 {
        println!("老雷:《{}》,{} 句词。对词!", script.title, script.lines.len());
        rehearsal.next_at = time.elapsed_secs() + 1.0;
    }
    if rehearsal.cursor < script.lines.len() && time.elapsed_secs() >= rehearsal.next_at {
        let line = &script.lines[rehearsal.cursor];
        println!("{}:{}", line.speaker, line.text);
        rehearsal.cursor += 1;
        rehearsal.next_at += 2.0;
        if rehearsal.cursor == script.lines.len() {
            println!("老雷:先这样。秋白要改词直接存盘,机器不停。");
        }
    }
}

// ANCHOR: rewrites
/// 热重载待命:剧本文件一变,从头重念;贴图文件一变,画面自己换
fn watch_rewrites(
    mut broadcasts: MessageReader<AssetEvent<Script>>,
    mut rehearsal: ResMut<Rehearsal>,
) {
    for event in broadcasts.read() {
        if let AssetEvent::Modified { id } = event {
            if *id == rehearsal.script.id() {
                println!("场务:秋白改稿送到!从头对词——");
                rehearsal.cursor = 0;
                rehearsal.next_at = 0.0;
            }
        }
    }
}
// ANCHOR_END: rewrites

Listing 14-11:完整示例——《长风渡》开机日(src/main.rs)

console
cargo run -p ch14-assets
text
老顾:《长风渡》开机日,家当 8 件,单子全开出去了。
老顾:到货 4/8。
老顾:到货 5/8。
老顾:到货 8/8。
老雷:全齐。装台——开机!
场务:台装好了,各就各位。
老雷:《渡口夜话》,5 句词。对词!
阿燕:二十年了,这把剑还认得回家的路。
梢公:客官,夜里风大,进舱吧。
阿燕:不了。我在等一个人。
梢公:那位贵客,怕是不会来喽。
阿燕:他会来。风往北吹,他就往南走。
老雷:先这样。秋白要改词直接存盘,机器不停。

加载画面与开机后的台面:

加载画面:深色背景上一块歪着头的像素场记板,下方一根金色进度条

Figure 14-6:开机前——场记板是嵌入资产,进度条是两个色块 Sprite

开机后的渡口夜景:星空满月的大幕布铺满窗口,灯笼悬在左上,长风旗立在右侧山脊,阿燕的红色身影持剑站在水畔,梢公候在一旁

Figure 14-7:开机后——八件家当各就各位,画面里每一笔都来自 assets/ 或二进制内嵌

四处值得回头多看一眼:

  • 资产注册打包成了 PluginStudioAssetsPlugininit_assetinit_asset_loaderembedded_asset! 三件登记收进一个插件——第 2 章“App 的能力来自 Plugin”在资产侧的标准用法,你的游戏里每个功能模块都可以这样自带资产;
  • 最短亮相时间done == total && time.elapsed_secs() > 1.6——快机器上八件货三帧装完,不加这道闸,加载画面就是一次黑屏闪烁。真实游戏的加载条几乎都有这道闸,只是玩家从未察觉;
  • 混类型清单。七张图片单与一张剧本单 .untyped() 之后睡同一个 Vecis_loaded_with_dependencies 对谁都问得出口——进度统计天然不关心货的种类;
  • load_embedded_asset!。main.rs 在 src/ 下,于是这个宏接管了 14.7 节手拼 embedded:// 路径的脏活;场记板从注册到取用,路径字符串一次都没出现。

试一把热重载收尾:游戏开着,改 assets/scripts/opening.script 的任何一句并存盘——场务立刻递上新稿,从头对词。

小结

  • Asset 是住在 World 外面的数据:图片、音频、模型、自定义格式。三大件分工——AssetServer 开单与调度、Assets<T> 货架存放到货的资产、Handle<A> 是轻量提货单;三者各司其职,靠路径与货号对账
  • 加载是异步的load 立刻返回 Handle,读盘解码在后台任务里跑;getNone 不是错误而是常态,资产驱动的系统拿不到货就安静等下一帧
  • Handle 的规矩:同路径同单;clone 只加计数不复制货;最后一张强单销毁,资产自动回收——单子要存在活得够久的地方;AssetId 是不保活的纯编号
  • 三种等货姿势:轮询 load_state(四态:NotLoaded/Loading/Loaded/Failed)、收听 AssetEvent<A> 广播(Added/Modified/Removed/Unused/LoadedWithDependencies,同频道不保证顺序)、AssetLoadFailedEvent 专报坏消息——失败不崩溃,但得有人管
  • 加载进度条 = 清单 + 计数 + StatesUntypedHandle 混装各类资产,is_loaded_with_dependencies 数到货,全齐切状态;记得给 Failed 留后路、给快机器加最短亮相时间
  • 自定义资产两步走#[derive(Asset, TypePath)] 声明类型,impl AssetLoader(async load + extensions)教会解析;init_asset + init_asset_loader 登记后与内置资产平权;Assets::add 则让运行时生成的数据直接上架
  • 热重载:开发期开 file_watcher feature,文件一变自动重载、Modified 广播随后就到;重载失败保留旧值不崩溃。Handle 指向货架格子,货换内容、单子不换——所有持单人自动看到新货
  • 细则:采样等加载设置走 load_with_settings(补丁式)或 .meta 档案(全量、跟文件走),同路径只认首次设置;embedded_asset! 把素材焊进二进制解决“加载画面自己的素材”;发布期可切 AssetMode::Processed 走预加工流水线(第 38 章)

练习

  1. 进度条的失败分支:往 Listing 14-6 的 PROP_LIST 里加一件不存在的道具(如 "props/ruyi-staff.png"),先预测进度条会发生什么,运行验证。然后修复 track_progress:用 load_stateFailed 的货也计为“处理完毕”,并在控制台报出缺货清单——戏照开,缺的货用 14.3 节的灰布顶上。
  2. 格式升级:给 .script 格式加一条新指令 停顿:秒数,表示此处冷场若干秒。改 ScriptLoader 的解析(ScriptLine 可能需要变成枚举)与 recite 的节拍逻辑。改完体会一个问题:剧本文件能热重载,装载器代码为什么必须重新编译?
  3. 全局采样:Listing 14-9 逐件改采样太啰嗦。查 ImagePlugin::default_nearest(),用第 2 章的 .set() 手法把全局默认采样换成 Nearest,验证三把剑全部锐利——像素风游戏的标准开局配置。
  4. 断粮测试:把 assets/ 目录整个改名,分别运行 Listing 14-1 与 Listing 14-10,先预测各自的表现再验证。改回来之后,再用 BEVY_ASSET_ROOT 环境变量把资产根指到改名后的目录,让 Listing 14-1 不改代码恢复工作。

下一章正式进入 2D 渲染的正题:道具进了门,该学怎么摆弄它们了。Sprite 还有一身没亮过的本事——图集与帧动画、九宫格切片、翻转与锚点;顺路把 Bevy 的颜色系统 bevy_color 一并讲透。