1use bevy::{
7 color::palettes::{basic::WHITE, css::DARK_GREEN},
8 feathers::{
9 controls::{FeathersNumberInput, NumberInputPrecision, NumberInputValue},
10 dark_theme::create_dark_theme,
11 display::caption,
12 theme::UiTheme,
13 FeathersPlugins,
14 },
15 prelude::*,
16 ui_widgets::ValueChange,
17};
18
19use argh::FromArgs;
20
21use crate::number_input_f32::number_input_f32;
22
23#[cfg(not(target_arch = "wasm32"))]
24use {
25 bevy::{asset::io::file::FileAssetReader, tasks::IoTaskPool},
26 ron::ser::PrettyConfig,
27 std::{fs::File, path::Path},
28};
29
30#[path = "../helpers/number_input_f32.rs"]
31mod number_input_f32;
32
33static ANIMATION_GRAPH_PATH: &str = "animation_graphs/Fox.animgraph.ron";
35
36static CLIP_NODE_INDICES: [u32; 3] = [2, 3, 4];
38
39static HELP_TEXT: &str =
41 "Click and drag an animation clip's number input to change its weight. Values must be between 0 and 1.";
42
43static NODE_TYPES: [NodeType; 5] = [
45 NodeType::Clip(ClipNode::new("Idle", 0)),
46 NodeType::Clip(ClipNode::new("Walk", 1)),
47 NodeType::Blend("Root"),
48 NodeType::Blend("Blend\n0.5"),
49 NodeType::Clip(ClipNode::new("Run", 2)),
50];
51
52static NODE_RECTS: [NodeRect; 5] = [
56 NodeRect::new(10.00, 10.00, 250., 48.41),
57 NodeRect::new(10.00, 78.41, 250., 48.41),
58 NodeRect::new(438.44, 78.41, 97.64, 48.41),
59 NodeRect::new(300.4, 112.61, 97.64, 48.41),
60 NodeRect::new(10.00, 146.82, 250., 48.41),
61];
62
63static HORIZONTAL_LINES: [Line; 6] = [
65 Line::new(260., 34.21, 158.24),
66 Line::new(260., 102.61, 20.20),
67 Line::new(260., 171.02, 20.20),
68 Line::new(280.2, 136.82, 20.20),
69 Line::new(398.04, 136.82, 20.20),
70 Line::new(418.04, 102.61, 20.20),
71];
72
73static VERTICAL_LINES: [Line; 2] = [
75 Line::new(280.19, 102.61, 68.40),
76 Line::new(418.24, 34.21, 102.61),
77];
78
79fn main() {
81 #[cfg(not(target_arch = "wasm32"))]
82 let args: Args = argh::from_env();
83 #[cfg(target_arch = "wasm32")]
84 let args = Args::from_args(&[], &[]).unwrap();
85
86 App::new()
87 .add_plugins((
88 DefaultPlugins.set(WindowPlugin {
89 primary_window: Some(Window {
90 title: "Bevy Animation Graph Example".into(),
91 ..default()
92 }),
93 ..default()
94 }),
95 FeathersPlugins,
96 ))
97 .insert_resource(UiTheme(create_dark_theme()))
98 .add_systems(Startup, (setup_assets, setup_scene, setup_ui))
99 .add_systems(Update, init_animations)
100 .add_systems(Update, sync_weights)
101 .add_observer(handle_weight_value_change)
102 .insert_resource(args)
103 .insert_resource(GlobalAmbientLight {
104 color: WHITE.into(),
105 brightness: 100.0,
106 ..default()
107 })
108 .run();
109}
110
111#[derive(FromArgs, Resource)]
113struct Args {
114 #[argh(switch)]
116 no_load: bool,
117 #[argh(switch)]
119 save: bool,
120}
121
122#[derive(Clone, Resource)]
125struct ExampleAnimationGraph(Handle<AnimationGraph>);
126
127#[derive(Component)]
129struct ExampleAnimationWeights {
130 weights: [f32; 3],
132}
133
134#[derive(Component, Default, Clone)]
136struct WeightBackground;
137
138fn setup_assets(
140 mut commands: Commands,
141 mut asset_server: ResMut<AssetServer>,
142 mut animation_graphs: ResMut<Assets<AnimationGraph>>,
143 args: Res<Args>,
144) {
145 if args.no_load || args.save {
147 setup_assets_programmatically(
148 &mut commands,
149 &mut asset_server,
150 &mut animation_graphs,
151 args.save,
152 );
153 } else {
154 setup_assets_via_serialized_animation_graph(&mut commands, &mut asset_server);
155 }
156}
157
158fn setup_ui(mut commands: Commands) {
159 setup_help_text(&mut commands);
160 setup_node_rects(&mut commands);
161 setup_node_lines(&mut commands);
162}
163
164fn setup_assets_programmatically(
168 commands: &mut Commands,
169 asset_server: &mut AssetServer,
170 animation_graphs: &mut Assets<AnimationGraph>,
171 _save: bool,
172) {
173 let mut animation_graph = AnimationGraph::new();
175 let blend_node = animation_graph.add_blend(0.5, animation_graph.root);
176 animation_graph.add_clip(
177 asset_server.load(GltfAssetLabel::Animation(0).from_asset("models/animated/Fox.glb")),
178 1.0,
179 animation_graph.root,
180 );
181 animation_graph.add_clip(
182 asset_server.load(GltfAssetLabel::Animation(1).from_asset("models/animated/Fox.glb")),
183 1.0,
184 blend_node,
185 );
186 animation_graph.add_clip(
187 asset_server.load(GltfAssetLabel::Animation(2).from_asset("models/animated/Fox.glb")),
188 1.0,
189 blend_node,
190 );
191
192 #[cfg(not(target_arch = "wasm32"))]
194 if _save {
195 let animation_graph = animation_graph.clone();
196
197 IoTaskPool::get()
198 .spawn(async move {
199 use std::io::Write;
200
201 let animation_graph: SerializedAnimationGraph = animation_graph
202 .try_into()
203 .expect("The animation graph failed to convert to its serialized form");
204
205 let serialized_graph =
206 ron::ser::to_string_pretty(&animation_graph, PrettyConfig::default())
207 .expect("Failed to serialize the animation graph");
208 let mut animation_graph_writer = File::create(Path::join(
209 &FileAssetReader::get_base_path(),
210 Path::join(Path::new("assets"), Path::new(ANIMATION_GRAPH_PATH)),
211 ))
212 .expect("Failed to open the animation graph asset");
213 animation_graph_writer
214 .write_all(serialized_graph.as_bytes())
215 .expect("Failed to write the animation graph");
216 })
217 .detach();
218 }
219
220 let handle = animation_graphs.add(animation_graph);
222
223 commands.insert_resource(ExampleAnimationGraph(handle));
225}
226
227fn setup_assets_via_serialized_animation_graph(
228 commands: &mut Commands,
229 asset_server: &mut AssetServer,
230) {
231 commands.insert_resource(ExampleAnimationGraph(
232 asset_server.load(ANIMATION_GRAPH_PATH),
233 ));
234}
235
236fn setup_scene(
238 mut commands: Commands,
239 asset_server: Res<AssetServer>,
240 mut meshes: ResMut<Assets<Mesh>>,
241 mut materials: ResMut<Assets<StandardMaterial>>,
242) {
243 commands.spawn((
244 Camera3d::default(),
245 Transform::from_xyz(-10.0, 5.0, 13.0).looking_at(Vec3::new(0., 1., 0.), Vec3::Y),
246 ));
247
248 commands.spawn((
249 PointLight {
250 intensity: 10_000_000.0,
251 shadow_maps_enabled: true,
252 ..default()
253 },
254 Transform::from_xyz(-4.0, 8.0, 13.0),
255 ));
256
257 commands.spawn((
258 WorldAssetRoot(
259 asset_server.load(GltfAssetLabel::Scene(0).from_asset("models/animated/Fox.glb")),
260 ),
261 Transform::from_scale(Vec3::splat(0.07)),
262 ));
263
264 commands.spawn((
267 Mesh3d(meshes.add(Circle::new(7.0))),
268 MeshMaterial3d(materials.add(Color::srgb(0.3, 0.5, 0.3))),
269 Transform::from_rotation(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)),
270 ));
271}
272
273fn setup_help_text(commands: &mut Commands) {
275 commands.spawn((
276 Text::new(HELP_TEXT),
277 Node {
278 position_type: PositionType::Absolute,
279 top: px(12),
280 left: px(12),
281 ..default()
282 },
283 ));
284}
285
286fn setup_node_rects(commands: &mut Commands) {
288 let base_node_scene = |node_rect: &NodeRect| {
289 bsn! {
290 Node {
291 position_type: PositionType::Absolute,
292 bottom: px(node_rect.bottom),
293 left: px(node_rect.left),
294 height: px(node_rect.height),
295 width: px(node_rect.width),
296 align_items: AlignItems::Center,
297 justify_items: JustifyItems::Center,
298 align_content: AlignContent::Center,
299 justify_content: JustifyContent::Center,
300 }
301 BorderColor::all(WHITE)
302 Outline::new(px(1), Val::ZERO, Color::WHITE)
303 }
304 };
305 for (node_rect, node_type) in NODE_RECTS.iter().zip(NODE_TYPES.iter()) {
306 match node_type {
307 NodeType::Clip(clip) => {
308 commands.spawn_scene(bsn! {
309 @base_node_scene(node_rect)
310 Children [
311 ZIndex(1)
312 @number_input_f32(clip.text, Some(clip.clone()),
313 ExampleAnimationWeights::default().weights[clip.index], NumberInputPrecision(2), 0. ..=1.)
314 --
315 WeightBackground
317 clip.clone()
318 Node {
319 position_type: PositionType::Absolute,
320 top: px(0),
321 left: px(0),
322 height: px(node_rect.height),
323 width: px(node_rect.width),
324 }
325 BackgroundColor({DARK_GREEN.with_alpha(0.5)})
326 ]
327 });
328 }
329 NodeType::Blend(text) => {
330 commands.spawn_scene(bsn! {
331 @base_node_scene(node_rect)
332 Children [
333 @caption(*text)
334 ]
335 });
336 }
337 };
338 }
339}
340
341fn setup_node_lines(commands: &mut Commands) {
346 for line in &HORIZONTAL_LINES {
347 commands.spawn((
348 Node {
349 position_type: PositionType::Absolute,
350 bottom: px(line.bottom),
351 left: px(line.left),
352 height: px(0),
353 width: px(line.length),
354 border: UiRect::bottom(px(1)),
355 ..default()
356 },
357 BorderColor::all(WHITE),
358 ));
359 }
360
361 for line in &VERTICAL_LINES {
362 commands.spawn((
363 Node {
364 position_type: PositionType::Absolute,
365 bottom: px(line.bottom),
366 left: px(line.left),
367 height: px(line.length),
368 width: px(0),
369 border: UiRect::left(px(1)),
370 ..default()
371 },
372 BorderColor::all(WHITE),
373 ));
374 }
375}
376
377fn init_animations(
379 mut commands: Commands,
380 mut query: Query<(Entity, &mut AnimationPlayer)>,
381 animation_graph: Res<ExampleAnimationGraph>,
382 mut done: Local<bool>,
383) {
384 if *done {
385 return;
386 }
387
388 for (entity, mut player) in query.iter_mut() {
389 commands.entity(entity).insert((
390 AnimationGraphHandle(animation_graph.0.clone()),
391 ExampleAnimationWeights::default(),
392 ));
393 for &node_index in &CLIP_NODE_INDICES {
394 player.play(node_index.into()).repeat();
395 }
396
397 *done = true;
398 }
399}
400
401fn handle_weight_value_change(
403 value_change: On<ValueChange<f32>>,
404 number_input_q: Query<&ClipNode, With<FeathersNumberInput>>,
405 mut weight_background_q: Query<(&mut Node, &ClipNode), With<WeightBackground>>,
406 mut animation_weights_query: Query<&mut ExampleAnimationWeights>,
407
408 mut commands: Commands,
409) {
410 let Ok(clip_node) = number_input_q.get(value_change.source) else {
411 return;
412 };
413
414 for mut animation_weights in animation_weights_query.iter_mut() {
415 animation_weights.weights[clip_node.index] = value_change.value;
416 }
417
418 commands
419 .entity(value_change.source)
420 .insert(NumberInputValue::F32(value_change.value));
421
422 for (mut node, weight_clip_node) in weight_background_q.iter_mut() {
424 if weight_clip_node.index == clip_node.index {
425 node.width = px(NODE_RECTS[0].width * value_change.value);
427 }
428 }
429}
430
431fn sync_weights(mut query: Query<(&mut AnimationPlayer, &ExampleAnimationWeights)>) {
434 for (mut animation_player, animation_weights) in query.iter_mut() {
435 for (&animation_node_index, &animation_weight) in CLIP_NODE_INDICES
436 .iter()
437 .zip(animation_weights.weights.iter())
438 {
439 if !animation_player.is_playing_animation(animation_node_index.into()) {
441 animation_player.play(animation_node_index.into());
442 }
443
444 if let Some(active_animation) =
446 animation_player.animation_mut(animation_node_index.into())
447 {
448 active_animation.set_weight(animation_weight);
449 }
450 }
451 }
452}
453
454#[derive(Debug)]
456struct NodeRect {
457 left: f32,
460 bottom: f32,
463 width: f32,
465 height: f32,
467}
468
469struct Line {
474 left: f32,
477 bottom: f32,
480 length: f32,
482}
483
484enum NodeType {
486 Clip(ClipNode),
488 Blend(&'static str),
490}
491
492#[derive(Clone, Component, Default)]
494struct ClipNode {
495 text: &'static str,
497 index: usize,
499}
500
501impl Default for ExampleAnimationWeights {
502 fn default() -> Self {
503 Self { weights: [1.0; 3] }
504 }
505}
506
507impl ClipNode {
508 const fn new(text: &'static str, index: usize) -> Self {
510 Self { text, index }
511 }
512}
513
514impl NodeRect {
515 const fn new(left: f32, bottom: f32, width: f32, height: f32) -> NodeRect {
521 NodeRect {
522 left,
523 bottom,
524 width,
525 height,
526 }
527 }
528}
529
530impl Line {
531 const fn new(left: f32, bottom: f32, length: f32) -> Self {
536 Self {
537 left,
538 bottom,
539 length,
540 }
541 }
542}