From 42cf541ead5bf290dd11f85180d722550d7306c6 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sat, 18 Jul 2026 12:25:12 -0500 Subject: [PATCH 1/3] Only allow one clickable per object. This is going to be disallowed because the engine's SceneInputInterface has assumptions that only one LogicMod is attached to any SceneObject. In theory, the engine supports multiple LogicMods per SceneObject, but it's best to not go there. --- korman/exporter/convert.py | 59 +++++++++++++++++++++++++++++++++ korman/nodes/node_conditions.py | 19 +++++++++-- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 1fa5d726..15909a79 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -13,6 +13,8 @@ # 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 collections import defaultdict @@ -70,6 +72,7 @@ def __init__(self, op): self.actors = set() self.want_node_trees = defaultdict(set) self.exported_nodes = {} + self._claims: dict[str, dict[str, bpy.types.bpy_struct]] = defaultdict(dict) def run(self): log = logger.ExportVerboseLogger if self._op.verbose else logger.ExportProgressLogger @@ -171,6 +174,62 @@ def _bake_static_lighting(self): if self._op.lighting_method != "skip": self.oven.bake_static_lighting(self._objects) + def claim_object( + self, + claimant: bpy.types.bpy_struct, + bl: bpy.types.Object, + claim_type: str, + *, + err: str = "", + strict: bool = True + ) -> Optional[bpy.types.bpy_struct]: + """ + This is a way to assign that a certain property group or object has claimed a Blender object + for a specific purpose and that object cannot be reused for that purpose. Claims are scoped + by ``claim_type``, so the same object can still be claimed for other, unrelated purposes without + conflict. + + If the object is already claimed under this ``claim_type`` by someone else, we will either raise + an ``ExportError`` (if ``strict``) or hand back the existing claimant so the caller can decide what to + do (if not ``strict``). By default, a generic error message is used, but you can supply your own + via ``err`` - it's passed through ``str.format()`` with ``object_name``, ``claim_type``, + ``owner_name``, and ``claimant_name`` available for substitution, where ``owner_name`` and + ``claimant_name`` are slash-joined paths identifying the existing/new claimant. + """ + def iter_name_parts(obj: bpy.types.bpy_struct): + if not isinstance(obj, bpy.types.ID): + yield obj.id_data.name + + name = getattr(obj, "name", None) + if name: + yield name + + claim = self._claims.get(bl.name) + if claim is None: + self._claims[bl.name][claim_type] = claimant + return None + + prev_claimant = claim.get(claim_type) + if prev_claimant is not None and prev_claimant != claimant: + if strict: + if not err: + err = ( + "'{object_name}' has already been claimed as a(n) {claim_type} by " + "'{owner_name}'. '{claimant_name}' cannot claim it again." + ) + raise explosions.ExportError( + err.format( + object_name=bl.name, + owner_name="/".join(iter_name_parts(prev_claimant)), + claimant_name="/".join(iter_name_parts(claimant)), + claim_type=claim_type + ) + ) + return prev_claimant + + claim[claim_type] = claimant + return None + def _collect_objects(self): scene = bpy.context.scene self.report.progress_advance() diff --git a/korman/nodes/node_conditions.py b/korman/nodes/node_conditions.py index de5adcf5..db7da893 100644 --- a/korman/nodes/node_conditions.py +++ b/korman/nodes/node_conditions.py @@ -75,15 +75,30 @@ def draw_buttons(self, context, layout): layout.prop(self, "clickable_object", icon="MESH_DATA") layout.prop(self, "bounds") - def export(self, exporter, parent_bo, parent_so): + def export(self, exporter: Exporter, parent_bo: bpy.types.Object, parent_so: plSceneObject): clickable_bo, clickable_so = self._get_objects(exporter, parent_so) if clickable_bo is None: clickable_bo = parent_bo + # It doesn't make sense to have the same object be used in multiple clickables. + # This can lead to surprising results. For example, if you have multiple logic modifiers + # on a single clickable object, any of them being disabled will cause the hotspot to + # vanish for all of them. The SceneInputInterface code assumes that there's only one + # LogicMod per InterfaceInfo, and MOUL's PhysX LOS probes bail if any LogicMod is disabled. + exporter.claim_object(self, clickable_bo, "clickable") + + # In PlasmaMAX, the InterfaceInfoModifier isn't created until late in the export process. + # It's basically supposed to be a list of any LogicMod keys that might cause the cursor + # to change from the typical "null" cursor. They do this by collecting the receivers of + # any PickingDetectors and any LogicMods attached to each SceneObject. The justification + # for this is that any single SceneObject can have multiple LogicMods attached. That's + # true in theory, but in practice having multiple LogicMods that interact with the cursor + # won't work due to how the LOS probe is filtered. interface = self._find_create_object(plInterfaceInfoModifier, exporter, bl=clickable_bo, so=clickable_so) logicmod = self._find_create_key(plLogicModifier, exporter, bl=clickable_bo, so=clickable_so) interface.addIntfKey(logicmod) - # Matches data seen in Cyan's PRPs... + # Matches data seen in Cyan's PRPs... See above explanation. This could probably be removed + # but leaving it in for now. interface.addIntfKey(logicmod) logicmod = logicmod.object From 284927227b99f733c8f6379fa557aacc25ce1ca8 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sun, 19 Jul 2026 16:39:09 -0500 Subject: [PATCH 2/3] Add SDL Hotspot node. This node allows the artist to turn on and off a clickable's hotspot based on the value of an SDL integer variable. It's used by Cyan to turn the Neighborhood light garden button clickables on and off using an SDL variable. That kind of thing is the best use for this node. Artists shouldn't use this to turn off clickables while a Responder is running. That runs the risk of a clickable getting stuck disabled if someone crashes while the Responder is running. --- korman/nodes/node_conditions.py | 93 +++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/korman/nodes/node_conditions.py b/korman/nodes/node_conditions.py index db7da893..02931552 100644 --- a/korman/nodes/node_conditions.py +++ b/korman/nodes/node_conditions.py @@ -25,6 +25,7 @@ from .node_core import * from .node_deprecated import PlasmaVersionedNode from .. import idprops +from ..ui.ui_list import draw_node_list class PlasmaClickableNode(idprops.IDPropObjectMixin, PlasmaVersionedNode, bpy.types.Node): bl_category = "CONDITIONS" @@ -56,6 +57,10 @@ class PlasmaClickableNode(idprops.IDPropObjectMixin, PlasmaVersionedNode, bpy.ty "text": "Avatar Facing Target", "type": "PlasmaFacingTargetSocket", }, + "sdl_hotspot": { + "text": "Hotspot SDL", + "type": "PlasmaClickableSDLSocket", + }, "message": { "text": "Message", "type": "PlasmaEnableMessageSocket", @@ -576,6 +581,94 @@ def export_once(self): return True + +class ClickableSDLValue(bpy.types.PropertyGroup): + value = IntProperty( + name="SDL Value", + description="Enable the clickable when the SDL Variable is set to this", + default=1, + options=set() + ) + + +class PlasmaClickableSDLNode(PlasmaNodeBase, bpy.types.Node): + bl_category = "CONDITIONS" + bl_idname = "PlasmaClickableSDLNode" + bl_label = "SDL Hotspot" + bl_width_default = 170 + + input_sockets: dict[str, dict[str, Any]] = { + "variable": { + "text": "Dependends on SDL", + "type": "PlasmaSDLTriggererSocket", + }, + } + + output_sockets: dict[str, dict[str, Any]] = { + "satisfies": { + "text": "Satisfies", + "type": "PlasmaClickableSDLSocket", + } + } + + states = CollectionProperty(type=ClickableSDLValue) + + def init(self, context): + self.states.add() + + def draw_buttons(self, context, layout: bpy.types.UILayout): + draw_node_list( + self, + layout, + "states", + self._draw_state, + header="Enable Clickable On", + footer="Add New State" + ) + + def _draw_state(self, state, layout: bpy.types.UILayout): + layout.alert = any((state.value == i.value for i in self.states if i != state)) + layout.prop(state, "value") + + def export(self, exporter: Exporter, bo, so: plSceneObject): + # If the artist has removed all of the state values from the node, don't export anything. + # This will (hopefully) be the least surprising option in this case. Remember: Blender + # property collections don't handle implicit truthiness correctly. + if not bool(self.states): + exporter.report.warn("No 'enabled' states specified for clickable, assuming always enabled") + return + + # This node may be exported multiple times if the node tree is reused in multiple + # Advanced Logic modifiers. Each time this node is re-exported, the PFM we generate + # is attached to the new host SceneObject. If the clickable node in the reused tree + # suddenly exposes a new LogicMod, we'll slurp that new LogicMod in as an activator. + # The "surprise" here is that this PFM will be attached to the host SceneObject, + # not the SceneObject referenced as the clickable object like the rest of the + # clickable logic objects. Whatever. + pfm = self._find_create_object(plPythonFileMod, exporter, so=so) + if not pfm.filename: + variable_node = self.find_input("variable") + if variable_node is None: + self.raise_error("SDL Variable must be linked!") + + pfm.filename = "xAgeSDLIntActEnabler" + self._add_py_parameter(pfm, 1, plPythonParameter.kString, variable_node.variable_name) + # Skipping ID 2 becuase these are the LogicMod keys - cluster them at the end. + self._add_py_parameter( + pfm, 3, plPythonParameter.kString, ",".join(frozenset((str(i.value) for i in self.states))) + ) + + extant_keys = frozenset((i.value for i in pfm.parameters if i.id == 2)) + for clickable_node in self.find_outputs("satisfies"): + logic_key = clickable_node.get_key(exporter, so) + if logic_key not in extant_keys: + self._add_py_parameter(pfm, 2, plPythonParameter.kActivator, logic_key) + + +class PlasmaClickableSDLSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket): + bl_color = (0.58, 0.65, 0.42, 1.0) + + class PlasmaVolumeReportNode(PlasmaNodeBase, bpy.types.Node): bl_category = "CONDITIONS" bl_idname = "PlasmaVolumeReportNode" From a89c3c639d1d4f37afcb3256b073482bab2955dd Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Mon, 20 Jul 2026 15:19:15 -0500 Subject: [PATCH 3/3] Ensure new sockets are spawned in the correct location. --- korman/nodes/node_core.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/korman/nodes/node_core.py b/korman/nodes/node_core.py index ed41c135..b763c47b 100644 --- a/korman/nodes/node_core.py +++ b/korman/nodes/node_core.py @@ -344,13 +344,18 @@ def update(self): def _update_init_sockets(self, defs, sockets): # Create any missing sockets and spawn any required empties. + insert_at = 0 for alias, options in defs.items(): working_sockets = [(i, socket) for i, socket in enumerate(sockets) if socket.alias == alias] num_sockets = options.get("min_sockets", 1) num_used = sum((1 for i, socket in working_sockets if socket.is_linked)) if not working_sockets: for _ in range(num_sockets): - self._spawn_socket(alias, options, sockets) + new_socket_id = len(sockets) + new_socket = self._spawn_socket(alias, options, sockets) + if new_socket_id != insert_at: + sockets.move(new_socket_id, insert_at) + insert_at += 1 else: last_socket_id = working_sockets[-1][0] if options.get("spawn_empty", False):