Skip to main content

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;