Skip to main content

audio_code/
lang.rs

1//! Translations: play the same game in several languages. Available as `ctx.lang`.
2//!
3//! Put one text file per language into the `lang` folder next to the game, named by the
4//! language code (`en.txt`, `de.txt`, `fr.txt`, ...). Each line is `key = text`:
5//!
6//! ```text
7//! # Lines starting with # are comments.
8//! language = Deutsch
9//! intro = Willkommen! Drücke Leertaste zum Springen.
10//! score = Du hast {points} Punkte.
11//! coins.one = 1 Münze
12//! coins.other = {n} Münzen
13//! ```
14//!
15//! Then speak keys instead of text:
16//!
17//! ```no_run
18//! # use audio_code::prelude::*;
19//! # fn f(ctx: &mut Context, points: u32) {
20//! ctx.speak(ctx.tr("intro"));
21//! ctx.speak(ctx.tr_with("score", &[("points", &points)]));
22//! ctx.speak(ctx.lang.plural("coins", 3)); // "3 Münzen"
23//! # }
24//! ```
25//!
26//! * The game starts in the language the player chose last time ([`Context::set_language`](crate::Context::set_language)
27//!   saves it), else in [`Engine::language`](crate::Engine::language), else in the system
28//!   language if there is a file for it, else in the fallback language (English).
29//! * A key missing in the current language comes from the fallback language. A key missing
30//!   everywhere is spoken as the key itself and reported once on the console, so a
31//!   forgotten translation never crashes the game.
32//! * `{name}` is replaced by the argument with that name. `\n` in a text is a line break.
33//! * The special key `language` is the language's own name, for a language menu
34//!   ([`Lang::name`]).
35//! * Texts can also be compiled into the game: `ctx.lang.add_text("de", include_str!("de.txt"))`.
36//!
37//! The engine's own announcements (menus, dialogs, [`Context::describe`](crate::Context::describe), vehicle speed) are
38//! translated as well. English and German are built in; other languages, or different
39//! wording, go into your files under the keys listed in `docs/translations.md`.
40
41use std::cell::RefCell;
42use std::collections::{BTreeMap, HashMap, HashSet};
43use std::fmt::Display;
44use std::path::Path;
45
46use glam::Vec3;
47
48use crate::math::{angle_difference, bearing, clock_position};
49use crate::vehicle::Vehicle;
50
51/// The translation tables and the current language. Reach it through `ctx.lang`.
52#[derive(Debug, Clone)]
53pub struct Lang {
54    current: String,
55    fallback: String,
56    tables: BTreeMap<String, HashMap<String, String>>,
57    /// Keys already reported as missing, so each is reported only once.
58    missing: RefCell<HashSet<String>>,
59}
60
61impl Default for Lang {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl Lang {
68    /// Empty tables, English as current and fallback language.
69    pub fn new() -> Self {
70        Self {
71            current: "en".into(),
72            fallback: "en".into(),
73            tables: BTreeMap::new(),
74            missing: RefCell::default(),
75        }
76    }
77
78    /// Loads every `<code>.txt` in `dir`. Returns how many languages were loaded. A missing
79    /// folder is fine and loads nothing.
80    pub fn load_dir(&mut self, dir: impl AsRef<Path>) -> usize {
81        let Ok(entries) = std::fs::read_dir(dir.as_ref()) else {
82            return 0;
83        };
84        let mut count = 0;
85        for path in entries.flatten().map(|e| e.path()) {
86            if !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("txt")) {
87                continue;
88            }
89            let Some(code) = path.file_stem().and_then(|s| s.to_str()) else {
90                continue;
91            };
92            match std::fs::read_to_string(&path) {
93                Ok(text) => {
94                    self.add_text(code, &text);
95                    count += 1;
96                }
97                Err(e) => eprintln!("audio-code: could not read {}: {e}", path.display()),
98            }
99        }
100        count
101    }
102
103    /// Adds the lines of a translation file (`key = text`) to a language. Later keys
104    /// replace earlier ones. Handy with `include_str!` to ship texts inside the executable.
105    pub fn add_text(&mut self, code: &str, text: &str) {
106        let table = self.tables.entry(normalize(code)).or_default();
107        for (number, line) in text.lines().enumerate() {
108            let line = line.trim_start_matches('\u{feff}').trim();
109            if line.is_empty() || line.starts_with('#') {
110                continue;
111            }
112            match line.split_once('=') {
113                Some((key, value)) => {
114                    table.insert(key.trim().to_string(), value.trim().replace("\\n", "\n"));
115                }
116                None => eprintln!("audio-code: language {code}, line {}: expected `key = text`", number + 1),
117            }
118        }
119    }
120
121    /// Adds or replaces one text.
122    pub fn insert(&mut self, code: &str, key: impl Into<String>, text: impl Into<String>) {
123        self.tables.entry(normalize(code)).or_default().insert(key.into(), text.into());
124    }
125
126    /// Switches the language. `"de-AT"` falls back to `"de"`. Returns `false` (and keeps the
127    /// current language) if no texts exist for it. English and German always exist, because
128    /// the engine's own texts are built in. To remember the choice, use
129    /// [`Context::set_language`](crate::Context::set_language)(crate::Context::set_language) instead.
130    pub fn set(&mut self, code: &str) -> bool {
131        match self.resolve(code) {
132            Some(code) => {
133                self.current = code;
134                true
135            }
136            None => {
137                eprintln!("audio-code: no translation for language \"{code}\"");
138                false
139            }
140        }
141    }
142
143    /// The current language code, e.g. `"de"`.
144    pub fn current(&self) -> &str {
145        &self.current
146    }
147
148    /// The language used for keys missing in the current one (default `"en"`).
149    pub fn fallback(&self) -> &str {
150        &self.fallback
151    }
152
153    /// Changes the fallback language.
154    pub fn set_fallback(&mut self, code: &str) {
155        self.fallback = normalize(code);
156    }
157
158    /// The languages with loaded texts, sorted by code. Built in engine texts do not count,
159    /// so this lists exactly the languages your game was translated to.
160    pub fn languages(&self) -> Vec<String> {
161        self.tables.keys().cloned().collect()
162    }
163
164    /// The name of a language as written in its file (key `language`), else the code.
165    pub fn name(&self, code: &str) -> String {
166        let code = normalize(code);
167        self.tables.get(&code).and_then(|t| t.get("language")).cloned().unwrap_or(code)
168    }
169
170    /// Whether `key` has a text in the current or the fallback language.
171    pub fn has(&self, key: &str) -> bool {
172        self.lookup(key).is_some()
173    }
174
175    /// The text for `key` in the current language. See the [module docs](self) for what
176    /// happens when it is missing.
177    pub fn get(&self, key: &str) -> String {
178        match self.lookup(key) {
179            Some(text) => text.to_string(),
180            None => {
181                if self.missing.borrow_mut().insert(key.to_string()) {
182                    eprintln!("audio-code: no text for \"{key}\" in language \"{}\"", self.current);
183                }
184                key.to_string()
185            }
186        }
187    }
188
189    /// The text for `key` with `{name}` placeholders filled in.
190    ///
191    /// ```no_run
192    /// # use audio_code::prelude::*;
193    /// # fn f(ctx: &mut Context) {
194    /// // score = {player} has {points} points
195    /// let text = ctx.lang.format("score", &[("player", &"Anna"), ("points", &12)]);
196    /// # }
197    /// ```
198    pub fn format(&self, key: &str, args: &[(&str, &dyn Display)]) -> String {
199        fill(self.get(key), args)
200    }
201
202    /// Picks `key.one` when `n` is 1 (or -1) and `key.other` otherwise, and fills in `{n}`.
203    /// Without those keys, `key` itself is used.
204    pub fn plural(&self, key: &str, n: i64) -> String {
205        self.plural_with(key, n, &[])
206    }
207
208    /// Like [`Lang::plural`] with more placeholders.
209    pub fn plural_with(&self, key: &str, n: i64, args: &[(&str, &dyn Display)]) -> String {
210        let form = format!("{key}.{}", if n.abs() == 1 { "one" } else { "other" });
211        let text = if self.has(&form) { self.get(&form) } else { self.get(key) };
212        fill(fill(text, &[("n", &n)]), args)
213    }
214
215    /// The player's system language (e.g. `"de"`), if the system tells.
216    pub fn system() -> Option<String> {
217        sys_locale::get_locale().map(|l| normalize(&l)).filter(|l| !l.is_empty())
218    }
219
220    /// Like [`math::direction_name`](crate::math::direction_name) in the current language.
221    pub fn direction_name(&self, degrees: f32) -> String {
222        const KEYS: [&str; 8] = [
223            "direction.north",
224            "direction.north_east",
225            "direction.east",
226            "direction.south_east",
227            "direction.south",
228            "direction.south_west",
229            "direction.west",
230            "direction.north_west",
231        ];
232        let i = ((crate::math::normalize_angle(degrees) + 22.5) / 45.0) as usize % 8;
233        self.get(KEYS[i])
234    }
235
236    /// Like [`math::describe_position`](crate::math::describe_position) in the current
237    /// language, e.g. `"3 Uhr, 12 Meter"`.
238    pub fn describe_position(&self, from: Vec3, yaw: f32, target: Vec3) -> String {
239        let d = target - from;
240        let distance = self.plural("unit.meters", d.length().round() as i64);
241        if d.x.hypot(d.y) < 0.5 && d.z.abs() >= 0.5 {
242            let key = if d.z > 0.0 { "describe.directly_above" } else { "describe.directly_below" };
243            return self.format(key, &[("distance", &distance)]);
244        }
245        let hour = clock_position(angle_difference(yaw, bearing(from, target)));
246        let mut text = format!("{}, {distance}", self.format("describe.clock", &[("hour", &hour)]));
247        let height = d.z.round() as i64;
248        if height != 0 {
249            let key = if height > 0 { "describe.above" } else { "describe.below" };
250            let meters = self.plural("unit.meters", height.abs());
251            text.push_str(", ");
252            text.push_str(&self.format(key, &[("distance", &meters)]));
253        }
254        text
255    }
256
257    /// Like [`Vehicle::describe_speed`] in the current language.
258    pub fn describe_speed(&self, vehicle: &Vehicle) -> String {
259        let speed = self.plural("unit.kmh", vehicle.speed_kmh().abs().round() as i64);
260        if vehicle.is_reversing() { self.format("vehicle.reversing", &[("speed", &speed)]) } else { speed }
261    }
262
263    fn lookup(&self, key: &str) -> Option<&str> {
264        let table = |code: &str| self.tables.get(code).and_then(|t| t.get(key)).map(String::as_str);
265        table(&self.current)
266            .or_else(|| table(&self.fallback))
267            .or_else(|| builtin(&self.current, key))
268            .or_else(|| builtin(&self.fallback, key))
269            .or_else(|| builtin("en", key))
270    }
271
272    /// The code under which texts for `code` exist: the code itself or its main language.
273    pub(crate) fn resolve(&self, code: &str) -> Option<String> {
274        let code = normalize(code);
275        let main = code.split('-').next().unwrap_or_default().to_string();
276        let exists = |c: &str| self.tables.contains_key(c) || BUILTIN.contains(&c);
277        [code, main].into_iter().find(|c| exists(c))
278    }
279
280    /// Like [`Lang::resolve`], but only for languages with loaded texts.
281    pub(crate) fn resolve_loaded(&self, code: &str) -> Option<String> {
282        self.resolve(code).filter(|c| self.tables.contains_key(c))
283    }
284}
285
286/// `"de_DE.UTF-8"` → `"de-de"`.
287fn normalize(code: &str) -> String {
288    let code = code.split(['.', '@']).next().unwrap_or_default();
289    code.trim().replace('_', "-").to_lowercase()
290}
291
292fn fill(mut text: String, args: &[(&str, &dyn Display)]) -> String {
293    for (name, value) in args {
294        let pattern = format!("{{{name}}}");
295        if text.contains(&pattern) {
296            text = text.replace(&pattern, &value.to_string());
297        }
298    }
299    text
300}
301
302/// Languages the engine's own texts are built in for.
303const BUILTIN: [&str; 2] = ["en", "de"];
304
305/// The engine's own texts.
306fn builtin(code: &str, key: &str) -> Option<&'static str> {
307    let text = match code {
308        "en" => match key {
309            "ui.checked" => "checked",
310            "ui.not_checked" => "not checked",
311            "ui.blank" => "blank",
312            "ui.empty" => "empty",
313            "ui.edit" => "{label}, edit, {value}",
314            "ui.characters.one" => "1 character",
315            "ui.characters.other" => "{n} characters",
316            "ui.position" => "{index} of {count}",
317            "ui.yes" => "Yes",
318            "ui.no" => "No",
319            "ui.star" => "star",
320            "char.space" => "space",
321            "char.dot" => "dot",
322            "char.comma" => "comma",
323            "char.exclamation" => "exclamation",
324            "char.question" => "question",
325            "char.dash" => "dash",
326            "char.underscore" => "underscore",
327            "char.colon" => "colon",
328            "char.semicolon" => "semicolon",
329            "char.apostrophe" => "apostrophe",
330            "char.quote" => "quote",
331            "char.slash" => "slash",
332            "char.backslash" => "backslash",
333            "char.at" => "at",
334            "char.number" => "number",
335            "char.left_paren" => "left paren",
336            "char.right_paren" => "right paren",
337            "direction.north" => "north",
338            "direction.north_east" => "north east",
339            "direction.east" => "east",
340            "direction.south_east" => "south east",
341            "direction.south" => "south",
342            "direction.south_west" => "south west",
343            "direction.west" => "west",
344            "direction.north_west" => "north west",
345            "describe.clock" => "{hour} o'clock",
346            "describe.above" => "{distance} above",
347            "describe.below" => "{distance} below",
348            "describe.directly_above" => "directly above, {distance}",
349            "describe.directly_below" => "directly below, {distance}",
350            "unit.meters.one" => "1 meter",
351            "unit.meters.other" => "{n} meters",
352            "unit.kmh.one" => "1 kilometer per hour",
353            "unit.kmh.other" => "{n} kilometers per hour",
354            "vehicle.reversing" => "reversing, {speed}",
355            _ => return None,
356        },
357        "de" => match key {
358            "ui.checked" => "aktiviert",
359            "ui.not_checked" => "nicht aktiviert",
360            "ui.blank" => "leer",
361            "ui.empty" => "leer",
362            "ui.edit" => "{label}, Eingabefeld, {value}",
363            "ui.characters.one" => "1 Zeichen",
364            "ui.characters.other" => "{n} Zeichen",
365            "ui.position" => "{index} von {count}",
366            "ui.yes" => "Ja",
367            "ui.no" => "Nein",
368            "ui.star" => "Stern",
369            "char.space" => "Leerzeichen",
370            "char.dot" => "Punkt",
371            "char.comma" => "Komma",
372            "char.exclamation" => "Ausrufezeichen",
373            "char.question" => "Fragezeichen",
374            "char.dash" => "Bindestrich",
375            "char.underscore" => "Unterstrich",
376            "char.colon" => "Doppelpunkt",
377            "char.semicolon" => "Semikolon",
378            "char.apostrophe" => "Apostroph",
379            "char.quote" => "Anführungszeichen",
380            "char.slash" => "Schrägstrich",
381            "char.backslash" => "Backslash",
382            "char.at" => "at",
383            "char.number" => "Raute",
384            "char.left_paren" => "Klammer auf",
385            "char.right_paren" => "Klammer zu",
386            "direction.north" => "Norden",
387            "direction.north_east" => "Nordosten",
388            "direction.east" => "Osten",
389            "direction.south_east" => "Südosten",
390            "direction.south" => "Süden",
391            "direction.south_west" => "Südwesten",
392            "direction.west" => "Westen",
393            "direction.north_west" => "Nordwesten",
394            "describe.clock" => "{hour} Uhr",
395            "describe.above" => "{distance} darüber",
396            "describe.below" => "{distance} darunter",
397            "describe.directly_above" => "direkt darüber, {distance}",
398            "describe.directly_below" => "direkt darunter, {distance}",
399            "unit.meters.one" => "1 Meter",
400            "unit.meters.other" => "{n} Meter",
401            "unit.kmh.one" => "1 Kilometer pro Stunde",
402            "unit.kmh.other" => "{n} Kilometer pro Stunde",
403            "vehicle.reversing" => "rückwärts, {speed}",
404            _ => return None,
405        },
406        _ => return None,
407    };
408    Some(text)
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    fn lang() -> Lang {
416        let mut lang = Lang::new();
417        lang.add_text("en", "# comment\nlanguage = English\nhello = Hello, {name}!\ncoins.one = 1 coin\ncoins.other = {n} coins\nonly_en = fallback\n");
418        lang.add_text("de", "\u{feff}language = Deutsch\nhello = Hallo, {name}!\nlines = a\\nb\n");
419        lang
420    }
421
422    #[test]
423    fn looks_up_formats_and_falls_back() {
424        let mut lang = lang();
425        assert_eq!(lang.format("hello", &[("name", &"Anna")]), "Hello, Anna!");
426        assert!(lang.set("de_AT.UTF-8"));
427        assert_eq!(lang.current(), "de");
428        assert_eq!(lang.format("hello", &[("name", &"Anna")]), "Hallo, Anna!");
429        assert_eq!(lang.get("only_en"), "fallback");
430        assert_eq!(lang.get("lines"), "a\nb");
431        assert_eq!(lang.get("nothing"), "nothing");
432        assert!(!lang.set("fr"));
433        assert_eq!(lang.current(), "de");
434    }
435
436    #[test]
437    fn plurals_and_names() {
438        let lang = lang();
439        assert_eq!(lang.plural("coins", 1), "1 coin");
440        assert_eq!(lang.plural("coins", 4), "4 coins");
441        assert_eq!(lang.plural("hello", 4), "Hello, {name}!");
442        assert_eq!(lang.languages(), ["de", "en"]);
443        assert_eq!(lang.name("de"), "Deutsch");
444        assert_eq!(lang.name("fr"), "fr");
445    }
446
447    #[test]
448    fn engine_texts_are_built_in() {
449        let mut lang = Lang::new();
450        assert!(lang.set("de"));
451        assert_eq!(lang.get("ui.checked"), "aktiviert");
452        assert_eq!(lang.describe_position(Vec3::ZERO, 0.0, Vec3::new(5.0, 0.0, 0.0)), "3 Uhr, 5 Meter");
453        assert_eq!(lang.describe_position(Vec3::ZERO, 0.0, Vec3::new(0.0, 0.0, -1.0)), "direkt darunter, 1 Meter");
454        assert_eq!(lang.direction_name(180.0), "Süden");
455        lang.insert("de", "ui.checked", "an");
456        assert_eq!(lang.get("ui.checked"), "an", "game texts win over built in ones");
457        assert!(lang.languages() == ["de"]);
458    }
459}