diff --git a/korman/nodes/node_conditions.py b/korman/nodes/node_conditions.py index 7d37d6d3..3c70bb52 100644 --- a/korman/nodes/node_conditions.py +++ b/korman/nodes/node_conditions.py @@ -395,6 +395,165 @@ def simple_mode(self): return (not self.is_linked and not self.is_output) +class PlasmaLinkEventNode(PlasmaNodeBase, bpy.types.Node): + bl_category = "CONDITIONS" + bl_idname = "PlasmaLinkEventNode" + bl_label = "Link Event" + + trigger_for = EnumProperty( + name="Trigger For", + items=[ + ("Me", "Local Player", "Trigger only for the local player"), + ("NPCs", "NPCs", "Trigger for NPCs (eg quabs)"), + ("Player Avatars", "Player Avatars", "Trigger for player controlled avatars"), + ("Everyone", "Everyone", "Trigger for all avatars"), + ], + default="Me", + options=set() + ) + trigger_on = EnumProperty( + name="Trigger On", + items=[ + ("Spawn", "Avatar Spawns", "Trigger when an avatar spawns"), + ("Link", "Avatar Links", "Trigger when an avatar links"), + ], + default="Spawn", + options=set() + ) + trigger_direction = EnumProperty( + name="Trigger Direction", + items=[ + ("In", "In", "Trigger when the avatar links/spawns into the Age"), + ("Out", "Out", "Trigger when the avatar links/spawns out of the Age"), + ], + default="In", + options=set() + ) + trigger_at = EnumProperty( + name="Trigger At", + items=[ + ("Start", "Event Start", "Trigger when the interesting event begins"), + ("End", "Event End", "Trigger when the interesting event ends"), + ], + default="Start", + options=set() + ) + + output_sockets: dict[str, dict[str, Any]] = { + "satisfies": { + "text": "Satisfies", + "type": "PlasmaConditionSocket", + "valid_link_sockets": {"PlasmaConditionSocket", "PlasmaPythonFileNodeSocket"}, + }, + } + + def draw_buttons(self, context, layout): + combo = (self.trigger_for, self.trigger_on, self.trigger_direction) + + layout.alert = combo == ("Me", "Spawn", "Out") + layout.prop(self, "trigger_for") + layout.alert = False + + layout.prop(self, "trigger_on") + layout.prop(self, "trigger_direction") + layout.prop(self, "trigger_at") + + def get_key(self, exporter: Exporter, so) -> plKey[plLogicModifier]: + return self._find_create_key(plLogicModifier, exporter, so=so) + + def export(self, exporter: Exporter, bo, so) -> None: + combo = (self.trigger_for, self.trigger_on, self.trigger_direction) + if combo == ("Me", "Spawn", "Out"): + self.raise_error("Cannot trigger when the local player spawns out") + + # There is no class in the engine that will fire when someone links in. There are messages, + # though, and they all get to Python in one way or another. So, to fire an event on link + # or spawn, we're going to use a helper script xLinkEventTrigger. It will send a notification + # to a kMultiTrigger LogicMod with a plActivatorActivatorConditionalObject. LMs forward + # all plNotifyMsgs to their conditions, and plAACO activates on any state==1.0 plNotifyMsg, + # assuming that the LogicMod is not already marked as triggered and all of the other conditions + # say that they're in a valid state. Once that LogicMod fires, anything we want can be fired + # off, like other PFMs or Responders. A potential optimization here is to avoid creation + # of the plAACO/plLM thunk if we only have responders attached. + logicmod = self.generate_logic_thunk(exporter, so, "satisfies") + pfm = self._find_create_object(plPythonFileMod, exporter, so=so) + self._add_py_parameter(pfm, 1, plPythonParameter.kResponder, logicmod.key) + self._add_py_parameter(pfm, 100, plPythonParameter.kString, f"{self.trigger_on} {self.trigger_direction}") + self._add_py_parameter(pfm, 101, plPythonParameter.kString, self.trigger_for) + self._add_py_parameter(pfm, 102, plPythonParameter.kString, self.trigger_at) + pfm.filename = "xLinkEventTrigger" + + @property + def export_once(self): + return True + + +class PlasmaSDLBoolConditionNode(PlasmaNodeBase, bpy.types.Node): + bl_category = "CONDITIONS" + bl_idname = "PlasmaSDLBoolConditionNode" + bl_label = "SDL Boolean Condition" + + input_sockets: dict[str, dict[str, Any]] = { + "condition": { + "text": "Condition", + "type": "PlasmaConditionSocket", + }, + "variable": { + "text": "Depends on SDL", + "type": "PlasmaSDLTriggererSocket", + }, + } + + output_sockets: dict[str, dict[str, Any]] = { + "satisfies_true": { + "text": "Satisfy on True", + "type": "PlasmaConditionSocket", + }, + "satisfies_false": { + "text": "Satisfy on False", + "type": "PlasmaConditionSocket", + } + } + + ffwd_init = BoolProperty( + name="F-Fwd on Init", + description="Fast-forward the matching Responder when the Age loads", + default=True, + options=set() + ) + + def draw_buttons(self, context, layout: bpy.types.UILayout): + layout.prop(self, "ffwd_init") + + def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): + condition_node = self.find_input("condition") + variable_node = self.find_input("variable") + if condition_node is None: + self.raise_error("Must be linked to a condition") + if variable_node is None: + self.raise_error("Must be linked to an SDL variable") + + # xAgeSDLBoolCondResp only handles one SDL value per modifier. We're exposing two + # node sockets as a convenience to match the behavior of the xAgeSDLBoolRespond node. + # This means we'll potentially be producing two PFMs here. + resp_socket_names = ("satisfies_false", "satisfies_true") + for i, resp_node in enumerate((self.find_output(i) for i in resp_socket_names)): + if resp_node is None: + continue + + pfm = self._find_create_object(plPythonFileMod, exporter, so=so, suffix=str(i)) + self._add_py_parameter(pfm, 1, plPythonParameter.kActivator, condition_node.get_key(exporter, so)) + self._add_py_parameter(pfm, 2, plPythonParameter.kString, variable_node.variable_name) + self._add_py_parameter(pfm, 3, plPythonParameter.kResponder, resp_node.get_key(exporter, so)) + self._add_py_parameter(pfm, 4, plPythonParameter.kBoolean, i == 1) + self._add_py_parameter(pfm, 5, plPythonParameter.kBoolean, self.ffwd_init) + pfm.filename = "xAgeSDLBoolCondResp" + + @property + def export_once(self): + return True + + class PlasmaVolumeReportNode(PlasmaNodeBase, bpy.types.Node): bl_category = "CONDITIONS" bl_idname = "PlasmaVolumeReportNode" diff --git a/korman/nodes/node_core.py b/korman/nodes/node_core.py index a875d5e4..bc9a7537 100644 --- a/korman/nodes/node_core.py +++ b/korman/nodes/node_core.py @@ -28,6 +28,34 @@ from ..exporter import Exporter class PlasmaNodeBase: + def _add_py_parameter(self, pfm: plPythonFileMod, id: int, param_type: int, value) -> None: + param = plPythonParameter() + param.id = id + param.valueType = param_type + param.value = value + pfm.addParameter(param) + + def generate_logic_thunk( + self, exporter: Exporter, so: plSceneObject, + notify_socket: Optional[str] = None, + notify_idname: Optional[str] = None + ) -> plLogicModifier: + """ + Generates a plLogicModifier with a plActivatorActivatorConditionalObject attached. This + configuration can be used as a thunk in logic nodes to trigger other logic modifiers or + responders. + """ + activator = self._find_create_object(plActivatorActivatorConditionalObject, exporter, so=so) + logicmod = self._find_create_object(plLogicModifier, exporter, so=so) + if notify_socket is not None: + logicmod.notify = self.generate_notify_msg(exporter, so, notify_socket, notify_idname) + else: + logicmod.notify = plNotifyMsg() + logicmod.addCondition(activator.key) + logicmod.setLogicFlag(plLogicModifier.kMultiTrigger, True) + logicmod.setLogicFlag(plLogicModifier.kLocalElement, True) + return logicmod + def generate_notify_msg(self, exporter: Exporter, so: plSceneObject, socket_id: str, idname: Optional[str] = None) -> plNotifyMsg: notify = plNotifyMsg() notify.BCastFlags = (plMessage.kNetPropagate | plMessage.kLocalPropagate) diff --git a/korman/nodes/node_logic.py b/korman/nodes/node_logic.py index 9f7f6188..cb923e71 100644 --- a/korman/nodes/node_logic.py +++ b/korman/nodes/node_logic.py @@ -17,13 +17,124 @@ import bpy from bpy.props import * +import itertools from typing import * from PyHSPlasma import * from .. import enum_props +from ..exporter import Exporter from .node_core import * from .. import idprops +class PlasmaChangeSDLNode(PlasmaNodeBase, bpy.types.Node): + bl_category = "LOGIC" + bl_idname = "PlasmaChangeSDLNode" + bl_label = "Change SDL" + + output_sockets: dict[str, dict[str, Any]] = { + "variable": { + "text": "SDL", + "type": "PlasmaSDLTriggereeSocket", + } + } + + action = EnumProperty( + name="Action", + items=[ + ("TOGGLE", "Toggle Boolean", "Toggle a boolean SDL variable"), + ("SET_BOOL", "Set Boolean", "Set the boolean value of an SDL Variable"), + ("SET_INT", "Set Integer", "Set the integer value of an SDL Variable"), + ], + options=set() + ) + + value_int = IntProperty( + name="Value", + options=set() + ) + + def _get_bool(self) -> bool: + return self.value_int != 0 + def _set_bool(self, value: bool) -> None: + if self.value_int == 0 and value: + self.value_int = 1 + if self.value_int != 0 and not value: + self.value_int = 0 + + value_bool = BoolProperty( + name="Value", + description="If checked, the value of the SDL Variable is true", + get=_get_bool, + set=_set_bool, + options=set() + ) + + tag_string = StringProperty( + name="Extra Info", + description="Tag string sent along as extra info for the SDL variable change", + options=set() + ) + + input_sockets: dict[str, dict[str, Any]] = { + "condition": { + "text": "Condition", + "type": "PlasmaConditionSocket", + "spawn_empty": True, + }, + } + + def draw_buttons(self, context, layout): + layout.prop(self, "action") + layout.prop(self, "tag_string") + + if self.action == "SET_BOOL": + layout.prop(self, "value_bool") + elif self.action == "SET_INT": + layout.prop(self, "value_int") + elif self.action == "TOGGLE": + pass + else: + raise ValueError(self.action) + + def get_key(self, exporter: Exporter, so: plSceneObject) -> plKey[plLogicModifier]: + return self._find_create_key(plLogicModifier, exporter, so=so) + + def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): + variable_node = self.find_output("variable") + if variable_node is None: + self.raise_error("Must be connected to an SDL Variable") + + pfm = self._find_create_object(plPythonFileMod, exporter, so=so) + + # The exporter is designed such that the condition generates the trigger. While we could + # technically be the end of the chain, we do use a PythonFileMod that needs to be + # activated. So, we need to set up a LogicMod that can trigger the PythonFileMod. + logicmod = self.generate_logic_thunk(exporter, so) + logicmod.notify.addReceiver(pfm.key) + + if self.action == "TOGGLE": + pfm.filename = "xAgeSDLBoolToggle" + self._add_py_parameter(pfm, 1, plPythonParameter.kActivator, logicmod.key) + self._add_py_parameter(pfm, 2, plPythonParameter.kString, variable_node.variable_name) + if self.tag_string: + self._add_py_parameter(pfm, 5, plPythonParameter.kString, self.tag_string) + elif self.action in {"SET_BOOL", "SET_INT"}: + # While the filename indicates boolean, it really treats the values as integers, so + # we can simply abuse this file for the integer setting as well. + pfm.filename = "xAgeSDLBoolSet" + self._add_py_parameter(pfm, 1, plPythonParameter.kActivator, logicmod.key) + self._add_py_parameter(pfm, 2, plPythonParameter.kString, variable_node.variable_name) + self._add_py_parameter(pfm, 7, plPythonParameter.kInt, self.value_int) + if self.tag_string: + self._add_py_parameter(pfm, 8, plPythonParameter.kString, self.tag_string) + else: + raise ValueError(self.action) + + @property + def export_once(self): + return True + + class PlasmaExcludeRegionNode(idprops.IDPropObjectMixin, PlasmaNodeBase, bpy.types.Node): bl_category = "LOGIC" bl_idname = "PlasmaExcludeRegionNode" @@ -137,3 +248,183 @@ def is_used(self): class PlasmaExcludeMessageSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket): bl_color = (0.467, 0.576, 0.424, 1.0) + + +class PlasmaSDLBoolTriggerNode(PlasmaNodeBase, bpy.types.Node): + bl_category = "LOGIC" + bl_idname = "PlasmaSDLBoolTriggerNode" + bl_label = "SDL Boolean Trigger" + + input_sockets: dict[str, dict[str, Any]] = { + "variable": { + "text": "Triggered by SDL", + "type": "PlasmaSDLTriggererSocket", + }, + "dependent": { + "text": "Dependends on SDL", + "type": "PlasmaSDLTriggererSocket", + } + } + + output_sockets: dict[str, dict[str, Any]] = { + "satisfies_true": { + "text": "Satisfy on True", + "type": "PlasmaConditionSocket", + }, + "satisfies_false": { + "text": "Satisfy on False", + "type": "PlasmaConditionSocket", + } + } + + ffwd_init = BoolProperty( + name="F-Fwd on Init", + description="Fast-forward the matching Responder when the Age loads", + default=True, + options=set() + ) + ffwd_vm = BoolProperty( + name="F-Fwd on VM", + description="Fast-forward the matching Responder when the SDL variable is changed in the Vault Manager", + default=True, + options=set() + ) + + def draw_buttons(self, context, layout: bpy.types.UILayout): + layout.prop(self, "ffwd_init") + layout.prop(self, "ffwd_vm") + + def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): + trigger_var = self.find_input("variable") + dependent_var = self.find_input("dependent") + if trigger_var is None: + self.raise_error("Must be linked to an SDL variable") + + # We're going to pick between xAgeSDLBoolRespond and xAgeSDLBoolAndRespond. The attributes + # are the same except the latter has an extra dependent variable as attribute 2. To avoid + # having to recode everything for the different IDs, we'll just use a counter. + attr_id_iter = itertools.count(1) + pfm = self._find_create_object(plPythonFileMod, exporter, so=so) + self._add_py_parameter(pfm, next(attr_id_iter), plPythonParameter.kString, trigger_var.variable_name) + if dependent_var is not None: + self._add_py_parameter(pfm, next(attr_id_iter), plPythonParameter.kString, dependent_var.variable_name) + + # Important: attr_id_iter should be the second argument to zip() so that no elements are + # consumed from the counter when the responder name tuple is exhausted. + for resp_name, attr_id in zip(("satisfies_true", "satisfies_false"), attr_id_iter): + resp_node = self.find_output(resp_name) + if resp_node is not None: + self._add_py_parameter(pfm, attr_id, plPythonParameter.kResponder, resp_node.get_key(exporter, so)) + + self._add_py_parameter(pfm, next(attr_id_iter), plPythonParameter.kBoolean, self.ffwd_vm) + self._add_py_parameter(pfm, next(attr_id_iter), plPythonParameter.kBoolean, self.ffwd_init) + pfm.filename = "xAgeSDLBoolAndRespond" if dependent_var is not None else "xAgeSDLBoolRespond" + + @property + def export_once(self): + return True + + +class PlasmaSDLBoolGateNode(PlasmaNodeBase, bpy.types.Node): + bl_category = "LOGIC" + bl_idname = "PlasmaSDLBoolGateNode" + bl_label = "SDL Boolean Gate" + + input_sockets: dict[str, dict[str, Any]] = { + "input_variable": { + "text": "Input SDL", + "type": "PlasmaSDLTriggererSocket", + "spawn_empty": True, + }, + } + + output_sockets: dict[str, dict[str, Any]] = { + "output_variable": { + "text": "Output SDL", + "type": "PlasmaSDLTriggereeSocket", + "link_limit": 1, + } + } + + operation = EnumProperty( + name="Operation", + description="Boolean operation to apply to the input SDL variables", + items=[ + ("AND", "AND", "Perform a logical AND"), + ("OR", "OR", "Perform a logical OR"), + ("NAND", "NAND", "Perform a logical NOT AND"), + ("XOR", "XOR", "Perform a logical eXclusive OR"), + ], + default="AND", + options=set() + ) + + def draw_buttons(self, context, layout: bpy.types.UILayout): + layout.props_enum(self, "operation") + + def export(self, exporter: Exporter, bo: bpy.types.Object, so: plSceneObject): + input_sdl_variables = frozenset((i.variable_name for i in self.find_inputs("input_variable"))) + output_sdl_node = self.find_output("output_variable") + if len(input_sdl_variables) < 2: + self.raise_error("At least two unique input SDL variables must be connected") + if output_sdl_node is None: + self.raise_error("Output SDL variable must be connected") + + # The only SDL boolean gate script that exists out of the box is xAgeSDLBoolAndSet. This + # script takes two SDL variables, performs a logical AND, and stores the result in a third + # variable. I wrote xAgeSDLBoolAndSetV2 or take an arbitrary number of variables, AND them, + # and store the result. Since I was working, I also took the liberty of writing xAgeSDLBoolOrSet, + # which should be self explanatory. Because we have quite a few nonstandard scripts, let's + # special case AND with two variables for the standard script. + pfm = self._find_create_object(plPythonFileMod, exporter, so=so) + if self.operation == "AND" and len(input_sdl_variables) == 2: + pfm.filename = "xAgeSDLBoolAndSet" + sdl_node_iter = itertools.chain(input_sdl_variables, (output_sdl_node.variable_name,)) + for attr_id, sdl_var in enumerate(sdl_node_iter, start=1): + self._add_py_parameter(pfm, attr_id, plPythonParameter.kString, sdl_var) + else: + pfm.filename = "xAgeSDLBoolGateSet" + self._add_py_parameter(pfm, 1, plPythonParameter.kString, ",".join(input_sdl_variables)) + self._add_py_parameter(pfm, 2, plPythonParameter.kString, output_sdl_node.variable_name) + self._add_py_parameter(pfm, 3, plPythonParameter.kString, self.operation) + + @property + def export_once(self) -> bool: + return True + + +class PlasmaSDLSocketBase: + bl_color = (0.18, 0.55, 0.34, 1.0) + + +class PlasmaSDLTriggererSocket(PlasmaSDLSocketBase, PlasmaNodeSocketBase, bpy.types.NodeSocket): pass +class PlasmaSDLTriggereeSocket(PlasmaSDLSocketBase, PlasmaNodeSocketBase, bpy.types.NodeSocket): pass + + +class PlasmaSDLVariableNode(PlasmaNodeBase, bpy.types.Node): + bl_category = "LOGIC" + bl_idname = "PlasmaSDLVariableNode" + bl_label = "SDL Variable" + + input_sockets: dict[str, dict[str, Any]] = { + "condition": { + "text": "Changed By", + "type": "PlasmaSDLTriggereeSocket", + } + } + + output_sockets: dict[str, dict[str, Any]] = { + "satisfies": { + "text": "Triggers", + "type": "PlasmaSDLTriggererSocket", + } + } + + variable_name = StringProperty( + name="Variable", + description="Name of an SDL Variable", + options=set() + ) + + def draw_buttons(self, context, layout: bpy.types.UILayout): + layout.prop(self, "variable_name") diff --git a/korman/nodes/node_messages.py b/korman/nodes/node_messages.py index ac35b516..26b063f8 100644 --- a/korman/nodes/node_messages.py +++ b/korman/nodes/node_messages.py @@ -1010,25 +1010,26 @@ def convert_message(self, exporter, so): class PlasmaTriggerMultiStageMsgNode(PlasmaMessageNode, bpy.types.Node): bl_category = "MSG" bl_idname = "PlasmaTriggerMultiStageMsgNode" - bl_label = "Trigger MultiStage" + bl_label = "Trigger" output_sockets: dict[str, dict[str, Any]] = { "satisfies": { "text": "Trigger", "type": "PlasmaConditionSocket", - "valid_link_nodes": "PlasmaMultiStageBehaviorNode", - "valid_link_sockets": "PlasmaConditionSocket", "link_limit": 1, } } def convert_message(self, exporter, so): - # Yeah, this is not a REAL Plasma message, but the Korman way is to try to hide these little - # low-level notifications behind higher level abstractions, so here you go. A notify message - # that only targets plMultiStageBehMod. You're welcome! + # This node was designed to target only MultiStageBehaviors. In Plasma, to trigger an MSB, + # you send a collision notify from a Responder. If a collision event is not present in a + # plNotifyMsg, then the receivers are blown away and rewritten to be whoever triggered the + # Responder last. That means an MSB trigger "message" is really a generic "trigger some + # logic" message as well. So, use the generic code here. I've also relaxed the MSB linkage + # requirement so some of the new SDL change nodes can be triggered from here. msg = self.generate_notify_msg(exporter, so, "satisfies") - # The MultiStageBehMod needs to receive the avatar key that whatdonetriggeredit. We don't know + # A MultiStageBehMod needs to receive the avatar key that whatdonetriggeredit. We don't know # this information at export-time, but plResponderModifier::IContinueSending will interpret # a collision event as "ohey, let's add the avatar key for MSBs" - nice. msg.addEvent(proCollisionEventData())