@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
697 lines (540 loc) • 18.6 kB
JavaScript
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;