Skip to main content

shuyi_assets/dialogue/
line_id.rs

1//! Line ids: every speech line, narration line and reply has
2//! an id `<scene id>_<8 hex>`, a hash of who says it and its English text
3//! as written. Translations and voice clips attach to it, so rewording a
4//! line gives it a new id and moving it does not.
5use crate::prelude::*;
6use serde::{Deserialize, Serialize};
7
8use alloc::collections::BTreeMap;
9use core::fmt;
10
11use super::{Scene, Step};
12
13/// What stands for the speaker in a narration line's hash and listing.
14pub const NARRATION_SPEAKER: &str = ">";
15/// What stands for the speaker in a reply's hash and listing.
16pub const REPLY_SPEAKER: &str = "*";
17
18/// The id of one dialogue line: `<scene id>_<8 hex>`, with `_2`, `_3`… on
19/// repeats of the same speaker and text in one scene. Unique across every
20/// dialogue file. Empty on a step built by hand until
21/// [`Scene::assign_line_ids`] runs (the parser runs it).
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)]
23pub struct LineId(String);
24
25impl LineId {
26    /// The id written `id`, as a file that refers to lines names one (a
27    /// voice manifest). Nothing checks that a line has it.
28    pub fn new(id: impl Into<String>) -> Self {
29        Self(id.into())
30    }
31
32    /// The id as text.
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36
37    /// The id of the scene the line is in: the id without its hash and
38    /// repeat number (`ch1_gate_1a2b3c4d_2` → `ch1_gate`). An id that
39    /// doesn't end like a line id is returned whole.
40    pub fn scene_id(&self) -> &str {
41        scene_of(&self.0).unwrap_or(&self.0)
42    }
43}
44
45/// The id of the scene the line with id `id` is in, or `None` if `id`
46/// doesn't end like a line id (`_<8 hex>`, then `_<n>` on a repeat).
47pub fn scene_of(id: &str) -> Option<&str> {
48    /// `id` without a `_<8 hex>` ending, if it has one.
49    fn strip_hash(id: &str) -> Option<&str> {
50        id.rsplit_once('_')
51            .filter(|(_, tail)| tail.len() == 8 && tail.bytes().all(|b| b.is_ascii_hexdigit()))
52            .map(|(scene, _)| scene)
53    }
54    // A repeat: `<scene>_<hash>_<n>`.
55    let repeat = || {
56        id.rsplit_once('_')
57            .filter(|(_, n)| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
58            .and_then(|(rest, _)| strip_hash(rest))
59    };
60    strip_hash(id).or_else(repeat)
61}
62
63impl fmt::Display for LineId {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.write_str(&self.0)
66    }
67}
68
69/// One line that shows text, as [`Scene::lines`] lists it.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct Line<'a> {
72    /// Its id.
73    pub id: &'a LineId,
74    /// Who says it: a character id, [`NARRATION_SPEAKER`] or
75    /// [`REPLY_SPEAKER`].
76    pub speaker: &'a str,
77    /// Its text as written (tokens unexpanded).
78    pub text: &'a str,
79    /// How it is said, for a voice or an actor; a reply has none.
80    pub direction: Option<&'a str>,
81}
82
83/// FNV-1a, 64 bits. Written here because line ids are stored in translation
84/// and voice files: the hash must never change (`std`'s hasher may).
85pub(super) fn fnv1a64(bytes: &[u8]) -> u64 {
86    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
87    const PRIME: u64 = 0x0000_0100_0000_01b3;
88    bytes.iter().fold(OFFSET_BASIS, |hash, &b| {
89        (hash ^ u64::from(b)).wrapping_mul(PRIME)
90    })
91}
92
93/// The hash part of a line id: the low 32 bits of FNV-1a 64 over
94/// `speaker`, a newline and `text`, as 8 lowercase hex digits.
95pub fn line_hash(speaker: &str, text: &str) -> String {
96    let hash = fnv1a64(format!("{speaker}\n{text}").as_bytes());
97    format!("{:08x}", hash & 0xffff_ffff)
98}
99
100/// What stands for the speaker in a caption's hash.
101const CAPTION_SPEAKER: &str = "@caption";
102
103/// The key a language pack translates the caption `text` of scene `scene`
104/// by: `caption.<scene id>_<8 hex>`, hashed like a line id.
105/// The same caption twice in a scene has one key.
106pub fn caption_key(scene: &str, text: &str) -> String {
107    format!("caption.{scene}_{}", line_hash(CAPTION_SPEAKER, text))
108}
109
110/// Calls `f(speaker, text, id)` for every line of `steps` that shows text,
111/// in script order: a reply, then its reaction, then the next reply; an
112/// `@if` block's lines, then its `@else`'s.
113fn visit_mut<D>(steps: &mut [Step<D>], f: &mut impl FnMut(&str, &str, &mut LineId)) {
114    for step in steps {
115        match step {
116            Step::Say {
117                speaker,
118                text,
119                line,
120                ..
121            } => f(&speaker.0, text, line),
122            Step::Narrate { text, line, .. } => f(NARRATION_SPEAKER, text, line),
123            Step::Choice { options } => {
124                for o in options {
125                    f(REPLY_SPEAKER, &o.text, &mut o.line);
126                    visit_mut(&mut o.steps, f);
127                }
128            }
129            Step::If {
130                then, otherwise, ..
131            } => {
132                visit_mut(then, f);
133                visit_mut(otherwise, f);
134            }
135            Step::Caption { .. }
136            | Step::Place { .. }
137            | Step::Clear { .. }
138            | Step::Music(_)
139            | Step::Directive(_) => {}
140        }
141    }
142}
143
144/// Adds the lines of `steps` to `out`, in the order of [`visit_mut`].
145fn collect<'a, D>(steps: &'a [Step<D>], out: &mut Vec<Line<'a>>) {
146    for step in steps {
147        match step {
148            Step::Say {
149                speaker,
150                text,
151                direction,
152                line,
153                ..
154            } => out.push(Line {
155                id: line,
156                speaker: &speaker.0,
157                text,
158                direction: direction.as_deref(),
159            }),
160            Step::Narrate {
161                text,
162                direction,
163                line,
164            } => out.push(Line {
165                id: line,
166                speaker: NARRATION_SPEAKER,
167                text,
168                direction: direction.as_deref(),
169            }),
170            Step::Choice { options } => {
171                for o in options {
172                    out.push(Line {
173                        id: &o.line,
174                        speaker: REPLY_SPEAKER,
175                        text: &o.text,
176                        direction: None,
177                    });
178                    collect(&o.steps, out);
179                }
180            }
181            Step::If {
182                then, otherwise, ..
183            } => {
184                collect(then, out);
185                collect(otherwise, out);
186            }
187            Step::Caption { .. }
188            | Step::Place { .. }
189            | Step::Clear { .. }
190            | Step::Music(_)
191            | Step::Directive(_) => {}
192        }
193    }
194}
195
196impl<D> Scene<D> {
197    /// Every line that shows text (speech, narration, replies and the lines
198    /// of their reactions), in script order, whoever is there: both parts
199    /// of every `@if` block.
200    pub fn lines(&self) -> Vec<Line<'_>> {
201        let mut out = Vec::new();
202        collect(&self.steps, &mut out);
203        out
204    }
205
206    /// Gives every line of the scene its id, from the scene id, the
207    /// speakers and the texts as they are now. The parser calls this on
208    /// each scene it reads; call it after building or editing a scene by
209    /// hand.
210    pub fn assign_line_ids(&mut self) {
211        let id = &self.id;
212        let mut seen: BTreeMap<String, u32> = BTreeMap::new();
213        visit_mut(&mut self.steps, &mut |speaker, text, line| {
214            let hash = line_hash(speaker, text);
215            let n = seen.entry(hash.clone()).or_insert(0);
216            *n += 1;
217            *line = LineId(if *n == 1 {
218                format!("{id}_{hash}")
219            } else {
220                format!("{id}_{hash}_{n}")
221            });
222        });
223    }
224}