mirror of
https://github.com/RYDE-WORK/ballistica.git
synced 2026-01-30 19:23:20 +08:00
412 lines
15 KiB
Python
412 lines
15 KiB
Python
"""Language related functionality."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from typing import TYPE_CHECKING
|
|
|
|
import _ba
|
|
|
|
if TYPE_CHECKING:
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
class Lstr:
|
|
"""Used to specify strings in a language-independent way.
|
|
|
|
category: General Utility Classes
|
|
|
|
These should be used whenever possible in place of hard-coded strings
|
|
so that in-game or UI elements show up correctly on all clients in their
|
|
currently-active language.
|
|
|
|
To see available resource keys, look at any of the bs_language_*.py files
|
|
in the game or the translations pages at bombsquadgame.com/translate.
|
|
|
|
# EXAMPLE 1: specify a string from a resource path
|
|
mynode.text = ba.Lstr(resource='audioSettingsWindow.titleText')
|
|
|
|
# EXAMPLE 2: specify a translated string via a category and english value;
|
|
# if a translated value is available, it will be used; otherwise the
|
|
# english value will be. To see available translation categories, look
|
|
# under the 'translations' resource section.
|
|
mynode.text = ba.Lstr(translate=('gameDescriptions', 'Defeat all enemies'))
|
|
|
|
# EXAMPLE 3: specify a raw value and some substitutions. Substitutions can
|
|
# be used with resource and translate modes as well.
|
|
mynode.text = ba.Lstr(value='${A} / ${B}',
|
|
subs=[('${A}', str(score)), ('${B}', str(total))])
|
|
|
|
# EXAMPLE 4: Lstrs can be nested. This example would display the resource
|
|
# at res_a but replace ${NAME} with the value of the resource at res_b
|
|
mytextnode.text = ba.Lstr(resource='res_a',
|
|
subs=[('${NAME}', ba.Lstr(resource='res_b'))])
|
|
"""
|
|
|
|
def __init__(self, *args: Any, **keywds: Any) -> None:
|
|
"""Instantiate a Lstr.
|
|
|
|
Pass a value for either 'resource', 'translate',
|
|
or 'value'. (see Lstr help for examples).
|
|
'subs' can be a sequence of 2-member sequences consisting of values
|
|
and replacements.
|
|
'fallback_resource' can be a resource key that will be used if the
|
|
main one is not present for
|
|
the current language in place of falling back to the english value
|
|
('resource' mode only).
|
|
'fallback_value' can be a literal string that will be used if neither
|
|
the resource nor the fallback resource is found ('resource' mode only).
|
|
"""
|
|
# pylint: disable=too-many-branches
|
|
if args:
|
|
raise Exception('Lstr accepts only keyword arguments')
|
|
|
|
# Basically just store the exact args they passed.
|
|
# However if they passed any Lstr values for subs,
|
|
# replace them with that Lstr's dict.
|
|
self.args = keywds
|
|
our_type = type(self)
|
|
|
|
if isinstance(self.args.get('value'), our_type):
|
|
raise Exception("'value' must be a regular string; not an Lstr")
|
|
|
|
if 'subs' in self.args:
|
|
subs_new = []
|
|
for key, value in keywds['subs']:
|
|
if isinstance(value, our_type):
|
|
subs_new.append((key, value.args))
|
|
else:
|
|
subs_new.append((key, value))
|
|
self.args['subs'] = subs_new
|
|
|
|
# As of protocol 31 we support compact key names
|
|
# ('t' instead of 'translate', etc). Convert as needed.
|
|
if 'translate' in keywds:
|
|
keywds['t'] = keywds['translate']
|
|
del keywds['translate']
|
|
if 'resource' in keywds:
|
|
keywds['r'] = keywds['resource']
|
|
del keywds['resource']
|
|
if 'value' in keywds:
|
|
keywds['v'] = keywds['value']
|
|
del keywds['value']
|
|
if 'fallback' in keywds:
|
|
from ba import _error
|
|
_error.print_error(
|
|
'deprecated "fallback" arg passed to Lstr(); use '
|
|
'either "fallback_resource" or "fallback_value"',
|
|
once=True)
|
|
keywds['f'] = keywds['fallback']
|
|
del keywds['fallback']
|
|
if 'fallback_resource' in keywds:
|
|
keywds['f'] = keywds['fallback_resource']
|
|
del keywds['fallback_resource']
|
|
if 'subs' in keywds:
|
|
keywds['s'] = keywds['subs']
|
|
del keywds['subs']
|
|
if 'fallback_value' in keywds:
|
|
keywds['fv'] = keywds['fallback_value']
|
|
del keywds['fallback_value']
|
|
|
|
def evaluate(self) -> str:
|
|
"""Evaluate the Lstr and returns a flat string in the current language.
|
|
|
|
You should avoid doing this as much as possible and instead pass
|
|
and store Lstr values.
|
|
"""
|
|
return _ba.evaluate_lstr(self._get_json())
|
|
|
|
def is_flat_value(self) -> bool:
|
|
"""Return whether the Lstr is a 'flat' value.
|
|
|
|
This is defined as a simple string value incorporating no translations,
|
|
resources, or substitutions. In this case it may be reasonable to
|
|
replace it with a raw string value, perform string manipulation on it,
|
|
etc.
|
|
"""
|
|
return bool('v' in self.args and not self.args.get('s', []))
|
|
|
|
def _get_json(self) -> str:
|
|
try:
|
|
return json.dumps(self.args, separators=(',', ':'))
|
|
except Exception:
|
|
from ba import _error
|
|
_error.print_exception('_get_json failed for', self.args)
|
|
return 'JSON_ERR'
|
|
|
|
def __str__(self) -> str:
|
|
return '<ba.Lstr: ' + self._get_json() + '>'
|
|
|
|
def __repr__(self) -> str:
|
|
return '<ba.Lstr: ' + self._get_json() + '>'
|
|
|
|
|
|
def setlanguage(language: Optional[str],
|
|
print_change: bool = True,
|
|
store_to_config: bool = True) -> None:
|
|
"""Set the active language used for the game.
|
|
|
|
category: General Utility Functions
|
|
|
|
Pass None to use OS default language.
|
|
"""
|
|
# pylint: disable=too-many-locals
|
|
# pylint: disable=too-many-branches
|
|
cfg = _ba.app.config
|
|
cur_language = cfg.get('Lang', None)
|
|
|
|
# Store this in the config if its changing.
|
|
if language != cur_language and store_to_config:
|
|
if language is None:
|
|
if 'Lang' in cfg:
|
|
del cfg['Lang'] # Clear it out for default.
|
|
else:
|
|
cfg['Lang'] = language
|
|
cfg.commit()
|
|
switched = True
|
|
else:
|
|
switched = False
|
|
|
|
with open('data/data/languages/english.json') as infile:
|
|
lenglishvalues = json.loads(infile.read())
|
|
|
|
# None implies default.
|
|
if language is None:
|
|
language = _ba.app.default_language
|
|
try:
|
|
if language == 'English':
|
|
lmodvalues = None
|
|
else:
|
|
lmodfile = 'data/data/languages/' + language.lower() + '.json'
|
|
with open(lmodfile) as infile:
|
|
lmodvalues = json.loads(infile.read())
|
|
except Exception:
|
|
from ba import _error
|
|
_error.print_exception('Exception importing language:', language)
|
|
_ba.screenmessage("Error setting language to '" + language +
|
|
"'; see log for details",
|
|
color=(1, 0, 0))
|
|
switched = False
|
|
lmodvalues = None
|
|
|
|
# Create an attrdict of *just* our target language.
|
|
_ba.app.language_target = AttrDict()
|
|
langtarget = _ba.app.language_target
|
|
assert langtarget is not None
|
|
_add_to_attr_dict(langtarget,
|
|
lmodvalues if lmodvalues is not None else lenglishvalues)
|
|
|
|
# Create an attrdict of our target language overlaid on our base (english).
|
|
languages = [lenglishvalues]
|
|
if lmodvalues is not None:
|
|
languages.append(lmodvalues)
|
|
lfull = AttrDict()
|
|
for lmod in languages:
|
|
_add_to_attr_dict(lfull, lmod)
|
|
_ba.app.language_merged = lfull
|
|
|
|
# Pass some keys/values in for low level code to use;
|
|
# start with everything in their 'internal' section.
|
|
internal_vals = [
|
|
v for v in list(lfull['internal'].items()) if isinstance(v[1], str)
|
|
]
|
|
|
|
# Cherry-pick various other values to include.
|
|
# (should probably get rid of the 'internal' section
|
|
# and do everything this way)
|
|
for value in [
|
|
'replayNameDefaultText', 'replayWriteErrorText',
|
|
'replayVersionErrorText', 'replayReadErrorText'
|
|
]:
|
|
internal_vals.append((value, lfull[value]))
|
|
internal_vals.append(
|
|
('axisText', lfull['configGamepadWindow']['axisText']))
|
|
lmerged = _ba.app.language_merged
|
|
assert lmerged is not None
|
|
random_names = [
|
|
n.strip() for n in lmerged['randomPlayerNamesText'].split(',')
|
|
]
|
|
random_names = [n for n in random_names if n != '']
|
|
_ba.set_internal_language_keys(internal_vals, random_names)
|
|
if switched and print_change:
|
|
_ba.screenmessage(Lstr(resource='languageSetText',
|
|
subs=[('${LANGUAGE}',
|
|
Lstr(translate=('languages', language)))
|
|
]),
|
|
color=(0, 1, 0))
|
|
|
|
|
|
def _add_to_attr_dict(dst: AttrDict, src: Dict) -> None:
|
|
for key, value in list(src.items()):
|
|
if isinstance(value, dict):
|
|
try:
|
|
dst_dict = dst[key]
|
|
except Exception:
|
|
dst_dict = dst[key] = AttrDict()
|
|
if not isinstance(dst_dict, AttrDict):
|
|
raise Exception("language key '" + key +
|
|
"' is defined both as a dict and value")
|
|
_add_to_attr_dict(dst_dict, value)
|
|
else:
|
|
if not isinstance(value, (float, int, bool, str, str, type(None))):
|
|
raise Exception("invalid value type for res '" + key + "': " +
|
|
str(type(value)))
|
|
dst[key] = value
|
|
|
|
|
|
class AttrDict(dict):
|
|
"""A dict that can be accessed with dot notation.
|
|
|
|
(so foo.bar is equivalent to foo['bar'])
|
|
"""
|
|
|
|
def __getattr__(self, attr: str) -> Any:
|
|
val = self[attr]
|
|
assert not isinstance(val, bytes)
|
|
return val
|
|
|
|
def __setattr__(self, attr: str, value: Any) -> None:
|
|
raise Exception()
|
|
|
|
|
|
def get_resource(resource: str,
|
|
fallback_resource: str = None,
|
|
fallback_value: Any = None) -> Any:
|
|
"""Return a translation resource by name."""
|
|
try:
|
|
# If we have no language set, go ahead and set it.
|
|
if _ba.app.language_merged is None:
|
|
language = _ba.app.language
|
|
try:
|
|
setlanguage(language,
|
|
print_change=False,
|
|
store_to_config=False)
|
|
except Exception:
|
|
from ba import _error
|
|
_error.print_exception('exception setting language to',
|
|
language)
|
|
|
|
# Try english as a fallback.
|
|
if language != 'English':
|
|
print('Resorting to fallback language (English)')
|
|
try:
|
|
setlanguage('English',
|
|
print_change=False,
|
|
store_to_config=False)
|
|
except Exception:
|
|
_error.print_exception(
|
|
'error setting language to english fallback')
|
|
|
|
# If they provided a fallback_resource value, try the
|
|
# target-language-only dict first and then fall back to trying the
|
|
# fallback_resource value in the merged dict.
|
|
if fallback_resource is not None:
|
|
try:
|
|
values = _ba.app.language_target
|
|
splits = resource.split('.')
|
|
dicts = splits[:-1]
|
|
key = splits[-1]
|
|
for dct in dicts:
|
|
assert values is not None
|
|
values = values[dct]
|
|
assert values is not None
|
|
val = values[key]
|
|
return val
|
|
except Exception:
|
|
# FIXME: Shouldn't we try the fallback resource in the merged
|
|
# dict AFTER we try the main resource in the merged dict?
|
|
try:
|
|
values = _ba.app.language_merged
|
|
splits = fallback_resource.split('.')
|
|
dicts = splits[:-1]
|
|
key = splits[-1]
|
|
for dct in dicts:
|
|
assert values is not None
|
|
values = values[dct]
|
|
assert values is not None
|
|
val = values[key]
|
|
return val
|
|
|
|
except Exception:
|
|
# If we got nothing for fallback_resource, default to the
|
|
# normal code which checks or primary value in the merge
|
|
# dict; there's a chance we can get an english value for
|
|
# it (which we weren't looking for the first time through).
|
|
pass
|
|
|
|
values = _ba.app.language_merged
|
|
splits = resource.split('.')
|
|
dicts = splits[:-1]
|
|
key = splits[-1]
|
|
for dct in dicts:
|
|
assert values is not None
|
|
values = values[dct]
|
|
assert values is not None
|
|
val = values[key]
|
|
return val
|
|
|
|
except Exception:
|
|
# Ok, looks like we couldn't find our main or fallback resource
|
|
# anywhere. Now if we've been given a fallback value, return it;
|
|
# otherwise fail.
|
|
if fallback_value is not None:
|
|
return fallback_value
|
|
raise Exception("resource not found: '" + resource + "'")
|
|
|
|
|
|
def translate(category: str,
|
|
strval: str,
|
|
raise_exceptions: bool = False,
|
|
print_errors: bool = False) -> str:
|
|
"""Translate a value (or return the value if no translation available)
|
|
|
|
Generally you should use ba.Lstr which handles dynamic translation,
|
|
as opposed to this which returns a flat string.
|
|
"""
|
|
try:
|
|
translated = get_resource('translations')[category][strval]
|
|
except Exception as exc:
|
|
if raise_exceptions:
|
|
raise
|
|
if print_errors:
|
|
print(('Translate error: category=\'' + category + '\' name=\'' +
|
|
strval + '\' exc=' + str(exc) + ''))
|
|
translated = None
|
|
translated_out: str
|
|
if translated is None:
|
|
translated_out = strval
|
|
else:
|
|
translated_out = translated
|
|
assert isinstance(translated_out, str)
|
|
return translated_out
|
|
|
|
|
|
def get_valid_languages() -> List[str]:
|
|
"""Return a list containing names of all available languages.
|
|
|
|
category: General Utility Functions
|
|
|
|
Languages that may be present but are not displayable on the running
|
|
version of the game are ignored.
|
|
"""
|
|
langs = set()
|
|
app = _ba.app
|
|
try:
|
|
names = os.listdir('data/data/languages')
|
|
names = [n.replace('.json', '').capitalize() for n in names]
|
|
except Exception:
|
|
from ba import _error
|
|
_error.print_exception()
|
|
names = []
|
|
for name in names:
|
|
if app.can_display_language(name):
|
|
langs.add(name)
|
|
return sorted(name for name in names if app.can_display_language(name))
|
|
|
|
|
|
def is_custom_unicode_char(char: str) -> bool:
|
|
"""Return whether a char is in the custom unicode range we use."""
|
|
if not isinstance(char, str) or len(char) != 1:
|
|
raise Exception("Invalid Input; not unicode or not length 1")
|
|
return 0xE000 <= ord(char) <= 0xF8FF
|