Skip to content
Moritz Brückner edited this page Oct 14, 2023 · 15 revisions

Logic Node Development

This page shows how to create your own custom logic nodes from scratch in a node package. A similar approach is used to edit or add new nodes to Armory itself. In this case, don't create a library as described on this page and please use the following paths for node sources instead:

Browsing Armory's node sources is a good reference point for creating new logic nodes! The same applies for Armory's material nodes (Python/GLSL), the sources can be found here.

There also exists an example project for creating logic node libraries.

Table of Content:

Anatomy of a logic node

Each logic node consists of two parts:

  1. A Python class that describes the node's UI and functionality in Blender itself. Here you define the header (title) of the node, it's category (where it is found in the Blender menu) and all of its attributes like input/output sockets and various properties the user can set in the node UI. If you add properties called property0 - property9 to the node, those properties are accessible during the node's execution while running the game.

    For bigger libraries it is recommended to put each class in a different Python file and create Python packages if the library consists of multiple node categories. Each logic node Python file name should start with with the prefix LN_ and each bl_idname attribute of a logic node must start with LN (without an underscore). The rest of the bl_idname attribute must be the same as the class name used in the Haxe part of the node.

    Helpful links:

  2. A Haxe file that describes the node's functionality in the game. When exporting the game, all logic nodes in a node tree are parsed into a Haxe script that executes the individual nodes (source). The only code that is included in the game is the Haxe code, there is no Python code used during execution.

    The Haxe file of a logic node consists of a class (with the same name as in bl_idname without the LN prefix), so each logic node Haxe file name must be named the same as the class. If there are properties in the Python code named property0 - property9, you must add public attributes in the class for them. You may add more attributes with other names as you want.

    Helpful links:

Create a library

We will make a new library to store the sources of custom logic nodes and keep them portable with no modifications to engine sources.

Locate your blend file and create a new Libraries folder alongside it. Navigate to the Libraries folder and create a new mynodes folder in it to place your new node.


Python

Next, we will create the logic node definition for Blender.

To do so, we have to create a file named blender.py in Libraries/mynodes folder. Armory automatically picks this file up once the library is loaded.

Define a simple node with single in/out socket like the one in the example below. This is the content of blender.py:

from bpy.types import Node

from arm.logicnode.arm_nodes import *
import arm.nodes_logic


# Extend from ArmLogicTreeNode so that the node is recognized as a logic node
class TestNode(ArmLogicTreeNode):
    """Here you can write a detailed description of the node.
    You can even use Markdown syntax here.

    If the node is not part of a custom node library but part of Armory's standard nodes,
    this docstring comment is displayed in the node reference manual.

    There are special "@" attributes to highlight certain node properties in the reference:

    @input Input Socket Name: Description of the input socket with the name "Input Socket Name"
    @output Output Socket Name: Description of the output socket with the name "Output Socket Name"
    @option Option Name: Description of a node option/setting with the name "Option Name"
    @see Link or reference to additional resources about this node
    @seeNode Node Label (automatically links to the node with the given node label)

    You can use multiple "@" attributes for the same attribute type, e.g. multiple "@input"s for multiple sockets.
    """
    bl_idname = 'LNTestNode'

    # The node's label that's displayed in the node header and in the "Add Node" menu
    bl_label = 'Test'

    # The tooltip of node node in the "Add Node" menu.
    # If `bl_description` does not exist, the docstring of this node (see above) is automatically used instead.
    # If the docstring is long and detailed, it might be useful to manually set a shorter description here.
    bl_description = 'Short description of this node'

    # The category in which this node is listed in the user interface
    arm_category = 'Custom Nodes'

    # Set the version of this node. If you update the node's Python
    # code later, increment this version so that older projects get
    # updated automatically.
    # See https://github.com/armory3d/armory/wiki/logicnodes#node-versioning
    arm_version = 1

    def init(self, context):
        self.add_input('ArmNodeSocketAction', 'In')
        self.add_output('ArmNodeSocketAction', 'Out')


def register():
    """This function is called when Armory loads this library."""

    # Add a new category of nodes in which we will put the TestNode.
    # This step is optional, you can also add nodes to Armory's default
    # categories.
    add_category('Custom Nodes', icon='EVENT_C')

    # Register the TestNode
    TestNode.on_register()

Restarting Blender and loading the project again, the new logic node is available for placement.


Python API

Armory provides a small API defined in arm_nodes.py to ease working with logic nodes.

Methods for ArmLogicTreeNode

  • Adding input/output sockets:

    def add_input(self, socket_type: str, socket_name: str, default_value: Any = None, is_var: bool = False) -> bpy.types.NodeSocket:
    def add_output(self, socket_type: str, socket_name: str, default_value: Any = None, is_var: bool = False) -> bpy.types.NodeSocket:

    Small wrapper methods around self.inputs.new() and self.outputs.new().

    If a default_value is given, the socket will use this value if it has no connection. If is_var is set to True, the socket will have a small dot in the middle to show that this socket can be used for accessing a variable.

    Additional available socket types can be found in arm_sockets.py. Libraries can also define their own socket types.

  • Node versioning

    def get_replacement_node(self, node_tree: bpy.types.NodeTree) -> arm.logicnode.arm_nodes.NodeReplacement:

    See Node versioning.

Static methods

There are a bunch of static methods that allow you to register nodes and create node categories. For a in-depth overview, please look at arm_nodes.py. On this page, only some often-used methods are documented.


  • def add_node(node_type: Type[bpy.types.Node], category: str, section: str = 'default', is_obsolete: bool = False) -> None:

    Registers a logic node so that it is displayed in the add node menu.

    • node_type: The class of the node (see example code in the Python section)
    • category: The category this node belongs in (see example code in the Python section). If the category does not exist yet, it is created. If you pass PKG_AS_CATEGORY (defined in arm_nodes.py), the capitalized name of the Python package the node definition file is in is used as the category name. When you later rename a category, you don't have to change all calls to add_node when using this constant.
    • section (optional): Add this node into a sub-section of nodes in that given category. Node sections are visually grouped together in the menu. If the section does not exist yet, it is created.
    • is_obsolete (optional): Todo

  • def add_category(category: str, section: str = 'default', icon: str = 'BLANK1', description: str = '') -> Optional[ArmNodeCategory]:

    Adds a category of nodes to the node menu and returns the ArmNodeCategory object if the category didn't exist yet.

    • category: The name of the category
    • section (optional): Just like node sections explained above, categories can also be grouped into visually separated sections. If the section does not exist yet, it is created.
    • icon (optional): Blender icon constant to give each node in this category a icon. The icon is also displayed in the node menu.
    • description (optional): Description of this category. This value is currently unused but might be used in the future to display tooltips.

  • def add_node_section(name: str, category: str) -> None:
    Adds a section of nodes to the sub menu of the given category to group multiple nodes visually together. The given name only acts as an ID and is not displayed in the user inferface.

  • def add_category_section(name: str) -> None:
    Adds a section of categories to the node menu to group multiple categories visually together. The given name only acts as an ID and is not displayed in the user inferface.

Node versioning

Armory provides you with a node replacement system that updates all nodes when opening old files in a newer SDK version. If you change the functionality of a node, you should implement an update procedure so that old nodes can be updated, even if the old node is compatible with the new node. Without an update routine, the UI of the node is not updated. You can trigger updates manually by typing Replace nodes into Blender's node operator search menu (F3).


To update a node, increment the arm_version attribute of the node and override the following method:

def get_replacement_node(self, node_tree: bpy.types.NodeTree) -> Union[NodeReplacement, ArmLogicTreeNode, list]:

It defines the action to be taken with the old node (self). There are three allowed return types:

  1. If a NodeReplacement object is returned, the node is updated according to the information stored in the NodeReplacement object. It describes which sockets and properties are replaced with other sockets/properties and what their new default values are. For a detailed explanation, please have a look at the source docstring.

  2. If a ArmLogicTreeNode object is returned, the current node is replaced with the returned node. However, you must do all the update handling yourself (e.g. setting all connections between the new node and other nodes). This can be useful when working with nodes which support varying numbers of inputs or outputs.

  3. If a list of ArmLogicTreeNode objects is returned, the same as above applies but for multiple new nodes.

You might also raise exceptions if the update failed for whatever reasons.

A detailed, more technical explanation can be found in the replacement system implementation here.


Haxe

Before the project can be run, we need to implement the actual node logic in Haxe.

Start by creating the folder structure Sources/armory/logicnode/ in the same folder of blender.py.

Next, create a TestNode.hx file inside the logicnode folder just created, and place the code from below in the file.

When the node gets executed, we let it print a 'Hello, World!' string.

package armory.logicnode;

class TestNode extends LogicNode {

    public function new(tree:LogicTree) {
        super(tree);
    }

    override function run(from: Int) {
        // Logic for this node
        trace("Hello, World!");

        // Execute next action linked to this node, this activates the output socket at position/index 0
        runOutput(0);
    }
}

A subclass of armory.logicnode.Logicnode may override the following functions:

  • run(from: Int): Void: Called when the logic node is activated by an impulse input socket. from contains the index of the activated socket. To activate an impulse output socket, call runOutput(i) where i is the index of the output socket you want to activate.
  • get(from: Int): Dynamic: Called when another node requests the value of a non-impulse (data) output socket. from contains the index of the requested socket. To retrieve the value of an input socket, call inputs[0].get(i) where i is the index of the input socket.

Impulse type sockets (red in Blender's UI) and boolean type sockets (yellow) are not the same thing! Impulses manage the execution flow of the tree whereas boolean sockets hold boolean (true/false) data.

Logic trees (armory.logicnode.LogicTree) are subclasses of iron.Trait, so you are able to use all trait related methods on them as well. You can access a node's tree with this.tree.

Clone this wiki locally