shuyi_assets/dialogue.rs
1//! Dialogue scripts (`.dlg` files): scenes of portraits, speech, narration,
2//! reply choices and music changes. [`parse_dlg`] turns a file into scenes,
3//! [`check_scene`] and [`check_duplicates`] validate them against the
4//! game's [`SceneRules`], and [`print_scene`] writes a scene back out.
5//! Every line that shows text has a [`LineId`]. Lines in an `@if` block
6//! play only while a character is there ([`Present`]).
7//!
8//! A game adds directives of its own (a place behind the talk, a cut to a
9//! map) with a [`Directive`] type: the parser hands it every `@name` it
10//! doesn't know, and a scene keeps the steps it makes in order.
11//!
12//! As a format (ADR-0007), [`Dlg`] reads a file's scenes
13//! ([`DialogueFile`]), and [`scripts_from_parsed`] checks them.
14//!
15//! The format, for a game that adds no directive:
16//!
17//! ```text
18//! @scene gate_talk
19//! @caption The east gate, dusk
20//! @left mentor neutral
21//! @right scout happy
22//! mentor: Is the road clear?
23//! scout[sad] (quietly, out of breath): Not since noon.
24//! Riders came over the hill.
25//! > Rain begins to fall.
26//! @delivery low, keeping out of sight
27//! mentor: Then we go by the river.
28//! @delivery clear
29//! @music storm
30//! @choice
31//! * earnest: Then we wait.
32//! mentor: Agreed.
33//! * wry: Then we get wet.
34//! @endchoice
35//! @if scout
36//! scout: I'll keep watch.
37//! @else
38//! > Nobody keeps watch.
39//! @endif
40//! @end
41//! ```
42use crate::prelude::*;
43use serde::{Deserialize, Serialize};
44
45mod check;
46mod line_id;
47mod parse;
48mod present;
49
50use alloc::collections::BTreeMap;
51use core::fmt;
52use core::marker::PhantomData;
53
54pub use shuyi_core::CharacterId;
55
56pub use check::{
57 NoTokens, SceneRules, Tokens, check_duplicates, check_scene, lines_at_longest, longest_width,
58 token_problems,
59};
60pub use line_id::{
61 Line, LineId, NARRATION_SPEAKER, REPLY_SPEAKER, caption_key, line_hash, scene_of,
62};
63pub use parse::{Lines, ParsedScene, PartLines, bad_id, char_problem, is_id, parse_dlg};
64pub use present::Present;
65
66use crate::error::{ContentError, codes};
67use crate::format::{AssetKind, AssetPath, DecodeCx, Format, TEXT_MAX_BYTES, text};
68
69/// How much text a game's dialogue box and reply menu hold: the limits the
70/// checks keep a script to, counted in cells ([`text_width`]).
71///
72/// [`text_width`]: crate::lang::text_width
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct DialogueLimits {
75 /// Cells per line of the text box.
76 pub box_width: usize,
77 /// Lines of text the text box shows at once: one page.
78 pub box_lines: usize,
79 /// Longest text of one speech or narration line.
80 pub max_text: usize,
81 /// Longest text of a reply choice: it must fit the menu.
82 pub max_option: usize,
83 /// Fewest replies a `@choice` offers.
84 pub min_options: usize,
85 /// Most replies a `@choice` offers.
86 pub max_options: usize,
87 /// Most text steps (speech or narration) in one reply's reaction, so
88 /// the scene rejoins quickly.
89 pub max_reaction_texts: usize,
90}
91
92/// A step a game adds to the format: `@<name> <args>`, for a name the
93/// engine's own directives don't use. The parser reads it with
94/// [`Directive::parse`], a scene keeps it in order as
95/// [`Step::Directive`], and the game plays it.
96pub trait Directive: Clone + fmt::Debug + PartialEq + Eq {
97 /// Reads the line `@<name> <args>` (`args` trimmed): `None` if none of
98 /// the game's directives is called `name`; otherwise the step, if one
99 /// could be made, and every problem with the line.
100 fn parse(name: &str, args: &str) -> Option<Parsed<Self>>;
101
102 /// The line as written, without its `@` (`place market 3,12`): what
103 /// [`print_scene`] writes. Parsing it gives the directive back.
104 fn print(&self) -> String;
105
106 /// For a directive that stays in force until the next one of its kind
107 /// (a place drawn behind the talk), that kind's name, written as the
108 /// directive is (`place`): every part of a `@choice` or an `@if` block
109 /// must then leave the same one in force. `None` for one that doesn't
110 /// stay.
111 fn kind(&self) -> Option<&'static str> {
112 None
113 }
114}
115
116/// What [`Directive::parse`] read from one line.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Parsed<D> {
119 /// The step, if the line makes one.
120 pub step: Option<D>,
121 /// What is wrong with the line, if anything.
122 pub errors: Vec<String>,
123}
124
125impl<D> Parsed<D> {
126 /// A good line that makes `step`.
127 pub fn step(step: D) -> Self {
128 Self {
129 step: Some(step),
130 errors: Vec::new(),
131 }
132 }
133
134 /// A line that makes no step, for `error`.
135 pub fn error(error: impl Into<String>) -> Self {
136 Self {
137 step: None,
138 errors: vec![error.into()],
139 }
140 }
141}
142
143/// The directives of a game that adds none: there are no such steps.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145pub enum NoDirective {}
146
147impl Directive for NoDirective {
148 fn parse(_name: &str, _args: &str) -> Option<Parsed<Self>> {
149 None
150 }
151
152 fn print(&self) -> String {
153 match *self {}
154 }
155}
156
157/// A side of the dialogue screen, where one portrait stands.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
159pub enum Side {
160 /// The left portrait.
161 Left,
162 /// The right portrait.
163 Right,
164}
165
166impl Side {
167 /// The side as written in a script: `left` or `right`.
168 pub fn name(self) -> &'static str {
169 match self {
170 Side::Left => "left",
171 Side::Right => "right",
172 }
173 }
174
175 /// The other side.
176 #[must_use]
177 pub fn other(self) -> Self {
178 match self {
179 Side::Left => Side::Right,
180 Side::Right => Side::Left,
181 }
182 }
183}
184
185/// One step of a scene, in script order. `D` is the game's own
186/// directives.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub enum Step<D = NoDirective> {
189 /// `@caption <text>`: a location/time caption, shown until the next one.
190 Caption {
191 /// The caption.
192 text: String,
193 },
194 /// `@left <id> <expression>`: a character enters (or replaces whoever
195 /// is) on `side`.
196 Place {
197 /// Where.
198 side: Side,
199 /// Who.
200 character: CharacterId,
201 /// With which expression.
202 expression: String,
203 },
204 /// `@left clear`: the portrait on `side` leaves.
205 Clear {
206 /// Which side empties.
207 side: Side,
208 },
209 /// `id: text` or `id[expression]: text`: an on-screen character speaks,
210 /// first changing expression if one is given.
211 Say {
212 /// Who speaks.
213 speaker: CharacterId,
214 /// The speaker's new expression, if it changes.
215 expression: Option<String>,
216 /// What they say.
217 text: String,
218 /// How it is said, for a voice or an actor; never shown, and not
219 /// part of the line's id.
220 direction: Option<String>,
221 /// The line's id.
222 line: LineId,
223 },
224 /// `> text`: narration, no speaker.
225 Narrate {
226 /// The narration.
227 text: String,
228 /// How it is said, for a voice or an actor; never shown, and not
229 /// part of the line's id.
230 direction: Option<String>,
231 /// The line's id.
232 line: LineId,
233 },
234 /// `@choice` … `@endchoice`: the replies the player picks from, each
235 /// with its own reaction steps. Every option rejoins the scene after
236 /// the block.
237 Choice {
238 /// The options, in menu order.
239 options: Vec<ChoiceOption<D>>,
240 },
241 /// `@music <cue>` or `@music stop`: the music changes when playback
242 /// reaches this line, and stays so after the scene.
243 Music(MusicLine),
244 /// A directive of the game's ([`Directive`]).
245 Directive(D),
246 /// `@if <character>` … `@else` … `@endif`: steps that play only if the
247 /// character is there when the scene plays ([`Present`]), and the
248 /// steps that play instead if they aren't.
249 If {
250 /// Who must be there.
251 character: CharacterId,
252 /// The steps played if they are.
253 then: Vec<Step<D>>,
254 /// The steps played if they aren't (none without an `@else`).
255 otherwise: Vec<Step<D>>,
256 },
257}
258
259/// What a `@music` line asks for.
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261pub enum MusicLine {
262 /// `@music <cue>`: switch to this music cue.
263 Cue(String),
264 /// `@music stop`: fade the music out.
265 Stop,
266}
267
268impl MusicLine {
269 /// What follows `@music` for [`MusicLine::Stop`].
270 pub const STOP: &'static str = "stop";
271
272 /// What follows `@music` in a script: the cue id, or `stop`.
273 pub fn arg(&self) -> &str {
274 match self {
275 MusicLine::Cue(cue) => cue,
276 MusicLine::Stop => Self::STOP,
277 }
278 }
279}
280
281/// One reply the player can pick: `* <tone>: <text>`, then its reaction.
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct ChoiceOption<D = NoDirective> {
284 /// The reply's tone (`earnest`, `wry`…), for writers; not shown.
285 pub tone: String,
286 /// What is said, shown in the menu.
287 pub text: String,
288 /// The reply's id.
289 pub line: LineId,
290 /// The reaction: steps played after picking this option (no choices).
291 pub steps: Vec<Step<D>>,
292}
293
294impl<D> Step<D> {
295 /// The text of a text step (one text box): a `Say` or `Narrate`.
296 pub fn text(&self) -> Option<&str> {
297 match self {
298 Step::Say { text, .. } | Step::Narrate { text, .. } => Some(text),
299 Step::Caption { .. }
300 | Step::Place { .. }
301 | Step::Clear { .. }
302 | Step::Choice { .. }
303 | Step::Music(_)
304 | Step::Directive(_)
305 | Step::If { .. } => None,
306 }
307 }
308
309 /// Whether the step is, or has inside it, a speech or narration line.
310 pub fn has_text(&self) -> bool {
311 let any = |steps: &[Step<D>]| steps.iter().any(Step::has_text);
312 match self {
313 Step::Choice { options } => options.iter().any(|o| any(&o.steps)),
314 Step::If {
315 then, otherwise, ..
316 } => any(then) || any(otherwise),
317 _ => self.text().is_some(),
318 }
319 }
320}
321
322/// One scene: `@scene <id>` … `@end`.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct Scene<D = NoDirective> {
325 /// Scene id, unique across every dialogue file.
326 pub id: String,
327 /// Its steps, in order.
328 pub steps: Vec<Step<D>>,
329}
330
331impl<D> Default for Scene<D> {
332 fn default() -> Self {
333 Self {
334 id: String::new(),
335 steps: Vec::new(),
336 }
337 }
338}
339
340/// Every scene of every dialogue file, by id. Build it with
341/// [`DialogueTable::new`] or [`Default`] (ADR-0039).
342#[derive(Debug, Clone, PartialEq, Eq)]
343#[non_exhaustive]
344pub struct DialogueTable<D = NoDirective> {
345 /// Scenes by id.
346 pub scenes: BTreeMap<String, Scene<D>>,
347 /// The file each scene was written in, by scene id (empty for a table
348 /// not read from files).
349 pub files: BTreeMap<String, String>,
350}
351
352impl<D> Default for DialogueTable<D> {
353 fn default() -> Self {
354 Self {
355 scenes: BTreeMap::new(),
356 files: BTreeMap::new(),
357 }
358 }
359}
360
361impl<D> DialogueTable<D> {
362 /// The table of `scenes`, by id, read from no file (`files` starts
363 /// empty).
364 #[must_use]
365 pub const fn new(scenes: BTreeMap<String, Scene<D>>) -> Self {
366 Self {
367 scenes,
368 files: BTreeMap::new(),
369 }
370 }
371
372 /// The scene with id `id`.
373 pub fn get(&self, id: &str) -> Option<&Scene<D>> {
374 self.scenes.get(id)
375 }
376}
377
378/// Every scene of every dialogue file, with where each was written: what
379/// checks that need more of the game's data run on once that is loaded.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct Scripts<D = NoDirective> {
382 /// The scenes, by id.
383 pub table: DialogueTable<D>,
384 /// The scenes as written, in file order.
385 pub parsed: Vec<ParsedScene<D>>,
386}
387
388impl<D> Default for Scripts<D> {
389 fn default() -> Self {
390 Self {
391 table: DialogueTable::default(),
392 parsed: Vec::new(),
393 }
394 }
395}
396
397/// The scenes of one `.dlg` file as written, each with where it is: what
398/// the `.dlg` format ([`Dlg`]) reads. [`scripts_from_parsed`] checks them
399/// against the game's rules, once its characters, names and music are
400/// loaded.
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
402pub struct DialogueFile<D = NoDirective> {
403 /// Its scenes, in file order.
404 pub scenes: Vec<ParsedScene<D>>,
405}
406
407impl<D: 'static> AssetKind for DialogueFile<D> {
408 const NAME: &'static str = "dialogue";
409}
410
411/// The `.dlg` format (`dlg`, `.dlg`) of a game whose own directives are
412/// `D`: a file's scenes, with every syntax and structure error
413/// [`parse_dlg`] finds. The checks that need the game's rules are
414/// [`scripts_from_parsed`]'s.
415pub struct Dlg<D = NoDirective>(PhantomData<fn() -> D>);
416
417impl<D> Dlg<D> {
418 /// The format, for a game's directives `D`.
419 pub const fn new() -> Self {
420 Self(PhantomData)
421 }
422}
423
424impl<D> Default for Dlg<D> {
425 fn default() -> Self {
426 Self::new()
427 }
428}
429
430impl<D> fmt::Debug for Dlg<D> {
431 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
432 f.write_str("Dlg")
433 }
434}
435
436impl<D: Directive + 'static> Format for Dlg<D> {
437 type Asset = DialogueFile<D>;
438
439 fn id(&self) -> &'static str {
440 "dlg"
441 }
442
443 fn extensions(&self) -> &'static [&'static str] {
444 &["dlg"]
445 }
446
447 fn max_bytes(&self) -> usize {
448 TEXT_MAX_BYTES
449 }
450
451 fn decode(
452 &self,
453 file: &AssetPath,
454 bytes: &[u8],
455 _cx: &DecodeCx<'_>,
456 ) -> Result<DialogueFile<D>, Vec<ContentError>> {
457 let (scenes, errors) = parse_dlg(file.shown(), text(file, bytes)?);
458 if errors.is_empty() {
459 Ok(DialogueFile { scenes })
460 } else {
461 Err(errors)
462 }
463 }
464}
465
466/// Like [`scripts_from_sources`], for files given as `(file name,
467/// source)` where a `None` source is a file that isn't valid UTF-8 (an
468/// error).
469pub fn scripts_from_files<D: Directive>(
470 files: &[(String, Option<&str>)],
471 rules: &impl SceneRules<D>,
472) -> Result<Scripts<D>, Vec<ContentError>> {
473 let mut sources = Vec::new();
474 let mut errors = Vec::new();
475 for (file, source) in files {
476 match source {
477 Some(source) => sources.push((file, *source)),
478 None => errors.push(
479 ContentError::new(file, "file is not valid UTF-8").with_code(codes::NOT_TEXT),
480 ),
481 }
482 }
483 match scripts_from_sources(&sources, rules) {
484 Ok(scripts) if errors.is_empty() => Ok(scripts),
485 Ok(_) => Err(errors),
486 Err(e) => {
487 errors.extend(e);
488 Err(errors)
489 }
490 }
491}
492
493/// Parses and validates dialogue files given as `(file name, source)`
494/// pairs, each scene with [`check_scene`] and the game's own
495/// [`SceneRules::scene_problems`], then the scene ids across files.
496/// Reports every problem, ordered by file and line.
497pub fn scripts_from_sources<D: Directive, F: AsRef<str>>(
498 files: &[(F, &str)],
499 rules: &impl SceneRules<D>,
500) -> Result<Scripts<D>, Vec<ContentError>> {
501 let mut scenes = Vec::new();
502 let mut errors = Vec::new();
503 for (file, source) in files {
504 let (parsed, e) = parse_dlg(file.as_ref(), source);
505 errors.extend(e);
506 scenes.extend(parsed);
507 }
508 ScriptsCheck::with_errors(scenes, errors).finish(rules)
509}
510
511/// Validates scenes already parsed (`files`, from the `.dlg` format
512/// [`Dlg`], in file order) as [`scripts_from_sources`] does: each scene
513/// with [`check_scene`] and the game's own [`SceneRules::scene_problems`],
514/// then the scene ids across files.
515pub fn scripts_from_parsed<D: Directive>(
516 files: impl IntoIterator<Item = DialogueFile<D>>,
517 rules: &impl SceneRules<D>,
518) -> Result<Scripts<D>, Vec<ContentError>> {
519 ScriptsCheck::new(files).finish(rules)
520}
521
522/// [`scripts_from_parsed`] one scene at a time, for a game that must hand
523/// control back to its platform between them (a handheld's update
524/// callback, which its OS ends when it runs too long): [`Self::new`], then
525/// [`Self::check_next`] as many times as [`Self::unchecked`] says, then
526/// [`Self::finish`]. The scripts and the errors are the same.
527pub struct ScriptsCheck<D = NoDirective> {
528 /// The scenes, in file order.
529 scenes: Vec<ParsedScene<D>>,
530 /// How many of them are checked.
531 checked: usize,
532 /// The problems found so far.
533 errors: Vec<ContentError>,
534}
535
536impl<D: Directive> ScriptsCheck<D> {
537 /// The check of the scenes of `files` (from the `.dlg` format [`Dlg`],
538 /// in file order), none checked yet.
539 pub fn new(files: impl IntoIterator<Item = DialogueFile<D>>) -> Self {
540 let scenes = files.into_iter().flat_map(|file| file.scenes).collect();
541 Self::with_errors(scenes, Vec::new())
542 }
543
544 /// The check of `scenes`, the problems found reading them in `errors`.
545 fn with_errors(scenes: Vec<ParsedScene<D>>, errors: Vec<ContentError>) -> Self {
546 Self {
547 scenes,
548 checked: 0,
549 errors,
550 }
551 }
552
553 /// How many scenes are left to check.
554 pub fn unchecked(&self) -> usize {
555 self.scenes.len() - self.checked
556 }
557
558 /// Checks the next scene with [`check_scene`] and the game's own
559 /// [`SceneRules::scene_problems`], if one is left.
560 pub fn check_next(&mut self, rules: &impl SceneRules<D>) {
561 if let Some(scene) = self.scenes.get(self.checked) {
562 self.errors.extend(scene_errors(scene, rules));
563 self.checked += 1;
564 }
565 }
566
567 /// [`Scripts`] of the scenes, or every problem with them, ordered by
568 /// file and line: those [`Self::check_next`] found, and the scene ids
569 /// across files. The scenes not checked yet are checked with `rules`
570 /// first.
571 ///
572 /// # Errors
573 ///
574 /// Every problem found, if there is one.
575 pub fn finish(self, rules: &impl SceneRules<D>) -> Result<Scripts<D>, Vec<ContentError>> {
576 let mut errors = self.errors;
577 for scene in &self.scenes[self.checked..] {
578 errors.extend(scene_errors(scene, rules));
579 }
580 let scenes = self.scenes;
581 errors.extend(check_duplicates(&scenes));
582 if errors.is_empty() {
583 let table = DialogueTable {
584 scenes: scenes
585 .iter()
586 .map(|p| (p.scene.id.clone(), p.scene.clone()))
587 .collect(),
588 files: scenes
589 .iter()
590 .map(|p| (p.scene.id.clone(), p.file.clone()))
591 .collect(),
592 };
593 Ok(Scripts {
594 table,
595 parsed: scenes,
596 })
597 } else {
598 errors.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line)));
599 Err(errors)
600 }
601 }
602}
603
604/// What [`check_scene`] and the game's own [`SceneRules::scene_problems`]
605/// find in `scene`.
606fn scene_errors<D: Directive>(
607 scene: &ParsedScene<D>,
608 rules: &impl SceneRules<D>,
609) -> Vec<ContentError> {
610 let mut errors = check_scene(scene, rules);
611 errors.extend(rules.scene_problems(scene));
612 errors
613}
614
615/// Writes `scene` in `.dlg` format (one line per step, reactions indented
616/// under their option, an `@if` block's lines as far in as its `@if`),
617/// ending with `@end` and a newline. Parsing the result gives `scene`
618/// back.
619pub fn print_scene<D: Directive>(scene: &Scene<D>) -> String {
620 let mut out = format!("@scene {}\n", scene.id);
621 for step in &scene.steps {
622 print_step(&mut out, step, "");
623 }
624 out.push_str("@end\n");
625 out
626}
627
628/// Appends `step`'s line(s) to `out`, each starting with `indent`.
629fn print_step<D: Directive>(out: &mut String, step: &Step<D>, indent: &str) {
630 let line = match step {
631 Step::Caption { text } => format!("@caption {text}"),
632 Step::Place {
633 side,
634 character,
635 expression,
636 } => format!("@{} {} {expression}", side.name(), character.0),
637 Step::Clear { side } => format!("@{} clear", side.name()),
638 Step::Say {
639 speaker,
640 expression,
641 text,
642 direction,
643 ..
644 } => {
645 let expression = expression
646 .as_ref()
647 .map_or(String::new(), |e| format!("[{e}]"));
648 let direction = direction
649 .as_ref()
650 .map_or(String::new(), |d| format!(" ({d})"));
651 format!("{}{expression}{direction}: {text}", speaker.0)
652 }
653 Step::Narrate {
654 text,
655 direction: Some(d),
656 ..
657 } => format!("> ({d}): {text}"),
658 Step::Narrate { text, .. } => format!("> {text}"),
659 Step::Music(music) => format!("@music {}", music.arg()),
660 Step::Directive(directive) => format!("@{}", directive.print()),
661 Step::Choice { options } => {
662 out.push_str("@choice\n");
663 for o in options {
664 for part in ["* ", &o.tone, ": ", &o.text, "\n"] {
665 out.push_str(part);
666 }
667 for s in &o.steps {
668 print_step(out, s, " ");
669 }
670 }
671 "@endchoice".to_owned()
672 }
673 Step::If {
674 character,
675 then,
676 otherwise,
677 } => {
678 for part in [indent, "@if ", &character.0, "\n"] {
679 out.push_str(part);
680 }
681 for s in then {
682 print_step(out, s, indent);
683 }
684 if !otherwise.is_empty() {
685 out.push_str(indent);
686 out.push_str("@else\n");
687 }
688 for s in otherwise {
689 print_step(out, s, indent);
690 }
691 "@endif".to_owned()
692 }
693 };
694 out.push_str(indent);
695 out.push_str(&line);
696 out.push('\n');
697}
698
699#[cfg(test)]
700pub(crate) mod tests;