UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

697 lines (540 loc) • 18.6 kB
import { assert } from "../../assert.js"; import { array_push_if_unique } from "../../collection/array/array_push_if_unique.js"; import { array_remove_first } from "../../collection/array/array_remove_first.js"; import List from "../../collection/list/List.js"; import IdPool from "../../IdPool.js"; import { Connection } from "./Connection.js"; import { NodeInstance } from "./node/NodeInstance.js"; import { PortDirection } from "./node/PortDirection.js"; /** * Graph of nodes, implements node-based programming pattern * Notable contemporary examples include: * * SideFX Houdini * * Blender - geometry nodes, shaders * * Unreal Engine - blueprints * * Grasshopper * @see https://en.wikipedia.org/wiki/Node_graph_architecture */ export class NodeGraph { /** * @private * @readonly * @type {List<NodeInstance>} */ nodes = new List(); /** * Flat hierarchy of all connections between nodes * @private * @readonly * @type {List<Connection>} */ connections = new List(); /** * * @type {IdPool} * @readonly * @private */ __idpNodes = new IdPool(); /** * * @type {IdPool} * @readonly * @private */ __idpConnections = new IdPool(); /** * Gets incremented every time structure of the graph changes * @type {number} */ #version = 0; /** * Gets incremented every time structure of the graph changes, meaning nodes or connections are added/removed * Unsigned integer value * @readonly * @return {number} */ get version() { return this.#version; } /** * @readonly */ on = { /** * @readonly * @type {Signal<NodeInstance,number>} */ nodeAdded: this.nodes.on.added, /** * @readonly * @type {Signal<NodeInstance,number>} */ nodeRemoved: this.nodes.on.removed, /** * @readonly * @type {Signal<Connection,number>} */ connectionAdded: this.connections.on.added, /** * @readonly * @type {Signal<Connection,number>} */ connectionRemoved: this.connections.on.removed }; /** * Clear out all data from the graph */ reset() { this.nodes.reset(); this.connections.reset(); this.__idpNodes.reset(); this.__idpConnections.reset(); this.#version++; } /** * Perform a deep copy * @param {NodeGraph} other */ copy(other) { if (this === other) { // pointless operation return; } this.reset(); this.merge(other); } /** * * @returns {NodeGraph} */ clone() { const r = new NodeGraph(); r.copy(this); return r; } /** * Merge another graph into this one * Supplied graph does not change as a result * @see {@link #mergeFragment} * @param {NodeGraph} other * @returns {{connections:Connection[], nodes:NodeInstance[]}} */ merge(other) { assert.defined(other, 'other'); assert.notNull(other, 'other'); if (other === this) { // can't merge with self, invalid operation throw new Error("Can't merge with self, invalid operation"); } return this.mergeFragment({ nodes: other.nodes.asArray(), connections: other.connections.asArray() }); } /** * Merge foreign nodes and associated connections into this graph * New node instances and connections will be created to reflect these inside this graph * NOTE: parameters on merged nodes are shallow copies * NOTE: if IDs are available - copied nodes will have the same IDs as the originals * @param {NodeInstance[]} nodes * @param {Connection[]} [connections] * @returns {{connections:Connection[], nodes:NodeInstance[]}} local created instances */ mergeFragment({ nodes, connections = [] }) { assert.defined(nodes, 'nodes'); assert.notNull(nodes, 'nodes'); assert.isArray(nodes, 'nodes'); assert.defined(connections, 'connections'); assert.notNull(connections, 'connections'); assert.isArray(connections, 'connections'); const previous_node_count = this.nodes.length; const previous_connection_count = this.connections.length; const additional_node_count = nodes.length; /** * Mapping from original IDs to IDs in this graph * @type {Object<number,number>} */ const this_nodes = {}; for (let i = 0; i < additional_node_count; i++) { const other_node = nodes[i]; const other_node_id = other_node.id; const this_node = new NodeInstance(); this_node.setDescription(other_node.description); // attempt to gain the same ID const can_use_same_id = !this.__idpNodes.isUsed(other_node_id); let this_node_id; if (can_use_same_id) { this_node_id = other_node_id; } else { this_node_id = this.__idpNodes.peek(); } this_node.id = this_node_id; this.addNode(this_node); this_nodes[other_node_id] = this_node_id; // copy parameters this_node.setParameters(other_node.parameters); } // create connections const additional_connection_count = connections.length; for (let i = 0; i < additional_connection_count; i++) { const other_connection = connections[i]; const other_source = other_connection.source; const this_source_node_id = this_nodes[other_source.instance.id]; const this_source_port_id = other_source.port.id; const other_target = other_connection.target; const this_target_node_id = this_nodes[other_target.instance.id]; const this_target_port_id = other_target.port.id; this.createConnection( this_source_node_id, this_source_port_id, this_target_node_id, this_target_port_id ); } return { connections: this.connections.asArray().slice(previous_connection_count, previous_connection_count + additional_connection_count), nodes: this.nodes.asArray().slice(previous_node_count, previous_node_count + additional_node_count) }; } /** * * @param {function(NodeInstance):*} visitor * @param [thisArg] */ traverseNodes(visitor, thisArg) { this.nodes.forEach(visitor, thisArg); } /** * * @param {function(Connection):*} visitor * @param [thisArg] */ traverseConnections(visitor, thisArg) { this.connections.forEach(visitor, thisArg); } /** * Returns an array of all node instances * NOTE: this array is a copy * @return {NodeInstance[]} */ getNodes() { return this.nodes.asArray().slice(); } /** * Returns an array of all connections * NOTE: this array is a copy * @return {Connection[]} */ getConnections() { return this.connections.asArray().slice(); } /** * * @param {NodeInstance} node * @returns {boolean} */ hasNode(node) { const existing_node = this.getNode(node.id); if (existing_node === undefined) { return false; } if (existing_node !== node) { // another node with this ID was found return false; } return true; } /** * * @param {NodeDescription} description * @returns {NodeInstance[]} */ getNodesByDescription(description) { assert.defined(description, 'description'); assert.notNull(description, 'description'); assert.equal(description.isNodeDescription, true, 'description.isNodeDescription !== true'); const result = []; const nodes = this.nodes; const n = nodes.length; for (let i = 0; i < n; i++) { const node = nodes.get(i); if (node.description === description) { result.push(node); } } return result; } /** * * @param {Type<NodeDescription>} Klass * @returns {NodeInstance[]} */ getNodesByDescriptionClass(Klass) { assert.defined(Klass, 'Klass'); assert.notNull(Klass, 'Klass'); const result = []; const nodes = this.nodes; const n = nodes.length; for (let i = 0; i < n; i++) { const node = nodes.get(i); if (node.description.constructor === Klass) { result.push(node); } } return result; } /** * * @param {number} id * @returns {NodeInstance|undefined} */ getNode(id) { assert.isNonNegativeInteger(id, 'id'); const nodes = this.nodes; const n = nodes.length; for (let i = 0; i < n; i++) { const node = nodes.get(i); if (node.id === id) { return node; } } // node not found, return undefined return undefined; } /** * Same as getNode but throw exception when node doesn't exist * @param {number} id * @returns {NodeInstance} * @throws if node doesn't exist */ getNodeSafe(id) { const result = this.getNode(id); if (result === undefined) { throw new Error(`Node ${id} not found`); } return result; } /** * * @param {number} id * @returns {Connection|undefined} */ getConnection(id) { const connections = this.connections; const n = connections.length; for (let i = 0; i < n; i++) { const connection = connections.get(i); if (connection.id === id) { return connection; } } // nothing found, undefined will be returned return undefined; } /** * * @param {number} node_id * @param {number} port_id * @returns {NodeInstancePortReference|undefined} */ getConnectionEndpoint(node_id, port_id) { const nodeInstance = this.getNode(node_id); if (nodeInstance === undefined) { // no node return undefined; } return nodeInstance.getEndpoint(port_id); } /** * * @param {NodeDescription} node * @returns {number} ID of the new node */ createNode(node) { const nodeInstance = new NodeInstance(); const id = this.__idpNodes.peek(); nodeInstance.id = id; nodeInstance.setDescription(node); //record the node this.addNode(nodeInstance); return id; } /** * * @param {NodeInstance} node */ addNode(node) { assert.defined(node, 'node'); assert.notNull(node, 'node'); assert.equal(node.isNodeInstance, true, 'node.isNodeInstance !== true'); const id_obtained = this.__idpNodes.getSpecific(node.id); if (id_obtained === false) { throw new Error(`Node with id '${node.id}' already exists`); } //record the node this.nodes.add(node); this.#version++; } /** * * @param {number} id * @returns {boolean} True if deleted, false if node was not found */ deleteNode(id) { const instance = this.getNode(id); if (instance === undefined) { //not found return false; } //find attached connections const deadConnections = []; this.getConnectionsAttachedToNode(id, deadConnections); //remove connections for (const deadConnection of deadConnections) { this.deleteConnection(deadConnection); } //delete the node this.nodes.removeOneOf(instance); //release id this.__idpNodes.release(id); this.#version++; return true; } /** * Utility method to help in creation of connections * Same as {@link #createConnection}, but ports are identified by their named instead * @param {number} sourceNode * @param {string} sourcePort * @param {number} targetNode * @param {string} targetPort * @returns {number} connection ID */ createConnectionByPortName( sourceNode, sourcePort, targetNode, targetPort ) { const source_node_instance = this.getNodeSafe(sourceNode); const target_node_instance = this.getNodeSafe(targetNode); const source_ports = source_node_instance.description.getPortsByName(sourcePort).filter(p => p.direction === PortDirection.Out); if (source_ports.length > 1) { throw new Error(`Multiple source ports match name '${sourcePort}'`); } const source_port_object = source_ports[0]; const target_ports = target_node_instance.description.getPortsByName(targetPort).filter(p => p.direction === PortDirection.In); if (target_ports.length > 1) { throw new Error(`Multiple target ports match name '${targetPort}'`); } const target_port_object = target_ports[0]; if (source_port_object === undefined) { throw new Error(`Source port '${sourcePort}' not found`); } if (target_port_object === undefined) { throw new Error(`Target port '${targetPort}' not found`); } return this.createConnection( sourceNode, source_port_object.id, targetNode, target_port_object.id, ); } /** * * @param {number} sourceNode * @param {number} sourcePort * @param {number} targetNode * @param {number} targetPort * @returns {number} ID of created or already existing connection * @throws if any node or port are not found */ createConnection(sourceNode, sourcePort, targetNode, targetPort) { assert.isNonNegativeInteger(sourceNode, 'sourceNode'); assert.isNonNegativeInteger(sourcePort, 'sourcePort'); assert.isNonNegativeInteger(targetNode, 'targetNode'); assert.isNonNegativeInteger(targetPort, 'targetPort'); const sourceNodeInstance = this.getNode(sourceNode); if (sourceNodeInstance === undefined) { throw new Error(`Source node '${sourceNode}' not found`); } const targetNodeInstance = this.getNode(targetNode); if (targetNodeInstance === undefined) { throw new Error(`Target node '${targetNode}' not found`); } //get endpoints const sourceEndpoint = sourceNodeInstance.getEndpoint(sourcePort); if (sourceEndpoint === undefined) { throw new Error(`Source port '${sourcePort}' not found on ${sourceNodeInstance}`); } const targetEndpoint = targetNodeInstance.getEndpoint(targetPort); if (targetEndpoint === undefined) { throw new Error(`Target port '${targetPort}' not found on ${targetNodeInstance}`); } //create connection const connection = new Connection(); connection.setSource(sourceEndpoint); connection.setTarget(targetEndpoint); const id = this.__idpConnections.get(); connection.id = id; this.connections.add(connection); // add connection links to the nodes sourceNodeInstance.connections.addUnique(connection); targetNodeInstance.connections.addUnique(connection); array_push_if_unique(sourceEndpoint.connections, connection); array_push_if_unique(targetEndpoint.connections, connection); this.#version++; return id; } /** * * @param {number} id * @returns {boolean} True if deleted, false if connection was not found */ deleteConnection(id) { const connection = this.getConnection(id); if (connection === undefined) { return false; } this.connections.removeOneOf(connection); // remove from end-point nodes const sourceEndpoint = connection.source; const targetEndpoint = connection.target; sourceEndpoint.instance.connections.removeOneOf(connection); targetEndpoint.instance.connections.removeOneOf(connection); array_remove_first(sourceEndpoint.connections, connection); array_remove_first(targetEndpoint.connections, connection); this.#version++; return true; } /** * * @param {number} id * @param {number[]} result IDs of attached connections * @returns {number} number of found connections */ getConnectionsAttachedToNode(id, result) { assert.isNonNegativeInteger(id, 'id'); assert.defined(result, 'result'); assert.isArray(result, 'result'); let count = 0; const connections = this.connections; const connection_count = connections.length; for (let i = 0; i < connection_count; i++) { const connection = connections.get(i); if (connection.isAttachedToNode(id)) { result[count] = connection.id; count++; } } return count; } } /** * Useful for type checks * @example * if(graph.isNodeGraph === true){ * // yep, that's a NodeGraph alright! * } * @readonly * @type {boolean} */ NodeGraph.prototype.isNodeGraph = true;