From 43247354486260662f91653b2873175bd1d2d0b6 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 3 Aug 2021 22:19:29 -0400 Subject: [PATCH 1/4] Add support pfLocEditor round-trips. Splits up MOUL/EOA localization files into per-language database thingos. Also fixes a bug with the PotS encoding escape hatch. --- korman/exporter/locman.py | 130 ++++++++++++++++++++++------------ korman/operators/op_export.py | 8 +++ korman/ui/ui_world.py | 1 + 3 files changed, 92 insertions(+), 47 deletions(-) diff --git a/korman/exporter/locman.py b/korman/exporter/locman.py index 979fe4a..8439a76 100644 --- a/korman/exporter/locman.py +++ b/korman/exporter/locman.py @@ -14,17 +14,25 @@ # along with Korman. If not, see . import bpy +from PyHSPlasma import * + from contextlib import contextmanager +import itertools +from pathlib import Path +import re +from xml.sax.saxutils import escape as xml_escape +import weakref + from .explosions import NonfatalExportError from .. import korlib from . import logger -from pathlib import Path -from PyHSPlasma import * -import weakref -from xml.sax.saxutils import escape as xml_escape _SP_LANGUAGES = {"English", "French", "German", "Italian", "Spanish"} +# Detects if there are any Plasma esHTML tags in the translated data. If so, we store +# as CDATA instead of XML encoding the entry. +_ESHTML_REGEX = re.compile("<.+>") + class LocalizationConverter: def __init__(self, exporter=None, **kwargs): if exporter is not None: @@ -46,11 +54,15 @@ class LocalizationConverter: name, language, indent=indent) journal = self._journals.setdefault(name, {}) journal[language] = text_id.as_string() + return True def add_string(self, set_name, element_name, language, value): trans_set = self._strings.setdefault(set_name, {}) trans_element = trans_set.setdefault(element_name, {}) trans_element[language] = value + if self._exporter is not None and self._exporter().mgr.getVer() <= pvPots: + return False + return True @contextmanager def _generate_file(self, filename, **kwargs): @@ -68,21 +80,21 @@ class LocalizationConverter: finally: handle.close() - def _generate_journal_texts(self): + def _generate_text_files(self): age_name = self._age_name - def write_journal_file(language, file_name, contents): - try: - with self._generate_file(dirname="ageresources", filename=file_name) as stream: + def write_text_file(language, file_name, contents): + with self._generate_file(dirname="ageresources", filename=file_name) as stream: + try: stream.write(contents.encode("windows-1252")) - except UnicodeEncodeError: - self._report.warn("Translation '{}': Contents contains characters that cannot be used in this version of Plasma. They will appear as a '?' in game.", - language, indent=2) + except UnicodeEncodeError: + self._report.warn("Translation '{}': Contents contains characters that cannot be used in this version of Plasma. They will appear as a '?' in game.", + language, indent=2) - # Yes, there are illegal characters... As a stopgap, we will export the file with - # replacement characters ("?") just so it'll work dammit. - stream.write(contents.encode("windows-1252", "replace")) - return True + # Yes, there are illegal characters... As a stopgap, we will export the file with + # replacement characters ("?") just so it'll work dammit. + stream.write(contents.encode("windows-1252", "replace")) + return True for journal_name, translations in self._journals.items(): self._report.msg("Copying Journal '{}'", journal_name, indent=1) @@ -93,7 +105,7 @@ class LocalizationConverter: continue suffix = "_{}".format(language_name.lower()) if language_name != "English" else "" file_name = "{}--{}{}.txt".format(age_name, journal_name, suffix) - write_journal_file(language_name, file_name, value) + write_text_file(language_name, file_name, value) # Ensure that default (read: "English") journal is available if "English" not in translations: @@ -102,55 +114,79 @@ class LocalizationConverter: if language_name is not None: file_name = "{}--{}.txt".format(age_name, journal_name) # If you manage to screw up this badly... Well, I am very sorry. - if write_journal_file(language_name, file_name, value): + if write_text_file(language_name, file_name, value): self._report.warn("No 'English' translation available, so '{}' will be used as the default", language_name, indent=2) else: self._report.port("No 'English' nor any other suitable default translation available", indent=2) - def _generate_loc_file(self): - # Only generate this junk if needed - if not self._strings and not self._journals: + def _generate_loc_files(self): + set_LUT = { + "Journals": self._journals + } + + # Merge in any manual strings, but error if dupe sets are encountered. + special_sets, string_sets = frozenset(set_LUT.keys()), frozenset(self._strings.keys()) + intersection = special_sets & string_sets + assert not intersection, "Duplicate localization sets: {}".format(" ".join(intersection)) + set_LUT.update(self._strings) + + if not any(itertools.chain.from_iterable(set_LUT.values())): return + method = bpy.context.scene.world.plasma_age.localization_method + if method == "single_file": + self._generate_loc_file("{}.loc".format(self._age_name), set_LUT) + elif method in {"database", "database_back_compat"}: + # Where the strings are set -> element -> language: str, we want language -> set -> element: str + # This is so we can mimic pfLocalizationEditor's English.loc pathing. + database = {} + for set_name, elements in set_LUT.items(): + for element_name, translations in elements.items(): + for language_name, value in translations.items(): + database.setdefault(language_name, {}).setdefault(set_name, {})[element_name] = value + + for language_name, sets in database.items(): + self._generate_loc_file("{}{}.loc".format(self._age_name, language_name), sets, language_name) + + # Generate an empty localization file to defeat any old ones from Korman 0.11 (and lower) + if method == "database_back_compat": + self._generate_loc_file("{}.loc".format(self._age_name), {}) + else: + raise RuntimeError("Unexpected localization method {}".format(method)) + + def _generate_loc_file(self, filename, sets, language_name=None): def write_line(value, *args, **kwargs): # tabs suck, then you die... whitespace = " " * kwargs.pop("indent", 0) if args or kwargs: value = value.format(*args, **kwargs) line = "".join((whitespace, value, "\n")) - stream.write(line.encode("utf-16_le")) + stream.write(line.encode("utf-8")) - age_name = self._age_name - enc = plEncryptedStream.kEncAes if self._version == pvEoa else None - file_name = "{}.loc".format(age_name) - with self._generate_file(file_name, enc=enc) as stream: - # UTF-16 little endian byte order mark - stream.write(b"\xFF\xFE") + def iter_element(element): + if language_name is None: + yield from element.items() + else: + yield language_name, element - write_line("") + enc = plEncryptedStream.kEncAes if self._version == pvEoa else None + with self._generate_file(filename, enc=enc) as stream: + write_line("") write_line("") - write_line("", age_name, indent=1) + write_line("", self._age_name, indent=1) - # Arbitrary strings defined by something like a GUI or a node tree - for set_name, elements in self._strings.items(): + for set_name, elements in sets.items(): write_line("", set_name, indent=2) - for element_name, translations in elements.items(): + for element_name, value in elements.items(): write_line("", element_name, indent=3) - for language_name, value in translations.items(): - write_line("{translation}", - language=language_name, translation=xml_escape(value), indent=4) - write_line("", indent=3) - write_line("", indent=2) - - # Journals - if self._journals: - write_line("", indent=2) - for journal_name, translations in self._journals.items(): - write_line("", journal_name, indent=3) - for language_name, value in translations.items(): + for translation_language, translation_value in iter_element(value): + if _ESHTML_REGEX.search(translation_value): + encoded_value = "".format(translation_value) + else: + encoded_value = xml_escape(translation_value) write_line("{translation}", - language=language_name, translation=xml_escape(value), indent=4) + language=translation_language, translation=encoded_value, indent=4) write_line("", indent=3) write_line("", indent=2) @@ -198,6 +234,6 @@ class LocalizationConverter: def save(self): if self._version > pvPots: - self._generate_loc_file() + self._generate_loc_files() else: - self._generate_journal_texts() + self._generate_text_files() diff --git a/korman/operators/op_export.py b/korman/operators/op_export.py index 7ccfa8b..5f41e18 100644 --- a/korman/operators/op_export.py +++ b/korman/operators/op_export.py @@ -96,6 +96,14 @@ class PlasmaAgeExportOperator(ExportOperator, bpy.types.Operator): "default": "as_requested", "options": set()}), + "localization_method": (EnumProperty, {"name": "Localization", + "description": "Specifies how localization data should be exported", + "items": [("database", "Localization Database", "A per-language database compatible with pfLocalizationEditor"), + ("database_back_compat", "Localization Database (Compat Mode)", "A per-language database compatible with pfLocalizationEditor and Korman <=0.11"), + ("single_file", "Single File", "A single file database, as in Korman <=0.11")], + "default": "database", + "options": set()}), + "export_active": (BoolProperty, {"name": "INTERNAL: Export currently running", "default": False, "options": {"SKIP_SAVE"}}), diff --git a/korman/ui/ui_world.py b/korman/ui/ui_world.py index 04e3d9a..4fb0792 100644 --- a/korman/ui/ui_world.py +++ b/korman/ui/ui_world.py @@ -276,6 +276,7 @@ class PlasmaAgePanel(AgeButtonsPanel, bpy.types.Panel): layout.separator() layout.prop(age, "envmap_method") layout.prop(age, "lighting_method") + layout.prop(age, "localization_method") layout.prop(age, "python_method") layout.prop(age, "texcache_method") From bf49fb1284cdeeee36eb1006c4a395c62d4ed0ea Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Mon, 16 Aug 2021 06:13:43 -0400 Subject: [PATCH 2/4] Add Python attribute node creation helper. --- korman/nodes/node_python.py | 8 ++++++++ korman/properties/modifiers/base.py | 24 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/korman/nodes/node_python.py b/korman/nodes/node_python.py index 817d651..b924ae9 100644 --- a/korman/nodes/node_python.py +++ b/korman/nodes/node_python.py @@ -138,6 +138,14 @@ class PlasmaAttributeArguments(bpy.types.PropertyGroup): class PlasmaAttribute(bpy.types.PropertyGroup): + # This is thy lookup helper + type_LUT = { + bool: "ptAttribBoolean", + float: "ptAttribFloat", + int: "ptAttribInt", + str: "ptAttribString", + } + attribute_id = IntProperty() attribute_type = StringProperty() attribute_name = StringProperty() diff --git a/korman/properties/modifiers/base.py b/korman/properties/modifiers/base.py index 388e4e8..247d2df 100644 --- a/korman/properties/modifiers/base.py +++ b/korman/properties/modifiers/base.py @@ -17,7 +17,7 @@ import bpy from bpy.props import * import abc -from typing import Any, Dict, Generator +from typing import Any, Dict, Generator, Optional class PlasmaModifierProperties(bpy.types.PropertyGroup): @property @@ -140,6 +140,28 @@ class PlasmaModifierLogicWiz: pfm_node.update() return pfm_node + def _create_python_attribute(self, pfm_node, attribute_name: str, attribute_type: Optional[str] = None, **kwargs): + """Creates and links a Python Attribute Node to the Python File Node given by `pfm_node`. + This will automatically handle simple attribute types such as numbers and strings, however, + for object linkage, you should specify the optional `attribute_type` to ensure the proper + attribute type is found. For attribute nodes that require multiple values, the `value` may + be set to None and handled in your code.""" + from ...nodes.node_python import PlasmaAttribute, PlasmaAttribNodeBase + if attribute_type is None: + assert len(kwargs) == 1 and "value" in kwargs, \ + "In order to deduce the attribute_type, exactly one attribute value must be passed as a kw named `value`" + attribute_type = PlasmaAttribute.type_LUT.get(kwargs["value"].__class__) + node_cls = next((i for i in PlasmaAttribNodeBase.__subclasses__() if attribute_type in i.pl_attrib), None) + assert node_cls is not None, "'{}': Unable to find attribute node type for '{}' ('{}')".format( + self.id_data.name, attribute_name, attribute_type + ) + + node = pfm_node.id_data.nodes.new(node_cls.bl_idname) + node.link_output(pfm_node, "pfm", attribute_name) + for i, j in kwargs.items(): + setattr(node, i, j) + return node + @abc.abstractmethod def logicwiz(self, bo, tree): pass From a31b8ac964e7cb59e272d7dd9a4d355ba573d249 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Mon, 16 Aug 2021 10:39:09 -0400 Subject: [PATCH 3/4] Add localized text modifier. --- korman/exporter/material.py | 7 +- korman/properties/modifiers/gui.py | 86 ++++++++------- korman/properties/modifiers/render.py | 148 +++++++++++++++++++++++++- korman/ui/modifiers/render.py | 45 ++++++++ 4 files changed, 248 insertions(+), 38 deletions(-) diff --git a/korman/exporter/material.py b/korman/exporter/material.py index b96d228..bceb1aa 100644 --- a/korman/exporter/material.py +++ b/korman/exporter/material.py @@ -799,7 +799,12 @@ class MaterialConverter: # when the exporter tells us to finalize all our shit if texture.image is None: dtm = self._mgr.find_create_object(plDynamicTextMap, name="{}_DynText".format(layer.key.name), bl=bo) - dtm.hasAlpha = texture.use_alpha + if texture.use_alpha: + dtm.hasAlpha = True + if not state.blendFlags & hsGMatState.kBlendMask: + state.blendFlags |= hsGMatState.kBlendAlpha + else: + dtm.hasAlpha = False dtm.visWidth = int(layer_props.dynatext_resolution) dtm.visHeight = int(layer_props.dynatext_resolution) layer.texture = dtm.key diff --git a/korman/properties/modifiers/gui.py b/korman/properties/modifiers/gui.py index 164405e..53b15c0 100644 --- a/korman/properties/modifiers/gui.py +++ b/korman/properties/modifiers/gui.py @@ -14,11 +14,13 @@ # along with Korman. If not, see . import bpy -import math import bmesh +from bpy.props import * import mathutils + +import math from pathlib import Path -from bpy.props import * + from PyHSPlasma import * from ...addon_prefs import game_versions @@ -113,14 +115,50 @@ class PlasmaJournalTranslation(bpy.types.PropertyGroup): items=languages, default=_DEFAULT_LANGUAGE_NAME, options=set()) - text_id = PointerProperty(name="Journal Contents", - description="Text data block containing the journal's contents for this language", + text_id = PointerProperty(name="Contents", + description="Text data block containing the text for this language", type=bpy.types.Text, poll=_poll_nonpytext, options=set()) -class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz): +class TranslationMixin: + def _get_translation(self): + # Ensure there is always a default (read: English) translation available. + default_idx, default = next(((idx, translation) for idx, translation in enumerate(self.translations) + if translation.language == _DEFAULT_LANGUAGE_NAME), (None, None)) + if default is None: + default_idx = len(self.translations) + default = self.translations.add() + default.language = _DEFAULT_LANGUAGE_NAME + if self.active_translation_index < len(self.translations): + language = self.translations[self.active_translation_index].language + else: + self.active_translation_index = default_idx + language = default.language + + # Due to the fact that we are using IDs to keep the data from becoming insane on new + # additions, we must return the integer id... + return next((idx for key, _, _, idx in languages if key == language)) + + def _set_translation(self, value): + # We were given an int here, must change to a string + language_name = next((key for key, _, _, i in languages if i == value)) + idx = next((idx for idx, translation in enumerate(self.translations) + if translation.language == language_name), None) + if idx is None: + self.active_translation_index = len(self.translations) + translation = self.translations.add() + translation.language = language_name + else: + self.active_translation_index = idx + + @property + def translations(self): + raise RuntimeError("TranslationMixin subclass needs a translation getter!") + + +class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz, TranslationMixin): pl_id = "journalbookmod" bl_category = "GUI" @@ -156,36 +194,6 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz type=bpy.types.Object, poll=idprops.poll_mesh_objects) - def _get_translation(self): - # Ensure there is always a default (read: English) translation available. - default_idx, default = next(((idx, translation) for idx, translation in enumerate(self.journal_translations) - if translation.language == _DEFAULT_LANGUAGE_NAME), (None, None)) - if default is None: - default_idx = len(self.journal_translations) - default = self.journal_translations.add() - default.language = _DEFAULT_LANGUAGE_NAME - if self.active_translation_index < len(self.journal_translations): - language = self.journal_translations[self.active_translation_index].language - else: - self.active_translation_index = default_idx - language = default.language - - # Due to the fact that we are using IDs to keep the data from becoming insane on new - # additions, we must return the integer id... - return next((idx for key, _, _, idx in languages if key == language)) - - def _set_translation(self, value): - # We were given an int here, must change to a string - language_name = next((key for key, _, _, i in languages if i == value)) - idx = next((idx for idx, translation in enumerate(self.journal_translations) - if translation.language == language_name), None) - if idx is None: - self.active_translation_index = len(self.journal_translations) - translation = self.journal_translations.add() - translation.language = language_name - else: - self.active_translation_index = idx - journal_translations = CollectionProperty(name="Journal Translations", type=PlasmaJournalTranslation, options=set()) @@ -193,7 +201,8 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz active_translation = EnumProperty(name="Language", description="Language of this translation", items=languages, - get=_get_translation, set=_set_translation, + get=TranslationMixin._get_translation, + set=TranslationMixin._set_translation, options=set()) def pre_export(self, exporter, bo): @@ -307,6 +316,11 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz # We are too late in the export to be harvested automatically, so let's be explicit return True + @property + def translations(self): + # Backwards compatibility thunk. + return self.journal_translations + linking_pfms = { pvPots : { diff --git a/korman/properties/modifiers/render.py b/korman/properties/modifiers/render.py index 6b39dfe..bf686a9 100644 --- a/korman/properties/modifiers/render.py +++ b/korman/properties/modifiers/render.py @@ -19,10 +19,11 @@ from bpy.props import * import functools from PyHSPlasma import * -from .base import PlasmaModifierProperties, PlasmaModifierUpgradable +from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz, PlasmaModifierUpgradable from ...exporter.etlight import _NUM_RENDER_LAYERS from ...exporter import utils from ...exporter.explosions import ExportError +from .gui import languages, PlasmaJournalTranslation, TranslationMixin from ... import idprops class PlasmaBlendOntoObject(bpy.types.PropertyGroup): @@ -604,6 +605,151 @@ class PlasmaLightingMod(PlasmaModifierProperties): return False +_LOCALIZED_TEXT_PFM = ( + { 'id': 1, 'type': "ptAttribDynamicMap", 'name': "dynTextMap", }, + { 'id': 2, 'type': "ptAttribString", 'name': "locPath" }, + { 'id': 3, 'type': "ptAttribString", 'name': "fontFace" }, + { 'id': 4, 'type': "ptAttribInt", 'name': "fontSize" }, + { 'id': 5, 'type': "ptAttribFloat", 'name': "fontColorR" }, + { 'id': 6, 'type': "ptAttribFloat", 'name': "fontColorG" }, + { 'id': 7, 'type': "ptAttribFloat", 'name': "fontColorB" }, + { 'id': 8, 'type': "ptAttribFloat", 'name': "fontColorA" }, + { 'id': 9, 'type': "ptAttribInt", 'name': "marginTop" }, + { 'id': 10, 'type': "ptAttribInt", 'name': "marginLeft" }, + { 'id': 11, 'type': "ptAttribInt", 'name': "marginBottom" }, + { 'id': 12, 'type': "ptAttribInt", 'name': "marginRight" }, + { 'id': 13, 'type': "ptAttribInt", 'name': "lineSpacing" }, + # Yes, it's really a ptAttribDropDownList, but those are only for use in + # artist generated node trees. + { 'id': 14, 'type': "ptAttribString", 'name': "justify" }, + { 'id': 15, 'type': "ptAttribFloat", 'name': "clearColorR" }, + { 'id': 16, 'type': "ptAttribFloat", 'name': "clearColorG" }, + { 'id': 17, 'type': "ptAttribFloat", 'name': "clearColorB" }, + { 'id': 18, 'type': "ptAttribFloat", 'name': "clearColorA" }, +) + +class PlasmaLocalizedTextModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz, TranslationMixin): + pl_id = "dynatext" + + bl_category = "Render" + bl_label = "Localized Text" + bl_description = "" + bl_icon = "TEXT" + + translations = CollectionProperty(name="Translations", + type=PlasmaJournalTranslation, + options=set()) + active_translation_index = IntProperty(options={"HIDDEN"}) + active_translation = EnumProperty(name="Language", + description="Language of this translation", + items=languages, + get=TranslationMixin._get_translation, + set=TranslationMixin._set_translation, + options=set()) + + def _poll_dyna_text(self, value: bpy.types.Texture) -> bool: + if value.type != "IMAGE": + return False + if value.image is not None: + return False + tex_materials = frozenset(value.users_material) + obj_materials = frozenset(filter(None, (i.material for i in self.id_data.material_slots))) + return bool(tex_materials & obj_materials) + + texture = PointerProperty(name="Texture", + description="The texture to write the localized text on", + type=bpy.types.Texture, + poll=_poll_dyna_text) + + font_face = StringProperty(name="Font Face", + default="Arial", + options=set()) + font_size = IntProperty(name="Font Size", + default=12, + min=0, soft_max=72, + options=set()) + font_color = FloatVectorProperty(name="Font Color", + default=(0.0, 0.0, 0.0, 1.0), + min=0.0, max=1.0, + subtype="COLOR", size=4, + options=set()) + + # Using individual properties for better UI documentation + margin_top = IntProperty(name="Margin Top", + min=-4096, soft_min=0, max=4096, + options=set()) + margin_left = IntProperty(name="Margin Left", + min=-4096, soft_min=0, max=4096, + options=set()) + margin_bottom = IntProperty(name="Margin Bottom", + min=-4096, soft_min=0, max=4096, + options=set()) + margin_right = IntProperty(name="Margin Right", + min=-4096, soft_min=0, max=4096, + options=set()) + + justify = EnumProperty(name="Justification", + items=[("left", "Left", ""), + ("center", "Center", ""), + ("right", "Right", "")], + default="left", + options=set()) + line_spacing = IntProperty(name="Line Spacing", + default=0, + soft_min=0, soft_max=10, + options=set()) + + def pre_export(self, exporter, bo): + yield self.convert_logic(bo, age_name=exporter.age_name, version=exporter.mgr.getVer()) + + def export(self, exporter, bo, so): + # TODO: This should probably be pulled out into its own export pass for locs + for i in filter(None, self.translations): + exporter.locman.add_string("DynaTexts", self.key_name, i.language, i.text_id, indent=2) + + def logicwiz(self, bo, tree, *, age_name, version): + # Rough justice. If the dynamic text map texture doesn't request alpha, then we'll want + # to explicitly clear it to the material's diffuse color. This will allow artists to trivially + # add text surfaces directly to objects, opposed to where Cyan tends to use a separate + # transparent object over the background object. + if not self.texture.use_alpha: + material_filter = lambda slot: slot and slot.material and self.texture in (i.texture for i in slot.material.texture_slots if i) + for slot in filter(material_filter, bo.material_slots): + self._create_nodes(bo, tree, age_name=age_name, version=version, + material=slot.material, clear_color=slot.material.diffuse_color) + else: + self._create_nodes(bo, tree, age_name=age_name, version=version) + + def _create_nodes(self, bo, tree, *, age_name, version, material=None, clear_color=None): + pfm_node = self._create_python_file_node(tree, "xDynTextLoc.py", _LOCALIZED_TEXT_PFM) + loc_path = self.key_name if version <= pvPots else "{}.DynaTexts.{}".format(age_name, self.key_name) + + self._create_python_attribute(pfm_node, "dynTextMap", "ptAttribDynamicMap", + target_object=bo, material=material, texture=self.texture) + self._create_python_attribute(pfm_node, "locPath", value=loc_path) + self._create_python_attribute(pfm_node, "fontFace", value=self.font_face) + self._create_python_attribute(pfm_node, "fontSize", value=self.font_size) + self._create_python_attribute(pfm_node, "fontColorR", value=self.font_color[0]) + self._create_python_attribute(pfm_node, "fontColorG", value=self.font_color[1]) + self._create_python_attribute(pfm_node, "fontColorB", value=self.font_color[2]) + self._create_python_attribute(pfm_node, "fontColorA", value=self.font_color[3]) + self._create_python_attribute(pfm_node, "marginTop", value=self.margin_top) + self._create_python_attribute(pfm_node, "marginLeft", value=self.margin_left) + self._create_python_attribute(pfm_node, "marginBottom", value=self.margin_bottom) + self._create_python_attribute(pfm_node, "marginRight", value=self.margin_right) + self._create_python_attribute(pfm_node, "justify", value=self.justify) + + if clear_color is not None: + self._create_python_attribute(pfm_node, "clearColorR", value=clear_color[0]) + self._create_python_attribute(pfm_node, "clearColorG", value=clear_color[1]) + self._create_python_attribute(pfm_node, "clearColorB", value=clear_color[2]) + self._create_python_attribute(pfm_node, "clearColorA", value=1.0) + + def sanity_check(self): + if self.texture is None: + raise ExportError("'{}': Localized Text modifier requires a texture", self.id_data.name) + + class PlasmaShadowCasterMod(PlasmaModifierProperties): pl_id = "rtshadow" diff --git a/korman/ui/modifiers/render.py b/korman/ui/modifiers/render.py index 3d78870..cc2d5fb 100644 --- a/korman/ui/modifiers/render.py +++ b/korman/ui/modifiers/render.py @@ -97,6 +97,51 @@ def decal_receive(modifier, layout, context): layout.alert = decal_mgr is None layout.prop_search(mgr_ref, "name", scene, "decal_managers", icon="BRUSH_DATA") +def dynatext(modifier, layout, context): + col = layout.column() + col.alert = modifier.texture is None + col.prop(modifier, "texture") + if modifier.texture is None: + col.label("You must specify a blank image texture to draw on.", icon="ERROR") + + split = layout.split() + col = split.column() + col.label("Content Translations:") + col.prop(modifier, "active_translation", text="") + # This should never fail... + try: + translation = modifier.translations[modifier.active_translation_index] + except Exception as e: + col.label(text="Error (see console)", icon="ERROR") + print(e) + else: + col.prop(translation, "text_id", text="") + + col = split.column() + col.label("Font:") + sub = col.row() + sub.alert = not modifier.font_face.strip() + sub.prop(modifier, "font_face", text="", icon="OUTLINER_DATA_FONT") + col.prop(modifier, "font_size", text="Size") + + layout.separator() + split = layout.split() + col = split.column(align=True) + if modifier.texture is not None: + col.alert = modifier.margin_top + modifier.margin_bottom >= int(modifier.texture.plasma_layer.dynatext_resolution) + col.prop(modifier, "margin_top") + col.prop(modifier, "margin_bottom") + col = split.column(align=True) + if modifier.texture is not None: + col.alert = modifier.margin_left + modifier.margin_right >= int(modifier.texture.plasma_layer.dynatext_resolution) + col.prop(modifier, "margin_left") + col.prop(modifier, "margin_right") + + layout.separator() + flow = layout.column_flow(columns=2) + flow.prop_menu_enum(modifier, "justify") + flow.prop(modifier, "line_spacing") + def fademod(modifier, layout, context): layout.prop(modifier, "fader_type") From fb493781b3010583be9b322eba989e74f37314a9 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Mon, 16 Aug 2021 12:57:32 -0400 Subject: [PATCH 4/4] Unify localized string handling. --- korman/exporter/convert.py | 16 ++++++ korman/exporter/locman.py | 75 +++++++++++---------------- korman/properties/modifiers/base.py | 9 ++++ korman/properties/modifiers/gui.py | 28 ++++++---- korman/properties/modifiers/render.py | 11 ++-- 5 files changed, 79 insertions(+), 60 deletions(-) diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 6b4cdb1..b7b2a21 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -69,6 +69,7 @@ class Exporter: self.report.progress_add_step("Collecting Objects") self.report.progress_add_step("Verify Competence") self.report.progress_add_step("Touching the Intangible") + self.report.progress_add_step("Unifying Superstrings") self.report.progress_add_step("Harvesting Actors") if self._op.lighting_method != "skip": etlight.LightBaker.add_progress_steps(self.report) @@ -98,6 +99,9 @@ class Exporter: # In other words, generate any ephemeral Blender objects that need to be exported. self._pre_export_scene_objects() + # Step 2.3: Run through all the objects and export localization. + self._export_localization() + # Step 2.5: Run through all the objects we collected in Step 2 and see if any relationships # that the artist made requires something to have a CoordinateInterface self._harvest_actors() @@ -248,6 +252,18 @@ class Exporter: return ci return so.coord.object + def _export_localization(self): + self.report.progress_advance() + self.report.progress_range = len(self._objects) + inc_progress = self.report.progress_increment + + self.report.msg("\nExporting localization...") + + for bl_obj in self._objects: + for mod in filter(lambda x: hasattr(x, "export_localization"), bl_obj.plasma_modifiers.modifiers): + mod.export_localization(self) + inc_progress() + def _export_scene_objects(self): self.report.progress_advance() self.report.progress_range = len(self._objects) diff --git a/korman/exporter/locman.py b/korman/exporter/locman.py index 8439a76..13418b7 100644 --- a/korman/exporter/locman.py +++ b/korman/exporter/locman.py @@ -16,6 +16,7 @@ import bpy from PyHSPlasma import * +from collections import defaultdict from contextlib import contextmanager import itertools from pathlib import Path @@ -45,24 +46,16 @@ class LocalizationConverter: self._age_name = kwargs.get("age_name") self._path = kwargs.get("path") self._version = kwargs.get("version") - self._journals = {} - self._strings = {} - - def add_journal(self, name, language, text_id, indent=0): - if text_id.is_modified: - self._report.warn("Journal '{}' translation for '{}' is modified on the disk but not reloaded in Blender.", - name, language, indent=indent) - journal = self._journals.setdefault(name, {}) - journal[language] = text_id.as_string() - return True - - def add_string(self, set_name, element_name, language, value): - trans_set = self._strings.setdefault(set_name, {}) - trans_element = trans_set.setdefault(element_name, {}) - trans_element[language] = value - if self._exporter is not None and self._exporter().mgr.getVer() <= pvPots: - return False - return True + self._strings = defaultdict(lambda: defaultdict(dict)) + + def add_string(self, set_name, element_name, language, value, indent=0): + self._report.msg("Accepted '{}' translation for '{}'.", element_name, language, indent=indent) + if isinstance(value, bpy.types.Text): + if value.is_modified: + self._report.warn("'{}' translation for '{}' is modified on the disk but not reloaded in Blender.", + element_name, language, indent=indent) + value = value.as_string() + self._strings[set_name][element_name][language] = value @contextmanager def _generate_file(self, filename, **kwargs): @@ -96,8 +89,9 @@ class LocalizationConverter: stream.write(contents.encode("windows-1252", "replace")) return True - for journal_name, translations in self._journals.items(): - self._report.msg("Copying Journal '{}'", journal_name, indent=1) + locs = itertools.chain(self._strings["Journals"].items(), self._strings["DynaTexts"].items()) + for journal_name, translations in locs: + self._report.msg("Copying localization '{}'", journal_name, indent=1) for language_name, value in translations.items(): if language_name not in _SP_LANGUAGES: self._report.warn("Translation '{}' will not be used because it is not supported in this version of Plasma.", @@ -121,30 +115,20 @@ class LocalizationConverter: self._report.port("No 'English' nor any other suitable default translation available", indent=2) def _generate_loc_files(self): - set_LUT = { - "Journals": self._journals - } - - # Merge in any manual strings, but error if dupe sets are encountered. - special_sets, string_sets = frozenset(set_LUT.keys()), frozenset(self._strings.keys()) - intersection = special_sets & string_sets - assert not intersection, "Duplicate localization sets: {}".format(" ".join(intersection)) - set_LUT.update(self._strings) - - if not any(itertools.chain.from_iterable(set_LUT.values())): + if not self._strings: return method = bpy.context.scene.world.plasma_age.localization_method if method == "single_file": - self._generate_loc_file("{}.loc".format(self._age_name), set_LUT) + self._generate_loc_file("{}.loc".format(self._age_name), self._strings) elif method in {"database", "database_back_compat"}: # Where the strings are set -> element -> language: str, we want language -> set -> element: str # This is so we can mimic pfLocalizationEditor's English.loc pathing. - database = {} - for set_name, elements in set_LUT.items(): + database = defaultdict(lambda: defaultdict(dict)) + for set_name, elements in self._strings.items(): for element_name, translations in elements.items(): for language_name, value in translations.items(): - database.setdefault(language_name, {}).setdefault(set_name, {})[element_name] = value + database[language_name][set_name][element_name] = value for language_name, sets in database.items(): self._generate_loc_file("{}{}.loc".format(self._age_name, language_name), sets, language_name) @@ -200,7 +184,7 @@ class LocalizationConverter: loc_path = str(Path(self._path) / "dat" / "{}.loc".format(self._age_name)) log = logger.ExportVerboseLogger if age_props.verbose else logger.ExportProgressLogger with korlib.ConsoleToggler(age_props.show_console), log(loc_path) as self._report: - self._report.progress_add_step("Harvesting Journals") + self._report.progress_add_step("Harvesting Translations") self._report.progress_add_step("Generating Localization") self._report.progress_start("Exporting Localization Data") @@ -212,20 +196,23 @@ class LocalizationConverter: self._report.raise_errors() def _run_harvest_journals(self): + from ..properties.modifiers import TranslationMixin + objects = bpy.context.scene.objects self._report.progress_advance() self._report.progress_range = len(objects) inc_progress = self._report.progress_increment for i in objects: - journal = i.plasma_modifiers.journalbookmod - if journal.enabled: - translations = [j for j in journal.journal_translations if j.text_id is not None] - if not translations: - self._report.error("Journal '{}': No content translations available. The journal will not be exported.", - i.name, indent=2) - for j in translations: - self.add_journal(journal.key_name, j.language, j.text_id, indent=1) + for mod_type in filter(None, (getattr(j, "pl_id", None) for j in TranslationMixin.__subclasses__())): + modifier = getattr(i.plasma_modifiers, mod_type) + if modifier.enabled: + translations = [j for j in modifier.translations if j.text_id is not None] + if not translations: + self._report.error("'{}': No content translations available. The localization will not be exported.", + i.name, indent=2) + for j in translations: + self.add_string(modifier.localization_set, modifier.key_name, j.language, j.text_id, indent=1) inc_progress() def _run_generate(self): diff --git a/korman/properties/modifiers/base.py b/korman/properties/modifiers/base.py index 247d2df..3ed56b1 100644 --- a/korman/properties/modifiers/base.py +++ b/korman/properties/modifiers/base.py @@ -57,6 +57,15 @@ class PlasmaModifierProperties(bpy.types.PropertyGroup): """ pass + # Commented out to prevent conflicts with TranslationMixin overload. + """ + def export_localization(self, exporter): + '''This is an auxiliary export phase that should only convert localization data. PRP objects + are in an undefined state and therefore should not be used. + ''' + pass + """ + @property def face_sort(self): """Indicates that the geometry's faces should be sorted by the engine""" diff --git a/korman/properties/modifiers/gui.py b/korman/properties/modifiers/gui.py index 53b15c0..d4921d2 100644 --- a/korman/properties/modifiers/gui.py +++ b/korman/properties/modifiers/gui.py @@ -123,6 +123,15 @@ class PlasmaJournalTranslation(bpy.types.PropertyGroup): class TranslationMixin: + def export_localization(self, exporter): + translations = [i for i in self.translations if i.text_id is not None] + if not translations: + exporter.report.error("'{}': '{}' No content translations available. The localization will not be exported.", + self.id_data.name, self.bl_label, indent=1) + return + for i in translations: + exporter.locman.add_string(self.localization_set, self.key_name, i.language, i.text_id, indent=1) + def _get_translation(self): # Ensure there is always a default (read: English) translation available. default_idx, default = next(((idx, translation) for idx, translation in enumerate(self.translations) @@ -153,6 +162,10 @@ class TranslationMixin: else: self.active_translation_index = idx + @property + def localization_set(self): + raise RuntimeError("TranslationMixin subclass needs a localization set getter!") + @property def translations(self): raise RuntimeError("TranslationMixin subclass needs a translation getter!") @@ -214,15 +227,6 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz bo.name, version, indent=2) return - # Export the Journal translation contents - translations = [i for i in self.journal_translations if i.text_id is not None] - if not translations: - exporter.report.error("Journal '{}': No content translations available. The journal will not be exported.", - bo.name, indent=2) - return - for i in translations: - exporter.locman.add_journal(self.key_name, i.language, i.text_id, indent=2) - if self.clickable_region is None: with utils.bmesh_object("{}_Journal_ClkRgn".format(self.key_name)) as (rgn_obj, bm): bmesh.ops.create_cube(bm, size=(6.0)) @@ -305,12 +309,16 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz locpath = nodes.new("PlasmaAttribStringNode") locpath.link_output(journalnode, "pfm", "LocPath") - locpath.value = "{}.Journals.{}".format(age_name, self.key_name) + locpath.value = "{}.{}.{}".format(age_name, self.localization_set, self.key_name) guitype = nodes.new("PlasmaAttribStringNode") guitype.link_output(journalnode, "pfm", "GUIType") guitype.value = self.book_type + @property + def localization_set(self): + return "Journals" + @property def requires_actor(self): # We are too late in the export to be harvested automatically, so let's be explicit diff --git a/korman/properties/modifiers/render.py b/korman/properties/modifiers/render.py index bf686a9..a5dbb15 100644 --- a/korman/properties/modifiers/render.py +++ b/korman/properties/modifiers/render.py @@ -702,11 +702,6 @@ class PlasmaLocalizedTextModifier(PlasmaModifierProperties, PlasmaModifierLogicW def pre_export(self, exporter, bo): yield self.convert_logic(bo, age_name=exporter.age_name, version=exporter.mgr.getVer()) - def export(self, exporter, bo, so): - # TODO: This should probably be pulled out into its own export pass for locs - for i in filter(None, self.translations): - exporter.locman.add_string("DynaTexts", self.key_name, i.language, i.text_id, indent=2) - def logicwiz(self, bo, tree, *, age_name, version): # Rough justice. If the dynamic text map texture doesn't request alpha, then we'll want # to explicitly clear it to the material's diffuse color. This will allow artists to trivially @@ -722,7 +717,7 @@ class PlasmaLocalizedTextModifier(PlasmaModifierProperties, PlasmaModifierLogicW def _create_nodes(self, bo, tree, *, age_name, version, material=None, clear_color=None): pfm_node = self._create_python_file_node(tree, "xDynTextLoc.py", _LOCALIZED_TEXT_PFM) - loc_path = self.key_name if version <= pvPots else "{}.DynaTexts.{}".format(age_name, self.key_name) + loc_path = self.key_name if version <= pvPots else "{}.{}.{}".format(age_name, self.localization_set, self.key_name) self._create_python_attribute(pfm_node, "dynTextMap", "ptAttribDynamicMap", target_object=bo, material=material, texture=self.texture) @@ -745,6 +740,10 @@ class PlasmaLocalizedTextModifier(PlasmaModifierProperties, PlasmaModifierLogicW self._create_python_attribute(pfm_node, "clearColorB", value=clear_color[2]) self._create_python_attribute(pfm_node, "clearColorA", value=1.0) + @property + def localization_set(self): + return "DynaTexts" + def sanity_check(self): if self.texture is None: raise ExportError("'{}': Localized Text modifier requires a texture", self.id_data.name)