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")