From 9780393345e6c230bbf985f7a2e0211d2f337132 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Thu, 6 Jul 2023 19:28:23 -0400 Subject: [PATCH 1/7] Add Note GUI Popups This adds just enough plumbing to be able to export GUI popup notes using the standard xDialogToggle.py. There are still a number of TODOs and FIXMEs in the basic stuff, but the design is mostly solid. The idea is that you'll create a GUI page for any objects that need to appear in a GUI. The GUI does not require an explicit GUI camera, however. For now, an automatic GUI camera will be made facing the largest object's -Z axis. --- korman/exporter/convert.py | 10 + korman/exporter/gui.py | 210 ++++++++++++++++ korman/exporter/manager.py | 4 +- korman/exporter/utils.py | 31 +++ korman/helpers.py | 13 + korman/operators/__init__.py | 1 + korman/operators/op_camera.py | 113 +++++++++ korman/properties/modifiers/__init__.py | 1 + korman/properties/modifiers/game_gui.py | 306 ++++++++++++++++++++++++ korman/properties/modifiers/gui.py | 193 ++++++++++++++- korman/properties/modifiers/region.py | 2 +- korman/properties/prop_world.py | 1 + korman/ui/modifiers/__init__.py | 1 + korman/ui/modifiers/game_gui.py | 42 ++++ korman/ui/modifiers/gui.py | 13 + 15 files changed, 934 insertions(+), 7 deletions(-) create mode 100644 korman/exporter/gui.py create mode 100644 korman/operators/op_camera.py create mode 100644 korman/properties/modifiers/game_gui.py create mode 100644 korman/ui/modifiers/game_gui.py diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 2e19b96..c0d5008 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -32,6 +32,7 @@ from .camera import CameraConverter from .decal import DecalConverter from . import explosions from .etlight import LightBaker +from .gui import GuiConverter from .image import ImageCache from .locman import LocalizationConverter from . import logger @@ -45,6 +46,7 @@ from . import utils class Exporter: if TYPE_CHECKING: + _objects: List[bpy.types.Object] = ... actors: Set[str] = ... want_node_trees: defaultdict[Set[str]] = ... report: logger._ExportLogger = ... @@ -60,6 +62,7 @@ class Exporter: locman: LocalizationConverter = ... decal: DecalConverter = ... oven: LightBaker = ... + gui: GuiConverter def __init__(self, op): self._op = op # Blender export operator @@ -83,6 +86,7 @@ class Exporter: self.locman = LocalizationConverter(self) self.decal = DecalConverter(self) self.oven = LightBaker(mesh=self.mesh, report=self.report) + self.gui = GuiConverter(self) # Step 0.8: Init the progress mgr self.mesh.add_progress_presteps(self.report) @@ -371,6 +375,12 @@ class Exporter: tree.export(self, bo, so) inc_progress() + def get_objects(self, page: Optional[str]) -> Iterator[bpy.types.Object]: + yield from filter( + lambda x: x.plasma_object.page == page, + self._objects + ) + def _harvest_actors(self): self.report.progress_advance() self.report.progress_range = len(self._objects) + len(bpy.data.textures) diff --git a/korman/exporter/gui.py b/korman/exporter/gui.py new file mode 100644 index 0000000..ddf5d00 --- /dev/null +++ b/korman/exporter/gui.py @@ -0,0 +1,210 @@ +# 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 __future__ import annotations + +import bpy +import mathutils + +from contextlib import contextmanager, ExitStack +import itertools +from PyHSPlasma import * +from typing import * +import weakref + +from .explosions import ExportError +from .. import helpers +from . import utils + +if TYPE_CHECKING: + from .convert import Exporter + from .logger import _ExportLogger as ExportLogger + + +class Clipping(NamedTuple): + hither: float + yonder: float + + +class PostEffectModMatrices(NamedTuple): + c2w: hsMatrix44 + w2c: hsMatrix44 + + +class GuiConverter: + + if TYPE_CHECKING: + _parent: weakref.ref[Exporter] = ... + + def __init__(self, parent: Optional[Exporter] = None): + self._parent = weakref.ref(parent) if parent is not None else None + + # Go ahead and prepare the GUI transparent material for future use. + if parent is not None: + self._transp_material = parent.exit_stack.enter_context( + helpers.TemporaryObject( + bpy.data.materials.new("GUITransparent"), + bpy.data.materials.remove + ) + ) + self._transp_material.diffuse_color = mathutils.Vector((1.0, 1.0, 0.0)) + self._transp_material.use_mist = False + + # Cyan's transparent GUI materials just set an opacity of 0% + tex_slot = self._transp_material.texture_slots.add() + tex_slot.texture = parent.exit_stack.enter_context( + helpers.TemporaryObject( + bpy.data.textures.new("AutoTransparentLayer", "NONE"), + bpy.data.textures.remove + ) + ) + tex_slot.texture.plasma_layer.opacity = 0.0 + else: + self._transp_material = None + + def calc_camera_matrix( + self, + scene: bpy.types.Scene, + objects: Sequence[bpy.types.Object], + fov: float, + scale: float = 1.0, + ) -> mathutils.Matrix: + if not objects: + raise ExportError("No objects specified for GUI Camera generation.") + + class ObjArea(NamedTuple): + obj: bpy.types.Object + area: float + + + remove_mesh = bpy.data.meshes.remove + obj_areas: List[ObjArea] = [] + for i in objects: + mesh = i.to_mesh(bpy.context.scene, True, "RENDER", calc_tessface=False) + with helpers.TemporaryObject(mesh, remove_mesh): + utils.transform_mesh(mesh, i.matrix_world) + obj_areas.append( + ObjArea(i, sum((polygon.area for polygon in mesh.polygons))) + ) + largest_obj = max(obj_areas, key=lambda x: x.area) + + # GUIs are generally flat planes, which, by default in Blender, have their normal pointing + # in the localspace z axis. Therefore, what we would like to do is have a camera that points + # at the localspace -Z axis. Where up is the localspace +Y axis. We are already pointing at + # -Z with +Y being up from what I can tel. So, just use this matrix. + mat = largest_obj.obj.matrix_world.to_3x3() + + # Now, we know the rotation of the camera. Great! What we need to do now is ensure that all + # of the objects in question fit within the view of a 4:3 camera rotated as above. Blender + # helpfully provides us with the localspace bounding boxes of all the objects and an API to + # fit points into the camera view. + with ExitStack() as stack: + stack.enter_context(self.generate_camera_render_settings(scene)) + + # Create a TEMPORARY camera object so we can use a certain Blender API. + camera = stack.enter_context(utils.temporary_camera_object(scene, "GUICameraTemplate")) + camera.matrix_world = mat.to_4x4() + camera.data.angle = fov + camera.data.lens_unit = "FOV" + + # Get all of the bounding points and make sure they all fit inside the camera's view frame. + bound_boxes = ( + obj.matrix_world * mathutils.Vector(bbox) + for obj in objects for bbox in obj.bound_box + ) + co, _ = camera.camera_fit_coords( + scene, + # bound_box is a list of vectors of each corner of all the objects' bounding boxes; + # however, Blender's API wants a sequence of individual channel positions. Therefore, + # we need to flatten the vectors. + list(itertools.chain.from_iterable(bound_boxes)) + ) + + # Calculate the distance from the largest object to the camera. The scale we were given + # will be used to push the camera back in the Z+ direction of that object by scale times. + bvh = mathutils.bvhtree.BVHTree.FromObject(largest_obj.obj, scene) + loc, normal, index, distance = bvh.find_nearest(co) + co += normal * distance * (scale - 1.0) + + # ... + mat.resize_4x4() + mat.translation = co + return mat + + def calc_clipping( + self, + pose: mathutils.Matrix, + scene: bpy.types.Scene, + objects: Sequence[bpy.types.Object], + fov: float + ) -> Clipping: + with ExitStack() as stack: + stack.enter_context(self.generate_camera_render_settings(scene)) + camera = stack.enter_context(utils.temporary_camera_object(scene, "GUICameraTemplate")) + camera.matrix_world = pose + camera.data.angle = fov + camera.data.lens_unit = "FOV" + + # Determine the camera plane's normal so we can do a distance check against the + # bounding boxes of the objects shown in the GUI. + view_frame = [i * pose for i in camera.data.view_frame(scene)] + cam_plane = mathutils.geometry.normal(view_frame) + bound_boxes = ( + obj.matrix_world * mathutils.Vector(bbox) + for obj in objects for bbox in obj.bound_box + ) + pos = pose.to_translation() + bounds_dists = [ + abs(mathutils.geometry.distance_point_to_plane(i, pos, cam_plane)) + for i in bound_boxes + ] + + # Offset them by some epsilon to ensure the objects are rendered. + hither, yonder = min(bounds_dists), max(bounds_dists) + if yonder - 0.5 < hither: + hither -= 0.25 + yonder += 0.25 + return Clipping(hither, yonder) + + def convert_post_effect_matrices(self, camera_matrix: mathutils.Matrix) -> PostEffectModMatrices: + # PostEffectMod matrices face *away* from the GUI... For some reason. + # See plPostEffectMod::SetWorldToCamera() + c2w = utils.matrix44(camera_matrix) + w2c = utils.matrix44(camera_matrix.inverted()) + for i in range(4): + c2w[i, 2] *= -1.0 + w2c[2, i] *= -1.0 + return PostEffectModMatrices(c2w, w2c) + + @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 + # viewport up as a 4:3 thingy to match Plasma. + with helpers.GoodNeighbor() as toggle: + toggle.track(scene.render, "resolution_x", 720) + toggle.track(scene.render, "resolution_y", 486) + toggle.track(scene.render, "pixel_aspect_x", 10.0) + toggle.track(scene.render, "pixel_aspect_y", 11.0) + yield + + @property + def _report(self) -> ExportLogger: + return self._parent().report + + @property + def transparent_material(self) -> bpy.types.Material: + assert self._transp_material is not None + return self._transp_material + diff --git a/korman/exporter/manager.py b/korman/exporter/manager.py index 1001511..61afd6f 100644 --- a/korman/exporter/manager.py +++ b/korman/exporter/manager.py @@ -265,8 +265,8 @@ class ExportManager: return self.add_object(pl=pClass, name=name, bl=bl, so=so) return key.object - def find_object(self, pClass: Type[KeyedT], bl=None, name=None, so=None) -> Optional[KeyedT]: - key = self.find_key(pClass, bl, name, so) + def find_object(self, pClass: Type[KeyedT], bl=None, name=None, so=None, loc=None) -> Optional[KeyedT]: + key = self.find_key(pClass, bl, name, so, loc) if key is not None: return key.object return None diff --git a/korman/exporter/utils.py b/korman/exporter/utils.py index fa180ee..0fc15a9 100644 --- a/korman/exporter/utils.py +++ b/korman/exporter/utils.py @@ -24,6 +24,8 @@ from typing import * from PyHSPlasma import * +from ..import helpers + def affine_parts(xform): # Decompose the matrix into the 90s-era 3ds max affine parts sillyness # All that's missing now is something like "(c) 1998 HeadSpin" oh wait... @@ -106,6 +108,20 @@ class BMeshObject: return self._obj +def create_empty_object(name: str, owner_object: Optional[bpy.types.Object] = None) -> bpy.types.Object: + empty_object = bpy.data.objects.new(name, None) + if owner_object is not None: + empty_object.plasma_object.enabled = owner_object.plasma_object.enabled + empty_object.plasma_object.page = owner_object.plasma_object.page + bpy.context.scene.objects.link(empty_object) + return empty_object + +def create_camera_object(name: str) -> bpy.types.Object: + cam_data = bpy.data.cameras.new(name) + cam_obj = bpy.data.objects.new(name, cam_data) + bpy.context.scene.objects.link(cam_obj) + return cam_obj + def create_cube_region(name: str, size: float, owner_object: bpy.types.Object) -> bpy.types.Object: """Create a cube shaped region object""" region_object = BMeshObject(name) @@ -136,6 +152,21 @@ def pre_export_optional_cube_region(source, attr: str, name: str, size: float, o # contextlib.contextmanager requires for us to yield. Sad. yield +@contextmanager +def temporary_camera_object(scene: bpy.types.Scene, name: str) -> bpy.types.Object: + try: + cam_data = bpy.data.cameras.new(name) + cam_obj = bpy.data.objects.new(name, cam_data) + scene.objects.link(cam_obj) + yield cam_obj + finally: + cam_obj = locals().get("cam_obj") + if cam_obj is not None: + bpy.data.objects.remove(cam_obj) + cam_data = locals().get("cam_data") + if cam_data is not None: + bpy.data.cameras.remove(cam_data) + @contextmanager def temporary_mesh_object(source : bpy.types.Object) -> bpy.types.Object: """Creates a temporary mesh object from a nonmesh object that will only exist for the duration diff --git a/korman/helpers.py b/korman/helpers.py index 07690d4..35c0176 100644 --- a/korman/helpers.py +++ b/korman/helpers.py @@ -137,3 +137,16 @@ def find_modifier(bo, modid): # if they give us the wrong modid, it is a bug and an AttributeError return getattr(bo.plasma_modifiers, modid) return None + +def get_page_type(page: str) -> str: + all_pages = bpy.context.scene.world.plasma_age.pages + if page: + page_type = next((i.page_type for i in all_pages if i.name == page), None) + if page_type is None: + raise LookupError(page) + return page_type + else: + # A falsey page name is likely a request for the default page, so look for Page ID 0. + # If it doesn't exist, that's an implicit default page (a "room" type). + page_type = next((i.page_type for i in all_pages if i.seq_suffix == 0), None) + return page_type if page_type is not None else "room" diff --git a/korman/operators/__init__.py b/korman/operators/__init__.py index 7ff172a..5e392c9 100644 --- a/korman/operators/__init__.py +++ b/korman/operators/__init__.py @@ -13,6 +13,7 @@ # You should have received a copy of the GNU General Public License # along with Korman. If not, see . +from . import op_camera as camera from . import op_export as exporter from . import op_image as image from . import op_lightmap as lightmap diff --git a/korman/operators/op_camera.py b/korman/operators/op_camera.py new file mode 100644 index 0000000..f2f218b --- /dev/null +++ b/korman/operators/op_camera.py @@ -0,0 +1,113 @@ +# 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 __future__ import annotations + +import bpy +from bpy.props import * + +import math + +from ..exporter.explosions import ExportError +from ..exporter.gui import GuiConverter + +class CameraOperator: + @classmethod + def poll(cls, context): + return context.scene.render.engine == "PLASMA_GAME" + + +class PlasmaGameGuiCameraOperator(CameraOperator, bpy.types.Operator): + bl_idname = "camera.plasma_create_game_gui_camera" + bl_label = "Create Game GUI Camera" + + fov: float = FloatProperty( + name="Field of View", + description="", + subtype="ANGLE", + default=math.radians(90.0), + min=0.0, + max=math.radians(360.0), + precision=1, + options=set() + ) + gui_page: str = StringProperty( + name="GUI Page", + description="", + options={"HIDDEN"} + ) + scale: float = FloatProperty( + name="GUI Scale", + description="", + default=1.02, + min=0.001, + soft_max=2.0, + precision=2, + options=set() + ) + + mod_id: str = StringProperty(options={"HIDDEN"}) + cam_prop_name: str = StringProperty(options={"HIDDEN"}) + + def execute(self, context): + # If the modifier has been given to us, select all of the objects in the + # given GUI page. + if self.gui_page: + for i in context.scene.objects: + i.select = i.plasma_object.page == self.gui_page + context.scene.update() + + visible_objects = [ + i for i in context.selected_objects + if i.type in {"MESH", "FONT"} + ] + + gui = GuiConverter() + try: + cam_matrix = gui.calc_camera_matrix( + context.scene, + visible_objects, + self.fov, + self.scale + ) + except ExportError as e: + self.report({"ERROR"}, str(e)) + return {"CANCELLED"} + + if self.mod_id and self.cam_prop_name: + modifier = getattr(context.object.plasma_modifiers, self.mod_id) + cam_obj = getattr(modifier, self.cam_prop_name) + else: + cam_obj = None + if cam_obj is None: + if self.gui_page: + name = f"{self.gui_page}_GUICamera" + else: + name = f"{context.object.name}_GUICamera" + cam_data = bpy.data.cameras.new(name) + cam_obj = bpy.data.objects.new(name, cam_data) + context.scene.objects.link(cam_obj) + + cam_obj.matrix_world = cam_matrix + cam_obj.data.angle = self.fov + cam_obj.data.lens_unit = "FOV" + + for i in context.scene.objects: + i.select = i == cam_obj + + if self.mod_id and self.cam_prop_name: + modifier = getattr(context.object.plasma_modifiers, self.mod_id) + setattr(modifier, self.cam_prop_name, cam_obj) + return {"FINISHED"} diff --git a/korman/properties/modifiers/__init__.py b/korman/properties/modifiers/__init__.py index ba179df..2374505 100644 --- a/korman/properties/modifiers/__init__.py +++ b/korman/properties/modifiers/__init__.py @@ -18,6 +18,7 @@ import bpy from .base import PlasmaModifierProperties from .anim import * from .avatar import * +from .game_gui import * from .gui import * from .logic import * from .physics import * diff --git a/korman/properties/modifiers/game_gui.py b/korman/properties/modifiers/game_gui.py new file mode 100644 index 0000000..4585aa4 --- /dev/null +++ b/korman/properties/modifiers/game_gui.py @@ -0,0 +1,306 @@ +# 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 __future__ import annotations + +import bpy +from bpy.props import * + +import itertools +import math +from typing import * + +from PyHSPlasma import * + +from ...exporter import ExportError +from .base import PlasmaModifierProperties +from ... import idprops + +if TYPE_CHECKING: + from ...exporter import Exporter + from ..prop_world import PlasmaAge, PlasmaPage + +class _GameGuiMixin: + def get_control(self, exporter: Exporter, bo: Optional[bpy.types.Object] = None, so: Optional[plSceneObject] = None) -> Optional[pfGUIControlMod]: + return None + + @property + def has_gui_proc(self) -> bool: + return True + + def iterate_control_modifiers(self) -> Iterator[_GameGuiMixin]: + pl_mods = self.id_data.plasma_modifiers + yield from ( + getattr(pl_mods, i.pl_id) + for i in self.iterate_control_subclasses() + if getattr(pl_mods, i.pl_id).enabled + ) + + @classmethod + def iterate_control_subclasses(cls) -> Iterator[_GameGuiMixin]: + yield from filter( + lambda x: x.is_game_gui_control(), + _GameGuiMixin.__subclasses__() + ) + + @classmethod + def is_game_gui_control(cls) -> bool: + return True + + @property + def requires_dyntext(self) -> bool: + return False + + def sanity_check(self): + age: PlasmaAge = bpy.context.scene.world.plasma_age + + # Game GUI modifiers must be attached to objects in a GUI page, ONLY + page_name: str = self.id_data.plasma_object.page + our_page: Optional[PlasmaPage] = next( + (i for i in age.pages if i.name == page_name) + ) + if our_page is None or our_page.page_type != "gui": + raise ExportError(f"'{self.id_data.name}': {self.bl_label} Modifier must be in a GUI page!") + + # Only one Game GUI Control per object. Continuously check this because objects can be + # generated/mutated during the pre-export phase. + modifiers = self.id_data.plasma_modifiers + controls = [i for i in self.iterate_control_subclasses() if getattr(modifiers, i.pl_id).enabled] + num_controls = len(controls) + if num_controls > 1: + raise ExportError(f"'{self.id_data.name}': Only 1 GUI Control modifier is allowed per object. We found {num_controls}.") + + +class PlasmaGameGuiControlModifier(PlasmaModifierProperties, _GameGuiMixin): + pl_id = "gui_control" + + bl_category = "GUI" + bl_label = "Ex: Game GUI Control" + bl_description = "XXX" + + tag_id = IntProperty( + name="Tag ID", + description="", + min=0, + options=set() + ) + visible = BoolProperty( + name="Visible", + description="", + default=True, + options=set() + ) + proc = EnumProperty( + name="Notification Procedure", + description="", + items=[ + ("default", "[Default]", "Send notifications to the owner's notification procedure."), + ("close_dialog", "Close Dialog", "Close the current Game GUI Dialog."), + ("console_command", "Run Console Command", "Run a Plasma Console command.") + ], + options=set() + ) + console_command = StringProperty( + name="Command", + description="", + options=set() + ) + + def convert_gui_control(self, exporter: Exporter, ctrl: pfGUIControlMod, bo: bpy.types.Object, so: plSceneObject): + ctrl.tagID = self.tag_id + ctrl.visible = self.visible + if self.proc == "default": + ctrl.setFlag(pfGUIControlMod.kInheritProcFromDlg, True) + elif self.proc == "close_dialog": + ctrl.handler = pfGUICloseDlgProc() + elif self.proc == "console_command": + handler = pfGUIConsoleCmdProc() + handler.command = self.console_command + ctrl.handler = handler + + def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): + ctrl_mods = list(self.iterate_control_modifiers()) + if not ctrl_mods: + exporter.report.msg(str(list(self.iterate_control_subclasses()))) + exporter.report.warn("This modifier has no effect because no GUI control modifiers are present!") + for ctrl_mod in ctrl_mods: + self.convert_gui_control(exporter, ctrl_mod.get_control(exporter, bo, so), bo, so) + + @property + def has_gui_proc(self) -> bool: + return any((i.has_gui_proc for i in self.iterate_control_modifiers())) + + @classmethod + def is_game_gui_control(cls) -> bool: + # How is a control not a control, you ask? Because, grasshopper, this modifier does not + # actually export a GUI control itself. Instead, it holds common properties that may + # or may not be used by other controls. This just helps fill out the other modifiers. + return False + + +class PlasmaGameGuiButtonModifier(PlasmaModifierProperties, _GameGuiMixin): + pl_id = "gui_button" + pl_depends = {"gui_control"} + + bl_category = "GUI" + bl_label = "Ex: Game GUI Button" + bl_description = "XXX" + + def _update_notify_type(self, context): + # It doesn't make sense to have no notify type at all selected, so + # default to at least one option. + if not self.notify_type: + self.notify_type = {"DOWN"} + + notify_type = EnumProperty( + name="Notify On", + description="When the button should perform its action", + items=[ + ("UP", "Up", "When the mouse button is down over the GUI button."), + ("DOWN", "Down", "When the mouse button is released over the GUI button."), + ], + default={"UP"}, + options={"ENUM_FLAG"}, + update=_update_notify_type + ) + + def get_control(self, exporter: Exporter, bo: Optional[bpy.types.Object] = None, so: Optional[plSceneObject] = None) -> pfGUIButtonMod: + return exporter.mgr.find_create_object(pfGUIButtonMod, bl=bo, so=so) + + def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): + ctrl = self.get_control(exporter, bo, so) + ctrl.setFlag(pfGUIControlMod.kWantsInterest, True) + + if self.notify_type == {"UP"}: + ctrl.notifyType = pfGUIButtonMod.kNotifyOnUp + elif self.notify_type == {"DOWN"}: + ctrl.notifyType = pfGUIButtonMod.kNotifyOnDown + elif self.notify_type == {"UP", "DOWN"}: + ctrl.notifyType = pfGUIButtonMod.kNotifyOnUpAndDown + else: + raise ValueError(self.notify_type) + + +class PlasmaGameGuiDialogModifier(PlasmaModifierProperties, _GameGuiMixin): + pl_id = "gui_dialog" + + bl_category = "GUI" + bl_label = "Ex: Game GUI Dialog" + bl_description = "XXX" + + camera_object: bpy.types.Object = PointerProperty( + name="GUI Camera", + description="Camera used to project the GUI to screenspace.", + type=bpy.types.Object, + poll=idprops.poll_camera_objects, + options=set() + ) + is_modal = BoolProperty( + name="Modal", + description="", + default=True, + options=set() + ) + + def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): + # Find all of the visible objects in the GUI page for use in hither/yon raycast and + # camera matrix calculations. + visible_objects = [ + i for i in exporter.get_objects(bo.plasma_object.page) + if i.type == "MESH" and i.data.materials + ] + + camera_object = self.id_data if self.id_data.type == "CAMERA" else self.camera_object + if camera_object: + exporter.report.msg(f"Using camera matrix from camera '{camera_object.name}'") + if camera_object != self.id_data and camera_object.plasma_object.enabled: + with exporter.report.indent(): + exporter.report.warn("The camera object should NOT be a Plasma Object!") + camera_matrix = camera_object.matrix_world + + # Save the clipping info from the camera for later use. + cam_data = camera_object.data + fov, hither, yonder = cam_data.angle, cam_data.clip_start, cam_data.clip_end + else: + exporter.report.msg(f"Building a camera matrix to view: {', '.join((i.name for i in visible_objects))}") + fov = math.radians(45.0) + camera_matrix = exporter.gui.calc_camera_matrix( + bpy.context.scene, + visible_objects, + fov, + 1.02 # FIXME + ) + + # There isn't a real camera, so just pretend like the user didn't set the clipping info. + hither, yonder = 0.0, 0.0 + with exporter.report.indent(): + exporter.report.msg(str(camera_matrix)) + + # If no hither or yonder was specified on the camera, then we need to determine that ourselves. + if not hither or not yonder: + exporter.report.msg(f"Incomplete clipping: H:{hither:.02f} Y:{yonder:.02f}; calculating new...") + with exporter.report.indent(): + clipping = exporter.gui.calc_clipping( + camera_matrix, + bpy.context.scene, + visible_objects, + fov + ) + exporter.report.msg(f"Calculated: H:{clipping.hither:.02f} Y:{clipping.yonder:.02f}") + if not hither: + hither = clipping.hither + if not yonder: + yonder = clipping.yonder + exporter.report.msg(f"Corrected clipping: H:{hither:.02f} Y:{yonder:.02f}") + + # Both of the objects we export go into the pool. + scene_node_key = exporter.mgr.get_scene_node(bl=bo) + + post_effect = exporter.mgr.find_create_object(plPostEffectMod, bl=bo) + post_effect.defaultC2W, post_effect.defaultW2C = exporter.gui.convert_post_effect_matrices(camera_matrix) + post_effect.fovX = math.degrees(fov) + post_effect.fovY = math.degrees(fov * (3.0 / 4.0)) + post_effect.hither = min((hither, yonder)) + post_effect.yon = max((hither, yonder)) + post_effect.nodeKey = scene_node_key + + dialog_mod = exporter.mgr.find_create_object(pfGUIDialogMod, bl=bo) + dialog_mod.name = bo.plasma_object.page + dialog_mod.setFlag(pfGUIDialogMod.kModal, self.is_modal) + dialog_mod.renderMod = post_effect.key + dialog_mod.sceneNode = scene_node_key + + @property + def has_gui_proc(self) -> bool: + return False + + @classmethod + def is_game_gui_control(cls) -> bool: + return False + + def post_export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): + # All objects have been exported. Now, we can establish linkage to all controls that + # have been exported. + dialog = exporter.mgr.find_object(pfGUIDialogMod, bl=bo, so=so) + control_modifiers: Iterable[_GameGuiMixin] = itertools.chain.from_iterable( + obj.plasma_modifiers.gui_control.iterate_control_modifiers() + for obj in exporter.get_objects(bo.plasma_object.page) + if obj.plasma_modifiers.gui_control.enabled + ) + for control_modifier in control_modifiers: + control = control_modifier.get_control(exporter, control_modifier.id_data) + ctrl_key = control.key + exporter.report.msg(f"GUIDialog '{bo.name}': [{control.ClassName()}] '{ctrl_key.name}'") + dialog.addControl(ctrl_key) diff --git a/korman/properties/modifiers/gui.py b/korman/properties/modifiers/gui.py index 641d2ae..40e65c0 100644 --- a/korman/properties/modifiers/gui.py +++ b/korman/properties/modifiers/gui.py @@ -13,20 +13,28 @@ # You should have received a copy of the GNU General Public License # along with Korman. If not, see . +from __future__ import annotations + import bpy -import bmesh from bpy.props import * -import mathutils +from contextlib import ExitStack +import itertools import math from pathlib import Path +from typing import * from PyHSPlasma import * from ...addon_prefs import game_versions from ...exporter import ExportError, utils -from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz, PlasmaModifierUpgradable -from ... import idprops +from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz +from ... import helpers, idprops + +if TYPE_CHECKING: + from ...exporter import Exporter + from .game_gui import PlasmaGameGuiDialogModifier + journal_pfms = { pvPots : { @@ -665,3 +673,180 @@ class PlasmaLinkingBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz raise ExportError("{}: Linking Book modifier requires a clickable!", self.id_data.name) if self.seek_point is None: raise ExportError("{}: Linking Book modifier requires a seek point!", self.id_data.name) + + +dialog_toggle = { + "filename": "xDialogToggle.py", + "attribs": ( + { 'id': 1, 'type': "ptAttribActivator", 'name': "Activate" }, + { 'id': 4, 'type': "ptAttribString", 'name': "Vignette" }, + ) +} + +class PlasmaNotePopupModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz): + pl_id = "note_popup" + + bl_category = "GUI" + bl_label = "Ex: Note Popup" + bl_description = "XXX" + bl_icon = "MATPLANE" + + def _get_gui_pages(self, context): + scene = context.scene if context is not None else bpy.context.scene + return [ + (i.name, i.name, i.name) + for i in scene.world.plasma_age.pages + if i.page_type == "gui" + ] + + clickable: bpy.types.Object = PointerProperty( + name="Clickable", + description="The object the player will click on to activate the GUI", + type=bpy.types.Object, + poll=idprops.poll_mesh_objects + ) + clickable_region: bpy.types.Object = PointerProperty( + name="Clickable Region", + description="The region in which the avatar must be standing before they can click on the note", + type=bpy.types.Object, + poll=idprops.poll_mesh_objects + ) + gui_page: str = EnumProperty( + name="GUI Page", + description="Page containing all of the objects to display", + items=_get_gui_pages, + options=set() + ) + gui_camera: bpy.types.Object = PointerProperty( + name="GUI Camera", + description="", + poll=idprops.poll_camera_objects, + type=bpy.types.Object, + options=set() + ) + + @property + def clickable_object(self) -> Optional[bpy.types.Object]: + if self.clickable is not None: + return self.clickable + if self.id_data.type == "MESH": + return self.id_data + + def sanity_check(self): + 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!") + + 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, + 1.02 # FIXME + ) + 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 + + # Auto-generate a six-foot cube region around the clickable if none was provided. + yield utils.pre_export_optional_cube_region( + self, "clickable_region", + f"{self.key_name}_DialogToggle_ClkRgn", 6.0, + self.clickable_object + ) + + # Everything is ready now - create an xDialogToggle.py in the room page to display the GUI. + yield self.convert_logic(bo) + + def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): + # You're not hallucinating... Everything is done in the pre-export phase. + pass + + def logicwiz(self, bo, tree): + nodes = tree.nodes + + # xDialogToggle.py PythonFile Node + dialog_node = self._create_python_file_node( + tree, + dialog_toggle["filename"], + dialog_toggle["attribs"] + ) + self._create_python_attribute(dialog_node, "Vignette", value=self.gui_page) + + # Clickable + clickable_region = nodes.new("PlasmaClickableRegionNode") + clickable_region.region_object = self.clickable_region + + clickable = nodes.new("PlasmaClickableNode") + clickable.find_input_socket("facing").allow_simple = False + clickable.clickable_object = self.clickable_object + clickable.link_input(clickable_region, "satisfies", "region") + clickable.link_output(dialog_node, "satisfies", "Activate") diff --git a/korman/properties/modifiers/region.py b/korman/properties/modifiers/region.py index 4ee5d24..ca811e1 100644 --- a/korman/properties/modifiers/region.py +++ b/korman/properties/modifiers/region.py @@ -327,7 +327,7 @@ class PlasmaSubworldRegion(PlasmaModifierProperties): def export(self, exporter, bo, so): # Due to the fact that our subworld modifier can produce both RidingAnimatedPhysical - # and [HK|PX]Subworlds depending on the situation, this could get hairy, fast. + # and [HK|PX]Subworlds depending on the situation, this could get hairy, fast. # Start by surveying the lay of the land. from_sub, to_sub = bo.plasma_object.subworld, self.subworld from_isded = exporter.physics.is_dedicated_subworld(from_sub) diff --git a/korman/properties/prop_world.py b/korman/properties/prop_world.py index e23341e..0b0356f 100644 --- a/korman/properties/prop_world.py +++ b/korman/properties/prop_world.py @@ -141,6 +141,7 @@ class PlasmaPage(bpy.types.PropertyGroup): items=[ ("room", "Room", "A standard Plasma Room containing scene objects"), ("external", "External", "A page generated by an external process. Korman will not write to this page, ever."), + ("gui", "Game GUI", "A page containing Game GUI objects.") ], default="room", options=set()) diff --git a/korman/ui/modifiers/__init__.py b/korman/ui/modifiers/__init__.py index aa6f7f6..6f25779 100644 --- a/korman/ui/modifiers/__init__.py +++ b/korman/ui/modifiers/__init__.py @@ -15,6 +15,7 @@ from .anim import * from .avatar import * +from .game_gui import * from .gui import * from .logic import * from .physics import * diff --git a/korman/ui/modifiers/game_gui.py b/korman/ui/modifiers/game_gui.py new file mode 100644 index 0000000..83c3c8f --- /dev/null +++ b/korman/ui/modifiers/game_gui.py @@ -0,0 +1,42 @@ +# 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 . + +def gui_button(modifier, layout, context): + layout.prop(modifier, "notify_type") + +def gui_control(modifier, layout, context): + split = layout.split() + col = split.column() + col.prop(modifier, "visible") + + col = split.column() + col.prop(modifier, "tag_id") + + col = layout.column() + col.active = modifier.has_gui_proc + col.prop(modifier, "proc") + row = col.row() + row.active = col.active and modifier.proc == "console_command" + row.prop(modifier, "console_command") + +def gui_dialog(modifier, layout, context): + row = layout.row(align=True) + row.prop(modifier, "camera_object") + op = row.operator("camera.plasma_create_game_gui_camera", text="", icon="CAMERA_DATA") + op.mod_id = modifier.pl_id + op.cam_prop_name = "camera_object" + op.gui_page = modifier.id_data.plasma_object.page + + layout.prop(modifier, "is_modal") diff --git a/korman/ui/modifiers/gui.py b/korman/ui/modifiers/gui.py index cf462c3..37ad9cb 100644 --- a/korman/ui/modifiers/gui.py +++ b/korman/ui/modifiers/gui.py @@ -116,3 +116,16 @@ def linkingbookmod(modifier, layout, context): row.label("Stamp Position:") row.prop(modifier, "stamp_x", text="X") row.prop(modifier, "stamp_y", text="Y") + +def note_popup(modifier, layout, context): + layout.prop(modifier, "gui_page") + + row = layout.row(align=True) + row.prop(modifier, "gui_camera") + op = row.operator("camera.plasma_create_game_gui_camera", text="", icon="CAMERA_DATA") + op.mod_id = modifier.pl_id + op.cam_prop_name = "gui_camera" + op.gui_page = modifier.gui_page + + layout.prop(modifier, "clickable") + layout.prop(modifier, "clickable_region") From 3ff64ff861751ab80d63f8c4650f6efd5fd3dc95 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Fri, 7 Jul 2023 17:28:31 -0400 Subject: [PATCH 2/7] Use the area weighted inverse normal for GUI Cameras. We will now calculate the area-weighted normal of all polygons in the GUI page, then flip it to create the direction that the GUI camera should face. This will, theoretically, be more intuitive to artists in that the GUI camera should always point at what they've modelled instead of imposing arbitrary rules on the coordinate system of GUI objects. --- korman/exporter/gui.py | 61 ++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/korman/exporter/gui.py b/korman/exporter/gui.py index ddf5d00..deb55ff 100644 --- a/korman/exporter/gui.py +++ b/korman/exporter/gui.py @@ -20,6 +20,7 @@ import mathutils from contextlib import contextmanager, ExitStack import itertools +import math from PyHSPlasma import * from typing import * import weakref @@ -84,27 +85,28 @@ class GuiConverter: if not objects: raise ExportError("No objects specified for GUI Camera generation.") - class ObjArea(NamedTuple): - obj: bpy.types.Object - area: float - - + # Generally, GUIs are flat planes. However, we are not Cyan, so artists cannot walk down + # the hallway to get smacked on the knuckles by programmers. This means that they might + # give us some three dimensional crap as a GUI. Therefore, to come up with a camera matrix, + # we'll use the average area-weighted inverse normal of all the polygons they give us. That + # way, the camera *always* should face the GUI as would be expected. remove_mesh = bpy.data.meshes.remove - obj_areas: List[ObjArea] = [] + avg_normal = mathutils.Vector() for i in objects: mesh = i.to_mesh(bpy.context.scene, True, "RENDER", calc_tessface=False) with helpers.TemporaryObject(mesh, remove_mesh): utils.transform_mesh(mesh, i.matrix_world) - obj_areas.append( - ObjArea(i, sum((polygon.area for polygon in mesh.polygons))) - ) - largest_obj = max(obj_areas, key=lambda x: x.area) - - # GUIs are generally flat planes, which, by default in Blender, have their normal pointing - # in the localspace z axis. Therefore, what we would like to do is have a camera that points - # at the localspace -Z axis. Where up is the localspace +Y axis. We are already pointing at - # -Z with +Y being up from what I can tel. So, just use this matrix. - mat = largest_obj.obj.matrix_world.to_3x3() + for polygon in mesh.polygons: + avg_normal += (polygon.normal * polygon.area) + avg_normal.normalize() + avg_normal *= -1.0 + + # From the inverse area weighted normal we found above, get the rotation from the up axis + # (that is to say, the +Z axis) and create our rotation matrix. + axis = mathutils.Vector((avg_normal.x, avg_normal.y, 0.0)) + axis.normalize() + angle = math.acos(avg_normal.z) + mat = mathutils.Matrix.Rotation(angle, 3, axis) # Now, we know the rotation of the camera. Great! What we need to do now is ensure that all # of the objects in question fit within the view of a 4:3 camera rotated as above. Blender @@ -120,10 +122,10 @@ class GuiConverter: camera.data.lens_unit = "FOV" # Get all of the bounding points and make sure they all fit inside the camera's view frame. - bound_boxes = ( + bound_boxes = [ obj.matrix_world * mathutils.Vector(bbox) for obj in objects for bbox in obj.bound_box - ) + ] co, _ = camera.camera_fit_coords( scene, # bound_box is a list of vectors of each corner of all the objects' bounding boxes; @@ -132,11 +134,24 @@ class GuiConverter: list(itertools.chain.from_iterable(bound_boxes)) ) - # Calculate the distance from the largest object to the camera. The scale we were given - # will be used to push the camera back in the Z+ direction of that object by scale times. - bvh = mathutils.bvhtree.BVHTree.FromObject(largest_obj.obj, scene) - loc, normal, index, distance = bvh.find_nearest(co) - co += normal * distance * (scale - 1.0) + # This generates a list of 6 faces per bounding box, which we then flatten out and pass + # into the BVHTree constructor. This is to calculate the distance from the camera to the + # "entire GUI" - which we can then use to apply the scale given to us. + if scale != 1.0: + bvh = mathutils.bvhtree.BVHTree.FromPolygons( + bound_boxes, + list(itertools.chain.from_iterable( + [(i + 0, i + 1, i + 5, i + 4), + (i + 1, i + 2, i + 5, i + 6), + (i + 3, i + 2, i + 6, i + 7), + (i + 0, i + 1, i + 2, i + 3), + (i + 0, i + 3, i + 7, i + 4), + (i + 4, i + 5, i + 6, i + 7), + ] for i in range(0, len(bound_boxes), 8) + )) + ) + loc, normal, index, distance = bvh.find_nearest(co) + co += normal * distance * (scale - 1.0) # ... mat.resize_4x4() From a21cc86db72bfa9ee46eeffd9963962d43750e17 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Fri, 7 Jul 2023 17:32:11 -0400 Subject: [PATCH 3/7] GUI Camera scaling should make things smaller. Our calculations try to fill the entire screen with the GUI objects. So, really, what we want the GUI Camera scaling to do is allow the artist to make the GUI smaller onscreen by pushing the camera away. This is a *less than* 100% scaling. --- korman/exporter/gui.py | 2 +- korman/operators/op_camera.py | 20 +++++++++++++------- korman/properties/modifiers/game_gui.py | 3 +-- korman/properties/modifiers/gui.py | 3 +-- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/korman/exporter/gui.py b/korman/exporter/gui.py index deb55ff..0a0a117 100644 --- a/korman/exporter/gui.py +++ b/korman/exporter/gui.py @@ -80,7 +80,7 @@ class GuiConverter: scene: bpy.types.Scene, objects: Sequence[bpy.types.Object], fov: float, - scale: float = 1.0, + scale: float = 0.75 ) -> mathutils.Matrix: if not objects: raise ExportError("No objects specified for GUI Camera generation.") diff --git a/korman/operators/op_camera.py b/korman/operators/op_camera.py index f2f218b..365dd7e 100644 --- a/korman/operators/op_camera.py +++ b/korman/operators/op_camera.py @@ -32,10 +32,11 @@ class CameraOperator: class PlasmaGameGuiCameraOperator(CameraOperator, bpy.types.Operator): bl_idname = "camera.plasma_create_game_gui_camera" bl_label = "Create Game GUI Camera" + bl_description = "Create a camera looking at all of the objects in this GUI page with a custom scale factor" fov: float = FloatProperty( name="Field of View", - description="", + description="Camera Field of View angle", subtype="ANGLE", default=math.radians(90.0), min=0.0, @@ -50,17 +51,22 @@ class PlasmaGameGuiCameraOperator(CameraOperator, bpy.types.Operator): ) scale: float = FloatProperty( name="GUI Scale", - description="", - default=1.02, - min=0.001, - soft_max=2.0, - precision=2, + description="GUI Camera distance scale factor.", + default=75.0, + min=0.1, + soft_max=100.0, + precision=1, + subtype="PERCENTAGE", options=set() ) mod_id: str = StringProperty(options={"HIDDEN"}) cam_prop_name: str = StringProperty(options={"HIDDEN"}) + def invoke(self, context, event): + context.window_manager.invoke_props_dialog(self) + return {"RUNNING_MODAL"} + def execute(self, context): # If the modifier has been given to us, select all of the objects in the # given GUI page. @@ -80,7 +86,7 @@ class PlasmaGameGuiCameraOperator(CameraOperator, bpy.types.Operator): context.scene, visible_objects, self.fov, - self.scale + self.scale / 100.0 ) except ExportError as e: self.report({"ERROR"}, str(e)) diff --git a/korman/properties/modifiers/game_gui.py b/korman/properties/modifiers/game_gui.py index 4585aa4..d872605 100644 --- a/korman/properties/modifiers/game_gui.py +++ b/korman/properties/modifiers/game_gui.py @@ -239,8 +239,7 @@ class PlasmaGameGuiDialogModifier(PlasmaModifierProperties, _GameGuiMixin): camera_matrix = exporter.gui.calc_camera_matrix( bpy.context.scene, visible_objects, - fov, - 1.02 # FIXME + fov ) # There isn't a real camera, so just pretend like the user didn't set the clipping info. diff --git a/korman/properties/modifiers/gui.py b/korman/properties/modifiers/gui.py index 40e65c0..98874ad 100644 --- a/korman/properties/modifiers/gui.py +++ b/korman/properties/modifiers/gui.py @@ -763,8 +763,7 @@ class PlasmaNotePopupModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz): camera_object.matrix_world = exporter.gui.calc_camera_matrix( bpy.context.scene, visible_objects, - camera_object.data.angle, - 1.02 # FIXME + camera_object.data.angle ) clipping = exporter.gui.calc_clipping( camera_object.matrix_world, From 405cc32cf7b929c3d034f65c8ab79c3bcfb79c75 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sat, 8 Jul 2023 17:56:22 -0400 Subject: [PATCH 4/7] Add SFX support to GUI buttons. This also adds the scaffolding for other GUI control types to easily export sound effects without requiring too much boilerplate. --- korman/properties/modifiers/game_gui.py | 76 ++++++++++++++++++++++++- korman/ui/modifiers/game_gui.py | 13 ++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/korman/properties/modifiers/game_gui.py b/korman/properties/modifiers/game_gui.py index d872605..a8a6c9a 100644 --- a/korman/properties/modifiers/game_gui.py +++ b/korman/properties/modifiers/game_gui.py @@ -33,6 +33,13 @@ if TYPE_CHECKING: from ..prop_world import PlasmaAge, PlasmaPage class _GameGuiMixin: + @property + def gui_sounds(self) -> Iterable[Tuple[str, int]]: + """Overload to automatically export GUI sounds on the control. This should return an iterable + of tuple attribute name and sound index. + """ + return [] + def get_control(self, exporter: Exporter, bo: Optional[bpy.types.Object] = None, so: Optional[plSceneObject] = None) -> Optional[pfGUIControlMod]: return None @@ -82,6 +89,16 @@ class _GameGuiMixin: if num_controls > 1: raise ExportError(f"'{self.id_data.name}': Only 1 GUI Control modifier is allowed per object. We found {num_controls}.") + # Blow up on invalid sounds + soundemit = self.id_data.plasma_modifiers.soundemit + for attr_name, _ in self.gui_sounds: + sound_name = getattr(self, attr_name) + if not sound_name: + continue + sound = next((i for i in soundemit.sounds if i.name == sound_name), None) + if sound is None: + raise ExportError(f"'{self.id_data.name}': Invalid '{attr_name}' GUI Sound '{sound_name}'") + class PlasmaGameGuiControlModifier(PlasmaModifierProperties, _GameGuiMixin): pl_id = "gui_control" @@ -130,13 +147,37 @@ class PlasmaGameGuiControlModifier(PlasmaModifierProperties, _GameGuiMixin): handler.command = self.console_command ctrl.handler = handler + def convert_gui_sounds(self, exporter: Exporter, ctrl: pfGUIControlMod, ctrl_mod: _GameGuiMixin): + soundemit = ctrl_mod.id_data.plasma_modifiers.soundemit + if not ctrl_mod.gui_sounds or not soundemit.enabled: + return + + # This is a lot like the plPhysicalSndGroup where we have a vector behaving as a lookup table. + # NOTE that zero is a special value here meaning no sound, so we need to offset the sounds + # that we get from the emitter modifier by +1. + sound_indices = {} + for attr_name, gui_sound_idx in ctrl_mod.gui_sounds: + sound_name = getattr(ctrl_mod, attr_name) + if not sound_name: + continue + sound_keys = soundemit.get_sound_keys(exporter, sound_name) + sound_key, soundemit_index = next(sound_keys, (None, -1)) + if sound_key is not None: + sound_indices[gui_sound_idx] = soundemit_index + 1 + + # Compress the list to include only the highest entry we need. + if sound_indices: + ctrl.soundIndices = [sound_indices.get(i, 0) for i in range(max(sound_indices) + 1)] + def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): ctrl_mods = list(self.iterate_control_modifiers()) if not ctrl_mods: exporter.report.msg(str(list(self.iterate_control_subclasses()))) exporter.report.warn("This modifier has no effect because no GUI control modifiers are present!") for ctrl_mod in ctrl_mods: - self.convert_gui_control(exporter, ctrl_mod.get_control(exporter, bo, so), bo, so) + ctrl_obj = ctrl_mod.get_control(exporter, bo, so) + self.convert_gui_control(exporter, ctrl_obj, bo, so) + self.convert_gui_sounds(exporter, ctrl_obj, ctrl_mod) @property def has_gui_proc(self) -> bool: @@ -176,6 +217,39 @@ class PlasmaGameGuiButtonModifier(PlasmaModifierProperties, _GameGuiMixin): update=_update_notify_type ) + mouse_down_sound: str = StringProperty( + name="Mouse Down SFX", + description="Sound played when the mouse button is down", + options=set() + ) + + mouse_up_sound: str = StringProperty( + name="Mouse Up SFX", + description="Sound played when the mouse button is released", + options=set() + ) + + mouse_over_sound: str = StringProperty( + name="Mouse Over SFX", + description="Sound played when the mouse moves over the GUI button", + options=set() + ) + + mouse_off_sound: str = StringProperty( + name="Mouse Off SFX", + description="Sound played when the mouse moves off of the GUI button", + options=set() + ) + + @property + def gui_sounds(self): + return ( + ("mouse_down_sound", pfGUIButtonMod.kMouseDown), + ("mouse_up_sound", pfGUIButtonMod.kMouseUp), + ("mouse_over_sound", pfGUIButtonMod.kMouseOver), + ("mouse_off_sound", pfGUIButtonMod.kMouseOff), + ) + def get_control(self, exporter: Exporter, bo: Optional[bpy.types.Object] = None, so: Optional[plSceneObject] = None) -> pfGUIButtonMod: return exporter.mgr.find_create_object(pfGUIButtonMod, bl=bo, so=so) diff --git a/korman/ui/modifiers/game_gui.py b/korman/ui/modifiers/game_gui.py index 83c3c8f..bf17c1e 100644 --- a/korman/ui/modifiers/game_gui.py +++ b/korman/ui/modifiers/game_gui.py @@ -14,7 +14,18 @@ # along with Korman. If not, see . def gui_button(modifier, layout, context): - layout.prop(modifier, "notify_type") + row = layout.row() + row.label("Notify On:") + row.prop(modifier, "notify_type") + + soundemit = modifier.id_data.plasma_modifiers.soundemit + col = layout.column() + col.label("Sound Effects:") + col.active = soundemit.enabled + col.prop_search(modifier, "mouse_down_sound", soundemit, "sounds", text="Mouse Down", icon="SPEAKER") + col.prop_search(modifier, "mouse_up_sound", soundemit, "sounds", text="Mouse Up", icon="SPEAKER") + col.prop_search(modifier, "mouse_over_sound", soundemit, "sounds", text="Mouse Over", icon="SPEAKER") + col.prop_search(modifier, "mouse_off_sound", soundemit, "sounds", text="Mouse Off", icon="SPEAKER") def gui_control(modifier, layout, context): split = layout.split() From bc9c6fd63d54e22dc5877148cfc408b995dbcd58 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sun, 9 Jul 2023 18:16:29 -0400 Subject: [PATCH 5/7] Add mouse click/over animations to GUI Buttons. --- korman/properties/modifiers/game_gui.py | 123 ++++++++++++++++++++++++ korman/ui/modifiers/game_gui.py | 83 ++++++++++++++-- 2 files changed, 198 insertions(+), 8 deletions(-) diff --git a/korman/properties/modifiers/game_gui.py b/korman/properties/modifiers/game_gui.py index a8a6c9a..4ebd050 100644 --- a/korman/properties/modifiers/game_gui.py +++ b/korman/properties/modifiers/game_gui.py @@ -191,6 +191,122 @@ class PlasmaGameGuiControlModifier(PlasmaModifierProperties, _GameGuiMixin): return False +class GameGuiAnimation(bpy.types.PropertyGroup): + def _poll_target_object(self, value): + # Only allow targetting things that are in our GUI page. + if value.plasma_object.page != self.id_data.plasma_object.page: + return False + if self.anim_type == "OBJECT": + return idprops.poll_animated_objects(self, value) + else: + return idprops.poll_drawable_objects(self, value) + + def _poll_texture(self, value): + # must be a legal option... but is it a member of this material... or, if no material, + # any of the materials attached to the object? + if self.target_material is not None: + return value.name in self.target_material.texture_slots + else: + target_object = self.target_object if self.target_object is not None else self.id_data + for i in (slot.material for slot in target_object.material_slots if slot and slot.material): + if value in (slot.texture for slot in i.texture_slots if slot and slot.texture): + return True + return False + + def _poll_material(self, value): + # Don't filter materials by texture - this would (potentially) result in surprising UX + # in that you would have to clear the texture selection before being able to select + # certain materials. + target_object = self.target_object if self.target_object is not None else self.id_data + object_materials = (slot.material for slot in target_object.material_slots if slot and slot.material) + return value in object_materials + + anim_type: str = EnumProperty( + name="Type", + description="Animation type to affect", + items=[ + ("OBJECT", "Object", "Object Animation"), + ("TEXTURE", "Texture", "Texture Animation"), + ], + default="OBJECT", + options=set() + ) + target_object: bpy.types.Object = PointerProperty( + name="Object", + description="Target object", + poll=_poll_target_object, + type=bpy.types.Object + ) + target_material: bpy.types.Material = PointerProperty( + name="Material", + description="Target material", + type=bpy.types.Material, + poll=_poll_material + ) + target_texture: bpy.types.Texture = PointerProperty( + name="Texture", + description="Target texture", + type=bpy.types.Texture, + poll=_poll_texture + ) + + +class GameGuiAnimationGroup(bpy.types.PropertyGroup): + def _update_animation_name(self, context) -> None: + if not self.animation_name: + self.animation_name = "(Entire Animation)" + + animations = CollectionProperty( + name="Animations", + description="", + type=GameGuiAnimation, + options=set() + ) + + animation_name: str = StringProperty( + name="Animation Name", + description="Name of the animation to play", + default="(Entire Animation)", + update=_update_animation_name, + options=set() + ) + + active_anim_index: int = IntProperty(options={"HIDDEN"}) + show_expanded: bool = BoolProperty(options={"HIDDEN"}) + + def export( + self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject, + ctrl_obj: pfGUIControlMod, add_func: Callable[[plKey], None], + anim_name_attr: str + ): + keys = set() + for anim in self.animations: + target_object = anim.target_object if anim.target_object is not None else bo + if anim.anim_type == "OBJECT": + keys.add(exporter.animation.get_animation_key(target_object)) + elif anim.anim_type == "TEXTURE": + # Layer animations don't respect the name field, so we need to grab exactly the + # layer animation key that is requested. Cyan's Max plugin does not allow specifying + # layer animations here as best I can tell, but I don't see why we shouldn't. + keys.update( + exporter.mesh.material.get_texture_animation_key( + target_object, + anim.target_material, + anim.target_texture, + self.animation_name + ) + ) + else: + raise RuntimeError() + + # This is to make sure that we only waste space in the PRP file with the animation + # name if we actually have some doggone animations. + if keys: + setattr(ctrl_obj, anim_name_attr, self.animation_name) + for i in keys: + add_func(i) + + class PlasmaGameGuiButtonModifier(PlasmaModifierProperties, _GameGuiMixin): pl_id = "gui_button" pl_depends = {"gui_control"} @@ -217,6 +333,10 @@ class PlasmaGameGuiButtonModifier(PlasmaModifierProperties, _GameGuiMixin): update=_update_notify_type ) + mouse_over_anims: GameGuiAnimationGroup = PointerProperty(type=GameGuiAnimationGroup) + mouse_click_anims: GameGuiAnimationGroup = PointerProperty(type=GameGuiAnimationGroup) + show_expanded_sounds: bool = BoolProperty(options={"HIDDEN"}) + mouse_down_sound: str = StringProperty( name="Mouse Down SFX", description="Sound played when the mouse button is down", @@ -266,6 +386,9 @@ class PlasmaGameGuiButtonModifier(PlasmaModifierProperties, _GameGuiMixin): else: raise ValueError(self.notify_type) + self.mouse_over_anims.export(exporter, bo, so, ctrl, ctrl.addMouseOverKey, "mouseOverAnimName") + self.mouse_click_anims.export(exporter, bo, so, ctrl, ctrl.addAnimationKey, "animName") + class PlasmaGameGuiDialogModifier(PlasmaModifierProperties, _GameGuiMixin): pl_id = "gui_dialog" diff --git a/korman/ui/modifiers/game_gui.py b/korman/ui/modifiers/game_gui.py index bf17c1e..6be792f 100644 --- a/korman/ui/modifiers/game_gui.py +++ b/korman/ui/modifiers/game_gui.py @@ -13,19 +13,86 @@ # You should have received a copy of the GNU General Public License # along with Korman. If not, see . +from __future__ import annotations +from typing import * + +import bpy.types + +from .. import ui_list + +if TYPE_CHECKING: + from ...properties.modifiers.game_gui import GameGuiAnimation, GameGuiAnimationGroup + +class GuiAnimListUI(bpy.types.UIList): + def _iter_target_names(self, item: GameGuiAnimation): + if item.target_object is not None: + yield item.target_object.name + else: + yield item.id_data.name + if item.target_material is not None: + yield item.target_material.name + if item.target_texture is not None: + yield item.target_texture.name + + def draw_item( + self, context, layout, data, item: GameGuiAnimation, icon, active_data, + active_property, index=0, flt_flag=0 + ): + if item.anim_type == "OBJECT": + name = item.target_object.name if item.target_object is not None else item.id_data.name + layout.label(name, icon="OBJECT_DATA") + elif item.anim_type == "TEXTURE": + name_seq = list(self._iter_target_names(item)) + layout.label(" / ".join(name_seq), icon="TEXTURE") + else: + raise RuntimeError() + + +def _gui_anim(name: str, group: GameGuiAnimationGroup, layout, context): + box = layout.box() + row = box.row(align=True) + + exicon = "TRIA_DOWN" if group.show_expanded else "TRIA_RIGHT" + row.prop(group, "show_expanded", text="", icon=exicon, emboss=False) + row.prop(group, "animation_name", text=name, icon="ANIM") + if not group.show_expanded: + return + + ui_list.draw_modifier_list(box, "GuiAnimListUI", group, "animations", "active_anim_index", rows=2) + try: + anim: GameGuiAnimation = group.animations[group.active_anim_index] + except: + pass + else: + col = box.column() + col.prop(anim, "anim_type") + col.prop(anim, "target_object") + if anim.anim_type == "TEXTURE": + col.prop(anim, "target_material") + col.prop(anim, "target_texture") + + def gui_button(modifier, layout, context): row = layout.row() row.label("Notify On:") row.prop(modifier, "notify_type") - soundemit = modifier.id_data.plasma_modifiers.soundemit - col = layout.column() - col.label("Sound Effects:") - col.active = soundemit.enabled - col.prop_search(modifier, "mouse_down_sound", soundemit, "sounds", text="Mouse Down", icon="SPEAKER") - col.prop_search(modifier, "mouse_up_sound", soundemit, "sounds", text="Mouse Up", icon="SPEAKER") - col.prop_search(modifier, "mouse_over_sound", soundemit, "sounds", text="Mouse Over", icon="SPEAKER") - col.prop_search(modifier, "mouse_off_sound", soundemit, "sounds", text="Mouse Off", icon="SPEAKER") + _gui_anim("Mouse Click", modifier.mouse_click_anims, layout, context) + _gui_anim("Mouse Over", modifier.mouse_over_anims, layout, context) + + box = layout.box() + row = box.row(align=True) + exicon = "TRIA_DOWN" if modifier.show_expanded_sounds else "TRIA_RIGHT" + row.prop(modifier, "show_expanded_sounds", text="", icon=exicon, emboss=False) + row.label("Sound Effects") + if modifier.show_expanded_sounds: + col = box.column() + soundemit = modifier.id_data.plasma_modifiers.soundemit + col.active = soundemit.enabled + col.prop_search(modifier, "mouse_down_sound", soundemit, "sounds", text="Mouse Down", icon="SPEAKER") + col.prop_search(modifier, "mouse_up_sound", soundemit, "sounds", text="Mouse Up", icon="SPEAKER") + col.prop_search(modifier, "mouse_over_sound", soundemit, "sounds", text="Mouse Over", icon="SPEAKER") + col.prop_search(modifier, "mouse_off_sound", soundemit, "sounds", text="Mouse Off", icon="SPEAKER") def gui_control(modifier, layout, context): split = layout.split() From 6d2fb2fdc5aa868ccd4bc5049f0f418961dbe2e3 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sun, 9 Jul 2023 18:28:46 -0400 Subject: [PATCH 6/7] Allow attaching a GUI Dialog to a Python File node. --- korman/nodes/node_python.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/korman/nodes/node_python.py b/korman/nodes/node_python.py index 265263a..a86d992 100644 --- a/korman/nodes/node_python.py +++ b/korman/nodes/node_python.py @@ -734,7 +734,8 @@ class PlasmaAttribObjectNode(idprops.IDPropObjectMixin, PlasmaAttribNodeBase, bp bl_label = "Object Attribute" pl_attrib = ("ptAttribSceneobject", "ptAttribSceneobjectList", "ptAttribAnimation", - "ptAttribSwimCurrent", "ptAttribWaveSet", "ptAttribGrassShader") + "ptAttribSwimCurrent", "ptAttribWaveSet", "ptAttribGrassShader", + "ptAttribGUIDialog") target_object = PointerProperty(name="Object", description="Object containing the required data", @@ -781,7 +782,13 @@ class PlasmaAttribObjectNode(idprops.IDPropObjectMixin, PlasmaAttribNodeBase, bp return None return [exporter.mgr.find_create_key(plGrassShaderMod, so=ref_so, name=i.name) for i in exporter.mesh.material.get_materials(bo)] - + elif attrib == "ptAttribGUIDialog": + gui_dialog = bo.plasma_modifiers.gui_dialog + if not gui_dialog.enabled: + self.raise_error(f"GUI Dialog modifier not enabled on '{self.object_name}'") + dialog_mod = exporter.mgr.find_create_object(pfGUIDialogMod, so=ref_so, bl=bo) + dialog_mod.procReceiver = attrib.node.get_key(exporter, so) + return dialog_mod.key @classmethod def _idprop_mapping(cls): From bc81e8c5e549049ee46f99260864996ca5ad1b80 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Mon, 11 Sep 2023 20:16:36 -0400 Subject: [PATCH 7/7] Only allow modifiers on sensible objects. This means you can't add VisRegions to your GUI pages. You also can't make an empty object be a panic link region. Those kinds of things just don't make sense. --- korman/operators/op_modifier.py | 45 ++++++++++++++++++------- korman/properties/modifiers/__init__.py | 21 ------------ korman/properties/modifiers/anim.py | 4 +++ korman/properties/modifiers/base.py | 15 ++++++++- korman/properties/modifiers/game_gui.py | 12 +++++-- korman/properties/modifiers/gui.py | 2 +- korman/properties/modifiers/logic.py | 1 + korman/properties/modifiers/physics.py | 1 + korman/properties/modifiers/region.py | 4 +++ korman/properties/modifiers/render.py | 15 +++++++++ korman/properties/modifiers/sound.py | 1 + korman/properties/modifiers/water.py | 4 +++ 12 files changed, 87 insertions(+), 38 deletions(-) diff --git a/korman/operators/op_modifier.py b/korman/operators/op_modifier.py index ce5df53..d7c75a2 100644 --- a/korman/operators/op_modifier.py +++ b/korman/operators/op_modifier.py @@ -23,17 +23,7 @@ import time from typing import * from ..properties import modifiers - -def _fetch_modifiers(): - items = [] - - mapping = modifiers.modifier_mapping() - for i in sorted(mapping.keys()): - items.append(("", i, "")) - items.extend(mapping[i]) - #yield ("", i, "") - #yield mapping[i] - return items +from ..helpers import find_modifier class ModifierOperator: def _get_modifier(self, context) -> modifiers.PlasmaModifierProperties: @@ -55,10 +45,41 @@ class ModifierAddOperator(ModifierOperator, bpy.types.Operator): bl_label = "Add Modifier" bl_description = "Adds a Plasma Modifier" + def _fetch_modifiers(self, context): + items = [] + + def filter_mod_name(mod): + # The modifier might include the cateogry name in its name, so we'll strip that. + if mod.bl_label != mod.bl_category: + if mod.bl_label.startswith(mod.bl_category): + return mod.bl_label[len(mod.bl_category)+1:] + if mod.bl_label.endswith(mod.bl_category): + return mod.bl_label[:-len(mod.bl_category)-1] + return mod.bl_label + + sorted_modifiers = sorted( + modifiers.PlasmaModifierProperties.__subclasses__(), + key=lambda x: f"{x.bl_category} - {filter_mod_name(x)}" + ) + last_category = None + for i, mod in enumerate(sorted_modifiers): + # Some modifiers aren't permissible in certain situations. Hide them. + if not find_modifier(context.object, mod.pl_id).allowed: + continue + if mod.bl_category != last_category: + items.append(("", mod.bl_category, "")) + last_category = mod.bl_category + items.append( + (mod.pl_id, filter_mod_name(mod), mod.bl_description, + getattr(mod, "bl_icon", ""), i) + ) + + return items + types = EnumProperty( name="Modifier Type", description="The type of modifier we add to the list", - items=_fetch_modifiers() + items=_fetch_modifiers ) def execute(self, context): diff --git a/korman/properties/modifiers/__init__.py b/korman/properties/modifiers/__init__.py index 2374505..87e35be 100644 --- a/korman/properties/modifiers/__init__.py +++ b/korman/properties/modifiers/__init__.py @@ -75,24 +75,3 @@ class PlasmaModifiers(bpy.types.PropertyGroup): class PlasmaModifierSpec(bpy.types.PropertyGroup): pass - -def modifier_mapping(): - """This returns a dict mapping Plasma Modifier categories to names""" - - d = {} - sorted_modifiers = sorted(PlasmaModifierProperties.__subclasses__(), key=lambda x: x.bl_label) - for i, mod in enumerate(sorted_modifiers): - pl_id, category, label, description = mod.pl_id, mod.bl_category, mod.bl_label, mod.bl_description - icon = getattr(mod, "bl_icon", "") - - # The modifier might include the cateogry name in its name, so we'll strip that. - if label != category: - if label.startswith(category): - label = label[len(category)+1:] - if label.endswith(category): - label = label[:-len(category)-1] - - tup = (pl_id, label, description, icon, i) - d_cat = d.setdefault(category, []) - d_cat.append(tup) - return d diff --git a/korman/properties/modifiers/anim.py b/korman/properties/modifiers/anim.py index df96850..684ea91 100644 --- a/korman/properties/modifiers/anim.py +++ b/korman/properties/modifiers/anim.py @@ -53,6 +53,7 @@ class ActionModifier: class PlasmaAnimationModifier(ActionModifier, PlasmaModifierProperties): pl_id = "animation" + pl_page_types = {"gui", "room"} bl_category = "Animation" bl_label = "Animation" @@ -170,6 +171,7 @@ class AnimGroupObject(idprops.IDPropObjectMixin, bpy.types.PropertyGroup): class PlasmaAnimationFilterModifier(PlasmaModifierProperties): pl_id = "animation_filter" + pl_page_types = {"gui", "room"} bl_category = "Animation" bl_label = "Filter Transform" @@ -214,6 +216,7 @@ class PlasmaAnimationFilterModifier(PlasmaModifierProperties): class PlasmaAnimationGroupModifier(ActionModifier, PlasmaModifierProperties): pl_id = "animation_group" pl_depends = {"animation"} + pl_page_types = {"gui", "room"} bl_category = "Animation" bl_label = "Group Master" @@ -272,6 +275,7 @@ class LoopMarker(bpy.types.PropertyGroup): class PlasmaAnimationLoopModifier(ActionModifier, PlasmaModifierProperties): pl_id = "animation_loop" pl_depends = {"animation"} + pl_page_types = {"gui", "room"} bl_category = "Animation" bl_label = "Loop Markers" diff --git a/korman/properties/modifiers/base.py b/korman/properties/modifiers/base.py index 1994484..ed2b76e 100644 --- a/korman/properties/modifiers/base.py +++ b/korman/properties/modifiers/base.py @@ -20,7 +20,20 @@ import abc import itertools from typing import Any, Dict, FrozenSet, Optional +from ... import helpers + class PlasmaModifierProperties(bpy.types.PropertyGroup): + @property + def allowed(self) -> bool: + """Returns if this modifier is allowed to be enabled on the owning Object""" + allowed_page_types = getattr(self, "pl_page_types", {"room"}) + allowed_object_types = getattr(self, "bl_object_types", set()) + page_name = self.id_data.plasma_object.page + if not allowed_object_types or self.id_data.type in allowed_object_types: + if helpers.get_page_type(page_name) in allowed_page_types: + return True + return False + @property def copy_material(self): """Materials MUST be single-user""" @@ -53,7 +66,7 @@ class PlasmaModifierProperties(bpy.types.PropertyGroup): @property def enabled(self) -> bool: - return self.display_order >= 0 + return self.display_order >= 0 and self.allowed @enabled.setter def enabled(self, value: bool) -> None: diff --git a/korman/properties/modifiers/game_gui.py b/korman/properties/modifiers/game_gui.py index 4ebd050..a13ee6b 100644 --- a/korman/properties/modifiers/game_gui.py +++ b/korman/properties/modifiers/game_gui.py @@ -102,10 +102,12 @@ class _GameGuiMixin: class PlasmaGameGuiControlModifier(PlasmaModifierProperties, _GameGuiMixin): pl_id = "gui_control" + pl_page_types = {"gui"} bl_category = "GUI" - bl_label = "Ex: Game GUI Control" + bl_label = "GUI Control (ex)" bl_description = "XXX" + bl_object_types = {"FONT", "MESH"} tag_id = IntProperty( name="Tag ID", @@ -310,10 +312,12 @@ class GameGuiAnimationGroup(bpy.types.PropertyGroup): class PlasmaGameGuiButtonModifier(PlasmaModifierProperties, _GameGuiMixin): pl_id = "gui_button" pl_depends = {"gui_control"} + pl_page_types = {"gui"} bl_category = "GUI" - bl_label = "Ex: Game GUI Button" + bl_label = "GUI Button (ex)" bl_description = "XXX" + bl_object_types = {"FONT", "MESH"} def _update_notify_type(self, context): # It doesn't make sense to have no notify type at all selected, so @@ -392,10 +396,12 @@ class PlasmaGameGuiButtonModifier(PlasmaModifierProperties, _GameGuiMixin): class PlasmaGameGuiDialogModifier(PlasmaModifierProperties, _GameGuiMixin): pl_id = "gui_dialog" + pl_page_types = {"gui"} bl_category = "GUI" - bl_label = "Ex: Game GUI Dialog" + bl_label = "GUI Dialog (ex)" bl_description = "XXX" + bl_object_types = {"FONT", "MESH"} camera_object: bpy.types.Object = PointerProperty( name="GUI Camera", diff --git a/korman/properties/modifiers/gui.py b/korman/properties/modifiers/gui.py index 98874ad..3fef942 100644 --- a/korman/properties/modifiers/gui.py +++ b/korman/properties/modifiers/gui.py @@ -687,7 +687,7 @@ class PlasmaNotePopupModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz): pl_id = "note_popup" bl_category = "GUI" - bl_label = "Ex: Note Popup" + bl_label = "Note Popup (ex)" bl_description = "XXX" bl_icon = "MATPLANE" diff --git a/korman/properties/modifiers/logic.py b/korman/properties/modifiers/logic.py index 15e19ec..f77a28a 100644 --- a/korman/properties/modifiers/logic.py +++ b/korman/properties/modifiers/logic.py @@ -82,6 +82,7 @@ class PlasmaSpawnPoint(PlasmaModifierProperties): bl_category = "Logic" bl_label = "Spawn Point" bl_description = "Point at which avatars link into the Age" + bl_object_types = {"EMPTY"} def export(self, exporter, bo, so): # Not much to this modifier... It's basically a flag that tells the engine, "hey, this is a diff --git a/korman/properties/modifiers/physics.py b/korman/properties/modifiers/physics.py index 96382ef..b029ca2 100644 --- a/korman/properties/modifiers/physics.py +++ b/korman/properties/modifiers/physics.py @@ -68,6 +68,7 @@ class PlasmaCollider(PlasmaModifierProperties): bl_label = "Collision" bl_icon = "MOD_PHYSICS" bl_description = "Simple physical collider" + bl_object_types = {"MESH", "FONT"} bounds = EnumProperty(name="Bounds Type", description="", diff --git a/korman/properties/modifiers/region.py b/korman/properties/modifiers/region.py index ca811e1..2f08872 100644 --- a/korman/properties/modifiers/region.py +++ b/korman/properties/modifiers/region.py @@ -67,6 +67,7 @@ class PlasmaCameraRegion(PlasmaModifierProperties): bl_label = "Camera Region" bl_description = "Camera Region" bl_icon = "CAMERA_DATA" + bl_object_types = {"MESH"} camera_type = EnumProperty(name="Camera Type", description="What kind of camera should be used?", @@ -133,6 +134,7 @@ class PlasmaFootstepRegion(PlasmaModifierProperties, PlasmaModifierLogicWiz): bl_category = "Region" bl_label = "Footstep" bl_description = "Footstep Region" + bl_object_types = {"MESH"} surface = EnumProperty(name="Surface", description="What kind of surface are we walking on?", @@ -177,6 +179,7 @@ class PlasmaPanicLinkRegion(PlasmaModifierProperties): bl_category = "Region" bl_label = "Panic Link" bl_description = "Panic Link Region" + bl_object_types = {"MESH"} play_anim = BoolProperty(name="Play Animation", description="Play the link-out animation when panic linking", @@ -313,6 +316,7 @@ class PlasmaSubworldRegion(PlasmaModifierProperties): bl_category = "Region" bl_label = "Subworld Region" bl_description = "Subworld transition region" + bl_object_types = {"MESH"} subworld = PointerProperty(name="Subworld", description="Subworld to transition into", diff --git a/korman/properties/modifiers/render.py b/korman/properties/modifiers/render.py index 14972a3..44c05c2 100644 --- a/korman/properties/modifiers/render.py +++ b/korman/properties/modifiers/render.py @@ -39,10 +39,12 @@ class PlasmaBlendOntoObject(bpy.types.PropertyGroup): class PlasmaBlendMod(PlasmaModifierProperties): pl_id = "blend" + pl_page_types = {"gui", "room"} bl_category = "Render" bl_label = "Blending" bl_description = "Advanced Blending Options" + bl_object_types = {"MESH", "FONT"} render_level = EnumProperty(name="Render Pass", description="Suggested render pass for this object.", @@ -150,6 +152,7 @@ class PlasmaDecalPrintMod(PlasmaDecalMod, PlasmaModifierProperties): bl_category = "Render" bl_label = "Print Decal" bl_description = "Prints a decal onto an object" + bl_object_types = {"MESH", "FONT"} decal_type = EnumProperty(name="Decal Type", description="Type of decal to print onto another object", @@ -201,6 +204,7 @@ class PlasmaDecalReceiveMod(PlasmaDecalMod, PlasmaModifierProperties): bl_category = "Render" bl_label = "Receive Decal" bl_description = "Allows this object to receive dynamic decals" + bl_object_types = {"MESH", "FONT"} managers = CollectionProperty(type=PlasmaDecalManagerRef) active_manager_index = IntProperty(options={"HIDDEN"}) @@ -220,6 +224,7 @@ class PlasmaFadeMod(PlasmaModifierProperties): bl_category = "Render" bl_label = "Opacity Fader" bl_description = "Fades an object based on distance or line-of-sight" + bl_object_types = {"MESH", "FONT"} fader_type = EnumProperty(name="Fader Type", description="Type of opacity fade", @@ -281,6 +286,7 @@ class PlasmaFollowMod(idprops.IDPropObjectMixin, PlasmaModifierProperties): bl_category = "Render" bl_label = "Follow" bl_description = "Follow the movement of the camera, player, or another object" + bl_object_types = {"MESH", "FONT"} follow_mode = EnumProperty(name="Mode", description="Leader's movement to follow", @@ -357,6 +363,7 @@ class PlasmaGrassShaderMod(PlasmaModifierProperties): bl_category = "Render" bl_label = "Grass Shader" bl_description = "Applies waving grass effect at run-time" + bl_object_types = {"MESH", "FONT"} wave1 = PointerProperty(type=PlasmaGrassWave) wave2 = PointerProperty(type=PlasmaGrassWave) @@ -404,6 +411,7 @@ class PlasmaLightMapGen(idprops.IDPropMixin, PlasmaModifierProperties, PlasmaMod bl_category = "Render" bl_label = "Bake Lighting" bl_description = "Auto-Bake Static Lighting" + bl_object_types = {"MESH", "FONT"} deprecated_properties = {"render_layers"} @@ -551,10 +559,12 @@ class PlasmaLightMapGen(idprops.IDPropMixin, PlasmaModifierProperties, PlasmaMod class PlasmaLightingMod(PlasmaModifierProperties): pl_id = "lighting" + pl_page_types = {"gui", "room"} bl_category = "Render" bl_label = "Lighting Info" bl_description = "Fine tune Plasma lighting settings" + bl_object_types = {"MESH", "FONT"} force_rt_lights = BoolProperty(name="Force RT Lighting", description="Unleashes satan by forcing the engine to dynamically light this object", @@ -640,11 +650,13 @@ _LOCALIZED_TEXT_PFM = ( class PlasmaLocalizedTextModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz, TranslationMixin): pl_id = "dynatext" + pl_page_types = {"gui", "room"} bl_category = "Render" bl_label = "Localized Text" bl_description = "" bl_icon = "TEXT" + bl_object_types = {"MESH", "FONT"} translations = CollectionProperty(name="Translations", type=PlasmaJournalTranslation, @@ -769,6 +781,7 @@ class PlasmaShadowCasterMod(PlasmaModifierProperties): bl_category = "Render" bl_label = "Cast RT Shadow" bl_description = "Cast runtime shadows" + bl_object_types = {"MESH", "FONT"} blur = IntProperty(name="Blur", description="Blur factor for the shadow map", @@ -809,6 +822,7 @@ class PlasmaViewFaceMod(idprops.IDPropObjectMixin, PlasmaModifierProperties): bl_category = "Render" bl_label = "Swivel" bl_description = "Swivel object to face the camera, player, or another object" + bl_object_types = {"MESH", "FONT"} preset_options = EnumProperty(name="Type", description="Type of Facing", @@ -952,6 +966,7 @@ class PlasmaVisibilitySet(PlasmaModifierProperties): bl_category = "Render" bl_label = "Visibility Set" bl_description = "Defines areas where this object is visible" + bl_object_types = {"MESH", "LAMP"} regions = CollectionProperty(name="Visibility Regions", type=VisRegion) diff --git a/korman/properties/modifiers/sound.py b/korman/properties/modifiers/sound.py index eff49b4..b626d7a 100644 --- a/korman/properties/modifiers/sound.py +++ b/korman/properties/modifiers/sound.py @@ -515,6 +515,7 @@ class PlasmaSound(idprops.IDPropMixin, bpy.types.PropertyGroup): class PlasmaSoundEmitter(PlasmaModifierProperties): pl_id = "soundemit" + pl_page_types = {"gui", "room"} bl_category = "Logic" bl_label = "Sound Emitter" diff --git a/korman/properties/modifiers/water.py b/korman/properties/modifiers/water.py index f6e651f..9b39587 100644 --- a/korman/properties/modifiers/water.py +++ b/korman/properties/modifiers/water.py @@ -29,6 +29,7 @@ class PlasmaSwimRegion(idprops.IDPropObjectMixin, PlasmaModifierProperties, bpy. bl_label = "Swimming Surface" bl_description = "Surface that the avatar can swim on" bl_icon = "MOD_WAVE" + bl_object_types = {"MESH"} _CURRENTS = { "NONE": plSwimRegionInterface, @@ -179,6 +180,7 @@ class PlasmaWaterModifier(idprops.IDPropMixin, PlasmaModifierProperties, bpy.typ bl_category = "Water" bl_label = "Basic Water" bl_description = "Basic water properties" + bl_object_types = {"MESH"} wind_object = PointerProperty(name="Wind Object", description="Object whose Y axis represents the wind direction", @@ -327,6 +329,7 @@ class PlasmaWaterShoreModifier(PlasmaModifierProperties): bl_category = "Water" bl_label = "Water Shore" bl_description = "" + bl_object_types = {"MESH"} # The basic modifier may want to export a default copy of us _shore_tint_default = (0.2, 0.4, 0.4) @@ -395,6 +398,7 @@ class PlasmaWaterShoreModifier(PlasmaModifierProperties): class PlasmaWaveState: pl_depends = {"water_basic"} + bl_object_types = {"MESH"} def convert_wavestate(self, state): state.minLength = self.min_length