diff --git a/korman/nodes/__init__.py b/korman/nodes/__init__.py index f1670ef..28c65eb 100644 --- a/korman/nodes/__init__.py +++ b/korman/nodes/__init__.py @@ -22,6 +22,7 @@ import nodeitems_utils from .node_avatar import * from .node_conditions import * from .node_core import * +from .node_deprecated import * from .node_logic import * from .node_messages import * from .node_python import * @@ -53,7 +54,11 @@ _kategory_names = { _kategories = {} for cls in dict(globals()).values(): if inspect.isclass(cls): - if not issubclass(cls, PlasmaNodeBase) or not issubclass(cls, bpy.types.Node): + if not issubclass(cls, PlasmaNodeBase): + continue + if not issubclass(cls, bpy.types.Node): + continue + if issubclass(cls, PlasmaDeprecatedNode): continue else: continue diff --git a/korman/nodes/node_conditions.py b/korman/nodes/node_conditions.py index d44363d..8f87474 100644 --- a/korman/nodes/node_conditions.py +++ b/korman/nodes/node_conditions.py @@ -82,6 +82,11 @@ class PlasmaClickableNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.types.N interface.addIntfKey(logicmod) logicmod = logicmod.object + # If we receive an enable message, this is a one-shot type deal that needs to be disabled + # while the attached responder is running. + if self.find_input("message", "PlasmaEnableMsgNode") is not None: + logicmod.setLogicFlag(plLogicModifier.kOneShot, True) + # Try to figure out the appropriate bounds type for the clickable.... phys_mod = clickable_bo.plasma_modifiers.collision bounds = phys_mod.bounds if phys_mod.enabled else self.bounds diff --git a/korman/nodes/node_core.py b/korman/nodes/node_core.py index 1bfdcca..ed439fe 100644 --- a/korman/nodes/node_core.py +++ b/korman/nodes/node_core.py @@ -65,11 +65,22 @@ class PlasmaNodeBase: if idname is None or idname == node.bl_idname: yield node - def find_input_socket(self, key): + def find_input_socket(self, key, spawn_empty=False): + # In the case that this socket will be used to make new input linkage, + # we might want to allow the spawning of a new input socket... :) + # This will only be done if the node's socket definitions allow it. + options = self._socket_defs[0].get(key, {}) + spawn_empty = spawn_empty and options.get("spawn_empty", False) + for i in self.inputs: if i.alias == key: + if spawn_empty and i.is_linked: + continue return i - raise KeyError(key) + if spawn_empty: + return self._spawn_socket(key, options, self.inputs) + else: + raise KeyError(key) def find_input_sockets(self, key, idname=None): for i in self.inputs: @@ -118,7 +129,7 @@ class PlasmaNodeBase: def link_input(self, node, out_key, in_key): """Links a given Node's output socket to a given input socket on this Node""" if isinstance(in_key, str): - in_socket = self.find_input_socket(in_key) + in_socket = self.find_input_socket(in_key, spawn_empty=True) else: in_socket = in_key if isinstance(out_key, str): @@ -130,7 +141,7 @@ class PlasmaNodeBase: def link_output(self, node, out_key, in_key): """Links a given Node's input socket to a given output socket on this Node""" if isinstance(in_key, str): - in_socket = node.find_input_socket(in_key) + in_socket = node.find_input_socket(in_key, spawn_empty=True) else: in_socket = in_key if isinstance(out_key, str): @@ -161,6 +172,15 @@ class PlasmaNodeBase: return (getattr(self.__class__, "input_sockets", {}), getattr(self.__class__, "output_sockets", {})) + def _spawn_socket(self, key, options, sockets): + socket = sockets.new(options["type"], options["text"], key) + link_limit = options.get("link_limit", None) + if link_limit is not None: + socket.link_limit = link_limit + socket.hide = options.get("hidden", False) + socket.hide_value = options.get("hidden", False) + return socket + def _tattle(self, socket, link, reason): direction = "->" if socket.is_output else "<-" print("Removing {} {} {} {}".format(link.from_node.name, direction, link.to_node.name, reason)) @@ -176,6 +196,7 @@ class PlasmaNodeBase: i = 0 while i < len(sockets): socket = sockets[i] + node = socket.node options = defs.get(socket.alias, None) if options is None or socket.bl_idname != options["type"]: @@ -187,11 +208,27 @@ class PlasmaNodeBase: link_limit = options.get("link_limit", None) if link_limit is not None: socket.link_limit = link_limit + socket.hide = options.get("hidden", False) + socket.hide_value = options.get("hidden", False) # Make sure the link is good allowed_sockets = options.get("valid_link_sockets", None) allowed_nodes = options.get("valid_link_nodes", None) + # The socket may decide it doesn't want anyone linked to it. + can_link_attr = options.get("can_link", None) + if can_link_attr is not None: + can_link = getattr(node, can_link_attr) + socket.enabled = can_link + if not can_link: + for link in socket.links: + try: + self._tattle(socket, link, "(socket refused link)") + self.id_data.links.remove(link) + except RuntimeError: + # was already removed by someone else + pass + # Helpful default... If neither are set, require the link to be to the same socket type if allowed_nodes is None and allowed_sockets is None: allowed_sockets = frozenset((options["type"],)) @@ -237,11 +274,12 @@ class PlasmaNodeBase: # Create any new sockets for alias in (j for j in defs if j not in done): - options = defs[alias] - socket = sockets.new(options["type"], options["text"], alias) - link_limit = options.get("link_limit", None) - if link_limit is not None: - socket.link_limit = link_limit + self._spawn_socket(alias, defs[alias], sockets) + + def _whine(self, msg, *args): + if args: + msg = msg.format(*args) + print("'{}' Node '{}': Whinging about {}".format(self.bl_idname, self.name, msg)) class PlasmaTreeOutputNodeBase(PlasmaNodeBase): diff --git a/korman/nodes/node_deprecated.py b/korman/nodes/node_deprecated.py new file mode 100644 index 0000000..4621768 --- /dev/null +++ b/korman/nodes/node_deprecated.py @@ -0,0 +1,131 @@ +# 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 . + +import bpy +from bpy.props import * +from collections import OrderedDict + +from .node_core import * + +class PlasmaDeprecatedNode(PlasmaNodeBase): + def upgrade(self): + raise NotImplementedError() + + +class PlasmaRespCommandSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket): + bl_color = (0.451, 0.0, 0.263, 1.0) + + +class PlasmaResponderCommandNode(PlasmaDeprecatedNode, bpy.types.Node): + bl_category = "LOGIC" + bl_idname = "PlasmaResponderCommandNode" + bl_label = "Responder Command" + + input_sockets = OrderedDict([ + ("whodoneit", { + "text": "Condition", + "type": "PlasmaRespCommandSocket", + }), + ]) + + output_sockets = OrderedDict([ + ("msg", { + "link_limit": 1, + "text": "Message", + "type": "PlasmaMessageSocket", + }), + ("trigger", { + "text": "Trigger", + "type": "PlasmaRespCommandSocket", + }), + ("reenable", { + "text": "Local Reenable", + "type": "PlasmaEnableMessageSocket", + }), + ]) + + def _find_message_sender_node(self, parentCmdNode=None): + if parentCmdNode is None: + parentCmdNode = self + else: + if self == parentCmdNode: + self._whine("responder tree is circular") + return None + + if parentCmdNode.bl_idname == "PlasmaResponderStateNode": + return parentCmdNode + elif parentCmdNode.bl_idname == "PlasmaResponderCommandNode": + # potentially a responder wait command... let's see if the message can wait + if parentCmdNode != self: + cmdMsgNode = parentCmdNode.find_output("msg") + if cmdMsgNode is not None and cmdMsgNode.has_callbacks: + return cmdMsgNode + + # can't wait on this command/message, so go up the tree... + grandParentCmdNode = parentCmdNode.find_input("whodoneit") + if grandParentCmdNode is None: + self._whine("orphaned responder command") + return None + return self._find_message_sender_node(grandParentCmdNode) + else: + self._whine("unexpected command node type '{}'", parentCmdNode.bl_idname) + return None + + + def upgrade(self): + senderNode = self._find_message_sender_node() + if senderNode is None: + return + + msgNode = self.find_output("msg") + if msgNode is not None: + senderNode.link_output(msgNode, "msgs", "sender") + else: + self._whine("command node does not send a message?") + + if self.find_output("reenable") is not None: + tree = self.id_data + enableMsgNode = tree.nodes.new("PlasmaEnableMsgNode") + enableMsgNode.cmd = "kEnable" + if msgNode.has_callbacks: + msgNode.link_output(enableMsgNode, "msgs", "sender") + else: + senderNode.link_output(enableMsgNode, "msgs", "sender") + + fromSocket = enableMsgNode.find_output_socket("receivers") + for link in self.find_output_socket("reenable").links: + if not link.is_valid: + continue + tree.links.new(link.to_socket, fromSocket) + +@bpy.app.handlers.persistent +def _upgrade_node_trees(dummy): + for tree in bpy.data.node_groups: + if tree.bl_idname != "PlasmaNodeTree": + continue + + # ensure node sockets match what we expect + for node in tree.nodes: + node.update() + nuke = [] + # upgrade to new node types/linkages + for node in tree.nodes: + if isinstance(node, PlasmaDeprecatedNode): + node.upgrade() + nuke.append(node) + # toss deprecated nodes + for node in nuke: + tree.nodes.remove(node) +bpy.app.handlers.load_post.append(_upgrade_node_trees) diff --git a/korman/nodes/node_messages.py b/korman/nodes/node_messages.py index af8b19a..3fff65c 100644 --- a/korman/nodes/node_messages.py +++ b/korman/nodes/node_messages.py @@ -34,17 +34,66 @@ class PlasmaMessageNode(PlasmaNodeBase): ("sender", { "text": "Sender", "type": "PlasmaMessageSocket", + "valid_link_sockets": "PlasmaMessageSocket", "spawn_empty": True, }), ]) @property def has_callbacks(self): - """This message has callbacks that can be waited on by a Responder""" + """This message does not have callbacks that can be waited on by a Responder""" return False -class PlasmaAnimCmdMsgNode(idprops.IDPropMixin, PlasmaMessageNode, bpy.types.Node): +class PlasmaMessageWithCallbacksNode(PlasmaMessageNode): + output_sockets = OrderedDict([ + ("msgs", { + "can_link": "can_link_callback", + "text": "Send On Completion", + "type": "PlasmaMessageSocket", + "valid_link_sockets": "PlasmaMessageSocket", + }), + ]) + + @property + def can_link_callback(self): + """Determines if a callback message can be linked to this socket""" + + # Node Graphs enable us to draw lots of fancy logic, unfortunately, not + # everything that can potentially be represented in a node tree can be + # exported to URU in a way that will actually work. Responder commands can + # wait on other responder commands, but the way they are executed in Plasma is + # serialized. It's really a list of commands that are executed until a wait + # is encountered. At that time, Plasma waits and resumes running the list when + # the wait callback is received. + # So what does this mean??? + # It means that only one "branch" of message nodes can have waits. + def check_for_callbacks(parent_node, child_node): + for sibling_node in parent_node.find_outputs("msgs"): + if sibling_node == child_node: + continue + if getattr(sibling_node, "has_linked_callbacks", False): + return True + for grandparent_node in parent_node.find_inputs("sender"): + return check_for_callbacks(grandparent_node, parent_node) + return False + + for sender_node in self.find_inputs("sender"): + if check_for_callbacks(sender_node, self): + return False + return True + + @property + def has_callbacks(self): + """This message has callbacks that can be waited on by a Responder""" + return True + + @property + def has_linked_callbacks(self): + return self.find_output("msgs") is not None + + +class PlasmaAnimCmdMsgNode(idprops.IDPropMixin, PlasmaMessageWithCallbacksNode, bpy.types.Node): bl_category = "MSG" bl_idname = "PlasmaAnimCmdMsgNode" bl_label = "Animation Command" @@ -407,7 +456,7 @@ class PlasmaLinkToAgeMsg(PlasmaMessageNode, bpy.types.Node): layout.prop(self, "spawn_point") -class PlasmaOneShotMsgNode(idprops.IDPropObjectMixin, PlasmaMessageNode, bpy.types.Node): +class PlasmaOneShotMsgNode(idprops.IDPropObjectMixin, PlasmaMessageWithCallbacksNode, bpy.types.Node): bl_category = "MSG" bl_idname = "PlasmaOneShotMsgNode" bl_label = "One Shot" @@ -426,7 +475,7 @@ class PlasmaOneShotMsgNode(idprops.IDPropObjectMixin, PlasmaMessageNode, bpy.typ animation = StringProperty(name="Animation", description="Name of the animation the avatar should execute") marker = StringProperty(name="Marker", - description="Name of the marker specifying when to notify the Responder") + description="Name of the marker specifying when to notify the Responder") drivable = BoolProperty(name="Drivable", description="Player retains control of the avatar during the OneShot", default=False) @@ -523,7 +572,7 @@ class PlasmaSceneObjectMsgRcvrNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bp return {"target_object": "object_name"} -class PlasmaSoundMsgNode(idprops.IDPropObjectMixin, PlasmaMessageNode, bpy.types.Node): +class PlasmaSoundMsgNode(idprops.IDPropObjectMixin, PlasmaMessageWithCallbacksNode, bpy.types.Node): bl_category = "MSG" bl_idname = "PlasmaSoundMsgNode" bl_label = "Sound" @@ -646,16 +695,12 @@ class PlasmaSoundMsgNode(idprops.IDPropObjectMixin, PlasmaMessageNode, bpy.types layout.prop(self, "looping") layout.prop(self, "volume") - @property - def has_callbacks(self): - return True - @classmethod def _idprop_mapping(cls): return {"emitter_object": "object_name"} -class PlasmaTimerCallbackMsgNode(PlasmaMessageNode, bpy.types.Node): +class PlasmaTimerCallbackMsgNode(PlasmaMessageWithCallbacksNode, bpy.types.Node): bl_category = "MSG" bl_idname = "PlasmaTimerCallbackMsgNode" bl_label = "Timed Callback" @@ -677,10 +722,6 @@ class PlasmaTimerCallbackMsgNode(PlasmaMessageNode, bpy.types.Node): msg.time = self.delay return msg - @property - def has_callbacks(self): - return True - class PlasmaFootstepSoundMsgNode(PlasmaMessageNode, bpy.types.Node): bl_category = "MSG" diff --git a/korman/nodes/node_responder.py b/korman/nodes/node_responder.py index 166e242..70a21b5 100644 --- a/korman/nodes/node_responder.py +++ b/korman/nodes/node_responder.py @@ -126,9 +126,18 @@ class PlasmaResponderStateNode(PlasmaNodeBase, bpy.types.Node): ]) output_sockets = OrderedDict([ + # This socket has been deprecated. ("cmds", { "text": "Commands", "type": "PlasmaRespCommandSocket", + "hidden": True, + }), + + # These sockets are valid. + ("msgs", { + "text": "Send Message", + "type": "PlasmaMessageSocket", + "valid_link_sockets": "PlasmaMessageSocket", }), ("gotostate", { "link_limit": 1, @@ -182,12 +191,44 @@ class PlasmaResponderStateNode(PlasmaNodeBase, bpy.types.Node): # Convert the commands commands = CommandMgr() - for i in self.find_outputs("cmds", "PlasmaResponderCommandNode"): + for i in self.find_outputs("msgs"): # slight optimization--commands attached to states can't wait on other commands # namely because it's impossible to wait on a command that doesn't exist... - i.convert_command(exporter, so, stateMgr.responder, commands) + self._generate_command(exporter, so, stateMgr.responder, commands, i) commands.save(state) + def _generate_command(self, exporter, so, responder, commandMgr, msgNode, waitOn=-1): + def prepare_message(exporter, so, responder, commandMgr, waitOn, msg): + idx, command = commandMgr.add_command(self, waitOn) + if msg.sender is None: + msg.sender = responder.key + msg.BCastFlags |= plMessage.kLocalPropagate + command.msg = msg + return (idx, command) + + # HACK: Some message nodes may need to sneakily send multiple messages. So, convert_message + # is therefore now a generator. We will ASSume that the first message generated is the + # primary msg that we should use for callbacks, if applicable + if inspect.isgeneratorfunction(msgNode.convert_message): + messages = tuple(msgNode.convert_message(exporter, so)) + msg = messages[0] + for i in messages[1:]: + prepare_message(exporter, so, responder, commandMgr, waitOn, i) + else: + msg = msgNode.convert_message(exporter, so) + idx, command = prepare_message(exporter, so, responder, commandMgr, waitOn, msg) + + # If the callback message node is not properly set up for event callbacks, we don't want to + if msgNode.has_callbacks and msgNode.find_output("msgs"): + childWaitOn = commandMgr.add_wait(idx) + msgNode.convert_callback_message(exporter, so, msg, responder.key, childWaitOn) + else: + childWaitOn = waitOn + + # Export any linked callback messages + for i in msgNode.find_outputs("msgs"): + self._generate_command(exporter, so, responder, commandMgr, i, childWaitOn) + def update(self): super().update() @@ -200,98 +241,3 @@ class PlasmaResponderStateNode(PlasmaNodeBase, bpy.types.Node): class PlasmaRespStateSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket): bl_color = (0.388, 0.78, 0.388, 1.0) - - -class PlasmaResponderCommandNode(PlasmaNodeBase, bpy.types.Node): - bl_category = "LOGIC" - bl_idname = "PlasmaResponderCommandNode" - bl_label = "Responder Command" - - input_sockets = OrderedDict([ - ("whodoneit", { - "text": "Condition", - "type": "PlasmaRespCommandSocket", - }), - ]) - - output_sockets = OrderedDict([ - ("msg", { - "link_limit": 1, - "text": "Message", - "type": "PlasmaMessageSocket", - }), - ("trigger", { - "text": "Trigger", - "type": "PlasmaRespCommandSocket", - }), - ("reenable", { - "text": "Local Reenable", - "type": "PlasmaEnableMessageSocket", - }), - ]) - - def convert_command(self, exporter, so, responder, commandMgr, waitOn=-1): - def prepare_message(exporter, so, responder, commandMgr, waitOn, msg): - idx, command = commandMgr.add_command(self, waitOn) - if msg.sender is None: - msg.sender = responder.key - msg.BCastFlags |= plMessage.kLocalPropagate - command.msg = msg - return (idx, command) - - # If this command has no message, there is no need to export it... - msgNode = self.find_output("msg") - if msgNode is not None: - # HACK: Some message nodes may need to sneakily send multiple messages. So, convert_message - # is therefore now a generator. We will ASSume that the first message generated is the - # primary msg that we should use for callbacks, if applicable - if inspect.isgeneratorfunction(msgNode.convert_message): - messages = tuple(msgNode.convert_message(exporter, so)) - msg = messages[0] - for i in messages[1:]: - prepare_message(exporter, so, responder, commandMgr, waitOn, i) - else: - msg = msgNode.convert_message(exporter, so) - idx, command = prepare_message(exporter, so, responder, commandMgr, waitOn, msg) - - # If we have child commands, we need to make sure that we support chaining this message as a callback - # If not, we'll export our children and tell them to not actually wait on us. - haveChildren = self.find_output("trigger", "PlasmaResponderCommandNode") is not None or \ - self.find_output("reenable") is not None - if msgNode.has_callbacks and haveChildren: - childWaitOn = commandMgr.add_wait(idx) - msgNode.convert_callback_message(exporter, so, msg, responder.key, childWaitOn) - else: - childWaitOn = waitOn - else: - childWaitOn = waitOn - - # If they linked us back to a condition or something that exports a LogicModifier, that - # means we need to reenable it here... NOTE: it would be incredibly stupid to do this - # if we're not waiting on anything to complete - if childWaitOn != -1: - for child in self.find_outputs("reenable"): - key = child.get_key(exporter, so) - if key is None: - continue - logicmod = key.object - if not isinstance(logicmod, plLogicModifier): - continue - logicmod.setLogicFlag(plLogicModifier.kOneShot, True) - - # Yep, this is an entirely new ResponderCommand that sends a plEnableMsg - enableMsg = plEnableMsg() - enableMsg.addReceiver(key) - enableMsg.sender = responder.key - enableMsg.BCastFlags |= plMessage.kLocalPropagate - enableMsg.setCmd(plEnableMsg.kEnable, True) - logicCmdIdx, logicCmd = commandMgr.add_command(self, childWaitOn) - logicCmd.msg = enableMsg - - # Export any child commands - for i in self.find_outputs("trigger", "PlasmaResponderCommandNode"): - i.convert_command(exporter, so, responder, commandMgr, childWaitOn) - - -class PlasmaRespCommandSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket): - bl_color = (0.451, 0.0, 0.263, 1.0) diff --git a/korman/properties/modifiers/region.py b/korman/properties/modifiers/region.py index 622f1c0..99cde54 100644 --- a/korman/properties/modifiers/region.py +++ b/korman/properties/modifiers/region.py @@ -101,12 +101,10 @@ class PlasmaFootstepRegion(PlasmaModifierProperties, PlasmaModifierLogicWiz): respstate = nodes.new("PlasmaResponderStateNode") respstate.link_input(respmod, "states", "condition") respstate.default_state = True - respcmd = nodes.new("PlasmaResponderCommandNode") - respcmd.link_input(respstate, "cmds", "whodoneit") # ArmatureEffectStateMsg msg = nodes.new("PlasmaFootstepSoundMsgNode") - msg.link_input(respcmd, "msg", "sender") + msg.link_input(respstate, "msgs", "sender") msg.surface = self.surface @property