diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index a55e18d..bd5353d 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -24,6 +24,7 @@ from . import camera from . import explosions from . import etlight from . import image +from . import locman from . import logger from . import manager from . import mesh @@ -52,6 +53,7 @@ class Exporter: self.output = outfile.OutputFiles(self, self._op.filepath) self.camera = camera.CameraConverter(self) self.image = image.ImageCache(self) + self.locman = locman.LocalizationConverter(self) # Step 0.8: Init the progress mgr self.mesh.add_progress_presteps(self.report) @@ -368,6 +370,7 @@ class Exporter: # If something bad happens in the final flush, it would be a shame to # simply toss away the potentially freshly regenerated texture cache. try: + self.locman.save() self.mgr.save_age() self.output.save() finally: diff --git a/korman/exporter/locman.py b/korman/exporter/locman.py new file mode 100644 index 0000000..72dd9ed --- /dev/null +++ b/korman/exporter/locman.py @@ -0,0 +1,142 @@ +# This file is part of Korman. +# +# Korman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Korman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Korman. If not, see . + +from PyHSPlasma import * +import weakref +from xml.sax.saxutils import escape as xml_escape + +_SP_LANGUAGES = {"English", "French", "German", "Italian", "Spanish"} + +class LocalizationConverter: + def __init__(self, exporter): + self._exporter = weakref.ref(exporter) + 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() + + 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 + + def _generate_journal_texts(self): + age_name = self._exporter().age_name + output = self._exporter().output + + def write_journal_file(language, file_name, contents): + try: + with output.generate_dat_file(dirname="ageresources", filename=file_name) as stream: + stream.write(contents.encode("windows-1252")) + except UnicodeEncodeError: + self._report.error("Translation '{}': Contents contains characters that cannot be used in this version of Plasma", + language, indent=2) + return False + else: + return True + + for journal_name, translations in self._journals.items(): + self._report.msg("Copying Journal '{}'", 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.", + language_name, indent=2) + 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) + + # Ensure that default (read: "English") journal is available + if "English" not in translations: + language_name, value = next(((language_name, value) for language_name, value in translations.items() + if language_name in _SP_LANGUAGES), (None, None)) + 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): + 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: + return + + 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")) + + age_name = self._exporter().age_name + enc = plEncryptedStream.kEncAes if self._version == pvEoa else None + file_name = "{}.loc".format(age_name) + with self._exporter().output.generate_dat_file(file_name, enc=enc) as stream: + # UTF-16 little endian byte order mark + stream.write(b"\xFF\xFE") + + write_line("") + write_line("") + write_line("", age_name, indent=1) + + # Arbitrary strings defined by something like a GUI or a node tree + for set_name, elements in self._strings.items(): + write_line("", set_name, indent=2) + for element_name, translations 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(): + write_line("{translation}", + language=language_name, translation=xml_escape(value), indent=4) + write_line("", indent=3) + write_line("", indent=2) + + # Verbose XML junk... + # You call it verbose. I call it unambiguously complete. + write_line("", indent=1) + write_line("") + + def save(self): + if self._version > pvPots: + self._generate_loc_file() + else: + self._generate_journal_texts() + + @property + def _report(self): + return self._exporter().report + + @property + def _version(self): + return self._exporter().mgr.getVer() diff --git a/korman/properties/modifiers/gui.py b/korman/properties/modifiers/gui.py index cc3adf7..773ec5a 100644 --- a/korman/properties/modifiers/gui.py +++ b/korman/properties/modifiers/gui.py @@ -21,19 +21,11 @@ from bpy.props import * from PyHSPlasma import * from ...addon_prefs import game_versions -from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz +from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz, PlasmaModifierUpgradable from ... import idprops journal_pfms = { - pvPrime : { - "filename": "xJournalBookGUIPopup.py", - "attribs": ( - { 'id': 1, 'type': "ptAttribActivator", "name": "actClickableBook" }, - { 'id': 3, 'type': "ptAttribString", "name": "JournalName" }, - { 'id': 10, 'type': "ptAttribBoolean", 'name': "StartOpen" }, - ) - }, pvPots : { # Supplied by the OfflineKI script: # https://gitlab.com/diafero/offline-ki/blob/master/offlineki/xSimpleJournal.py @@ -59,6 +51,26 @@ journal_pfms = { }, } +# Do not change the numeric IDs. They allow the list to be rearranged. +_languages = [("Dutch", "Nederlands", "Dutch", 0), + ("English", "English", "", 1), + ("Finnish", "Suomi", "Finnish", 2), + ("French", "Français", "French", 3), + ("German", "Deutsch", "German", 4), + ("Hungarian", "Magyar", "Hungarian", 5), + ("Italian", "Italiano ", "Italian", 6), + # Blender 2.79b can't render 日本語 by default + ("Japanese", "Nihongo", "Japanese", 7), + ("Norwegian", "Norsk", "Norwegian", 8), + ("Polish", "Polski", "Polish", 9), + ("Romanian", "Română", "Romanian", 10), + ("Russian", "Pyccĸий", "Russian", 11), + ("Spanish", "Español", "Spanish", 12), + ("Swedish", "Svenska", "Swedish", 13)] +languages = sorted(_languages, key=lambda x: x[1]) +_DEFAULT_LANGUAGE_NAME = "English" +_DEFAULT_LANGUAGE_ID = 1 + class ImageLibraryItem(bpy.types.PropertyGroup): image = bpy.props.PointerProperty(name="Image Item", @@ -91,6 +103,22 @@ class PlasmaImageLibraryModifier(PlasmaModifierProperties): exporter.mesh.material.export_prepared_image(owner=ilmod, image=item.image, allowed_formats={"JPG", "PNG"}, extension="hsm") +class PlasmaJournalTranslation(bpy.types.PropertyGroup): + def _poll_nonpytext(self, value): + return not value.name.endswith(".py") + + language = EnumProperty(name="Language", + description="Language of this translation", + 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", + type=bpy.types.Text, + poll=_poll_nonpytext, + options=set()) + + class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz): pl_id = "journalbookmod" @@ -122,28 +150,69 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz description="Height scale", default=100, min=0, max=100, subtype="PERCENTAGE") - book_source_locpath = StringProperty(name="Book Source LocPath", - description="LocPath for book's text (MO:UL)", - default="Global.Journals.Empty") - book_source_filename = StringProperty(name="Book Source Filename", - description="Filename for book's text (Uru:CC)", - default="") - book_source_name = StringProperty(name="Book Source Name", - description="Name of xJournalBookDefs.py entry for book's text (Uru:ABM)", - default="Dummy") clickable_region = PointerProperty(name="Region", description="Region inside which the avatar must stand to be able to open the journal (optional)", 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()) + active_translation_index = IntProperty(options={"HIDDEN"}) + active_translation = EnumProperty(name="Language", + description="Language of this translation", + items=languages, + get=_get_translation, set=_set_translation, + options=set()) + def export(self, exporter, bo, so): - our_versions = [globals()[j] for j in self.versions] + our_versions = (globals()[j] for j in self.versions) version = exporter.mgr.getVer() if version not in our_versions: # We aren't needed here - exporter.report.port("Object '{}' has a JournalMod not enabled for export to the selected engine. Skipping.".format(bo.name, version), indent=2) + exporter.report.port("Object '{}' has a JournalMod not enabled for export to the selected engine. Skipping.", + 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: # Create a region for the clickable's condition rgn_mesh = bpy.data.meshes.new("{}_Journal_ClkRgn".format(self.key_name)) @@ -163,14 +232,14 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz self.temp_rgn = self.clickable_region # Generate the logic nodes - with self.generate_logic(bo, version=version) as tree: + with self.generate_logic(bo, age_name=exporter.age_name, version=version) as tree: tree.export(exporter, bo, so) # Get rid of our temporary clickable region if self.clickable_region is None: bpy.context.scene.objects.unlink(self.temp_rgn) - def logicwiz(self, bo, tree, version): + def logicwiz(self, bo, tree, age_name, version): nodes = tree.nodes # Assign journal script based on target version @@ -188,36 +257,12 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz new_attr.attribute_name = attr["name"] journalnode.update() - if version == pvPrime: - self.create_prime_nodes(bo, nodes, journalnode) - elif version == pvPots: - self.create_pots_nodes(bo, nodes, journalnode) - elif version == pvMoul: - self.create_moul_nodes(bo, nodes, journalnode) - - def create_prime_nodes(self, clickable_object, nodes, journalnode): - clickable_region = nodes.new("PlasmaClickableRegionNode") - clickable_region.region_object = self.temp_rgn - - facing_object = nodes.new("PlasmaFacingTargetNode") - facing_object.directional = False - facing_object.tolerance = math.degrees(-1) - - clickable = nodes.new("PlasmaClickableNode") - clickable.link_input(clickable_region, "satisfies", "region") - clickable.link_input(facing_object, "satisfies", "facing") - clickable.link_output(journalnode, "satisfies", "actClickableBook") - clickable.clickable_object = clickable_object - - start_open = nodes.new("PlasmaAttribBoolNode") - start_open.link_output(journalnode, "pfm", "StartOpen") - start_open.value = self.start_state == "OPEN" - - journal_name = nodes.new("PlasmaAttribStringNode") - journal_name.link_output(journalnode, "pfm", "JournalName") - journal_name.value = self.book_source_name + if version <= pvPots: + self._create_pots_nodes(bo, nodes, journalnode, age_name) + else: + self._create_moul_nodes(bo, nodes, journalnode, age_name) - def create_pots_nodes(self, clickable_object, nodes, journalnode): + def _create_pots_nodes(self, clickable_object, nodes, journalnode, age_name): clickable_region = nodes.new("PlasmaClickableRegionNode") clickable_region.region_object = self.temp_rgn @@ -233,7 +278,7 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz srcfile = nodes.new("PlasmaAttribStringNode") srcfile.link_output(journalnode, "pfm", "journalFileName") - srcfile.value = self.book_source_filename + srcfile.value = self.key_name guitype = nodes.new("PlasmaAttribBoolNode") guitype.link_output(journalnode, "pfm", "isNotebook") @@ -247,7 +292,7 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz height.link_output(journalnode, "pfm", "BookHeight") height.value_float = self.book_scale_h / 100.0 - def create_moul_nodes(self, clickable_object, nodes, journalnode): + def _create_moul_nodes(self, clickable_object, nodes, journalnode, age_name): clickable_region = nodes.new("PlasmaClickableRegionNode") clickable_region.region_object = self.temp_rgn @@ -275,7 +320,7 @@ class PlasmaJournalBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz locpath = nodes.new("PlasmaAttribStringNode") locpath.link_output(journalnode, "pfm", "LocPath") - locpath.value = self.book_source_locpath + locpath.value = "{}.Journals.{}".format(age_name, self.key_name) guitype = nodes.new("PlasmaAttribStringNode") guitype.link_output(journalnode, "pfm", "GUIType") diff --git a/korman/ui/modifiers/gui.py b/korman/ui/modifiers/gui.py index 143469d..68fe543 100644 --- a/korman/ui/modifiers/gui.py +++ b/korman/ui/modifiers/gui.py @@ -36,22 +36,34 @@ def imagelibmod(modifier, layout, context): def journalbookmod(modifier, layout, context): layout.prop_menu_enum(modifier, "versions") + layout.separator() - if not {"pvPrime", "pvMoul"}.isdisjoint(modifier.versions): - layout.prop(modifier, "start_state") + split = layout.split() + main_col = split.column() - if not {"pvPots", "pvMoul"}.isdisjoint(modifier.versions): - layout.prop(modifier, "book_type") - row = layout.row(align=True) - row.label("Book Scaling:") - row.prop(modifier, "book_scale_w", text="Width", slider=True) - row.prop(modifier, "book_scale_h", text="Height", slider=True) - - if "pvPrime" in modifier.versions: - layout.prop(modifier, "book_source_name", text="Name") - if "pvPots" in modifier.versions: - layout.prop(modifier, "book_source_filename", text="Filename") - if "pvMoul" in modifier.versions: - layout.prop(modifier, "book_source_locpath", text="LocPath") - - layout.prop(modifier, "clickable_region") + main_col.label("Display Settings:") + col = main_col.column() + col.active = "pvMoul" in modifier.versions + col.prop(modifier, "start_state", text="") + main_col.prop(modifier, "book_type", text="") + main_col.separator() + main_col.label("Book Scaling:") + col = main_col.column(align=True) + col.prop(modifier, "book_scale_w", text="Width", slider=True) + col.prop(modifier, "book_scale_h", text="Height", slider=True) + + main_col = split.column() + main_col.label("Content Translations:") + main_col.prop(modifier, "active_translation", text="") + # This should never fail... + try: + translation = modifier.journal_translations[modifier.active_translation_index] + except Exception as e: + main_col.label(text="Error (see console)", icon="ERROR") + print(e) + else: + main_col.prop(translation, "text_id", text="") + main_col.separator() + + main_col.label("Clickable Region:") + main_col.prop(modifier, "clickable_region", text="")