bevy_talks 0.5.0

A Bevy plugin to write dialogues for your characters to say and do things, together with player choices.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! Talk Asset structs and types.

use crate::{
    builder::{BuildNodeId, TalkBuilder},
    prelude::{Actor, ActorSlug},
};
use bevy::{prelude::*, reflect::TypePath, utils::HashMap};
use indexmap::IndexMap;

/// A unique identifier for an action in a Talk.
///
/// This type alias is used to define a unique identifier for an action in a Talk. Each action
/// in the Talk is assigned a unique ID, which is used to link the actions together in the
/// Talk graph.
pub(crate) type ActionId = usize;

/// An enumeration of the different kinds of actions that can be performed in a Talk.
#[derive(Debug, Default, Clone, Hash, Eq, PartialEq, serde::Deserialize)]
pub enum NodeKind {
    /// An entry point of the dialogue graph
    Start,
    /// A talk action, where a character speaks dialogue.
    #[default]
    Talk,
    /// A choice action, where the user is presented with a choice.
    Choice,
    /// An enter action, where a character enters a scene.
    Join,
    /// An exit action, where a character exits a scene.
    Leave,
}

/// A struct that represents an action in a Talk.
///
/// This struct is used to define an action in a Talk. It contains the ID of the action, the
/// kind of action, the actors involved in the action, any choices that the user can make during
/// the action, the text of the action, the ID of the next action to perform, whether the action is
/// the start of the Talk, and any sound effect associated with the action.
#[derive(Debug, Default, Clone, Eq, Hash, PartialEq)]
pub(crate) struct Action {
    /// The kind of action.
    pub(crate) kind: NodeKind,
    /// The actors involved in the action.
    pub(crate) actors: Vec<ActorSlug>,
    /// Any choices that the user can make during the action.
    pub(crate) choices: Vec<ChoiceData>,
    /// The text of the action.
    pub(crate) text: String,
    /// The ID of the next action to perform.
    pub(crate) next: Option<ActionId>,
}
/// A struct that represents a choice in a Talk.
///
/// This struct is used to define a choice in a Talk. It contains the text of the choice and
/// the ID of the next action to perform if the choice is selected.
#[derive(Default, Debug, Clone, Eq, Hash, PartialEq)]
pub(crate) struct ChoiceData {
    /// The text of the choice.
    pub(crate) text: String,
    /// The ID of the next action to perform if the choice is selected.
    pub(crate) next: ActionId,
}

/// The asset representation of a Talk. It is assumed to represent a well formed Talk,
/// because the loader should have already validated it while loading.
///
#[derive(Asset, Debug, Default, Clone, TypePath)]
pub struct TalkData {
    /// The list of actions that make up the Talk.
    pub(crate) script: IndexMap<ActionId, Action>,
    /// The list of actors that appear in the Talk.
    pub(crate) actors: Vec<Actor>,
}

impl TalkData {
    /// Creates a new `TalkData` with the given script and actors.
    #[allow(dead_code)]
    pub(crate) fn new(script: IndexMap<ActionId, Action>, actors: Vec<Actor>) -> Self {
        Self { script, actors }
    }

    /// Take a builder and fill it with the talk actions
    pub(crate) fn fill_builder(&self, mut builder: TalkBuilder) -> TalkBuilder {
        builder = builder.add_actors(self.actors.clone());

        if self.script.is_empty() {
            return builder;
        }

        let mut visited = HashMap::with_capacity(self.script.len());
        let start_id = self.script.keys().next().unwrap();
        prepare_builder(*start_id, &self.script, builder, &mut visited)
    }
}

/// Build the builder
fn prepare_builder(
    starting_action_id: usize,
    actions: &IndexMap<ActionId, Action>,
    mut builder: TalkBuilder,
    visited: &mut HashMap<usize, BuildNodeId>,
) -> TalkBuilder {
    // get the first action
    let mut the_action = &actions[&starting_action_id];
    let mut the_id = starting_action_id;

    let mut done = false;
    while !done {
        match the_action.kind {
            NodeKind::Start => (), // nothing to do for this as of now
            NodeKind::Talk => {
                builder = match the_action.actors.len() {
                    0 => builder.say(&the_action.text),
                    1 => builder.actor_say(&the_action.actors[0], &the_action.text),
                    2.. => builder.actors_say(&the_action.actors, &the_action.text),
                }
            }
            NodeKind::Choice => {
                let mut choice_vec = Vec::with_capacity(the_action.choices.len());

                for c in the_action.choices.iter() {
                    let text = c.text.clone();
                    let next = c.next;
                    let mut inner_builder = TalkBuilder::default();

                    // if already visited, just connect to it instead of recursively building
                    if visited.get(&next).is_some() {
                        inner_builder = inner_builder.connect_to(visited[&next].clone());
                    } else {
                        inner_builder = prepare_builder(next, actions, inner_builder, visited);
                    }
                    choice_vec.push((text, inner_builder));
                }

                builder = builder.choose(choice_vec);
                visited.insert(the_id, builder.last_node_id());
                break; // no other nodes to visit from a choice (nexts are not used in this case)
            }
            NodeKind::Join => builder = builder.join(&the_action.actors),
            NodeKind::Leave => builder = builder.leave(&the_action.actors),
        }

        visited.insert(the_id, builder.last_node_id());
        if let Some(next) = the_action.next {
            // just connect if already processed
            if visited.get(&next).is_some() {
                builder = builder.connect_to(visited[&next].clone());
                done = true; // no need to continue
            }
            // move to the next action
            the_action = &actions[&next];
            the_id = next;
        } else {
            done = true; // reached an end node
        }
    }

    builder
}

#[cfg(test)]
mod tests {
    use crate::{
        prelude::*,
        tests::{count, talks_minimal_app},
        FollowedBy,
    };

    use aery::{edges::Root, operations::utils::Relations, tuple_traits::RelationEntries};
    use bevy::{ecs::system::Command, prelude::*, utils::hashbrown::HashMap};
    use indexmap::{indexmap, IndexMap};
    use rstest::rstest;

    fn build(talk_data: TalkData) -> World {
        let mut app = talks_minimal_app();

        BuildTalkCommand::new(
            app.world.spawn_empty().id(),
            talk_data.fill_builder(TalkBuilder::default()),
        )
        .apply(&mut app.world);
        app.world
    }

    #[rstest]
    #[case(1)]
    #[case(2)]
    #[case(10)]
    #[case(200)]
    fn linear_talk_nodes(#[case] nodes: usize) {
        let mut script = IndexMap::with_capacity(nodes);
        let mut map = HashMap::with_capacity(nodes);
        for index in 0..nodes {
            script.insert(
                index,
                Action {
                    text: "Hello".to_string(),
                    next: if nodes > 1 && index < nodes - 1 {
                        Some(index + 1)
                    } else {
                        None
                    },
                    ..default()
                },
            );
            let target = if nodes > 1 && index < nodes - 1 {
                Some((index + 3) as u32)
            } else {
                None
            };
            // + 2 because there is the graph parent entity and the start node in front
            map.insert(index + 2, (target, "Hello"));
        }
        let mut world = build(TalkData::new(script, vec![]));
        assert_eq!(count::<&TextNode>(&mut world), nodes);
        assert_on_text_nodes(world, map);
    }

    #[test]
    fn talk_nodes_with_loop() {
        let script = indexmap! {
            1 => Action { text: "1".to_string(), next: Some(10), ..default() },
            2 => Action { text: "2".to_string(), next: Some(10), ..default() },
            10 => Action { text: "10".to_string(), next: Some(2), ..default() },
        };

        let mut world = build(TalkData::new(script, vec![]));
        assert_eq!(count::<&TextNode>(&mut world), 3);
        let mut map = HashMap::new();
        map.insert(2, (Some(3), "1"));
        map.insert(3, (Some(4), "10"));
        map.insert(4, (Some(3), "2"));
        assert_on_text_nodes(world, map);
    }

    #[test]
    fn choice_pointing_to_talks() {
        let script = indexmap! {
            0 =>
            Action {
                choices: vec![
                    ChoiceData { text: "Choice 1".to_string(), next: 1, },
                    ChoiceData { text: "Choice 2".to_string(), next: 2, },
                ],
                kind: NodeKind::Choice,
                ..default()
            },
            1 => Action { text: "Hello".to_string(), next: Some(2), ..default() },
            2 => Action { text: "Fin".to_string(), ..default() },
        };

        let mut world = build(TalkData::new(script, vec![]));

        assert_eq!(count::<&TextNode>(&mut world), 2);
        assert_eq!(count::<&ChoiceNode>(&mut world), 1);
        assert_eq!(count::<Root<FollowedBy>>(&mut world), 1);
        let mut map: HashMap<usize, (Vec<u32>, Vec<&str>)> = HashMap::new();
        map.insert(2, (vec![3, 4], vec!["Choice 1", "Choice 2"]));
        assert_on_choice_nodes(&mut world, map);
    }

    #[test]
    fn connect_back_from_branch_book_example() {
        // From the Branching and Manual Connections builder section
        let script = indexmap! {
            0 => Action { text: "First Text".to_string(), next: Some(1), ..default() },
            1 => Action { text: "Second Text".to_string(), next: Some(2), ..default() },
            2 =>
            Action {
                choices: vec![
                    ChoiceData { text: "Choice 1".to_string(), next: 3, },
                    ChoiceData { text: "Choice 2".to_string(), next: 4, },
                ],
                kind: NodeKind::Choice,
                ..default()
            },
            3 => Action { text: "Third Text (End)".to_string(), ..default() },
            4 => Action { text: "Fourth Text".to_string(), next: Some(0), ..default() },
        };
        let mut world = build(TalkData::new(script, vec![]));

        assert_eq!(count::<&TextNode>(&mut world), 4);
        assert_eq!(count::<&ChoiceNode>(&mut world), 1);
        assert_eq!(count::<Root<FollowedBy>>(&mut world), 1);

        let mut choice_map = HashMap::new();
        choice_map.insert(4, (vec![5, 6], vec!["Choice 1", "Choice 2"]));
        assert_on_choice_nodes(&mut world, choice_map);

        let mut talk_map = HashMap::new();
        talk_map.insert(2, (Some(3), "First Text"));
        talk_map.insert(3, (Some(4), "Second Text"));
        talk_map.insert(5, (None, "Third Text (End)"));
        talk_map.insert(6, (Some(2), "Fourth Text"));
        assert_on_text_nodes(world, talk_map);
    }

    #[test]
    fn connect_forward_from_book_example() {
        // From the Connecting To The Same Node builder section
        let script = indexmap! {
            0 => // entity: 2
            Action {
                choices: vec![
                    ChoiceData { text: "First Choice 1".to_string(), next: 1, },
                    ChoiceData { text: "First Choice 2".to_string(), next: 2, },
                ],
                kind: NodeKind::Choice,
                ..default()
            },
            1 => Action { text: "First Text".to_string(), next: Some(3), ..default() },
            2 => Action { text: "Last Text".to_string(), next: None, ..default() },
            3 =>
            Action {
                choices: vec![
                    ChoiceData { text: "Second Choice 1".to_string(), next: 2, },
                    ChoiceData { text: "Second Choice 2".to_string(), next: 4, },
                ],
                kind: NodeKind::Choice,
                ..default()
            },
            4 => Action { text: "Second Text".to_string(), next: Some(2), ..default() },
        };
        let mut world = build(TalkData::new(script, vec![]));

        assert_eq!(count::<&TextNode>(&mut world), 3);
        assert_eq!(count::<&ChoiceNode>(&mut world), 2);
        assert_eq!(count::<Root<FollowedBy>>(&mut world), 1);

        let mut choice_map = HashMap::new();
        choice_map.insert(2, (vec![3, 5], vec!["First Choice 1", "First Choice 2"]));
        choice_map.insert(4, (vec![5, 6], vec!["Second Choice 1", "Second Choice 2"]));
        assert_on_choice_nodes(&mut world, choice_map);

        let mut talk_map = HashMap::new();
        talk_map.insert(3, (Some(4), "First Text"));
        talk_map.insert(5, (None, "Last Text"));
        talk_map.insert(6, (Some(5), "Second Text"));
        assert_on_text_nodes(world, talk_map);
    }

    #[rstest]
    #[case(1)]
    #[case(2)]
    #[case(10)]
    #[case(200)]
    fn linear_talk_nodes_with_actors(#[case] nodes: usize) {
        let actors = vec![
            Actor::new("actor1", "Actor 1"),
            Actor::new("actor2", "Actor 2"),
            Actor::new("actor3", "Actor 3"),
        ];

        let mut script = IndexMap::with_capacity(nodes);
        let mut map = HashMap::with_capacity(nodes);
        for index in 0..nodes {
            script.insert(
                index,
                Action {
                    text: "Hello".to_string(),
                    next: if nodes > 1 && index < nodes - 1 {
                        Some(index + 1)
                    } else {
                        None
                    },
                    actors: vec![actors[index % 3].slug.clone()],
                    ..default()
                },
            );
            let target = if nodes > 1 && index < nodes - 1 {
                Some((index + 3) as u32)
            } else {
                None
            };
            // + 2 because there is the graph parent entity and the start node in front
            map.insert(index + 2, (target, "Hello"));
        }
        let mut world = build(TalkData::new(script, actors));

        assert_eq!(count::<&TextNode>(&mut world), nodes);
        assert_eq!(count::<&Actor>(&mut world), 3);

        assert_on_text_nodes(world, map);
    }

    /// Asserts that the talk nodes are correct. It wants a map to check the targets of the edges.
    /// The map is a map of entity index to (target entity index, text).
    #[track_caller]
    fn assert_on_text_nodes(mut world: World, map: HashMap<usize, (Option<u32>, &str)>) {
        for (e, t, edges) in world
            .query::<(Entity, &TextNode, Relations<FollowedBy>)>()
            .iter(&world)
        {
            let eid = e.index() as usize;
            let expected_text = map[&eid].1;
            let maybe_target = map[&eid].0;
            let mut expected_count = 0;

            if let Some(expected_target) = maybe_target {
                assert_eq!(
                    edges.targets(FollowedBy).iter().next().unwrap().index(),
                    expected_target
                );
                expected_count = 1;
            }

            assert_eq!(edges.targets(FollowedBy).iter().count(), expected_count);
            assert_eq!(t.0, expected_text);
        }
    }

    /// Asserts that the choice nodes are correct. It wants a map to check the targets of the edges.
    /// The map is a map of entity index to (entity targets, choice texts).
    #[track_caller]
    fn assert_on_choice_nodes(world: &mut World, map: HashMap<usize, (Vec<u32>, Vec<&str>)>) {
        for (e, t, edges) in world
            .query::<(Entity, &ChoiceNode, Relations<FollowedBy>)>()
            .iter(&world)
        {
            let eid = e.index() as usize;
            let expected_texts = map[&eid].1.clone();
            let expected_count = expected_texts.len();
            for target in map[&eid].0.clone() {
                assert!(edges
                    .targets(FollowedBy)
                    .iter()
                    .any(|e| e.index() == target));
            }

            assert_eq!(edges.targets(FollowedBy).iter().count(), expected_count);

            for (i, c) in t.0.iter().enumerate() {
                assert_eq!(c.text, expected_texts[i]);
            }
        }
    }
}