1use 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
31#[serde(default)]
32pub struct Story {
33 #[serde(skip)]
35 pub asset_id: AssetId,
36 pub title: String,
38 pub nodes: Vec<StoryNode>,
42 pub text_speed: f32,
45 pub scaffold: StoryScaffold,
48 pub save_key: String,
52}
53
54#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
58#[serde(default)]
59pub struct StoryScaffold {
60 #[serde(deserialize_with = "de_opt_asset_ref")]
62 pub screen: Option<AssetId>,
63 #[serde(deserialize_with = "de_opt_asset_ref")]
65 pub ending: Option<AssetId>,
66 #[serde(deserialize_with = "de_opt_asset_ref")]
68 pub bg: Option<AssetId>,
69 #[serde(deserialize_with = "de_opt_asset_ref")]
71 pub left: Option<AssetId>,
72 #[serde(deserialize_with = "de_opt_asset_ref")]
74 pub center: Option<AssetId>,
75 #[serde(deserialize_with = "de_opt_asset_ref")]
77 pub right: Option<AssetId>,
78 #[serde(deserialize_with = "de_opt_asset_ref")]
80 pub dialog_box: Option<AssetId>,
81 #[serde(deserialize_with = "de_opt_asset_ref")]
83 pub name_label: Option<AssetId>,
84 #[serde(deserialize_with = "de_opt_asset_ref")]
86 pub text_label: Option<AssetId>,
87 pub option_boxes: Vec<AssetId>,
89 pub options: Vec<AssetId>,
91 #[serde(deserialize_with = "de_opt_asset_ref")]
96 pub start_label: Option<AssetId>,
97 #[serde(deserialize_with = "de_opt_asset_ref")]
99 pub quit_label: Option<AssetId>,
100 #[serde(deserialize_with = "de_opt_asset_ref")]
103 pub continue_label: Option<AssetId>,
104 #[serde(deserialize_with = "de_opt_asset_ref")]
107 pub title: Option<AssetId>,
108 #[serde(deserialize_with = "de_opt_asset_ref")]
111 pub load_label: Option<AssetId>,
112 #[serde(deserialize_with = "de_opt_asset_ref")]
116 pub pause: Option<AssetId>,
117 #[serde(deserialize_with = "de_opt_asset_ref")]
120 pub settings: Option<AssetId>,
121 #[serde(deserialize_with = "de_opt_asset_ref")]
124 pub settings_label: Option<AssetId>,
125 #[serde(deserialize_with = "de_opt_asset_ref")]
128 pub advance_marker: Option<AssetId>,
129 #[serde(deserialize_with = "de_opt_asset_ref")]
131 pub log_label: Option<AssetId>,
132 #[serde(deserialize_with = "de_opt_asset_ref")]
134 pub auto_label: Option<AssetId>,
135 #[serde(deserialize_with = "de_opt_asset_ref")]
137 pub skip_label: Option<AssetId>,
138 #[serde(deserialize_with = "de_opt_asset_ref")]
140 pub save_label: Option<AssetId>,
141 #[serde(deserialize_with = "de_opt_asset_ref")]
144 pub overlay_dim: Option<AssetId>,
145 #[serde(deserialize_with = "de_opt_asset_ref")]
147 pub backlog_label: Option<AssetId>,
148 #[serde(deserialize_with = "de_opt_asset_ref")]
150 pub slot_title: Option<AssetId>,
151 pub slot_boxes: Vec<AssetId>,
153 pub slot_labels: Vec<AssetId>,
155}
156
157#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
160#[serde(default)]
161pub struct StoryNode {
162 pub slug: String,
164 pub pages: Vec<StoryPage>,
166 pub choices: Vec<StoryChoice>,
168 pub choice_stage: StoryStage,
170 #[serde(deserialize_with = "de_opt_audio_clip_handle")]
172 pub choice_music: Option<AudioClipHandle>,
173 #[serde(deserialize_with = "de_audio_clip_handle_vec")]
175 pub choice_sounds: Vec<AudioClipHandle>,
176 pub choice_ops: Vec<StoryOp>,
178 pub choice_gates: Vec<StoryGate>,
180}
181
182#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
184#[serde(default)]
185pub struct StoryPage {
186 pub speaker: Option<StorySpeaker>,
188 pub text: String,
190 pub jump: Option<u32>,
193 #[serde(deserialize_with = "de_opt_audio_clip_handle")]
196 pub music: Option<AudioClipHandle>,
197 #[serde(deserialize_with = "de_audio_clip_handle_vec")]
199 pub sounds: Vec<AudioClipHandle>,
200 pub stage: StoryStage,
202 pub ops: Vec<StoryOp>,
204 pub gates: Vec<StoryGate>,
207}
208
209#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
211#[serde(default)]
212pub struct StorySpeaker {
213 pub name: String,
215 pub color: [f32; 3],
217}
218
219#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
222#[serde(default)]
223pub struct StoryStage {
224 pub bg: Option<StoryImage>,
226 pub left: Option<StoryImage>,
228 pub center: Option<StoryImage>,
230 pub right: Option<StoryImage>,
232}
233
234#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
237#[serde(default)]
238pub struct StoryImage {
239 #[serde(deserialize_with = "de_texture_handle")]
241 pub texture: TextureHandle,
242 pub x: f32,
244 pub y: f32,
246 pub width: f32,
248 pub height: f32,
250}
251
252#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
254#[serde(default)]
255pub struct StoryChoice {
256 pub label: String,
258 pub target: u32,
260 pub condition: Option<StoryCondition>,
263}
264
265#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
269#[serde(default)]
270pub struct StoryOp {
271 pub name: String,
273 pub value: i32,
275 pub add: bool,
277}
278
279#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
281#[serde(default)]
282pub struct StoryGate {
283 pub name: String,
285 pub op: StoryCompareOp,
287 pub value: i32,
289 pub target: u32,
291}
292
293#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
295#[serde(default)]
296pub struct StoryCondition {
297 pub name: String,
299 pub op: StoryCompareOp,
301 pub value: i32,
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
308#[serde(rename_all = "lowercase")]
309pub enum StoryCompareOp {
310 Eq,
312 #[default]
314 Ne,
315 Lt,
317 Le,
319 Gt,
321 Ge,
323}
324
325impl StoryCompareOp {
326 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#[derive(Debug, Clone)]
358pub struct StoryReload {
359 pub story: Story,
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
366#[serde(rename_all = "lowercase")]
367pub enum StoryPlayback {
368 #[default]
370 Start,
371 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 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 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 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 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}