From 9363757d362284aaf8c3a0b3a4579f61fec79bff Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sun, 7 Jan 2024 20:29:57 -0500 Subject: [PATCH 1/5] Add entry cameras to the Spawn Point Modifier. This is a 1:1 implementation of how entry cameras are implemented in Doobes' Ages. The problem with this is that it assumes there is a single link-in point that we will always link into. --- korman/exporter/utils.py | 15 +++-- korman/properties/modifiers/logic.py | 98 ++++++++++++++++++++++++++-- korman/ui/modifiers/logic.py | 19 +++++- 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/korman/exporter/utils.py b/korman/exporter/utils.py index 12db7e8..872cb6e 100644 --- a/korman/exporter/utils.py +++ b/korman/exporter/utils.py @@ -20,6 +20,7 @@ import bpy import mathutils from contextlib import contextmanager +import enum from typing import * from PyHSPlasma import * @@ -122,7 +123,12 @@ def create_camera_object(name: str) -> bpy.types.Object: 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: +class CubeRegionOrigin(enum.Enum): + center = enum.auto() + bottom = enum.auto() + + +def create_cube_region(name: str, size: float, owner_object: bpy.types.Object, origin: CubeRegionOrigin = CubeRegionOrigin.center) -> bpy.types.Object: """Create a cube shaped region object""" region_object = BMeshObject(name) region_object.plasma_object.enabled = True @@ -130,11 +136,12 @@ def create_cube_region(name: str, size: float, owner_object: bpy.types.Object) - region_object.hide_render = True with region_object as bm: bmesh.ops.create_cube(bm, size=(size)) + origin = owner_object.matrix_world.translation - region_object.matrix_world.translation + if origin == CubeRegionOrigin.bottom: + origin.z += size * 0.5 bmesh.ops.transform( bm, - matrix=mathutils.Matrix.Translation( - owner_object.matrix_world.translation - region_object.matrix_world.translation - ), + matrix=mathutils.Matrix.Translation(origin), space=region_object.matrix_world, verts=bm.verts ) return region_object.release() diff --git a/korman/properties/modifiers/logic.py b/korman/properties/modifiers/logic.py index 1dec936..b4daeeb 100644 --- a/korman/properties/modifiers/logic.py +++ b/korman/properties/modifiers/logic.py @@ -13,16 +13,26 @@ # You should have received a copy of the GNU General Public License # along with Korman. If not, see . +from __future__ import annotations + import bmesh import bpy from bpy.props import * import mathutils from PyHSPlasma import * +from typing import * + +if TYPE_CHECKING: + from ...exporter import Exporter + from ...nodes.node_conditions import * + from ...nodes.node_messages import * + from ...nodes.node_responder import * from ...addon_prefs import game_versions from .base import PlasmaModifierProperties, PlasmaModifierLogicWiz from ...exporter import ExportError, utils from ... import idprops +from .physics import bounds_type_index, bounds_type_str, bounds_types class PlasmaVersionedNodeTree(idprops.IDPropMixin, bpy.types.PropertyGroup): version = EnumProperty(name="Version", @@ -76,7 +86,7 @@ class PlasmaAdvancedLogic(PlasmaModifierProperties): return any((i.node_tree.requires_actor for i in self.logic_groups if i.node_tree)) -class PlasmaSpawnPoint(PlasmaModifierProperties): +class PlasmaSpawnPoint(PlasmaModifierProperties, PlasmaModifierLogicWiz): pl_id = "spawnpoint" bl_category = "Logic" @@ -84,10 +94,90 @@ class PlasmaSpawnPoint(PlasmaModifierProperties): bl_description = "Point at which avatars link into the Age" bl_object_types = {"EMPTY"} + def _get_bounds(self) -> int: + if self.exit_region is not None: + return bounds_type_index(self.exit_region.plasma_modifiers.collision.bounds_type) + return bounds_type_index("hull") + + def _set_bounds(self, value: int) -> None: + if self.exit_region is not None: + self.exit_region.plasma_modifiers.collision.bounds_type = bounds_type_str(value) + + entry_camera = PointerProperty( + name="Entry Camera", + description="Camera to use when the player spawns at this location", + type=bpy.types.Object, + poll=idprops.poll_camera_objects + ) + + exit_region = PointerProperty( + name="Exit Region", + description="Pop the camera when the player exits this region", + type=bpy.types.Object, + poll=idprops.poll_mesh_objects + ) + + bounds_type = EnumProperty( + name="Bounds", + description="", + items=bounds_types, + get=_get_bounds, + set=_set_bounds, + default="hull" + ) + + def pre_export(self, exporter: Exporter, bo: bpy.types.Object) -> None: + if self.entry_camera is None: + return + + if self.exit_region is None: + self.exit_region = yield utils.create_cube_region( + f"{self.key_name}_ExitRgn", 6.0, + bo, utils.CubeRegionOrigin.bottom + ) + + yield self.convert_logic(bo) + + def logicwiz(self, bo, tree): + nodes = tree.nodes + + # Generate two responders. The first responder will push the entry camera + # onto the camera stack when we first enter the region - usually on link in. + # Then, disable the entry sensor once it's fired. The second responder will + # always pop the entry camera + self._create_nodes(nodes, enter=True, exit=False, camCmd="push", enable="disable") + self._create_nodes(nodes, enter=False, exit=True, camCmd="pop") + + def _create_nodes(self, nodes, *, enter: Optional[bool] = None, camCmd: Optional[str] = None, enable: Optional[str] = None): + volume_sensor: PlasmaVolumeSensorNode = nodes.new("PlasmaVolumeSensorNode") + volume_sensor.bounds = self.bounds_type + if enter is not None: + volume_sensor.find_input_socket("enter").allow = enter + if exit is not None: + volume_sensor.find_input_socket("exit").allow = exit + + responder: PlasmaResponderNode = nodes.new("PlasmaResponderNode") + responder.link_input(volume_sensor, "satisfies", "condition") + + responder_state: PlasmaResponderStateNode = nodes.new("PlasmaResponderStateNode") + responder_state.link_input(responder, "state_refs", "resp") + + camera_msg: PlasmaCameraMsgNode = nodes.new("PlasmaCameraMsgNode") + assert camCmd in {"push", "pop"} + camera_msg.cmd = camCmd + camera_msg.camera = self.entry_camera + camera_msg.link_input(responder_state, "msgs", "sender") + + if enable is not None: + enable_msg: PlasmaEnableMsgNode = nodes.new("PlasmaEnableMsgNode") + enable_LUT = {"enable": "kEnable", "disable": "kDisable"} + enable_msg.cmd = enable_LUT[enable] + enable_msg.settings = {"kModifiers"} + enable_msg.link_input(responder_state, "msgs", "sender") + enable_msg.link_output(volume_sensor, "receivers", "message") + def export(self, exporter, bo, so): - # Not much to this modifier... It's basically a flag that tells the engine, "hey, this is a - # place the avatar can show up." Nice to have a simple one to get started with. - spawn = exporter.mgr.add_object(pl=plSpawnModifier, so=so, name=self.key_name) + exporter.mgr.add_object(pl=plSpawnModifier, so=so, name=self.key_name) @property def requires_actor(self): diff --git a/korman/ui/modifiers/logic.py b/korman/ui/modifiers/logic.py index c2da1fd..a99b169 100644 --- a/korman/ui/modifiers/logic.py +++ b/korman/ui/modifiers/logic.py @@ -13,8 +13,15 @@ # 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 typing import * + +if TYPE_CHECKING: + from ...properties.modifiers.logic import * + from .. import ui_list class LogicListUI(bpy.types.UIList): @@ -36,8 +43,18 @@ def advanced_logic(modifier, layout, context): layout.row().prop_menu_enum(logic, "version") layout.prop(logic, "node_tree", icon="NODETREE") -def spawnpoint(modifier, layout, context): +def spawnpoint(modifier: PlasmaSpawnPoint, layout, context): layout.label(text="Avatar faces negative Y.") + layout.separator() + + col = layout.column() + col.prop(modifier, "entry_camera", icon="CAMERA_DATA") + sub = col.row() + sub.active = modifier.entry_camera is not None + sub.prop(modifier, "exit_region", icon="MESH_DATA") + sub = col.row() + sub.active = modifier.entry_camera is not None and modifier.exit_region is not None + sub.prop(modifier, "bounds_type") def maintainersmarker(modifier, layout, context): layout.label(text="Positive Y is North, positive Z is up.") From e8d3cb0e5e722d6ebfbe85b850a0f34472e78bcf Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sat, 13 Jan 2024 14:14:43 -0500 Subject: [PATCH 2/5] Fix `_create_python_attribute` for non-"simple" types. --- korman/properties/modifiers/base.py | 32 ++++++++++++++++------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/korman/properties/modifiers/base.py b/korman/properties/modifiers/base.py index ed2b76e..2a984a2 100644 --- a/korman/properties/modifiers/base.py +++ b/korman/properties/modifiers/base.py @@ -13,12 +13,17 @@ # 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 abc import itertools -from typing import Any, Dict, FrozenSet, Optional +from typing import * + +if TYPE_CHECKING: + from ...nodes.node_python import * from ... import helpers @@ -202,24 +207,23 @@ class PlasmaModifierLogicWiz: pfm_node.update() return pfm_node - def _create_python_attribute(self, pfm_node, attribute_name: str, attribute_type: Optional[str] = None, **kwargs): + def _create_python_attribute(self, pfm_node: PlasmaPythonFileNode, attribute_name: str, **kwargs): """Creates and links a Python Attribute Node to the Python File Node given by `pfm_node`. - This will automatically handle simple attribute types such as numbers and strings, however, - for object linkage, you should specify the optional `attribute_type` to ensure the proper - attribute type is found. For attribute nodes that require multiple values, the `value` may - be set to None and handled in your code.""" - from ...nodes.node_python import PlasmaAttribute, PlasmaAttribNodeBase - if attribute_type is None: - assert len(kwargs) == 1 and "value" in kwargs, \ - "In order to deduce the attribute_type, exactly one attribute value must be passed as a kw named `value`" - attribute_type = PlasmaAttribute.type_LUT.get(kwargs["value"].__class__) + For attribute nodes that require multiple values, the `value` may be set to None and + handled in your code.""" + attribute_defn = next((i for i in pfm_node.attributes if i.attribute_name == attribute_name), None) + if attribute_defn is None: + raise KeyError(attribute_name) + + from ...nodes.node_python import PlasmaAttribNodeBase + attribute_type = attribute_defn.attribute_type node_cls = next((i for i in PlasmaAttribNodeBase.__subclasses__() if attribute_type in i.pl_attrib), None) - assert node_cls is not None, "'{}': Unable to find attribute node type for '{}' ('{}')".format( - self.id_data.name, attribute_name, attribute_type - ) + assert node_cls is not None, \ + f"'{self.id_data.name}': Unable to find attribute node type for '{attribute_name}' ('{attribute_type}')" node = pfm_node.id_data.nodes.new(node_cls.bl_idname) node.link_output(pfm_node, "pfm", attribute_name) + assert kwargs for i, j in kwargs.items(): setattr(node, i, j) return node From 5085cf58140bc54fa67212c0b3732a4d3bbac536 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sat, 13 Jan 2024 14:16:28 -0500 Subject: [PATCH 3/5] Add box region helper for non-cube boxes. Entry cameras need skinny detector boxes, not cubes. This allows the region to be exited with basically any movement. --- korman/exporter/utils.py | 48 +++++++++++++++++++++------- korman/properties/modifiers/logic.py | 2 +- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/korman/exporter/utils.py b/korman/exporter/utils.py index 872cb6e..73c3aef 100644 --- a/korman/exporter/utils.py +++ b/korman/exporter/utils.py @@ -123,27 +123,53 @@ def create_camera_object(name: str) -> bpy.types.Object: bpy.context.scene.objects.link(cam_obj) return cam_obj -class CubeRegionOrigin(enum.Enum): +class RegionOrigin(enum.Enum): center = enum.auto() bottom = enum.auto() -def create_cube_region(name: str, size: float, owner_object: bpy.types.Object, origin: CubeRegionOrigin = CubeRegionOrigin.center) -> bpy.types.Object: +def create_cube_region(name: str, size: float, owner_object: bpy.types.Object, origin: RegionOrigin = RegionOrigin.center) -> bpy.types.Object: """Create a cube shaped region object""" + # Proxy over to the generic rectangular prism implementation to + # ensure that it is well tested. + return create_box_region(name, (size, size, size), owner_object, origin) + +def create_box_region( + name: str, size: Union[Tuple[float, float, float], mathutils.Vector], + owner_object: bpy.types.Object, + origin: RegionOrigin = RegionOrigin.center +) -> bpy.types.Object: + """Create a rectangular prism region object""" + if not isinstance(size, mathutils.Vector): + size = mathutils.Vector(size) + region_object = BMeshObject(name) region_object.plasma_object.enabled = True region_object.plasma_object.page = owner_object.plasma_object.page region_object.hide_render = True with region_object as bm: - bmesh.ops.create_cube(bm, size=(size)) - origin = owner_object.matrix_world.translation - region_object.matrix_world.translation - if origin == CubeRegionOrigin.bottom: - origin.z += size * 0.5 - bmesh.ops.transform( - bm, - matrix=mathutils.Matrix.Translation(origin), - space=region_object.matrix_world, verts=bm.verts - ) + center = owner_object.matrix_world.translation + if origin == RegionOrigin.bottom: + center.z += size.z * 0.5 + vert_src = [ + (center.x + size.x * 0.5, center.y + size.y * 0.5, center.z + size.z * 0.5), + (center.x + size.x * 0.5, center.y + size.y * 0.5, center.z - size.z * 0.5), + (center.x + size.x * 0.5, center.y - size.y * 0.5, center.z + size.z * 0.5), + (center.x + size.x * 0.5, center.y - size.y * 0.5, center.z - size.z * 0.5), + (center.x - size.x * 0.5, center.y + size.y * 0.5, center.z + size.z * 0.5), + (center.x - size.x * 0.5, center.y + size.y * 0.5, center.z - size.z * 0.5), + (center.x - size.x * 0.5, center.y - size.y * 0.5, center.z + size.z * 0.5), + (center.x - size.x * 0.5, center.y - size.y * 0.5, center.z - size.z * 0.5), + ] + verts = [bm.verts.new(i) for i in vert_src] + + new_face = bm.faces.new + new_face((verts[0], verts[1], verts[3], verts[2])) # X+ + new_face((verts[4], verts[5], verts[7], verts[6])) # X- + new_face((verts[0], verts[1], verts[5], verts[4])) # Y+ + new_face((verts[2], verts[3], verts[7], verts[6])) # Y- + new_face((verts[0], verts[2], verts[6], verts[4])) # Z+ + new_face((verts[1], verts[3], verts[7], verts[5])) # Z- return region_object.release() @contextmanager diff --git a/korman/properties/modifiers/logic.py b/korman/properties/modifiers/logic.py index b4daeeb..8088d78 100644 --- a/korman/properties/modifiers/logic.py +++ b/korman/properties/modifiers/logic.py @@ -133,7 +133,7 @@ class PlasmaSpawnPoint(PlasmaModifierProperties, PlasmaModifierLogicWiz): if self.exit_region is None: self.exit_region = yield utils.create_cube_region( f"{self.key_name}_ExitRgn", 6.0, - bo, utils.CubeRegionOrigin.bottom + bo, utils.RegionOrigin.bottom ) yield self.convert_logic(bo) From e3d3c953dfe77aded55e1de755ff876d6c3827fe Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sat, 13 Jan 2024 14:19:25 -0500 Subject: [PATCH 4/5] Convert to xEntryCam.py This new python file modifier will disable all entry camera regions when the local player links in. That will prevent spurious entry cameras being used if there are multiple spawn points in the Age with entry cameras. --- korman/properties/modifiers/logic.py | 64 ++++++++++++---------------- 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/korman/properties/modifiers/logic.py b/korman/properties/modifiers/logic.py index 8088d78..b37dde2 100644 --- a/korman/properties/modifiers/logic.py +++ b/korman/properties/modifiers/logic.py @@ -34,6 +34,15 @@ from ...exporter import ExportError, utils from ... import idprops from .physics import bounds_type_index, bounds_type_str, bounds_types +entry_cam_pfm = { + "filename": "xEntryCam.py", + "attribs": ( + { 'id': 1, 'type': "ptAttribActivator", 'name': "actRegionSensor" }, + { 'id': 2, 'type': "ptAttribSceneobject", 'name': "camera" }, + { 'id': 3, 'type': "ptAttribBoolean", 'name': "undoFirstPerson" }, + ) +} + class PlasmaVersionedNodeTree(idprops.IDPropMixin, bpy.types.PropertyGroup): version = EnumProperty(name="Version", description="Plasma versions this node tree exports under", @@ -131,50 +140,33 @@ class PlasmaSpawnPoint(PlasmaModifierProperties, PlasmaModifierLogicWiz): return if self.exit_region is None: - self.exit_region = yield utils.create_cube_region( - f"{self.key_name}_ExitRgn", 6.0, + self.exit_region = yield utils.create_box_region( + f"{self.key_name}_ExitRgn", (2.0, 2.0, 6.0), bo, utils.RegionOrigin.bottom ) yield self.convert_logic(bo) def logicwiz(self, bo, tree): - nodes = tree.nodes + pfm_node = self._create_python_file_node( + tree, + entry_cam_pfm["filename"], + entry_cam_pfm["attribs"] + ) + + volume_sensor: PlasmaVolumeSensorNode = tree.nodes.new("PlasmaVolumeSensorNode") + volume_sensor.find_input_socket("enter").allow = True + volume_sensor.find_input_socket("exit").allow = True + volume_sensor.region_object = self.exit_region + volume_sensor.bounds = self.bounds_type + volume_sensor.link_output(pfm_node, "satisfies", "actRegionSensor") - # Generate two responders. The first responder will push the entry camera - # onto the camera stack when we first enter the region - usually on link in. - # Then, disable the entry sensor once it's fired. The second responder will - # always pop the entry camera - self._create_nodes(nodes, enter=True, exit=False, camCmd="push", enable="disable") - self._create_nodes(nodes, enter=False, exit=True, camCmd="pop") + self._create_python_attribute( + pfm_node, + "camera", + target_object=self.entry_camera + ) - def _create_nodes(self, nodes, *, enter: Optional[bool] = None, camCmd: Optional[str] = None, enable: Optional[str] = None): - volume_sensor: PlasmaVolumeSensorNode = nodes.new("PlasmaVolumeSensorNode") - volume_sensor.bounds = self.bounds_type - if enter is not None: - volume_sensor.find_input_socket("enter").allow = enter - if exit is not None: - volume_sensor.find_input_socket("exit").allow = exit - - responder: PlasmaResponderNode = nodes.new("PlasmaResponderNode") - responder.link_input(volume_sensor, "satisfies", "condition") - - responder_state: PlasmaResponderStateNode = nodes.new("PlasmaResponderStateNode") - responder_state.link_input(responder, "state_refs", "resp") - - camera_msg: PlasmaCameraMsgNode = nodes.new("PlasmaCameraMsgNode") - assert camCmd in {"push", "pop"} - camera_msg.cmd = camCmd - camera_msg.camera = self.entry_camera - camera_msg.link_input(responder_state, "msgs", "sender") - - if enable is not None: - enable_msg: PlasmaEnableMsgNode = nodes.new("PlasmaEnableMsgNode") - enable_LUT = {"enable": "kEnable", "disable": "kDisable"} - enable_msg.cmd = enable_LUT[enable] - enable_msg.settings = {"kModifiers"} - enable_msg.link_input(responder_state, "msgs", "sender") - enable_msg.link_output(volume_sensor, "receivers", "message") def export(self, exporter, bo, so): exporter.mgr.add_object(pl=plSpawnModifier, so=so, name=self.key_name) From c873cf968b94cfbafcce47faaadf28de4c135501 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Mon, 15 Jan 2024 18:47:50 -0500 Subject: [PATCH 5/5] Don't move the original object! --- korman/exporter/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/korman/exporter/utils.py b/korman/exporter/utils.py index 73c3aef..a8c35c8 100644 --- a/korman/exporter/utils.py +++ b/korman/exporter/utils.py @@ -150,6 +150,7 @@ def create_box_region( with region_object as bm: center = owner_object.matrix_world.translation if origin == RegionOrigin.bottom: + center = center.copy() center.z += size.z * 0.5 vert_src = [ (center.x + size.x * 0.5, center.y + size.y * 0.5, center.z + size.z * 0.5),