diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 95173c3..bace2de 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -229,7 +229,7 @@ class Exporter: for mod in bl_obj.plasma_modifiers.modifiers: fn = getattr(mod, "sanity_check", None) if fn is not None: - fn() + fn(self) inc_progress() self.report.msg("... Age is grinning and holding a spatula. Must be OK, then.") @@ -502,7 +502,9 @@ class Exporter: # Wow, recursively generated objects. Aren't you special? with indent(): for mod in temporary.plasma_modifiers.modifiers: - mod.sanity_check() + fn = getattr(mod, "sanity_check", None) + if fn is not None: + fn(self) do_pre_export(temporary) return temporary diff --git a/korman/exporter/gui.py b/korman/exporter/gui.py index 0a0a117..bf9b952 100644 --- a/korman/exporter/gui.py +++ b/korman/exporter/gui.py @@ -32,6 +32,7 @@ from . import utils if TYPE_CHECKING: from .convert import Exporter from .logger import _ExportLogger as ExportLogger + from ..properties.modifiers.game_gui import * class Clipping(NamedTuple): @@ -48,9 +49,13 @@ class GuiConverter: if TYPE_CHECKING: _parent: weakref.ref[Exporter] = ... + _pages: Dict[str, Any] = ... + _mods_exported: Set[str] = ... def __init__(self, parent: Optional[Exporter] = None): self._parent = weakref.ref(parent) if parent is not None else None + self._pages = {} + self._mods_exported = set() # Go ahead and prepare the GUI transparent material for future use. if parent is not None: @@ -203,6 +208,93 @@ class GuiConverter: w2c[2, i] *= -1.0 return PostEffectModMatrices(c2w, w2c) + def check_pre_export(self, name: str, **kwargs): + previous = self._pages.setdefault(name, kwargs) + if previous != kwargs: + diff = set(previous.items()) - set(kwargs.items()) + raise ExportError(f"GUI Page '{name}' has target modifiers with conflicting settings:\n{diff}") + + def create_note_gui(self, gui_page: str, gui_camera: bpy.types.Object): + if not gui_page in self._mods_exported: + guidialog_object = utils.create_empty_object(f"{gui_page}_NoteDialog") + guidialog_object.plasma_object.enabled = True + guidialog_object.plasma_object.page = gui_page + yield guidialog_object + + guidialog_mod: PlasmaGameGuiDialogModifier = guidialog_object.plasma_modifiers.gui_dialog + guidialog_mod.enabled = True + guidialog_mod.is_modal = True + if gui_camera is not None: + guidialog_mod.camera_object = gui_camera + else: + # Abuse the GUI Dialog's lookat caLculation to make us a camera that looks at everything + # the artist has placed into the GUI page. We want to do this NOW because we will very + # soon be adding more objects into the GUI page. + camera_object = yield utils.create_camera_object(f"{gui_page}_GUICamera") + camera_object.data.angle = math.radians(45.0) + camera_object.data.lens_unit = "FOV" + + visible_objects = [ + i for i in self._parent().get_objects(gui_page) + if i.type == "MESH" and i.data.materials + ] + camera_object.matrix_world = self.calc_camera_matrix( + bpy.context.scene, + visible_objects, + camera_object.data.angle + ) + clipping = self.calc_clipping( + camera_object.matrix_world, + bpy.context.scene, + visible_objects, + camera_object.data.angle + ) + camera_object.data.clip_start = clipping.hither + camera_object.data.clip_end = clipping.yonder + guidialog_mod.camera_object = camera_object + + # Begin creating the object for the clickoff plane. We want to yield it immediately + # to the exporter in case something goes wrong during the export, allowing the stale + # object to be cleaned up. + click_plane_object = utils.BMeshObject(f"{gui_page}_Exit") + click_plane_object.matrix_world = guidialog_mod.camera_object.matrix_world + click_plane_object.plasma_object.enabled = True + click_plane_object.plasma_object.page = gui_page + yield click_plane_object + + # We have a camera on guidialog_mod.camera_object. We will now use it to generate the + # points for the click-off plane button. + # TODO: Allow this to be configurable to 4:3, 16:9, or 21:9? + with ExitStack() as stack: + stack.enter_context(self.generate_camera_render_settings(bpy.context.scene)) + toggle = stack.enter_context(helpers.GoodNeighbor()) + + # Temporarily adjust the clipping plane out to the farthest point we can find to ensure + # that the click-off button ecompasses everything. This is a bit heavy-handed, but if + # you want more refined control, you won't be using this helper. + clipping = max((guidialog_mod.camera_object.data.clip_start, guidialog_mod.camera_object.data.clip_end)) + toggle.track(guidialog_mod.camera_object.data, "clip_start", clipping - 0.1) + view_frame = guidialog_mod.camera_object.data.view_frame(bpy.context.scene) + + click_plane_object.data.materials.append(self.transparent_material) + with click_plane_object as click_plane_mesh: + verts = [click_plane_mesh.verts.new(i) for i in view_frame] + face = click_plane_mesh.faces.new(verts) + # TODO: Ensure the face is pointing toward the camera! + # I feel like we should be fine by assuming that Blender returns the viewframe + # verts in the correct order, but this is Blender... So test that assumption carefully. + # TODO: Apparently not! + face.normal_flip() + + # We've now created the mesh object - handle the GUI Button stuff + click_plane_object.plasma_modifiers.gui_button.enabled = True + + # NOTE: We will be using xDialogToggle.py, so we use a special tag ID instead of the + # close dialog procedure. + click_plane_object.plasma_modifiers.gui_control.tag_id = 99 + + self._mods_exported.add(gui_page) + @contextmanager def generate_camera_render_settings(self, scene: bpy.types.Scene) -> Iterator[None]: # Set the render info to basically TV NTSC 4:3, which will set Blender's camera diff --git a/korman/properties/modifiers/anim.py b/korman/properties/modifiers/anim.py index 684ea91..8db48fb 100644 --- a/korman/properties/modifiers/anim.py +++ b/korman/properties/modifiers/anim.py @@ -42,7 +42,7 @@ class ActionModifier: return None raise ExportError("'{}': Object has an animation modifier but is not animated".format(bo.name)) - def sanity_check(self) -> None: + def sanity_check(self, exporter) -> None: if not self.id_data.plasma_object.has_animation_data: raise ExportError("'{}': Has an animation modifier but no animation data.", self.id_data.name) diff --git a/korman/properties/modifiers/avatar.py b/korman/properties/modifiers/avatar.py index 5dc4b59..17cc9eb 100644 --- a/korman/properties/modifiers/avatar.py +++ b/korman/properties/modifiers/avatar.py @@ -189,7 +189,7 @@ class PlasmaSittingBehavior(idprops.IDPropObjectMixin, PlasmaModifierProperties, # This should be an empty, really... return True - def sanity_check(self): + def sanity_check(self, exporter): # The user absolutely MUST specify a clickable or this won't export worth crap. if self.clickable_object is None: raise ExportError("'{}': Sitting Behavior's clickable object is invalid".format(self.key_name)) diff --git a/korman/properties/modifiers/game_gui.py b/korman/properties/modifiers/game_gui.py index 9f44a18..d930264 100644 --- a/korman/properties/modifiers/game_gui.py +++ b/korman/properties/modifiers/game_gui.py @@ -70,7 +70,7 @@ class _GameGuiMixin: def requires_dyntext(self) -> bool: return False - def sanity_check(self): + def sanity_check(self, exporter): age: PlasmaAge = bpy.context.scene.world.plasma_age # Game GUI modifiers must be attached to objects in a GUI page, ONLY diff --git a/korman/properties/modifiers/gui.py b/korman/properties/modifiers/gui.py index 3efa136..5b06df2 100644 --- a/korman/properties/modifiers/gui.py +++ b/korman/properties/modifiers/gui.py @@ -660,7 +660,7 @@ class PlasmaLinkingBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz share.link_input(share_anim_stage, "stage", "stage_refs") share.link_output(linkingnode, "hosts", "shareBookSeek") - def sanity_check(self): + def sanity_check(self, exporter): if self.clickable is None: raise ExportError("{}: Linking Book modifier requires a clickable!", self.id_data.name) if self.seek_point is None: @@ -724,88 +724,18 @@ class PlasmaNotePopupModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz): if self.id_data.type == "MESH": return self.id_data - def sanity_check(self): + def sanity_check(self, exporter: Exporter): page_type = helpers.get_page_type(self.id_data.plasma_object.page) if page_type != "room": raise ExportError(f"Note Popup modifiers should be in a 'room' page, not a '{page_type}' page!") + # It's OK if multiple note popups point to the same GUI page, + # they just need to have the same camera. + exporter.gui.check_pre_export(self.gui_page, pl_id="note_popup", camera=self.gui_camera) + def pre_export(self, exporter: Exporter, bo: bpy.types.Object): - guidialog_object = utils.create_empty_object(f"{self.gui_page}_NoteDialog") - guidialog_object.plasma_object.enabled = True - guidialog_object.plasma_object.page = self.gui_page - yield guidialog_object - - guidialog_mod: PlasmaGameGuiDialogModifier = guidialog_object.plasma_modifiers.gui_dialog - guidialog_mod.enabled = True - guidialog_mod.is_modal = True - if self.gui_camera: - guidialog_mod.camera_object = self.gui_camera - else: - # Abuse the GUI Dialog's lookat caLculation to make us a camera that looks at everything - # the artist has placed into the GUI page. We want to do this NOW because we will very - # soon be adding more objects into the GUI page. - camera_object = yield utils.create_camera_object(f"{self.key_name}_GUICamera") - camera_object.data.angle = math.radians(45.0) - camera_object.data.lens_unit = "FOV" - - visible_objects = [ - i for i in exporter.get_objects(self.gui_page) - if i.type == "MESH" and i.data.materials - ] - camera_object.matrix_world = exporter.gui.calc_camera_matrix( - bpy.context.scene, - visible_objects, - camera_object.data.angle - ) - clipping = exporter.gui.calc_clipping( - camera_object.matrix_world, - bpy.context.scene, - visible_objects, - camera_object.data.angle - ) - camera_object.data.clip_start = clipping.hither - camera_object.data.clip_end = clipping.yonder - guidialog_mod.camera_object = camera_object - - # Begin creating the object for the clickoff plane. We want to yield it immediately - # to the exporter in case something goes wrong during the export, allowing the stale - # object to be cleaned up. - click_plane_object = utils.BMeshObject(f"{self.key_name}_Exit") - click_plane_object.matrix_world = guidialog_mod.camera_object.matrix_world - click_plane_object.plasma_object.enabled = True - click_plane_object.plasma_object.page = self.gui_page - yield click_plane_object - - # We have a camera on guidialog_mod.camera_object. We will now use it to generate the - # points for the click-off plane button. - # TODO: Allow this to be configurable to 4:3, 16:9, or 21:9? - with ExitStack() as stack: - stack.enter_context(exporter.gui.generate_camera_render_settings(bpy.context.scene)) - toggle = stack.enter_context(helpers.GoodNeighbor()) - - # Temporarily adjust the clipping plane out to the farthest point we can find to ensure - # that the click-off button ecompasses everything. This is a bit heavy-handed, but if - # you want more refined control, you won't be using this helper. - clipping = max((guidialog_mod.camera_object.data.clip_start, guidialog_mod.camera_object.data.clip_end)) - toggle.track(guidialog_mod.camera_object.data, "clip_start", clipping - 0.1) - view_frame = guidialog_mod.camera_object.data.view_frame(bpy.context.scene) - - click_plane_object.data.materials.append(exporter.gui.transparent_material) - with click_plane_object as click_plane_mesh: - verts = [click_plane_mesh.verts.new(i) for i in view_frame] - face = click_plane_mesh.faces.new(verts) - # TODO: Ensure the face is pointing toward the camera! - # I feel like we should be fine by assuming that Blender returns the viewframe - # verts in the correct order, but this is Blender... So test that assumption carefully. - # TODO: Apparently not! - face.normal_flip() - - # We've now created the mesh object - handle the GUI Button stuff - click_plane_object.plasma_modifiers.gui_button.enabled = True - - # NOTE: We will be using xDialogToggle.py, so we use a special tag ID instead of the - # close dialog procedure. - click_plane_object.plasma_modifiers.gui_control.tag_id = 99 + # The GUI converter will debounce duplicate GUI dialogs. + yield from exporter.gui.create_note_gui(self.gui_page, self.gui_camera) # Auto-generate a six-foot cube region around the clickable if none was provided. if self.clickable_region is None: diff --git a/korman/properties/modifiers/logic.py b/korman/properties/modifiers/logic.py index 1dec936..675b4fe 100644 --- a/korman/properties/modifiers/logic.py +++ b/korman/properties/modifiers/logic.py @@ -154,7 +154,7 @@ class PlasmaTelescope(PlasmaModifierProperties, PlasmaModifierLogicWiz): type=bpy.types.Object, poll=idprops.poll_camera_objects) - def sanity_check(self): + def sanity_check(self, exporter): if self.camera_object is None: raise ExportError(f"'{self.id_data.name}': Telescopes must specify a camera!") diff --git a/korman/properties/modifiers/render.py b/korman/properties/modifiers/render.py index 44c05c2..ec0645e 100644 --- a/korman/properties/modifiers/render.py +++ b/korman/properties/modifiers/render.py @@ -120,7 +120,7 @@ class PlasmaBlendMod(PlasmaModifierProperties): for i in (j.blend_onto for j in self.dependencies if j.blend_onto is not None and j.enabled): yield i - def sanity_check(self): + def sanity_check(self, exporter): if self.has_circular_dependency: raise ExportError("'{}': Circular Render Dependency detected!".format(self.id_data.name)) @@ -770,7 +770,7 @@ class PlasmaLocalizedTextModifier(PlasmaModifierProperties, PlasmaModifierLogicW def localization_set(self): return "DynaTexts" - def sanity_check(self): + def sanity_check(self, exporter): if self.texture is None: raise ExportError("'{}': Localized Text modifier requires a texture", self.id_data.name) diff --git a/korman/properties/modifiers/sound.py b/korman/properties/modifiers/sound.py index 394afc2..477427e 100644 --- a/korman/properties/modifiers/sound.py +++ b/korman/properties/modifiers/sound.py @@ -533,7 +533,7 @@ class PlasmaSoundEmitter(PlasmaModifierProperties): stereize_left = PointerProperty(type=bpy.types.Object, options={"HIDDEN", "SKIP_SAVE"}) stereize_right = PointerProperty(type=bpy.types.Object, options={"HIDDEN", "SKIP_SAVE"}) - def sanity_check(self): + def sanity_check(self, exporter): modifiers = self.id_data.plasma_modifiers # Sound emitters can potentially export sounds to more than one emitter SceneObject. Currently,