Skip to main content

concinnity_core/components/
story.rs

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