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..0a0a117
--- /dev/null
+++ b/korman/exporter/gui.py
@@ -0,0 +1,225 @@
+# 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
+import math
+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 = 0.75
+ ) -> mathutils.Matrix:
+ if not objects:
+ raise ExportError("No objects specified for GUI Camera generation.")
+
+ # 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
+ 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)
+ 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
+ # 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))
+ )
+
+ # 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()
+ 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/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):
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..365dd7e
--- /dev/null
+++ b/korman/operators/op_camera.py
@@ -0,0 +1,119 @@
+# 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"
+ 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="Camera Field of View angle",
+ 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="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.
+ 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 / 100.0
+ )
+ 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/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 ba179df..87e35be 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 *
@@ -74,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
new file mode 100644
index 0000000..a13ee6b
--- /dev/null
+++ b/korman/properties/modifiers/game_gui.py
@@ -0,0 +1,508 @@
+# 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:
+ @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
+
+ @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}.")
+
+ # 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"
+ pl_page_types = {"gui"}
+
+ bl_category = "GUI"
+ bl_label = "GUI Control (ex)"
+ bl_description = "XXX"
+ bl_object_types = {"FONT", "MESH"}
+
+ 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 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:
+ 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:
+ 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 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"}
+ pl_page_types = {"gui"}
+
+ bl_category = "GUI"
+ 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
+ # 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
+ )
+
+ 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",
+ 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)
+
+ 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)
+
+ 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"
+ pl_page_types = {"gui"}
+
+ bl_category = "GUI"
+ bl_label = "GUI Dialog (ex)"
+ bl_description = "XXX"
+ bl_object_types = {"FONT", "MESH"}
+
+ 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
+ )
+
+ # 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..3fef942 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,179 @@ 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 = "Note Popup (ex)"
+ 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
+ )
+ 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/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 7bed918..eb30e6a 100644
--- a/korman/properties/modifiers/physics.py
+++ b/korman/properties/modifiers/physics.py
@@ -74,6 +74,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 4ee5d24..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",
@@ -327,7 +331,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/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
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..6be792f
--- /dev/null
+++ b/korman/ui/modifiers/game_gui.py
@@ -0,0 +1,120 @@
+# 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
+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")
+
+ _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()
+ 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")