开机大吉
全章零件总装。开机日的完整流程:嵌入的场记板撑起加载画面,八件家当(四张幕布、三件道具、一本剧本)整单装货,金条爬满、亮足时辰才开机;开机后装台、按剧本连排;秋白的热重载通道全程待命。
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: rewritesListing 14-11:完整示例——《长风渡》开机日(src/main.rs)
console
cargo run -p ch14-assetstext
老顾:《长风渡》开机日,家当 8 件,单子全开出去了。
老顾:到货 4/8。
老顾:到货 5/8。
老顾:到货 8/8。
老雷:全齐。装台——开机!
场务:台装好了,各就各位。
老雷:《渡口夜话》,5 句词。对词!
阿燕:二十年了,这把剑还认得回家的路。
梢公:客官,夜里风大,进舱吧。
阿燕:不了。我在等一个人。
梢公:那位贵客,怕是不会来喽。
阿燕:他会来。风往北吹,他就往南走。
老雷:先这样。秋白要改词直接存盘,机器不停。加载画面与开机后的台面:

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

Figure 14-7:开机后——八件家当各就各位,画面里每一笔都来自 assets/ 或二进制内嵌
四处值得回头多看一眼:
- 资产注册打包成了 Plugin。
StudioAssetsPlugin把init_asset、init_asset_loader、embedded_asset!三件登记收进一个插件——第 2 章“App 的能力来自 Plugin”在资产侧的标准用法,你的游戏里每个功能模块都可以这样自带资产; - 最短亮相时间。
done == total && time.elapsed_secs() > 1.6——快机器上八件货三帧装完,不加这道闸,加载画面就是一次黑屏闪烁。真实游戏的加载条几乎都有这道闸,只是玩家从未察觉; - 混类型清单。七张图片单与一张剧本单
.untyped()之后睡同一个Vec,is_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,读盘解码在后台任务里跑;get到None不是错误而是常态,资产驱动的系统拿不到货就安静等下一帧 - Handle 的规矩:同路径同单;clone 只加计数不复制货;最后一张强单销毁,资产自动回收——单子要存在活得够久的地方;
AssetId是不保活的纯编号 - 三种等货姿势:轮询
load_state(四态:NotLoaded/Loading/Loaded/Failed)、收听AssetEvent<A>广播(Added/Modified/Removed/Unused/LoadedWithDependencies,同频道不保证顺序)、AssetLoadFailedEvent专报坏消息——失败不崩溃,但得有人管 - 加载进度条 = 清单 + 计数 + States:
UntypedHandle混装各类资产,is_loaded_with_dependencies数到货,全齐切状态;记得给 Failed 留后路、给快机器加最短亮相时间 - 自定义资产两步走:
#[derive(Asset, TypePath)]声明类型,impl AssetLoader(async load + extensions)教会解析;init_asset+init_asset_loader登记后与内置资产平权;Assets::add则让运行时生成的数据直接上架 - 热重载:开发期开
file_watcherfeature,文件一变自动重载、Modified广播随后就到;重载失败保留旧值不崩溃。Handle 指向货架格子,货换内容、单子不换——所有持单人自动看到新货 - 细则:采样等加载设置走
load_with_settings(补丁式)或.meta档案(全量、跟文件走),同路径只认首次设置;embedded_asset!把素材焊进二进制解决“加载画面自己的素材”;发布期可切AssetMode::Processed走预加工流水线(第 38 章)
练习
- 进度条的失败分支:往 Listing 14-6 的
PROP_LIST里加一件不存在的道具(如"props/ruyi-staff.png"),先预测进度条会发生什么,运行验证。然后修复track_progress:用load_state把Failed的货也计为“处理完毕”,并在控制台报出缺货清单——戏照开,缺的货用 14.3 节的灰布顶上。 - 格式升级:给
.script格式加一条新指令停顿:秒数,表示此处冷场若干秒。改ScriptLoader的解析(ScriptLine可能需要变成枚举)与recite的节拍逻辑。改完体会一个问题:剧本文件能热重载,装载器代码为什么必须重新编译? - 全局采样:Listing 14-9 逐件改采样太啰嗦。查
ImagePlugin::default_nearest(),用第 2 章的.set()手法把全局默认采样换成 Nearest,验证三把剑全部锐利——像素风游戏的标准开局配置。 - 断粮测试:把
assets/目录整个改名,分别运行 Listing 14-1 与 Listing 14-10,先预测各自的表现再验证。改回来之后,再用BEVY_ASSET_ROOT环境变量把资产根指到改名后的目录,让 Listing 14-1 不改代码恢复工作。
下一章正式进入 2D 渲染的正题:道具进了门,该学怎么摆弄它们了。Sprite 还有一身没亮过的本事——图集与帧动画、九宫格切片、翻转与锚点;顺路把 Bevy 的颜色系统 bevy_color 一并讲透。