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