1use 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
28#[serde(default)]
29pub struct Story {
30 #[serde(skip)]
32 pub asset_id: AssetId,
33 pub title: String,
35 pub nodes: Vec<StoryNode>,
39 pub text_speed: f32,
42 pub scaffold: StoryScaffold,
45 pub save_key: String,
49}
50
51#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
55#[serde(default)]
56pub struct StoryScaffold {
57 #[serde(deserialize_with = "de_opt_asset_ref")]
59 pub screen: Option<AssetId>,
60 #[serde(deserialize_with = "de_opt_asset_ref")]
62 pub ending: Option<AssetId>,
63 #[serde(deserialize_with = "de_opt_asset_ref")]
65 pub bg: Option<AssetId>,
66 #[serde(deserialize_with = "de_opt_asset_ref")]
68 pub left: Option<AssetId>,
69 #[serde(deserialize_with = "de_opt_asset_ref")]
71 pub center: Option<AssetId>,
72 #[serde(deserialize_with = "de_opt_asset_ref")]
74 pub right: Option<AssetId>,
75 #[serde(deserialize_with = "de_opt_asset_ref")]
77 pub dialog_box: Option<AssetId>,
78 #[serde(deserialize_with = "de_opt_asset_ref")]
80 pub name_label: Option<AssetId>,
81 #[serde(deserialize_with = "de_opt_asset_ref")]
83 pub text_label: Option<AssetId>,
84 pub option_boxes: Vec<AssetId>,
86 pub options: Vec<AssetId>,
88 #[serde(deserialize_with = "de_opt_asset_ref")]
93 pub start_label: Option<AssetId>,
94 #[serde(deserialize_with = "de_opt_asset_ref")]
96 pub quit_label: Option<AssetId>,
97 #[serde(deserialize_with = "de_opt_asset_ref")]
100 pub continue_label: Option<AssetId>,
101 #[serde(deserialize_with = "de_opt_asset_ref")]
104 pub title: Option<AssetId>,
105 #[serde(deserialize_with = "de_opt_asset_ref")]
108 pub load_label: Option<AssetId>,
109 #[serde(deserialize_with = "de_opt_asset_ref")]
113 pub pause: Option<AssetId>,
114 #[serde(deserialize_with = "de_opt_asset_ref")]
117 pub settings: Option<AssetId>,
118 #[serde(deserialize_with = "de_opt_asset_ref")]
121 pub settings_label: Option<AssetId>,
122 #[serde(deserialize_with = "de_opt_asset_ref")]
125 pub advance_marker: Option<AssetId>,
126 #[serde(deserialize_with = "de_opt_asset_ref")]
128 pub log_label: Option<AssetId>,
129 #[serde(deserialize_with = "de_opt_asset_ref")]
131 pub auto_label: Option<AssetId>,
132 #[serde(deserialize_with = "de_opt_asset_ref")]
134 pub skip_label: Option<AssetId>,
135 #[serde(deserialize_with = "de_opt_asset_ref")]
137 pub save_label: Option<AssetId>,
138 #[serde(deserialize_with = "de_opt_asset_ref")]
141 pub overlay_dim: Option<AssetId>,
142 #[serde(deserialize_with = "de_opt_asset_ref")]
144 pub backlog_label: Option<AssetId>,
145 #[serde(deserialize_with = "de_opt_asset_ref")]
147 pub slot_title: Option<AssetId>,
148 pub slot_boxes: Vec<AssetId>,
150 pub slot_labels: Vec<AssetId>,
152}
153
154#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
157#[serde(default)]
158pub struct StoryNode {
159 pub slug: String,
161 pub pages: Vec<StoryPage>,
163 pub choices: Vec<StoryChoice>,
165 pub choice_stage: StoryStage,
167 #[serde(deserialize_with = "de_opt_audio_clip_handle")]
169 pub choice_music: Option<AudioClipHandle>,
170 #[serde(deserialize_with = "de_audio_clip_handle_vec")]
172 pub choice_sounds: Vec<AudioClipHandle>,
173 pub choice_ops: Vec<StoryOp>,
175 pub choice_gates: Vec<StoryGate>,
177}
178
179#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
181#[serde(default)]
182pub struct StoryPage {
183 pub speaker: Option<StorySpeaker>,
185 pub text: String,
187 pub jump: Option<u32>,
190 #[serde(deserialize_with = "de_opt_audio_clip_handle")]
193 pub music: Option<AudioClipHandle>,
194 #[serde(deserialize_with = "de_audio_clip_handle_vec")]
196 pub sounds: Vec<AudioClipHandle>,
197 pub stage: StoryStage,
199 pub ops: Vec<StoryOp>,
201 pub gates: Vec<StoryGate>,
204}
205
206#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
208#[serde(default)]
209pub struct StorySpeaker {
210 pub name: String,
212 pub color: [f32; 3],
214}
215
216#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
219#[serde(default)]
220pub struct StoryStage {
221 pub bg: Option<StoryImage>,
223 pub left: Option<StoryImage>,
225 pub center: Option<StoryImage>,
227 pub right: Option<StoryImage>,
229}
230
231#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
234#[serde(default)]
235pub struct StoryImage {
236 #[serde(deserialize_with = "de_texture_handle")]
238 pub texture: TextureHandle,
239 pub x: f32,
241 pub y: f32,
243 pub width: f32,
245 pub height: f32,
247}
248
249#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
251#[serde(default)]
252pub struct StoryChoice {
253 pub label: String,
255 pub target: u32,
257 pub condition: Option<StoryCondition>,
260}
261
262#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
266#[serde(default)]
267pub struct StoryOp {
268 pub name: String,
270 pub value: i32,
272 pub add: bool,
274}
275
276#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
278#[serde(default)]
279pub struct StoryGate {
280 pub name: String,
282 pub op: StoryCompareOp,
284 pub value: i32,
286 pub target: u32,
288}
289
290#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
292#[serde(default)]
293pub struct StoryCondition {
294 pub name: String,
296 pub op: StoryCompareOp,
298 pub value: i32,
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
305#[serde(rename_all = "lowercase")]
306pub enum StoryCompareOp {
307 Eq,
309 #[default]
311 Ne,
312 Lt,
314 Le,
316 Gt,
318 Ge,
320}
321
322impl StoryCompareOp {
323 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#[derive(Debug, Clone)]
355pub struct StoryReload {
356 pub story: Story,
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
363#[serde(rename_all = "lowercase")]
364pub enum StoryPlayback {
365 #[default]
367 Start,
368 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 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 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 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 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}