Skip to main content

concinnity_asset/
story.rs

1// Branching-story graph schema.
2
3use crate::{
4    AssetId, AudioClipHandle, TextureHandle, de_audio_clip_handle_vec, de_opt_asset_ref,
5    de_opt_audio_clip_handle, de_texture_handle,
6};
7use alloc::string::String;
8use alloc::vec::Vec;
9
10/// A compiled branching story graph, played at runtime by the story system.
11///
12/// A `Story` is normally produced by a [StoryImport](#storyimport) expansion
13/// at build time rather than written by hand: the Markdown source compiles
14/// into this graph plus the stage scaffolding (a single dialogue
15/// [Screen](#screen) whose labels and sprites the story system mutates page by
16/// page). All references are pre-resolved: dialog text is pre-wrapped,
17/// speakers carry their display name and color, stage images carry their
18/// on-canvas rectangle, and jump / choice targets are node indices into
19/// `nodes`.
20///
21/// The story system reads the graph and drives the stage screen named
22/// `<name>_stage`: it fills the dialogue and name-plate labels (revealing
23/// text at `text_speed`), swaps the backdrop and portrait sprite textures,
24/// shows the choice menu when a node ends in one, and plays page audio.
25/// Clicking the stage (or pressing Space) advances; `story:start` restarts
26/// from the first node.
27#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
28#[serde(default)]
29pub struct Story {
30    /// Asset identity; injected via `inject_name`. Not part of `args`.
31    #[serde(skip)]
32    pub asset_id: AssetId,
33    /// The story title, as shown on the generated title screen.
34    pub title: String,
35    /// The node graph in document order. Play starts at the first node; a
36    /// node whose last page has no jump and no choices falls through to the
37    /// next node, and the last node ends the story.
38    pub nodes: Vec<StoryNode>,
39    /// Dialogue reveal speed in characters per second. `0` shows each page
40    /// instantly.
41    pub text_speed: f32,
42    /// The generated stage assets the story system drives. All references
43    /// are resolved to ids at build time, like every other cross-reference.
44    pub scaffold: StoryScaffold,
45    /// Stable key naming this story's save file (position + flags,
46    /// auto-saved page by page under the project data directory). Empty
47    /// disables saving.
48    pub save_key: String,
49}
50
51/// The stage scaffolding a [Story](#story)'s build expansion generated: the
52/// [Screen](#screen)s, [Sprite](#sprite)s, and [TextLabel](#textlabel)s the
53/// story system mutates page by page.
54#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
55#[serde(default)]
56pub struct StoryScaffold {
57    /// The stage [Screen](#screen) the story plays inside.
58    #[serde(deserialize_with = "de_opt_asset_ref")]
59    pub screen: Option<AssetId>,
60    /// The [Screen](#screen) shown when the story ends.
61    #[serde(deserialize_with = "de_opt_asset_ref")]
62    pub ending: Option<AssetId>,
63    /// Backdrop [Sprite](#sprite).
64    #[serde(deserialize_with = "de_opt_asset_ref")]
65    pub bg: Option<AssetId>,
66    /// Stage-left portrait [Sprite](#sprite).
67    #[serde(deserialize_with = "de_opt_asset_ref")]
68    pub left: Option<AssetId>,
69    /// Stage-center portrait [Sprite](#sprite).
70    #[serde(deserialize_with = "de_opt_asset_ref")]
71    pub center: Option<AssetId>,
72    /// Stage-right portrait [Sprite](#sprite).
73    #[serde(deserialize_with = "de_opt_asset_ref")]
74    pub right: Option<AssetId>,
75    /// Dialog box backdrop [Sprite](#sprite).
76    #[serde(deserialize_with = "de_opt_asset_ref")]
77    pub dialog_box: Option<AssetId>,
78    /// Speaker name-plate [TextLabel](#textlabel).
79    #[serde(deserialize_with = "de_opt_asset_ref")]
80    pub name_label: Option<AssetId>,
81    /// Dialog text [TextLabel](#textlabel).
82    #[serde(deserialize_with = "de_opt_asset_ref")]
83    pub text_label: Option<AssetId>,
84    /// Choice button box [Sprite](#sprite)s, one per option slot.
85    pub option_boxes: Vec<AssetId>,
86    /// Choice button [TextLabel](#textlabel)s, one per option slot.
87    pub options: Vec<AssetId>,
88    /// The title screen's Start [TextLabel](#textlabel). The story lays the
89    /// title menu out at runtime, keeping only the buttons that apply
90    /// contiguous (Continue and Load appear only when a save exists), so these
91    /// labels are moved and cleared per the save state on disk.
92    #[serde(deserialize_with = "de_opt_asset_ref")]
93    pub start_label: Option<AssetId>,
94    /// The title screen's Quit [TextLabel](#textlabel).
95    #[serde(deserialize_with = "de_opt_asset_ref")]
96    pub quit_label: Option<AssetId>,
97    /// The title screen's Continue [TextLabel](#textlabel), hidden while no
98    /// save exists.
99    #[serde(deserialize_with = "de_opt_asset_ref")]
100    pub continue_label: Option<AssetId>,
101    /// The title screen [Screen](#screen), returned to when the load overlay is
102    /// dismissed before play started.
103    #[serde(deserialize_with = "de_opt_asset_ref")]
104    pub title: Option<AssetId>,
105    /// The title screen's Load [TextLabel](#textlabel), hidden while no
106    /// slot save exists.
107    #[serde(deserialize_with = "de_opt_asset_ref")]
108    pub load_label: Option<AssetId>,
109    /// The pause-menu [Screen](#screen) (the injected Escape overlay), shown over
110    /// the stage and returned from to the stage. Unset when the world declares
111    /// no pause menu.
112    #[serde(deserialize_with = "de_opt_asset_ref")]
113    pub pause: Option<AssetId>,
114    /// The settings-screen entry [Screen](#screen) opened by the pause menu's and
115    /// the title screen's Settings items. Unset when there is no pause menu.
116    #[serde(deserialize_with = "de_opt_asset_ref")]
117    pub settings: Option<AssetId>,
118    /// The title screen's Settings [TextLabel](#textlabel), laid out with the
119    /// other title buttons and hidden when there is no settings screen.
120    #[serde(deserialize_with = "de_opt_asset_ref")]
121    pub settings_label: Option<AssetId>,
122    /// The small pulsing [Sprite](#sprite) shown when a fully revealed page
123    /// waits for input.
124    #[serde(deserialize_with = "de_opt_asset_ref")]
125    pub advance_marker: Option<AssetId>,
126    /// Quick-row Log [TextLabel](#textlabel) (dialogue history toggle).
127    #[serde(deserialize_with = "de_opt_asset_ref")]
128    pub log_label: Option<AssetId>,
129    /// Quick-row Auto [TextLabel](#textlabel) (auto-advance toggle).
130    #[serde(deserialize_with = "de_opt_asset_ref")]
131    pub auto_label: Option<AssetId>,
132    /// Quick-row Skip [TextLabel](#textlabel) (fast-forward toggle).
133    #[serde(deserialize_with = "de_opt_asset_ref")]
134    pub skip_label: Option<AssetId>,
135    /// Quick-row Save [TextLabel](#textlabel) (opens the slot overlay).
136    #[serde(deserialize_with = "de_opt_asset_ref")]
137    pub save_label: Option<AssetId>,
138    /// Full-canvas dim [Sprite](#sprite) behind the backlog and slot
139    /// overlays.
140    #[serde(deserialize_with = "de_opt_asset_ref")]
141    pub overlay_dim: Option<AssetId>,
142    /// The backlog overlay's history [TextLabel](#textlabel).
143    #[serde(deserialize_with = "de_opt_asset_ref")]
144    pub backlog_label: Option<AssetId>,
145    /// The slot overlay's heading [TextLabel](#textlabel) ("Save" / "Load").
146    #[serde(deserialize_with = "de_opt_asset_ref")]
147    pub slot_title: Option<AssetId>,
148    /// Slot row box [Sprite](#sprite)s.
149    pub slot_boxes: Vec<AssetId>,
150    /// Slot row [TextLabel](#textlabel)s.
151    pub slot_labels: Vec<AssetId>,
152}
153
154/// One jump target in a [Story](#story): a run of pages optionally ending in
155/// a choice menu.
156#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
157#[serde(default)]
158pub struct StoryNode {
159    /// The heading slug this node was compiled from (diagnostics only).
160    pub slug: String,
161    /// The click-through pages, in order.
162    pub pages: Vec<StoryPage>,
163    /// The choice menu shown after the last page. Empty = no menu.
164    pub choices: Vec<StoryChoice>,
165    /// Stage dressing current at the choice menu.
166    pub choice_stage: StoryStage,
167    /// Music current at the choice menu ([AudioClip](#audioclip) reference).
168    #[serde(deserialize_with = "de_opt_audio_clip_handle")]
169    pub choice_music: Option<AudioClipHandle>,
170    /// One-shots played when the choice menu shows.
171    #[serde(deserialize_with = "de_audio_clip_handle_vec")]
172    pub choice_sounds: Vec<AudioClipHandle>,
173    /// Flag operations run when the choice menu shows.
174    pub choice_ops: Vec<StoryOp>,
175    /// Conditional jumps evaluated before the choice menu shows.
176    pub choice_gates: Vec<StoryGate>,
177}
178
179/// One click-through page of a [StoryNode](#storynode).
180#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
181#[serde(default)]
182pub struct StoryPage {
183    /// The speaking character, shown as a name plate. `None` = narration.
184    pub speaker: Option<StorySpeaker>,
185    /// The dialog text, pre-wrapped with explicit newlines.
186    pub text: String,
187    /// BehaviorNode index advancing jumps to, overriding the default next-page /
188    /// fall-through order.
189    pub jump: Option<u32>,
190    /// Music current at this page ([AudioClip](#audioclip) reference).
191    /// Re-triggering the already-playing track is seamless.
192    #[serde(deserialize_with = "de_opt_audio_clip_handle")]
193    pub music: Option<AudioClipHandle>,
194    /// One-shot effects played when the page shows.
195    #[serde(deserialize_with = "de_audio_clip_handle_vec")]
196    pub sounds: Vec<AudioClipHandle>,
197    /// Stage dressing current at this page.
198    pub stage: StoryStage,
199    /// Flag operations run when the page shows.
200    pub ops: Vec<StoryOp>,
201    /// Conditional jumps evaluated before the page shows: the first gate
202    /// whose condition passes redirects play to its target node instead.
203    pub gates: Vec<StoryGate>,
204}
205
206/// A resolved speaker attribution on a [StoryPage](#storypage).
207#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
208#[serde(default)]
209pub struct StorySpeaker {
210    /// Display name for the name plate.
211    pub name: String,
212    /// Name-plate text color.
213    pub color: [f32; 3],
214}
215
216/// The stage dressing current at a page or choice menu: the backdrop and the
217/// character portraits standing on stage.
218#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
219#[serde(default)]
220pub struct StoryStage {
221    /// Backdrop image. `None` = flat dark fill.
222    pub bg: Option<StoryImage>,
223    /// Portrait at stage left.
224    pub left: Option<StoryImage>,
225    /// Portrait at stage center.
226    pub center: Option<StoryImage>,
227    /// Portrait at stage right.
228    pub right: Option<StoryImage>,
229}
230
231/// One placed stage image: which [Texture](#texture) to sample and where it
232/// sits on the reference canvas.
233#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
234#[serde(default)]
235pub struct StoryImage {
236    /// [Texture](#texture) to sample.
237    #[serde(deserialize_with = "de_texture_handle")]
238    pub texture: TextureHandle,
239    /// Left edge on the reference canvas.
240    pub x: f32,
241    /// Top edge on the reference canvas.
242    pub y: f32,
243    /// Width on the reference canvas.
244    pub width: f32,
245    /// Height on the reference canvas.
246    pub height: f32,
247}
248
249/// One option in a [StoryNode](#storynode)'s choice menu.
250#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
251#[serde(default)]
252pub struct StoryChoice {
253    /// Button text.
254    pub label: String,
255    /// BehaviorNode index chosen; play continues at that node's first page.
256    pub target: u32,
257    /// Condition gating the option: shown only while it passes. `None` is
258    /// always shown.
259    pub condition: Option<StoryCondition>,
260}
261
262/// One variable operation in a [Story](#story)'s script. All story state is
263/// named integer variables, starting at `0` each playthrough: a plain flag
264/// is a variable set to `1` and cleared to `0`.
265#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
266#[serde(default)]
267pub struct StoryOp {
268    /// The variable name.
269    pub name: String,
270    /// The value assigned (or added).
271    pub value: i32,
272    /// `false` assigns `value`; `true` adds it to the current value.
273    pub add: bool,
274}
275
276/// One conditional jump in a [Story](#story)'s script.
277#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
278#[serde(default)]
279pub struct StoryGate {
280    /// The variable the condition tests.
281    pub name: String,
282    /// How the variable compares against `value`.
283    pub op: StoryCompareOp,
284    /// The literal compared against.
285    pub value: i32,
286    /// BehaviorNode index play jumps to when the condition passes.
287    pub target: u32,
288}
289
290/// A condition on a [StoryChoice](#storychoice).
291#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
292#[serde(default)]
293pub struct StoryCondition {
294    /// The variable the condition tests.
295    pub name: String,
296    /// How the variable compares against `value`.
297    pub op: StoryCompareOp,
298    /// The literal compared against.
299    pub value: i32,
300}
301
302/// A comparison operator in a [Story](#story) condition. An unset variable
303/// reads as `0`, so a plain flag test is `Ne 0` and its negation `Eq 0`.
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
305#[serde(rename_all = "lowercase")]
306pub enum StoryCompareOp {
307    /// Equal.
308    Eq,
309    /// Not equal.
310    #[default]
311    Ne,
312    /// Less than.
313    Lt,
314    /// Less than or equal.
315    Le,
316    /// Greater than.
317    Gt,
318    /// Greater than or equal.
319    Ge,
320}
321
322impl StoryCompareOp {
323    /// Evaluate `lhs <op> rhs`.
324    pub fn eval(self, lhs: i32, rhs: i32) -> bool {
325        match self {
326            StoryCompareOp::Eq => lhs == rhs,
327            StoryCompareOp::Ne => lhs != rhs,
328            StoryCompareOp::Lt => lhs < rhs,
329            StoryCompareOp::Le => lhs <= rhs,
330            StoryCompareOp::Gt => lhs > rhs,
331            StoryCompareOp::Ge => lhs >= rhs,
332        }
333    }
334}
335
336impl Default for Story {
337    fn default() -> Self {
338        Self {
339            asset_id: AssetId::default(),
340            title: String::new(),
341            nodes: Vec::new(),
342            text_speed: 45.0,
343            scaffold: StoryScaffold::default(),
344            save_key: String::new(),
345        }
346    }
347}
348
349/// Runtime event carrying a freshly re-compiled [Story](#story) graph. The
350/// story system swaps its graph for the new one in place, keeping the
351/// current position (matched by node slug) and raised flags, so edits to a
352/// story's source land in the running game. A plain event, not a declarable
353/// asset.
354#[derive(Debug, Clone)]
355pub struct StoryReload {
356    /// The replacement graph. Matched to its story system by the scaffold's
357    /// stage screen reference.
358    pub story: Story,
359}
360
361/// The [Story](#story) playback command a [Behavior](#behavior) node sends.
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
363#[serde(rename_all = "lowercase")]
364pub enum StoryPlayback {
365    /// Start the story from its beginning.
366    #[default]
367    Start,
368    /// Resume the story from its auto-save.
369    Continue,
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use alloc::vec;
376
377    #[test]
378    fn a_blank_story_has_no_nodes_and_types_at_the_default_speed() {
379        let s = Story::default();
380        assert!(s.nodes.is_empty());
381        assert!(s.title.is_empty());
382        assert_eq!(s.text_speed, 45.0);
383        // No save key means the story never touches persisted state.
384        assert!(s.save_key.is_empty());
385        assert!(s.scaffold.screen.is_none());
386        assert!(s.scaffold.options.is_empty());
387    }
388
389    #[test]
390    fn every_comparison_agrees_with_the_operator_it_names() {
391        for (lhs, rhs) in [(1, 2), (2, 2), (3, 2)] {
392            assert_eq!(StoryCompareOp::Eq.eval(lhs, rhs), lhs == rhs);
393            assert_eq!(StoryCompareOp::Ne.eval(lhs, rhs), lhs != rhs);
394            assert_eq!(StoryCompareOp::Lt.eval(lhs, rhs), lhs < rhs);
395            assert_eq!(StoryCompareOp::Le.eval(lhs, rhs), lhs <= rhs);
396            assert_eq!(StoryCompareOp::Gt.eval(lhs, rhs), lhs > rhs);
397            assert_eq!(StoryCompareOp::Ge.eval(lhs, rhs), lhs >= rhs);
398        }
399    }
400
401    #[test]
402    fn an_omitted_comparison_defaults_to_not_equal() {
403        // A gate written with only a name and a value reads as "flag is set",
404        // which is the common case in an imported markdown story.
405        assert_eq!(StoryCompareOp::default(), StoryCompareOp::Ne);
406        let g: StoryGate = serde_json::from_str(r#"{"name":"met_ana","target":3}"#).unwrap();
407        assert_eq!(g.op, StoryCompareOp::Ne);
408        assert!(g.op.eval(1, 0));
409    }
410
411    #[test]
412    fn comparison_and_playback_names_parse_in_lowercase() {
413        let op = |s: &str| serde_json::from_str::<StoryCompareOp>(s).unwrap();
414        assert_eq!(op(r#""eq""#), StoryCompareOp::Eq);
415        assert_eq!(op(r#""ne""#), StoryCompareOp::Ne);
416        assert_eq!(op(r#""lt""#), StoryCompareOp::Lt);
417        assert_eq!(op(r#""le""#), StoryCompareOp::Le);
418        assert_eq!(op(r#""gt""#), StoryCompareOp::Gt);
419        assert_eq!(op(r#""ge""#), StoryCompareOp::Ge);
420        assert_eq!(
421            serde_json::to_string(&StoryCompareOp::Ge).unwrap(),
422            r#""ge""#
423        );
424
425        assert_eq!(StoryPlayback::default(), StoryPlayback::Start);
426        assert_eq!(
427            serde_json::from_str::<StoryPlayback>(r#""continue""#).unwrap(),
428            StoryPlayback::Continue
429        );
430        assert_eq!(
431            serde_json::to_string(&StoryPlayback::Start).unwrap(),
432            r#""start""#
433        );
434    }
435
436    #[test]
437    fn a_compiled_graph_parses_its_pages_choices_and_audio() {
438        crate::test_support::install_resolvers();
439        let s: Story = serde_json::from_str(
440            r#"{"title":"Ash","text_speed":30.0,"save_key":"ash",
441                "nodes":[{"slug":"intro",
442                  "pages":[{"speaker":{"name":"Ana","color":[1,0,0]},"text":"Hello",
443                            "music":"theme","sounds":["door","",3],
444                            "stage":{"bg":{"texture":"bg_room","width":1280,"height":720}},
445                            "ops":[{"name":"visits","value":1,"add":true}],
446                            "gates":[{"name":"visits","op":"gt","value":2,"target":4}]}],
447                  "choices":[{"label":"Stay","target":1,
448                              "condition":{"name":"visits","op":"ge","value":1}}],
449                  "choice_sounds":["click"]}]}"#,
450        )
451        .unwrap();
452
453        assert_eq!(s.title, "Ash");
454        assert_eq!(s.text_speed, 30.0);
455        let node = &s.nodes[0];
456        assert_eq!(node.slug, "intro");
457        assert_eq!(node.choice_sounds, vec![AudioClipHandle(5)]);
458
459        let page = &node.pages[0];
460        assert_eq!(page.text, "Hello");
461        assert_eq!(page.speaker.as_ref().expect("speaker").name, "Ana");
462        assert_eq!(page.music, Some(AudioClipHandle(5)));
463        // Empty entries drop out of a sound list rather than becoming handle 0.
464        assert_eq!(page.sounds, vec![AudioClipHandle(4), AudioClipHandle(3)]);
465        assert_eq!(page.jump, None);
466        let bg = page.stage.bg.as_ref().expect("background image");
467        assert_eq!(bg.texture, TextureHandle(7));
468        assert_eq!((bg.width, bg.height), (1280.0, 720.0));
469        assert!(page.stage.left.is_none());
470        assert_eq!(page.ops[0].name, "visits");
471        assert!(page.ops[0].add);
472        assert_eq!(page.gates[0].op, StoryCompareOp::Gt);
473        assert_eq!(page.gates[0].target, 4);
474
475        let choice = &node.choices[0];
476        assert_eq!(choice.label, "Stay");
477        assert_eq!(choice.target, 1);
478        assert_eq!(
479            choice.condition.as_ref().expect("condition").op,
480            StoryCompareOp::Ge
481        );
482    }
483
484    #[test]
485    fn a_graph_round_trips_through_postcard() {
486        // Stories ride the blob in the baked form, so the whole nested graph has
487        // to survive a format that carries no field names.
488        let mut s = Story {
489            title: alloc::string::String::from("Ash"),
490            ..Story::default()
491        };
492        s.nodes.push(StoryNode {
493            slug: alloc::string::String::from("intro"),
494            pages: vec![StoryPage {
495                text: alloc::string::String::from("Hello"),
496                jump: Some(2),
497                music: Some(AudioClipHandle(1)),
498                sounds: vec![AudioClipHandle(2)],
499                stage: StoryStage {
500                    center: Some(StoryImage {
501                        texture: TextureHandle(3),
502                        width: 512.0,
503                        ..StoryImage::default()
504                    }),
505                    ..StoryStage::default()
506                },
507                ..StoryPage::default()
508            }],
509            choice_gates: vec![StoryGate {
510                op: StoryCompareOp::Le,
511                target: 7,
512                ..StoryGate::default()
513            }],
514            ..StoryNode::default()
515        });
516        s.scaffold.slot_labels.push(AssetId(9));
517
518        let bytes = postcard::to_allocvec(&s).unwrap();
519        let back: Story = postcard::from_bytes(&bytes).unwrap();
520        assert_eq!(back.title, "Ash");
521        let page = &back.nodes[0].pages[0];
522        assert_eq!(page.jump, Some(2));
523        assert_eq!(page.music, Some(AudioClipHandle(1)));
524        assert_eq!(page.sounds, vec![AudioClipHandle(2)]);
525        assert_eq!(
526            page.stage.center.as_ref().expect("center image").texture,
527            TextureHandle(3)
528        );
529        assert_eq!(back.nodes[0].choice_gates[0].op, StoryCompareOp::Le);
530        assert_eq!(back.scaffold.slot_labels, vec![AssetId(9)]);
531    }
532
533    #[test]
534    fn a_reload_carries_the_replacement_graph() {
535        let reload = StoryReload {
536            story: Story::default(),
537        };
538        assert!(reload.story.nodes.is_empty());
539        assert!(alloc::format!("{reload:?}").contains("StoryReload"));
540    }
541}