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::net::Reason;
50use crate::vehicle::Vehicle;
51
52/// The translation tables and the current language. Reach it through `ctx.lang`.
53#[derive(Debug, Clone)]
54pub struct Lang {
55    current: String,
56    fallback: String,
57    tables: BTreeMap<String, HashMap<String, String>>,
58    /// Keys already reported as missing, so each is reported only once.
59    missing: RefCell<HashSet<String>>,
60}
61
62impl Default for Lang {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl Lang {
69    /// Empty tables, English as current and fallback language.
70    pub fn new() -> Self {
71        Self {
72            current: "en".into(),
73            fallback: "en".into(),
74            tables: BTreeMap::new(),
75            missing: RefCell::default(),
76        }
77    }
78
79    /// Loads every `<code>.txt` in `dir`. Returns how many languages were loaded. A missing
80    /// folder is fine and loads nothing.
81    pub fn load_dir(&mut self, dir: impl AsRef<Path>) -> usize {
82        let Ok(entries) = std::fs::read_dir(dir.as_ref()) else {
83            return 0;
84        };
85        let mut count = 0;
86        for path in entries.flatten().map(|e| e.path()) {
87            if !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("txt")) {
88                continue;
89            }
90            let Some(code) = path.file_stem().and_then(|s| s.to_str()) else {
91                continue;
92            };
93            match std::fs::read_to_string(&path) {
94                Ok(text) => {
95                    self.add_text(code, &text);
96                    count += 1;
97                }
98                Err(e) => eprintln!("audio-code: could not read {}: {e}", path.display()),
99            }
100        }
101        count
102    }
103
104    /// Adds the lines of a translation file (`key = text`) to a language. Later keys
105    /// replace earlier ones. Handy with `include_str!` to ship texts inside the executable.
106    pub fn add_text(&mut self, code: &str, text: &str) {
107        let table = self.tables.entry(normalize(code)).or_default();
108        for (number, line) in text.lines().enumerate() {
109            let line = line.trim_start_matches('\u{feff}').trim();
110            if line.is_empty() || line.starts_with('#') {
111                continue;
112            }
113            match line.split_once('=') {
114                Some((key, value)) => {
115                    table.insert(key.trim().to_string(), value.trim().replace("\\n", "\n"));
116                }
117                None => eprintln!("audio-code: language {code}, line {}: expected `key = text`", number + 1),
118            }
119        }
120    }
121
122    /// Adds or replaces one text.
123    pub fn insert(&mut self, code: &str, key: impl Into<String>, text: impl Into<String>) {
124        self.tables.entry(normalize(code)).or_default().insert(key.into(), text.into());
125    }
126
127    /// Switches the language. `"de-AT"` falls back to `"de"`. Returns `false` (and keeps the
128    /// current language) if no texts exist for it. English and German always exist, because
129    /// the engine's own texts are built in. To remember the choice, use
130    /// [`Context::set_language`](crate::Context::set_language)(crate::Context::set_language) instead.
131    pub fn set(&mut self, code: &str) -> bool {
132        match self.resolve(code) {
133            Some(code) => {
134                self.current = code;
135                true
136            }
137            None => {
138                eprintln!("audio-code: no translation for language \"{code}\"");
139                false
140            }
141        }
142    }
143
144    /// The current language code, e.g. `"de"`.
145    pub fn current(&self) -> &str {
146        &self.current
147    }
148
149    /// The language used for keys missing in the current one (default `"en"`).
150    pub fn fallback(&self) -> &str {
151        &self.fallback
152    }
153
154    /// Changes the fallback language.
155    pub fn set_fallback(&mut self, code: &str) {
156        self.fallback = normalize(code);
157    }
158
159    /// The languages with loaded texts, sorted by code. Built in engine texts do not count,
160    /// so this lists exactly the languages your game was translated to.
161    pub fn languages(&self) -> Vec<String> {
162        self.tables.keys().cloned().collect()
163    }
164
165    /// The name of a language as written in its file (key `language`), else the code.
166    pub fn name(&self, code: &str) -> String {
167        let code = normalize(code);
168        self.tables.get(&code).and_then(|t| t.get("language")).cloned().unwrap_or(code)
169    }
170
171    /// Whether `key` has a text in the current or the fallback language.
172    pub fn has(&self, key: &str) -> bool {
173        self.lookup(key).is_some()
174    }
175
176    /// The text for `key` in the current language. See the [module docs](self) for what
177    /// happens when it is missing.
178    pub fn get(&self, key: &str) -> String {
179        match self.lookup(key) {
180            Some(text) => text.to_string(),
181            None => {
182                if self.missing.borrow_mut().insert(key.to_string()) {
183                    eprintln!("audio-code: no text for \"{key}\" in language \"{}\"", self.current);
184                }
185                key.to_string()
186            }
187        }
188    }
189
190    /// The text for `key` with `{name}` placeholders filled in.
191    ///
192    /// ```no_run
193    /// # use audio_code::prelude::*;
194    /// # fn f(ctx: &mut Context) {
195    /// // score = {player} has {points} points
196    /// let text = ctx.lang.format("score", &[("player", &"Anna"), ("points", &12)]);
197    /// # }
198    /// ```
199    pub fn format(&self, key: &str, args: &[(&str, &dyn Display)]) -> String {
200        fill(self.get(key), args)
201    }
202
203    /// Picks `key.one` when `n` is 1 (or -1) and `key.other` otherwise, and fills in `{n}`.
204    /// Without those keys, `key` itself is used.
205    pub fn plural(&self, key: &str, n: i64) -> String {
206        self.plural_with(key, n, &[])
207    }
208
209    /// Like [`Lang::plural`] with more placeholders.
210    pub fn plural_with(&self, key: &str, n: i64, args: &[(&str, &dyn Display)]) -> String {
211        let form = format!("{key}.{}", if n.abs() == 1 { "one" } else { "other" });
212        let text = if self.has(&form) { self.get(&form) } else { self.get(key) };
213        fill(fill(text, &[("n", &n)]), args)
214    }
215
216    /// The player's system language (e.g. `"de"`), if the system tells.
217    pub fn system() -> Option<String> {
218        sys_locale::get_locale().map(|l| normalize(&l)).filter(|l| !l.is_empty())
219    }
220
221    /// Like [`math::direction_name`](crate::math::direction_name) in the current language.
222    pub fn direction_name(&self, degrees: f32) -> String {
223        const KEYS: [&str; 8] = [
224            "direction.north",
225            "direction.north_east",
226            "direction.east",
227            "direction.south_east",
228            "direction.south",
229            "direction.south_west",
230            "direction.west",
231            "direction.north_west",
232        ];
233        let i = ((crate::math::normalize_angle(degrees) + 22.5) / 45.0) as usize % 8;
234        self.get(KEYS[i])
235    }
236
237    /// Like [`math::describe_position`](crate::math::describe_position) in the current
238    /// language, e.g. `"3 Uhr, 12 Meter"`.
239    pub fn describe_position(&self, from: Vec3, yaw: f32, target: Vec3) -> String {
240        let d = target - from;
241        let distance = self.plural("unit.meters", d.length().round() as i64);
242        if d.x.hypot(d.y) < 0.5 && d.z.abs() >= 0.5 {
243            let key = if d.z > 0.0 { "describe.directly_above" } else { "describe.directly_below" };
244            return self.format(key, &[("distance", &distance)]);
245        }
246        let hour = clock_position(angle_difference(yaw, bearing(from, target)));
247        let mut text = format!("{}, {distance}", self.format("describe.clock", &[("hour", &hour)]));
248        let height = d.z.round() as i64;
249        if height != 0 {
250            let key = if height > 0 { "describe.above" } else { "describe.below" };
251            let meters = self.plural("unit.meters", height.abs());
252            text.push_str(", ");
253            text.push_str(&self.format(key, &[("distance", &meters)]));
254        }
255        text
256    }
257
258    /// Like [`Vehicle::describe_speed`] in the current language.
259    pub fn describe_speed(&self, vehicle: &Vehicle) -> String {
260        let speed = self.plural("unit.kmh", vehicle.speed_kmh().abs().round() as i64);
261        if vehicle.is_reversing() { self.format("vehicle.reversing", &[("speed", &speed)]) } else { speed }
262    }
263
264    /// Why a network connection ended, as a sentence in the current language, e.g.
265    /// `"The server is full"`.
266    ///
267    /// ```no_run
268    /// # use audio_code::prelude::*;
269    /// # fn f(ctx: &mut Context) {
270    /// for (_, reason) in ctx.net.left() {
271    ///     ctx.speak(ctx.lang.describe_reason(&reason));
272    /// }
273    /// # }
274    /// ```
275    pub fn describe_reason(&self, reason: &Reason) -> String {
276        match reason {
277            Reason::Left => self.get("net.left"),
278            Reason::Closed => self.get("net.closed"),
279            Reason::Kicked(text) if text.is_empty() => self.get("net.kicked"),
280            Reason::Kicked(text) => self.format("net.kicked_reason", &[("reason", text)]),
281            Reason::ServerFull => self.get("net.server_full"),
282            Reason::WrongGame(game) => self.format("net.wrong_game", &[("game", game)]),
283            Reason::WrongVersion(version) => self.format("net.wrong_version", &[("version", version)]),
284            Reason::TimedOut => self.get("net.timed_out"),
285            Reason::Failed(error) => self.format("net.failed", &[("error", error)]),
286            Reason::Lost(error) => self.format("net.lost", &[("error", error)]),
287        }
288    }
289
290    fn lookup(&self, key: &str) -> Option<&str> {
291        let table = |code: &str| self.tables.get(code).and_then(|t| t.get(key)).map(String::as_str);
292        table(&self.current)
293            .or_else(|| table(&self.fallback))
294            .or_else(|| builtin(&self.current, key))
295            .or_else(|| builtin(&self.fallback, key))
296            .or_else(|| builtin("en", key))
297    }
298
299    /// The code under which texts for `code` exist: the code itself or its main language.
300    pub(crate) fn resolve(&self, code: &str) -> Option<String> {
301        let code = normalize(code);
302        let main = code.split('-').next().unwrap_or_default().to_string();
303        let exists = |c: &str| self.tables.contains_key(c) || BUILTIN.contains(&c);
304        [code, main].into_iter().find(|c| exists(c))
305    }
306
307    /// Like [`Lang::resolve`], but only for languages with loaded texts.
308    pub(crate) fn resolve_loaded(&self, code: &str) -> Option<String> {
309        self.resolve(code).filter(|c| self.tables.contains_key(c))
310    }
311}
312
313/// `"de_DE.UTF-8"` → `"de-de"`.
314fn normalize(code: &str) -> String {
315    let code = code.split(['.', '@']).next().unwrap_or_default();
316    code.trim().replace('_', "-").to_lowercase()
317}
318
319fn fill(mut text: String, args: &[(&str, &dyn Display)]) -> String {
320    for (name, value) in args {
321        let pattern = format!("{{{name}}}");
322        if text.contains(&pattern) {
323            text = text.replace(&pattern, &value.to_string());
324        }
325    }
326    text
327}
328
329/// Languages the engine's own texts are built in for.
330const BUILTIN: [&str; 2] = ["en", "de"];
331
332/// The engine's own texts.
333fn builtin(code: &str, key: &str) -> Option<&'static str> {
334    let text = match code {
335        "en" => match key {
336            "ui.checked" => "checked",
337            "ui.not_checked" => "not checked",
338            "ui.blank" => "blank",
339            "ui.empty" => "empty",
340            "ui.edit" => "{label}, edit, {value}",
341            "ui.characters.one" => "1 character",
342            "ui.characters.other" => "{n} characters",
343            "ui.position" => "{index} of {count}",
344            "ui.yes" => "Yes",
345            "ui.no" => "No",
346            "ui.star" => "star",
347            "char.space" => "space",
348            "char.dot" => "dot",
349            "char.comma" => "comma",
350            "char.exclamation" => "exclamation",
351            "char.question" => "question",
352            "char.dash" => "dash",
353            "char.underscore" => "underscore",
354            "char.colon" => "colon",
355            "char.semicolon" => "semicolon",
356            "char.apostrophe" => "apostrophe",
357            "char.quote" => "quote",
358            "char.slash" => "slash",
359            "char.backslash" => "backslash",
360            "char.at" => "at",
361            "char.number" => "number",
362            "char.left_paren" => "left paren",
363            "char.right_paren" => "right paren",
364            "direction.north" => "north",
365            "direction.north_east" => "north east",
366            "direction.east" => "east",
367            "direction.south_east" => "south east",
368            "direction.south" => "south",
369            "direction.south_west" => "south west",
370            "direction.west" => "west",
371            "direction.north_west" => "north west",
372            "describe.clock" => "{hour} o'clock",
373            "describe.above" => "{distance} above",
374            "describe.below" => "{distance} below",
375            "describe.directly_above" => "directly above, {distance}",
376            "describe.directly_below" => "directly below, {distance}",
377            "unit.meters.one" => "1 meter",
378            "unit.meters.other" => "{n} meters",
379            "unit.kmh.one" => "1 kilometer per hour",
380            "unit.kmh.other" => "{n} kilometers per hour",
381            "vehicle.reversing" => "reversing, {speed}",
382            "net.left" => "Left the game",
383            "net.closed" => "The server was closed",
384            "net.kicked" => "Kicked from the server",
385            "net.kicked_reason" => "Kicked from the server: {reason}",
386            "net.server_full" => "The server is full",
387            "net.wrong_game" => "This server runs another game: {game}",
388            "net.wrong_version" => "Wrong game version. The server has version {version}",
389            "net.timed_out" => "The connection timed out",
390            "net.failed" => "Could not connect: {error}",
391            "net.lost" => "Connection lost: {error}",
392            _ => return None,
393        },
394        "de" => match key {
395            "ui.checked" => "aktiviert",
396            "ui.not_checked" => "nicht aktiviert",
397            "ui.blank" => "leer",
398            "ui.empty" => "leer",
399            "ui.edit" => "{label}, Eingabefeld, {value}",
400            "ui.characters.one" => "1 Zeichen",
401            "ui.characters.other" => "{n} Zeichen",
402            "ui.position" => "{index} von {count}",
403            "ui.yes" => "Ja",
404            "ui.no" => "Nein",
405            "ui.star" => "Stern",
406            "char.space" => "Leerzeichen",
407            "char.dot" => "Punkt",
408            "char.comma" => "Komma",
409            "char.exclamation" => "Ausrufezeichen",
410            "char.question" => "Fragezeichen",
411            "char.dash" => "Bindestrich",
412            "char.underscore" => "Unterstrich",
413            "char.colon" => "Doppelpunkt",
414            "char.semicolon" => "Semikolon",
415            "char.apostrophe" => "Apostroph",
416            "char.quote" => "Anführungszeichen",
417            "char.slash" => "Schrägstrich",
418            "char.backslash" => "Backslash",
419            "char.at" => "at",
420            "char.number" => "Raute",
421            "char.left_paren" => "Klammer auf",
422            "char.right_paren" => "Klammer zu",
423            "direction.north" => "Norden",
424            "direction.north_east" => "Nordosten",
425            "direction.east" => "Osten",
426            "direction.south_east" => "Südosten",
427            "direction.south" => "Süden",
428            "direction.south_west" => "Südwesten",
429            "direction.west" => "Westen",
430            "direction.north_west" => "Nordwesten",
431            "describe.clock" => "{hour} Uhr",
432            "describe.above" => "{distance} darüber",
433            "describe.below" => "{distance} darunter",
434            "describe.directly_above" => "direkt darüber, {distance}",
435            "describe.directly_below" => "direkt darunter, {distance}",
436            "unit.meters.one" => "1 Meter",
437            "unit.meters.other" => "{n} Meter",
438            "unit.kmh.one" => "1 Kilometer pro Stunde",
439            "unit.kmh.other" => "{n} Kilometer pro Stunde",
440            "vehicle.reversing" => "rückwärts, {speed}",
441            "net.left" => "Hat das Spiel verlassen",
442            "net.closed" => "Der Server wurde geschlossen",
443            "net.kicked" => "Vom Server geworfen",
444            "net.kicked_reason" => "Vom Server geworfen: {reason}",
445            "net.server_full" => "Der Server ist voll",
446            "net.wrong_game" => "Auf diesem Server läuft ein anderes Spiel: {game}",
447            "net.wrong_version" => "Falsche Spielversion. Der Server hat Version {version}",
448            "net.timed_out" => "Zeitüberschreitung der Verbindung",
449            "net.failed" => "Verbindung fehlgeschlagen: {error}",
450            "net.lost" => "Verbindung verloren: {error}",
451            _ => return None,
452        },
453        _ => return None,
454    };
455    Some(text)
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    fn lang() -> Lang {
463        let mut lang = Lang::new();
464        lang.add_text("en", "# comment\nlanguage = English\nhello = Hello, {name}!\ncoins.one = 1 coin\ncoins.other = {n} coins\nonly_en = fallback\n");
465        lang.add_text("de", "\u{feff}language = Deutsch\nhello = Hallo, {name}!\nlines = a\\nb\n");
466        lang
467    }
468
469    #[test]
470    fn looks_up_formats_and_falls_back() {
471        let mut lang = lang();
472        assert_eq!(lang.format("hello", &[("name", &"Anna")]), "Hello, Anna!");
473        assert!(lang.set("de_AT.UTF-8"));
474        assert_eq!(lang.current(), "de");
475        assert_eq!(lang.format("hello", &[("name", &"Anna")]), "Hallo, Anna!");
476        assert_eq!(lang.get("only_en"), "fallback");
477        assert_eq!(lang.get("lines"), "a\nb");
478        assert_eq!(lang.get("nothing"), "nothing");
479        assert!(!lang.set("fr"));
480        assert_eq!(lang.current(), "de");
481    }
482
483    #[test]
484    fn plurals_and_names() {
485        let lang = lang();
486        assert_eq!(lang.plural("coins", 1), "1 coin");
487        assert_eq!(lang.plural("coins", 4), "4 coins");
488        assert_eq!(lang.plural("hello", 4), "Hello, {name}!");
489        assert_eq!(lang.languages(), ["de", "en"]);
490        assert_eq!(lang.name("de"), "Deutsch");
491        assert_eq!(lang.name("fr"), "fr");
492    }
493
494    #[test]
495    fn engine_texts_are_built_in() {
496        let mut lang = Lang::new();
497        assert!(lang.set("de"));
498        assert_eq!(lang.get("ui.checked"), "aktiviert");
499        assert_eq!(lang.describe_position(Vec3::ZERO, 0.0, Vec3::new(5.0, 0.0, 0.0)), "3 Uhr, 5 Meter");
500        assert_eq!(lang.describe_position(Vec3::ZERO, 0.0, Vec3::new(0.0, 0.0, -1.0)), "direkt darunter, 1 Meter");
501        assert_eq!(lang.describe_reason(&Reason::ServerFull), "Der Server ist voll");
502        assert_eq!(lang.describe_reason(&Reason::Kicked("Spam".into())), "Vom Server geworfen: Spam");
503        assert_eq!(lang.direction_name(180.0), "Süden");
504        lang.insert("de", "ui.checked", "an");
505        assert_eq!(lang.get("ui.checked"), "an", "game texts win over built in ones");
506        assert!(lang.languages() == ["de"]);
507    }
508}