diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 2e19b96..c0d5008 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -32,6 +32,7 @@ from .camera import CameraConverter from .decal import DecalConverter from . import explosions from .etlight import LightBaker +from .gui import GuiConverter from .image import ImageCache from .locman import LocalizationConverter from . import logger @@ -45,6 +46,7 @@ from . import utils class Exporter: if TYPE_CHECKING: + _objects: List[bpy.types.Object] = ... actors: Set[str] = ... want_node_trees: defaultdict[Set[str]] = ... report: logger._ExportLogger = ... @@ -60,6 +62,7 @@ class Exporter: locman: LocalizationConverter = ... decal: DecalConverter = ... oven: LightBaker = ... + gui: GuiConverter def __init__(self, op): self._op = op # Blender export operator @@ -83,6 +86,7 @@ class Exporter: self.locman = LocalizationConverter(self) self.decal = DecalConverter(self) self.oven = LightBaker(mesh=self.mesh, report=self.report) + self.gui = GuiConverter(self) # Step 0.8: Init the progress mgr self.mesh.add_progress_presteps(self.report) @@ -371,6 +375,12 @@ class Exporter: tree.export(self, bo, so) inc_progress() + def get_objects(self, page: Optional[str]) -> Iterator[bpy.types.Object]: + yield from filter( + lambda x: x.plasma_object.page == page, + self._objects + ) + def _harvest_actors(self): self.report.progress_advance() self.report.progress_range = len(self._objects) + len(bpy.data.textures) diff --git a/korman/exporter/gui.py b/korman/exporter/gui.py new file mode 100644 index 0000000..ddf5d00 --- /dev/null +++ b/korman/exporter/gui.py @@ -0,0 +1,210 @@ +# This file is part of Korman. +# +# Korman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Korman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Korman. If not, see . + +from __future__ import annotations + +import bpy +import mathutils + +from contextlib import contextmanager, ExitStack +import itertools +from PyHSPlasma import * +from typing import * +import weakref + +from .explosions import ExportError +from .. import helpers +from . import utils + +if TYPE_CHECKING: + from .convert import Exporter + from .logger import _ExportLogger as ExportLogger + + +class Clipping(NamedTuple): + hither: float + yonder: float + + +class PostEffectModMatrices(NamedTuple): + c2w: hsMatrix44 + w2c: hsMatrix44 + + +class GuiConverter: + + if TYPE_CHECKING: + _parent: weakref.ref[Exporter] = ... + + def __init__(self, parent: Optional[Exporter] = None): + self._parent = weakref.ref(parent) if parent is not None else None + + # Go ahead and prepare the GUI transparent material for future use. + if parent is not None: + self._transp_material = parent.exit_stack.enter_context( + helpers.TemporaryObject( + bpy.data.materials.new("GUITransparent"), + bpy.data.materials.remove + ) + ) + self._transp_material.diffuse_color = mathutils.Vector((1.0, 1.0, 0.0)) + self._transp_material.use_mist = False + + # Cyan's transparent GUI materials just set an opacity of 0% + tex_slot = self._transp_material.texture_slots.add() + tex_slot.texture = parent.exit_stack.enter_context( + helpers.TemporaryObject( + bpy.data.textures.new("AutoTransparentLayer", "NONE"), + bpy.data.textures.remove + ) + ) + tex_slot.texture.plasma_layer.opacity = 0.0 + else: + self._transp_material = None + + def calc_camera_matrix( + self, + scene: bpy.types.Scene, + objects: Sequence[bpy.types.Object], + fov: float, + scale: float = 1.0, + ) -> mathutils.Matrix: + if not objects: + raise ExportError("No objects specified for GUI Camera generation.") + + class ObjArea(NamedTuple): + obj: bpy.types.Object + area: float + + + remove_mesh = bpy.data.meshes.remove + obj_areas: List[ObjArea] = [] + for i in objects: + mesh = i.to_mesh(bpy.context.scene, True, "RENDER", calc_tessface=False) + with helpers.TemporaryObject(mesh, remove_mesh): + utils.transform_mesh(mesh, i.matrix_world) + obj_areas.append( + ObjArea(i, sum((polygon.area for polygon in mesh.polygons))) + ) + largest_obj = max(obj_areas, key=lambda x: x.area) + + # GUIs are generally flat planes, which, by default in Blender, have their normal pointing + # in the localspace z axis. Therefore, what we would like to do is have a camera that points + # at the localspace -Z axis. Where up is the localspace +Y axis. We are already pointing at + # -Z with +Y being up from what I can tel. So, just use this matrix. + mat = largest_obj.obj.matrix_world.to_3x3() + + # Now, we know the rotation of the camera. Great! What we need to do now is ensure that all + # of the objects in question fit within the view of a 4:3 camera rotated as above. Blender + # helpfully provides us with the localspace bounding boxes of all the objects and an API to + # fit points into the camera view. + with ExitStack() as stack: + stack.enter_context(self.generate_camera_render_settings(scene)) + + # Create a TEMPORARY camera object so we can use a certain Blender API. + camera = stack.enter_context(utils.temporary_camera_object(scene, "GUICameraTemplate")) + camera.matrix_world = mat.to_4x4() + camera.data.angle = fov + camera.data.lens_unit = "FOV" + + # Get all of the bounding points and make sure they all fit inside the camera's view frame. + bound_boxes = ( + obj.matrix_world * mathutils.Vector(bbox) + for obj in objects for bbox in obj.bound_box + ) + co, _ = camera.camera_fit_coords( + scene, + # bound_box is a list of vectors of each corner of all the objects' bounding boxes; + # however, Blender's API wants a sequence of individual channel positions. Therefore, + # we need to flatten the vectors. + list(itertools.chain.from_iterable(bound_boxes)) + ) + + # Calculate the distance from the largest object to the camera. The scale we were given + # will be used to push the camera back in the Z+ direction of that object by scale times. + bvh = mathutils.bvhtree.BVHTree.FromObject(largest_obj.obj, scene) + loc, normal, index, distance = bvh.find_nearest(co) + co += normal * distance * (scale - 1.0) + + # ... + mat.resize_4x4() + mat.translation = co + return mat + + def calc_clipping( + self, + pose: mathutils.Matrix, + scene: bpy.types.Scene, + objects: Sequence[bpy.types.Object], + fov: float + ) -> Clipping: + with ExitStack() as stack: + stack.enter_context(self.generate_camera_render_settings(scene)) + camera = stack.enter_context(utils.temporary_camera_object(scene, "GUICameraTemplate")) + camera.matrix_world = pose + camera.data.angle = fov + camera.data.lens_unit = "FOV" + + # Determine the camera plane's normal so we can do a distance check against the + # bounding boxes of the objects shown in the GUI. + view_frame = [i * pose for i in camera.data.view_frame(scene)] + cam_plane = mathutils.geometry.normal(view_frame) + bound_boxes = ( + obj.matrix_world * mathutils.Vector(bbox) + for obj in objects for bbox in obj.bound_box + ) + pos = pose.to_translation() + bounds_dists = [ + abs(mathutils.geometry.distance_point_to_plane(i, pos, cam_plane)) + for i in bound_boxes + ] + + # Offset them by some epsilon to ensure the objects are rendered. + hither, yonder = min(bounds_dists), max(bounds_dists) + if yonder - 0.5 < hither: + hither -= 0.25 + yonder += 0.25 + return Clipping(hither, yonder) + + def convert_post_effect_matrices(self, camera_matrix: mathutils.Matrix) -> PostEffectModMatrices: + # PostEffectMod matrices face *away* from the GUI... For some reason. + # See plPostEffectMod::SetWorldToCamera() + c2w = utils.matrix44(camera_matrix) + w2c = utils.matrix44(camera_matrix.inverted()) + for i in range(4): + c2w[i, 2] *= -1.0 + w2c[2, i] *= -1.0 + return PostEffectModMatrices(c2w, w2c) + + @contextmanager + def generate_camera_render_settings(self, scene: bpy.types.Scene) -> Iterator[None]: + # Set the render info to basically TV NTSC 4:3, which will set Blender's camera + # viewport up as a 4:3 thingy to match Plasma. + with helpers.GoodNeighbor() as toggle: + toggle.track(scene.render, "resolution_x", 720) + toggle.track(scene.render, "resolution_y", 486) + toggle.track(scene.render, "pixel_aspect_x", 10.0) + toggle.track(scene.render, "pixel_aspect_y", 11.0) + yield + + @property + def _report(self) -> ExportLogger: + return self._parent().report + + @property + def transparent_material(self) -> bpy.types.Material: + assert self._transp_material is not None + return self._transp_material + diff --git a/korman/exporter/manager.py b/korman/exporter/manager.py index 1001511..61afd6f 100644 --- a/korman/exporter/manager.py +++ b/korman/exporter/manager.py @@ -265,8 +265,8 @@ class ExportManager: return self.add_object(pl=pClass, name=name, bl=bl, so=so) return key.object - def find_object(self, pClass: Type[KeyedT], bl=None, name=None, so=None) -> Optional[KeyedT]: - key = self.find_key(pClass, bl, name, so) + def find_object(self, pClass: Type[KeyedT], bl=None, name=None, so=None, loc=None) -> Optional[KeyedT]: + key = self.find_key(pClass, bl, name, so, loc) if key is not None: return key.object return None diff --git a/korman/exporter/utils.py b/korman/exporter/utils.py index fa180ee..0fc15a9 100644 --- a/korman/exporter/utils.py +++ b/korman/exporter/utils.py @@ -24,6 +24,8 @@ from typing import * from PyHSPlasma import * +from ..import helpers + def affine_parts(xform): # Decompose the matrix into the 90s-era 3ds max affine parts sillyness # All that's missing now is something like "(c) 1998 HeadSpin" oh wait... @@ -106,6 +108,20 @@ class BMeshObject: return self._obj +def create_empty_object(name: str, owner_object: Optional[bpy.types.Object] = None) -> bpy.types.Object: + empty_object = bpy.data.objects.new(name, None) + if owner_object is not None: + empty_object.plasma_object.enabled = owner_object.plasma_object.enabled + empty_object.plasma_object.page = owner_object.plasma_object.page + bpy.context.scene.objects.link(empty_object) + return empty_object + +def create_camera_object(name: str) -> bpy.types.Object: + cam_data = bpy.data.cameras.new(name) + cam_obj = bpy.data.objects.new(name, cam_data) + bpy.context.scene.objects.link(cam_obj) + return cam_obj + def create_cube_region(name: str, size: float, owner_object: bpy.types.Object) -> bpy.types.Object: """Create a cube shaped region object""" region_object = BMeshObject(name) @@ -136,6 +152,21 @@ def pre_export_optional_cube_region(source, attr: str, name: str, size: float, o # contextlib.contextmanager requires for us to yield. Sad. yield +@contextmanager +def temporary_camera_object(scene: bpy.types.Scene, name: str) -> bpy.types.Object: + try: + cam_data = bpy.data.cameras.new(name) + cam_obj = bpy.data.objects.new(name, cam_data) + scene.objects.link(cam_obj) + yield cam_obj + finally: + cam_obj = locals().get("cam_obj") + if cam_obj is not None: + bpy.data.objects.remove(cam_obj) + cam_data = locals().get("cam_data") + if cam_data is not None: + bpy.data.cameras.remove(cam_data) + @contextmanager def temporary_mesh_object(source : bpy.types.Object) -> bpy.types.Object: """Creates a temporary mesh object from a nonmesh object that will only exist for the duration diff --git a/korman/helpers.py b/korman/helpers.py index 07690d4..35c0176 100644 --- a/korman/helpers.py +++ b/korman/helpers.py @@ -137,3 +137,16 @@ def find_modifier(bo, modid): # if they give us the wrong modid, it is a bug and an AttributeError return getattr(bo.plasma_modifiers, modid) return None + +def get_page_type(page: str) -> str: + all_pages = bpy.context.scene.world.plasma_age.pages + if page: + page_type = next((i.page_type for i in all_pages if i.name == page), None) + if page_type is None: + raise LookupError(page) + return page_type + else: + # A falsey page name is likely a request for the default page, so look for Page ID 0. + # If it doesn't exist, that's an implicit default page (a "room" type). + page_type = next((i.page_type for i in all_pages if i.seq_suffix == 0), None) + return page_type if page_type is not None else "room" diff --git a/korman/operators/__init__.py b/korman/operators/__init__.py index 7ff172a..5e392c9 100644 --- a/korman/operators/__init__.py +++ b/korman/operators/__init__.py @@ -13,6 +13,7 @@ # You should have received a copy of the GNU General Public License # along with Korman. If not, see . +from . import op_camera as camera from . import op_export as exporter from . import op_image as image from . import op_lightmap as lightmap diff --git a/korman/operators/op_camera.py b/korman/operators/op_camera.py new file mode 100644 index 0000000..f2f218b --- /dev/null +++ b/korman/operators/op_camera.py @@ -0,0 +1,113 @@ +# This file is part of Korman. +# +# Korman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Korman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Korman. If not, see . + +from __future__ import annotations + +import bpy +from bpy.props import * + +import math + +from ..exporter.explosions import ExportError +from ..exporter.gui import GuiConverter + +class CameraOperator: + @classmethod + def poll(cls, context): + return context.scene.render.engine == "PLASMA_GAME" + + +class PlasmaGameGuiCameraOperator(CameraOperator, bpy.types.Operator): + bl_idname = "camera.plasma_create_game_gui_camera" + bl_label = "Create Game GUI Camera" + + fov: float = FloatProperty( + name="Field of View", + description="", + subtype="ANGLE", + default=math.radians(90.0), + min=0.0, + max=math.radians(360.0), + precision=1, + options=set() + ) + gui_page: str = StringProperty( + name="GUI Page", + description="", + options={"HIDDEN"} + ) + scale: float = FloatProperty( + name="GUI Scale", + description="", + default=1.02, + min=0.001, + soft_max=2.0, + precision=2, + options=set() + ) + + mod_id: str = StringProperty(options={"HIDDEN"}) + cam_prop_name: str = StringProperty(options={"HIDDEN"}) + + def execute(self, context): + # If the modifier has been given to us, select all of the objects in the + # given GUI page. + if self.gui_page: + for i in context.scene.objects: + i.select = i.plasma_object.page == self.gui_page + context.scene.update() + + visible_objects = [ + i for i in context.selected_objects + if i.type in {"MESH", "FONT"} + ] + + gui = GuiConverter() + try: + cam_matrix = gui.calc_camera_matrix( + context.scene, + visible_objects, + self.fov, + self.scale + ) + except ExportError as e: + self.report({"ERROR"}, str(e)) + return {"CANCELLED"} + + if self.mod_id and self.cam_prop_name: + modifier = getattr(context.object.plasma_modifiers, self.mod_id) + cam_obj = getattr(modifier, self.cam_prop_name) + else: + cam_obj = None + if cam_obj is None: + if self.gui_page: + name = f"{self.gui_page}_GUICamera" + else: + name = f"{context.object.name}_GUICamera" + cam_data = bpy.data.cameras.new(name) + cam_obj = bpy.data.objects.new(name, cam_data) + context.scene.objects.link(cam_obj) + + cam_obj.matrix_world = cam_matrix + cam_obj.data.angle = self.fov + cam_obj.data.lens_unit = "FOV" + + for i in context.scene.objects: + i.select = i == cam_obj + + if self.mod_id and self.cam_prop_name: + modifier = getattr(context.object.plasma_modifiers, self.mod_id) + setattr(modifier, self.cam_prop_name, cam_obj) + return {"FINISHED"} diff --git a/korman/properties/modifiers/__init__.py b/korman/properties/modifiers/__init__.py index ba179df..2374505 100644 --- a/korman/properties/modifiers/__init__.py +++ b/korman/properties/modifiers/__init__.py @@ -18,6 +18,7 @@ import bpy from .base import PlasmaModifierProperties from .anim import * from .avatar import * +from .game_gui import * from .gui import * from .logic import * from .physics import * diff --git a/korman/properties/modifiers/game_gui.py b/korman/properties/modifiers/game_gui.py new file mode 100644 index 0000000..4585aa4 --- /dev/null +++ b/korman/properties/modifiers/game_gui.py @@ -0,0 +1,306 @@ +# This file is part of Korman. +# +# Korman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Korman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Korman. If not, see . + +from __future__ import annotations + +import bpy +from bpy.props import * + +import itertools +import math +from typing import * + +from PyHSPlasma import * + +from ...exporter import ExportError +from .base import PlasmaModifierProperties +from ... import idprops + +if TYPE_CHECKING: + from ...exporter import Exporter + from ..prop_world import PlasmaAge, PlasmaPage + +class _GameGuiMixin: + def get_control(self, exporter: Exporter, bo: Optional[bpy.types.Object] = None, so: Optional[plSceneObject] = None) -> Optional[pfGUIControlMod]: + return None + + @property + def has_gui_proc(self) -> bool: + return True + + def iterate_control_modifiers(self) -> Iterator[_GameGuiMixin]: + pl_mods = self.id_data.plasma_modifiers + yield from ( + getattr(pl_mods, i.pl_id) + for i in self.iterate_control_subclasses() + if getattr(pl_mods, i.pl_id).enabled + ) + + @classmethod + def iterate_control_subclasses(cls) -> Iterator[_GameGuiMixin]: + yield from filter( + lambda x: x.is_game_gui_control(), + _GameGuiMixin.__subclasses__() + ) + + @classmethod + def is_game_gui_control(cls) -> bool: + return True + + @property + def requires_dyntext(self) -> bool: + return False + + def sanity_check(self): + age: PlasmaAge = bpy.context.scene.world.plasma_age + + # Game GUI modifiers must be attached to objects in a GUI page, ONLY + page_name: str = self.id_data.plasma_object.page + our_page: Optional[PlasmaPage] = next( + (i for i in age.pages if i.name == page_name) + ) + if our_page is None or our_page.page_type != "gui": + raise ExportError(f"'{self.id_data.name}': {self.bl_label} Modifier must be in a GUI page!") + + # Only one Game GUI Control per object. Continuously check this because objects can be + # generated/mutated during the pre-export phase. + modifiers = self.id_data.plasma_modifiers + controls = [i for i in self.iterate_control_subclasses() if getattr(modifiers, i.pl_id).enabled] + num_controls = len(controls) + if num_controls > 1: + raise ExportError(f"'{self.id_data.name}': Only 1 GUI Control modifier is allowed per object. We found {num_controls}.") + + +class PlasmaGameGuiControlModifier(PlasmaModifierProperties, _GameGuiMixin): + pl_id = "gui_control" + + bl_category = "GUI" + bl_label = "Ex: Game GUI Control" + bl_description = "XXX" + + tag_id = IntProperty( + name="Tag ID", + description="", + min=0, + options=set() + ) + visible = BoolProperty( + name="Visible", + description="", + default=True, + options=set() + ) + proc = EnumProperty( + name="Notification Procedure", + description="", + items=[ + ("default", "[Default]", "Send notifications to the owner's notification procedure."), + ("close_dialog", "Close Dialog", "Close the current Game GUI Dialog."), + ("console_command", "Run Console Command", "Run a Plasma Console command.") + ], + options=set() + ) + console_command = StringProperty( + name="Command", + description="", + options=set() + ) + + def convert_gui_control(self, exporter: Exporter, ctrl: pfGUIControlMod, bo: bpy.types.Object, so: plSceneObject): + ctrl.tagID = self.tag_id + ctrl.visible = self.visible + if self.proc == "default": + ctrl.setFlag(pfGUIControlMod.kInheritProcFromDlg, True) + elif self.proc == "close_dialog": + ctrl.handler = pfGUICloseDlgProc() + elif self.proc == "console_command": + handler = pfGUIConsoleCmdProc() + handler.command = self.console_command + ctrl.handler = handler + + def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): + ctrl_mods = list(self.iterate_control_modifiers()) + if not ctrl_mods: + exporter.report.msg(str(list(self.iterate_control_subclasses()))) + exporter.report.warn("This modifier has no effect because no GUI control modifiers are present!") + for ctrl_mod in ctrl_mods: + self.convert_gui_control(exporter, ctrl_mod.get_control(exporter, bo, so), bo, so) + + @property + def has_gui_proc(self) -> bool: + return any((i.has_gui_proc for i in self.iterate_control_modifiers())) + + @classmethod + def is_game_gui_control(cls) -> bool: + # How is a control not a control, you ask? Because, grasshopper, this modifier does not + # actually export a GUI control itself. Instead, it holds common properties that may + # or may not be used by other controls. This just helps fill out the other modifiers. + return False + + +class PlasmaGameGuiButtonModifier(PlasmaModifierProperties, _GameGuiMixin): + pl_id = "gui_button" + pl_depends = {"gui_control"} + + bl_category = "GUI" + bl_label = "Ex: Game GUI Button" + bl_description = "XXX" + + def _update_notify_type(self, context): + # It doesn't make sense to have no notify type at all selected, so + # default to at least one option. + if not self.notify_type: + self.notify_type = {"DOWN"} + + notify_type = EnumProperty( + name="Notify On", + description="When the button should perform its action", + items=[ + ("UP", "Up", "When the mouse button is down over the GUI button."), + ("DOWN", "Down", "When the mouse button is released over the GUI button."), + ], + default={"UP"}, + options={"ENUM_FLAG"}, + update=_update_notify_type + ) + + def get_control(self, exporter: Exporter, bo: Optional[bpy.types.Object] = None, so: Optional[plSceneObject] = None) -> pfGUIButtonMod: + return exporter.mgr.find_create_object(pfGUIButtonMod, bl=bo, so=so) + + def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): + ctrl = self.get_control(exporter, bo, so) + ctrl.setFlag(pfGUIControlMod.kWantsInterest, True) + + if self.notify_type == {"UP"}: + ctrl.notifyType = pfGUIButtonMod.kNotifyOnUp + elif self.notify_type == {"DOWN"}: + ctrl.notifyType = pfGUIButtonMod.kNotifyOnDown + elif self.notify_type == {"UP", "DOWN"}: + ctrl.notifyType = pfGUIButtonMod.kNotifyOnUpAndDown + else: + raise ValueError(self.notify_type) + + +class PlasmaGameGuiDialogModifier(PlasmaModifierProperties, _GameGuiMixin): + pl_id = "gui_dialog" + + bl_category = "GUI" + bl_label = "Ex: Game GUI Dialog" + bl_description = "XXX" + + camera_object: bpy.types.Object = PointerProperty( + name="GUI Camera", + description="Camera used to project the GUI to screenspace.", + type=bpy.types.Object, + poll=idprops.poll_camera_objects, + options=set() + ) + is_modal = BoolProperty( + name="Modal", + description="", + default=True, + options=set() + ) + + def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): + # Find all of the visible objects in the GUI page for use in hither/yon raycast and + # camera matrix calculations. + visible_objects = [ + i for i in exporter.get_objects(bo.plasma_object.page) + if i.type == "MESH" and i.data.materials + ] + + camera_object = self.id_data if self.id_data.type == "CAMERA" else self.camera_object + if camera_object: + exporter.report.msg(f"Using camera matrix from camera '{camera_object.name}'") + if camera_object != self.id_data and camera_object.plasma_object.enabled: + with exporter.report.indent(): + exporter.report.warn("The camera object should NOT be a Plasma Object!") + camera_matrix = camera_object.matrix_world + + # Save the clipping info from the camera for later use. + cam_data = camera_object.data + fov, hither, yonder = cam_data.angle, cam_data.clip_start, cam_data.clip_end + else: + exporter.report.msg(f"Building a camera matrix to view: {', '.join((i.name for i in visible_objects))}") + fov = math.radians(45.0) + camera_matrix = exporter.gui.calc_camera_matrix( + bpy.context.scene, + visible_objects, + fov, + 1.02 # FIXME + ) + + # There isn't a real camera, so just pretend like the user didn't set the clipping info. + hither, yonder = 0.0, 0.0 + with exporter.report.indent(): + exporter.report.msg(str(camera_matrix)) + + # If no hither or yonder was specified on the camera, then we need to determine that ourselves. + if not hither or not yonder: + exporter.report.msg(f"Incomplete clipping: H:{hither:.02f} Y:{yonder:.02f}; calculating new...") + with exporter.report.indent(): + clipping = exporter.gui.calc_clipping( + camera_matrix, + bpy.context.scene, + visible_objects, + fov + ) + exporter.report.msg(f"Calculated: H:{clipping.hither:.02f} Y:{clipping.yonder:.02f}") + if not hither: + hither = clipping.hither + if not yonder: + yonder = clipping.yonder + exporter.report.msg(f"Corrected clipping: H:{hither:.02f} Y:{yonder:.02f}") + + # Both of the objects we export go into the pool. + scene_node_key = exporter.mgr.get_scene_node(bl=bo) + + post_effect = exporter.mgr.find_create_object(plPostEffectMod, bl=bo) + post_effect.defaultC2W, post_effect.defaultW2C = exporter.gui.convert_post_effect_matrices(camera_matrix) + post_effect.fovX = math.degrees(fov) + post_effect.fovY = math.degrees(fov * (3.0 / 4.0)) + post_effect.hither = min((hither, yonder)) + post_effect.yon = max((hither, yonder)) + post_effect.nodeKey = scene_node_key + + dialog_mod = exporter.mgr.find_create_object(pfGUIDialogMod, bl=bo) + dialog_mod.name = bo.plasma_object.page + dialog_mod.setFlag(pfGUIDialogMod.kModal, self.is_modal) + dialog_mod.renderMod = post_effect.key + dialog_mod.sceneNode = scene_node_key + + @property + def has_gui_proc(self) -> bool: + return False + + @classmethod + def is_game_gui_control(cls) -> bool: + return False + + def post_export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): + # All objects have been exported. Now, we can establish linkage to all controls that + # have been exported. + dialog = exporter.mgr.find_object(pfGUIDialogMod, bl=bo, so=so) + control_modifiers: Iterable[_GameGuiMixin] = itertools.chain.from_iterable( + obj.plasma_modifiers.gui_control.iterate_control_modifiers() + for obj in exporter.get_objects(bo.plasma_object.page) + if obj.plasma_modifiers.gui_control.enabled + ) + for control_modifier in control_modifiers: + control = control_modifier.get_control(exporter, control_modifier.id_data) + ctrl_key = control.key + exporter.report.msg(f"GUIDialog '{bo.name}': [{control.ClassName()}] '{ctrl_key.name}'") + dialog.addControl(ctrl_key) diff --git a/korman/properties/modifiers/gui.py b/korman/properties/modifiers/gui.py index 641d2ae..40e65c0 100644 --- a/korman/properties/modifiers/gui.py +++ b/korman/properties/modifiers/gui.py @@ -13,20 +13,28 @@ # You should have received a copy of the GNU General Public License # along with Korman. If not, see . +from __future__ import annotations + import bpy -import bmesh from bpy.props import * -import mathutils +from contextlib import ExitStack +import itertools import math from pathlib import Path +from typing import * from PyHSPlasma import * from ...addon_prefs import game_versions from ...exporter import ExportError, utils -from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz, PlasmaModifierUpgradable -from ... import idprops +from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz +from ... import helpers, idprops + +if TYPE_CHECKING: + from ...exporter import Exporter + from .game_gui import PlasmaGameGuiDialogModifier + journal_pfms = { pvPots : { @@ -665,3 +673,180 @@ class PlasmaLinkingBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz raise ExportError("{}: Linking Book modifier requires a clickable!", self.id_data.name) if self.seek_point is None: raise ExportError("{}: Linking Book modifier requires a seek point!", self.id_data.name) + + +dialog_toggle = { + "filename": "xDialogToggle.py", + "attribs": ( + { 'id': 1, 'type': "ptAttribActivator", 'name': "Activate" }, + { 'id': 4, 'type': "ptAttribString", 'name': "Vignette" }, + ) +} + +class PlasmaNotePopupModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz): + pl_id = "note_popup" + + bl_category = "GUI" + bl_label = "Ex: Note Popup" + bl_description = "XXX" + bl_icon = "MATPLANE" + + def _get_gui_pages(self, context): + scene = context.scene if context is not None else bpy.context.scene + return [ + (i.name, i.name, i.name) + for i in scene.world.plasma_age.pages + if i.page_type == "gui" + ] + + clickable: bpy.types.Object = PointerProperty( + name="Clickable", + description="The object the player will click on to activate the GUI", + type=bpy.types.Object, + poll=idprops.poll_mesh_objects + ) + clickable_region: bpy.types.Object = PointerProperty( + name="Clickable Region", + description="The region in which the avatar must be standing before they can click on the note", + type=bpy.types.Object, + poll=idprops.poll_mesh_objects + ) + gui_page: str = EnumProperty( + name="GUI Page", + description="Page containing all of the objects to display", + items=_get_gui_pages, + options=set() + ) + gui_camera: bpy.types.Object = PointerProperty( + name="GUI Camera", + description="", + poll=idprops.poll_camera_objects, + type=bpy.types.Object, + options=set() + ) + + @property + def clickable_object(self) -> Optional[bpy.types.Object]: + if self.clickable is not None: + return self.clickable + if self.id_data.type == "MESH": + return self.id_data + + def sanity_check(self): + page_type = helpers.get_page_type(self.id_data.plasma_object.page) + if page_type != "room": + raise ExportError(f"Note Popup modifiers should be in a 'room' page, not a '{page_type}' page!") + + def pre_export(self, exporter: Exporter, bo: bpy.types.Object): + guidialog_object = utils.create_empty_object(f"{self.gui_page}_NoteDialog") + guidialog_object.plasma_object.enabled = True + guidialog_object.plasma_object.page = self.gui_page + yield guidialog_object + + guidialog_mod: PlasmaGameGuiDialogModifier = guidialog_object.plasma_modifiers.gui_dialog + guidialog_mod.enabled = True + guidialog_mod.is_modal = True + if self.gui_camera: + guidialog_mod.camera_object = self.gui_camera + else: + # Abuse the GUI Dialog's lookat caLculation to make us a camera that looks at everything + # the artist has placed into the GUI page. We want to do this NOW because we will very + # soon be adding more objects into the GUI page. + camera_object = yield utils.create_camera_object(f"{self.key_name}_GUICamera") + camera_object.data.angle = math.radians(45.0) + camera_object.data.lens_unit = "FOV" + + visible_objects = [ + i for i in exporter.get_objects(self.gui_page) + if i.type == "MESH" and i.data.materials + ] + camera_object.matrix_world = exporter.gui.calc_camera_matrix( + bpy.context.scene, + visible_objects, + camera_object.data.angle, + 1.02 # FIXME + ) + clipping = exporter.gui.calc_clipping( + camera_object.matrix_world, + bpy.context.scene, + visible_objects, + camera_object.data.angle + ) + camera_object.data.clip_start = clipping.hither + camera_object.data.clip_end = clipping.yonder + guidialog_mod.camera_object = camera_object + + # Begin creating the object for the clickoff plane. We want to yield it immediately + # to the exporter in case something goes wrong during the export, allowing the stale + # object to be cleaned up. + click_plane_object = utils.BMeshObject(f"{self.key_name}_Exit") + click_plane_object.matrix_world = guidialog_mod.camera_object.matrix_world + click_plane_object.plasma_object.enabled = True + click_plane_object.plasma_object.page = self.gui_page + yield click_plane_object + + # We have a camera on guidialog_mod.camera_object. We will now use it to generate the + # points for the click-off plane button. + # TODO: Allow this to be configurable to 4:3, 16:9, or 21:9? + with ExitStack() as stack: + stack.enter_context(exporter.gui.generate_camera_render_settings(bpy.context.scene)) + toggle = stack.enter_context(helpers.GoodNeighbor()) + + # Temporarily adjust the clipping plane out to the farthest point we can find to ensure + # that the click-off button ecompasses everything. This is a bit heavy-handed, but if + # you want more refined control, you won't be using this helper. + clipping = max((guidialog_mod.camera_object.data.clip_start, guidialog_mod.camera_object.data.clip_end)) + toggle.track(guidialog_mod.camera_object.data, "clip_start", clipping - 0.1) + view_frame = guidialog_mod.camera_object.data.view_frame(bpy.context.scene) + + click_plane_object.data.materials.append(exporter.gui.transparent_material) + with click_plane_object as click_plane_mesh: + verts = [click_plane_mesh.verts.new(i) for i in view_frame] + face = click_plane_mesh.faces.new(verts) + # TODO: Ensure the face is pointing toward the camera! + # I feel like we should be fine by assuming that Blender returns the viewframe + # verts in the correct order, but this is Blender... So test that assumption carefully. + # TODO: Apparently not! + face.normal_flip() + + # We've now created the mesh object - handle the GUI Button stuff + click_plane_object.plasma_modifiers.gui_button.enabled = True + + # NOTE: We will be using xDialogToggle.py, so we use a special tag ID instead of the + # close dialog procedure. + click_plane_object.plasma_modifiers.gui_control.tag_id = 99 + + # Auto-generate a six-foot cube region around the clickable if none was provided. + yield utils.pre_export_optional_cube_region( + self, "clickable_region", + f"{self.key_name}_DialogToggle_ClkRgn", 6.0, + self.clickable_object + ) + + # Everything is ready now - create an xDialogToggle.py in the room page to display the GUI. + yield self.convert_logic(bo) + + def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): + # You're not hallucinating... Everything is done in the pre-export phase. + pass + + def logicwiz(self, bo, tree): + nodes = tree.nodes + + # xDialogToggle.py PythonFile Node + dialog_node = self._create_python_file_node( + tree, + dialog_toggle["filename"], + dialog_toggle["attribs"] + ) + self._create_python_attribute(dialog_node, "Vignette", value=self.gui_page) + + # Clickable + clickable_region = nodes.new("PlasmaClickableRegionNode") + clickable_region.region_object = self.clickable_region + + clickable = nodes.new("PlasmaClickableNode") + clickable.find_input_socket("facing").allow_simple = False + clickable.clickable_object = self.clickable_object + clickable.link_input(clickable_region, "satisfies", "region") + clickable.link_output(dialog_node, "satisfies", "Activate") diff --git a/korman/properties/modifiers/region.py b/korman/properties/modifiers/region.py index 4ee5d24..ca811e1 100644 --- a/korman/properties/modifiers/region.py +++ b/korman/properties/modifiers/region.py @@ -327,7 +327,7 @@ class PlasmaSubworldRegion(PlasmaModifierProperties): def export(self, exporter, bo, so): # Due to the fact that our subworld modifier can produce both RidingAnimatedPhysical - # and [HK|PX]Subworlds depending on the situation, this could get hairy, fast. + # and [HK|PX]Subworlds depending on the situation, this could get hairy, fast. # Start by surveying the lay of the land. from_sub, to_sub = bo.plasma_object.subworld, self.subworld from_isded = exporter.physics.is_dedicated_subworld(from_sub) diff --git a/korman/properties/prop_world.py b/korman/properties/prop_world.py index e23341e..0b0356f 100644 --- a/korman/properties/prop_world.py +++ b/korman/properties/prop_world.py @@ -141,6 +141,7 @@ class PlasmaPage(bpy.types.PropertyGroup): items=[ ("room", "Room", "A standard Plasma Room containing scene objects"), ("external", "External", "A page generated by an external process. Korman will not write to this page, ever."), + ("gui", "Game GUI", "A page containing Game GUI objects.") ], default="room", options=set()) diff --git a/korman/ui/modifiers/__init__.py b/korman/ui/modifiers/__init__.py index aa6f7f6..6f25779 100644 --- a/korman/ui/modifiers/__init__.py +++ b/korman/ui/modifiers/__init__.py @@ -15,6 +15,7 @@ from .anim import * from .avatar import * +from .game_gui import * from .gui import * from .logic import * from .physics import * diff --git a/korman/ui/modifiers/game_gui.py b/korman/ui/modifiers/game_gui.py new file mode 100644 index 0000000..83c3c8f --- /dev/null +++ b/korman/ui/modifiers/game_gui.py @@ -0,0 +1,42 @@ +# This file is part of Korman. +# +# Korman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Korman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Korman. If not, see . + +def gui_button(modifier, layout, context): + layout.prop(modifier, "notify_type") + +def gui_control(modifier, layout, context): + split = layout.split() + col = split.column() + col.prop(modifier, "visible") + + col = split.column() + col.prop(modifier, "tag_id") + + col = layout.column() + col.active = modifier.has_gui_proc + col.prop(modifier, "proc") + row = col.row() + row.active = col.active and modifier.proc == "console_command" + row.prop(modifier, "console_command") + +def gui_dialog(modifier, layout, context): + row = layout.row(align=True) + row.prop(modifier, "camera_object") + op = row.operator("camera.plasma_create_game_gui_camera", text="", icon="CAMERA_DATA") + op.mod_id = modifier.pl_id + op.cam_prop_name = "camera_object" + op.gui_page = modifier.id_data.plasma_object.page + + layout.prop(modifier, "is_modal") diff --git a/korman/ui/modifiers/gui.py b/korman/ui/modifiers/gui.py index cf462c3..37ad9cb 100644 --- a/korman/ui/modifiers/gui.py +++ b/korman/ui/modifiers/gui.py @@ -116,3 +116,16 @@ def linkingbookmod(modifier, layout, context): row.label("Stamp Position:") row.prop(modifier, "stamp_x", text="X") row.prop(modifier, "stamp_y", text="Y") + +def note_popup(modifier, layout, context): + layout.prop(modifier, "gui_page") + + row = layout.row(align=True) + row.prop(modifier, "gui_camera") + op = row.operator("camera.plasma_create_game_gui_camera", text="", icon="CAMERA_DATA") + op.mod_id = modifier.pl_id + op.cam_prop_name = "gui_camera" + op.gui_page = modifier.gui_page + + layout.prop(modifier, "clickable") + layout.prop(modifier, "clickable_region")