Files
OpenNoodl/packages/noodl-viewer-react/src/nodes/std-library/states.js
Michael Cartner b9c60b07dc Initial commit
Co-Authored-By: Eric Tuvesson <eric.tuvesson@gmail.com>
Co-Authored-By: mikaeltellhed <2311083+mikaeltellhed@users.noreply.github.com>
Co-Authored-By: kotte <14197736+mrtamagotchi@users.noreply.github.com>
Co-Authored-By: Anders Larsson <64838990+anders-topp@users.noreply.github.com>
Co-Authored-By: Johan  <4934465+joolsus@users.noreply.github.com>
Co-Authored-By: Tore Knudsen <18231882+torekndsn@users.noreply.github.com>
Co-Authored-By: victoratndl <99176179+victoratndl@users.noreply.github.com>
2024-01-26 11:52:55 +01:00

765 lines
25 KiB
JavaScript

'use strict';
const { EdgeTriggeredInput } = require('@noodl/runtime');
const EaseCurves = require('../../easecurves');
const BezierEasing = require('bezier-easing');
const defaultDuration = 300;
const previousStates = {},
previousValues = {};
function setRGBA(result, hex) {
if (hex === 'transparent' || !hex) {
result[3] = 0;
return;
}
const numComponents = (hex.length - 1) / 2;
for (let i = 0; i < numComponents; ++i) {
const index = 1 + i * 2;
result[i] = parseInt(hex.substring(index, index + 2), 16);
}
}
function componentToHex(c) {
var hex = c.toString(16);
return hex.length == 1 ? '0' + hex : hex;
}
function rgbaToHex(rgba) {
return '#' + componentToHex(rgba[0]) + componentToHex(rgba[1]) + componentToHex(rgba[2]) + componentToHex(rgba[3]);
}
const StatesNode = {
name: 'States',
docs: 'https://docs.noodl.net/nodes/utilities/logic/states',
shortDesc: 'Define states with values and this node can interpolate between these values when the state is changed.',
category: 'Animation',
initialize: function () {
var _this = this,
_internal = this._internal;
_internal.useTransitions = true;
_internal.currentValues = {};
_internal.stateParameters = {};
_internal.stateParameterTypes = {};
_internal.startValues = {};
_internal.bezierEaseCurves = {};
_internal.transitionFuncs = {};
_internal.valuesAreInitialised = false;
_internal.animation = this.context.timerScheduler.createTimer({
duration: defaultDuration,
ease: EaseCurves.easeOut,
onStart: function () {
var values = _internal.values;
var startValues = _internal.startValues;
var stateValues = _internal.stateParameters;
var valueTypes = _internal.stateParameterTypes;
var prefix = 'value-' + _internal.state + '-';
this.targetValues = {};
this.startValues = {};
this.valueTypes = {};
for (var v in this.transitionCurves) {
// var v = values[i];
if (valueTypes['type-' + v] === 'number' || valueTypes['type-' + v] === undefined) {
this.valueTypes[v] = 'number';
this.startValues[v] = startValues[v];
this.targetValues[v] = stateValues[prefix + v] || 0;
} else if (valueTypes['type-' + v] === 'color') {
this.valueTypes[v] = 'color';
this.startValues[v] = [0, 0, 0, 255];
setRGBA(this.startValues[v], _this.context.styles.resolveColor(startValues[v] || '#000000'));
this.targetValues[v] = [0, 0, 0, 255];
setRGBA(this.targetValues[v], _this.context.styles.resolveColor(stateValues[prefix + v] || '#000000'));
}
}
},
onRunning: function (t) {
var ms = t * this.duration;
var currentValues = _internal.currentValues;
var rgba2 = [0, 0, 0, 255];
for (var v in this.transitionCurves) {
var c = this.transitionCurves[v];
// var v = values[i];
if (ms < c.delay) currentValues[v] = this.startValues[v];
else if (ms >= c.delay + c.dur)
currentValues[v] = this.valueTypes[v] === 'color' ? rgbaToHex(this.targetValues[v]) : this.targetValues[v];
else {
var _t = _internal.transitionFuncs[v].get((ms - c.delay) / c.dur);
if (this.valueTypes[v] === 'number') {
//convert values to Numers, since they might be strings, which can cause NaN
currentValues[v] = EaseCurves.linear(Number(this.startValues[v]), Number(this.targetValues[v]), _t);
} else if (this.valueTypes[v] === 'color') {
let rgba0 = this.startValues[v];
let rgba1 = this.targetValues[v];
rgba2[0] = Math.floor(EaseCurves.linear(rgba0[0], rgba1[0], _t));
rgba2[1] = Math.floor(EaseCurves.linear(rgba0[1], rgba1[1], _t));
rgba2[2] = Math.floor(EaseCurves.linear(rgba0[2], rgba1[2], _t));
rgba2[3] = Math.floor(EaseCurves.linear(rgba0[3], rgba1[3], _t));
currentValues[v] = rgbaToHex(rgba2);
}
}
_this.flagOutputDirty(v);
}
},
onFinish: function () {
var port = 'reached-' + _internal.state;
if (_this.hasOutput(port)) _this.sendSignalOnOutput(port);
}
});
},
getInspectInfo() {
return `Current state: ${this._internal.state}`;
},
inputs: {
states: {
type: { name: 'stringlist', allowEditOnly: true },
displayName: 'States',
group: 'States',
set: function (value) {
this._internal.states = value ? value.split(',') : [];
// Set the state to first value if no state change is scheduled during
// input updates
if (this._internal.states.length > 0) {
var _this = this;
if (!_this._internal.state) this.scheduleGoToState(_this._internal.startState || _this._internal.states[0]);
/* this.scheduleAfterInputsHaveUpdated(function () {
if (!_this._internal.state) _this.goToState(_this._internal.startState ||_this._internal.states[0]);
});*/
}
}
},
values: {
type: { name: 'stringlist', allowEditOnly: true },
displayName: 'Values',
group: 'Values',
set: function (value) {
var internal = this._internal;
internal.values = value.split(',');
// Register output values at this point
for (var i in internal.values) {
this.registerOutputIfNeeded(internal.values[i]);
}
}
},
toggle: {
group: 'Go to state',
displayName: 'Toggle',
valueChangedToTrue: function () {
var internal = this._internal;
var _this = this;
if (!internal.states) return;
// Figure out which state to toggle to
var idx = internal.states.indexOf(internal.state);
var nextIdx = (idx + 1) % internal.states.length;
// Go to state when all updates have updated
//this._internal.scheduledToGoToState = internal.states[nextIdx];
this.scheduleGoToState(internal.states[nextIdx]);
/* this.scheduleAfterInputsHaveUpdated(function () {
_this.goToState(internal.states[nextIdx]);
});*/
}
},
useTransitions: {
type: 'boolean',
displayName: 'Use Transitions',
group: 'General',
default: true,
set: function (value) {
var internal = this._internal;
internal.useTransitions = value;
}
}
},
outputs: {
currentState: {
type: 'string',
displayName: 'State',
group: 'Current State',
getter: function () {
return this._internal.state;
}
},
stateChanged: {
type: 'signal',
displayName: 'State Changed',
group: 'Current State'
}
},
prototypeExtensions: {
registerOutputIfNeeded: function (name) {
var internal = this._internal;
if (this.hasOutput(name)) return;
this.registerOutput(name, {
getter: function () {
return internal.currentValues[name];
}
});
},
registerInputIfNeeded: function (name) {
var _this = this;
var internal = this._internal;
if (this.hasInput(name)) return;
if (name.indexOf('to-') === 0) {
// This is a go to state signal input
var state = name.substring(3);
this.registerInput(name, {
set: EdgeTriggeredInput.createSetter({
valueChangedToTrue: function () {
//this._internal.scheduledToGoToState = state;
this.scheduleGoToState(state);
//this.scheduleAfterInputsHaveUpdated(function () { _this.goToState(state) });
}
})
});
} else if (name === 'startState') {
// Note: this is kept for backwards compatability, but this port is no longer part of the dynamic ports def
// Other state parameters are stored
this.registerInput(name, {
set: function (value) {
//this._internal.scheduledToGoToState = value;
this._internal.startState = value;
this.scheduleGoToState(value);
// this.scheduleAfterInputsHaveUpdated(function () { _this.goToState(value); });
}
});
} else if (name === 'currentState') {
this.registerInput(name, {
set: this.scheduleGoToState.bind(this)
});
} else if (name.indexOf('type-') === 0) {
this.registerInput(name, {
set: function (value) {
internal.stateParameterTypes[name] = value;
}
});
} else if (name.indexOf('value-') === 0) {
// Other state parameters are stored
var parts = name.split('-');
var state = parts[1];
var valueName = parts[2];
this.registerInput(name, {
set: function (value) {
internal.stateParameters[name] = value;
if (internal.state === state) {
// If we are at the state, update the current value immediately
internal.currentValues[valueName] = value;
_this.flagOutputDirty(valueName);
}
}
});
} else if (name.search(/duration-/g) === 0) {
this.registerInput(name, {
set: function (value) {
internal.stateParameters[name] = value;
}
});
} else if (name.search(/transition/g) === 0) {
var state = name.substring(11);
this.registerInput(name, {
set: function (value) {
internal.stateParameters[name] = value;
/* if (value === 'cubicBezier') {
this.updateCubicBezierFunction(state); //create a default bezier easing curve
}*/
}
});
}
/* else if (name.search(/cubicBezierP[1-2][X-Y]-/g) === 0) {
var state = name.substring(15);
this.registerInput(name, {
set: function (value) {
internal.stateParameters[name] = value;
this.updateCubicBezierFunction(state); //update easing curve
}
});
}*/
},
/* updateCubicBezierFunction: function (state) {
var points = [];
var internal = this._internal;
['1X', '1Y', '2X', '2Y'].forEach(function (p, i) {
var value = internal.stateParameters['cubicBezierP' + p + '-' + state] || 0;
if (i % 2 === 0) {
//X values must equal [0,1]
value = Math.min(1, Math.max(0, value));
}
points.push(value);
});
var cubicBezierEase = BezierEasing(points);
internal.bezierEaseCurves[state] = function (start, end, t) {
return EaseCurves.linear(start, end, cubicBezierEase.get(t));
};
},*/
setCurrentState: function (value) {
this.scheduleGoToState(value);
},
jumpToState: function (state) {
var internal = this._internal;
if (!internal.states) return;
if (!state) state = internal.states[0];
if (internal.state === state) return;
internal.animation.stop();
var prefix = 'value-' + state + '-';
for (var i in internal.values) {
var v = internal.values[i];
internal.currentValues[v] = internal.stateParameters[prefix + v] || 0;
this.flagOutputDirty(v);
}
internal.state = state;
//console.log('currentState dirty:' + state);
this.flagOutputDirty('currentState');
if (internal.valuesAreInitialised) {
// Do not send state changed on first initial state set
//console.log('stateChanged signal', state);
this.sendSignalOnOutput('stateChanged');
}
internal.valuesAreInitialised = true;
this.updateAtStatePorts();
},
scheduleGoToState: function (state) {
var _this = this;
this._internal.goToState = state;
//console.log('set go to state: ' + state)
if (this.hasScheduledGoToState) return;
this.hasScheduledGoToState = true;
this.scheduleAfterInputsHaveUpdated(function () {
//console.log('changing state: ' + _this._internal.goToState)
_this.hasScheduledGoToState = false;
_this.goToState(_this._internal.goToState);
});
},
goToState: function (state) {
var internal = this._internal;
if (!internal.states) return;
if (!state) state = internal.states[0];
if (internal.state === state) return;
//this._internal.scheduledToGoToState = undefined;
if (!internal.valuesAreInitialised) {
// First time go to state is called, jump to the state
this.jumpToState(state);
} else {
// Copy current values as start values
var delay = 0;
var dur = 0;
var transitionCurves = {};
for (var i in internal.values) {
var v = internal.values[i];
internal.startValues[v] = internal.currentValues[v];
const parameterType = internal.stateParameterTypes['type-' + v];
if (parameterType === 'boolean') {
// These types don't transition, just set them
var _b = internal.stateParameters['value-' + state + '-' + v];
internal.currentValues[v] = _b === undefined ? false : !!_b;
this.flagOutputDirty(v);
} else if (parameterType === 'string' || parameterType === 'textStyle') {
// These types don't transition, just set them
internal.currentValues[v] = internal.stateParameters['value-' + state + '-' + v];
this.flagOutputDirty(v);
} else {
// Figure out transition curve
var transitionCurve = internal.stateParameters['transition-' + state + '-' + v];
if (!transitionCurve)
transitionCurve = internal.stateParameters['transitiondef-' + state] || {
curve: [0.0, 0.0, 0.58, 1.0],
dur: 300,
delay: 0
};
if ((transitionCurve.dur === 0 && transitionCurve.delay === 0) || !internal.useTransitions) {
// Simply set the target value
internal.currentValues[v] = internal.stateParameters['value-' + state + '-' + v];
this.flagOutputDirty(v);
} else {
// Calculate total duration and delay
internal.transitionFuncs[v] = BezierEasing(transitionCurve.curve);
transitionCurves[v] = transitionCurve;
delay = Math.min(delay, transitionCurve.delay);
dur = Math.max(dur, transitionCurve.dur + transitionCurve.delay);
}
}
}
// Setup and start animation
//var easeCurveName = internal.stateParameters['transition-' + state] || 'easeOut';
//internal.animation.ease = easeCurveName === 'cubicBezier' ? internal.bezierEaseCurves[state] : EaseCurves[easeCurveName];
//var durationKey = 'duration-' + state;
//internal.animation.duration = internal.stateParameters.hasOwnProperty(durationKey) ? internal.stateParameters[durationKey] : defaultDuration;
if (dur > 0 || delay > 0) {
internal.animation.transitionCurves = transitionCurves;
internal.animation.duration = dur;
internal.animation.delay = delay;
internal.animation.start();
}
internal.state = state;
//console.log('currentState dirty:' + state);
this.flagOutputDirty('currentState');
//console.log('stateChanged signal', state);
this.sendSignalOnOutput('stateChanged');
this.updateAtStatePorts();
if (dur == 0 && delay == 0) {
// Send reached signal if no transition
var port = 'reached-' + internal.state;
if (this.hasOutput(port)) this.sendSignalOnOutput(port);
}
}
},
updateAtStatePorts: function () {
var internal = this._internal;
var states = internal.states;
for (var i in states) {
var s = states[i];
var port = 'at-' + s;
internal.currentValues[port] = internal.state === s;
if (this.hasOutput(port)) this.flagOutputDirty(port);
}
}
}
};
function detectRename(before, after) {
if (!before || !after) return;
if (before.length !== after.length) return; // Must be of same length
var res = {};
for (var i = 0; i < before.length; i++) {
if (after.indexOf(before[i]) === -1) {
if (res.before) return; // Can only be one from before that is missing
res.before = before[i];
}
if (before.indexOf(after[i]) === -1) {
if (res.after) return; // Only one can be missing,otherwise we cannot match
res.after = after[i];
}
}
return res.before && res.after ? res : undefined;
}
function updatePorts(nodeId, parameters, editorConnection) {
var states = parameters.states;
var values = parameters.values;
var ports = [];
// Add value outputs
values = values ? values.split(',') : undefined;
for (var i in values) {
var p = values[i];
ports.push({
type: {
name: parameters['type-' + p] || 'number',
allowConnectionsOnly: true
},
plug: 'output',
group: 'Values',
name: p
});
// Type selector
ports.push({
type: {
name: 'enum',
enums: [
{ label: 'Number', value: 'number' },
{ label: 'String', value: 'string' },
{ label: 'Boolean', value: 'boolean' },
{ label: 'Color', value: 'color' },
{ label: 'Text Style', value: 'textStyle' }
],
allowEditOnly: true
},
default: 'number',
plug: 'input',
group: 'Types',
displayName: p,
name: 'type-' + p
});
}
// Add state value inputs
states = states ? states.split(',') : undefined;
states &&
states.forEach(function (state) {
values &&
values.forEach(function (value) {
ports.push({
plug: 'input',
type: parameters['type-' + value] || 'number',
group: state + ' Values',
name: 'value-' + state + '-' + value,
displayName: value,
editorName: state + '|' + value
});
});
// State transition
if (values && parameters['useTransitions'] !== false) {
ports.push({
plug: 'input',
type: 'curve',
displayName: 'Default',
default: { curve: [0.0, 0.0, 0.58, 1.0], dur: 300, delay: 0 },
group: state + ' Transitions',
name: 'transitiondef-' + state
});
values.forEach(function (value) {
if (
parameters['type-' + value] === undefined ||
parameters['type-' + value] === 'number' ||
parameters['type-' + value] === 'color'
) {
ports.push({
plug: 'input',
type: { name: 'curve' },
default: parameters['transitiondef-' + state] || {
curve: [0.0, 0.0, 0.58, 1.0],
dur: 300,
delay: 0
},
group: state + ' Transitions',
name: 'transition-' + state + '-' + value,
displayName: value,
editorName: 'Transition ' + state + '|' + value
});
}
});
}
/* ports.push({
plug: 'input',
type: {
name: 'enum',
enums: [
{ value: 'easeOut', label: "Ease Out" },
{ value: 'easeIn', label: "Ease In" },
{ value: 'linear', label: "Linear" },
{ value: 'easeInOut', label: "Ease In Out" },
{ value: 'cubicBezier', label: 'Cubic Bezier' }
]
},
default: 'easeOut',
displayName: "Easing Curve",
group: state + ' Transition',
name: 'transition-' + state,
});
//add cubic bezier inputs if transition is set to cubic
if (parameters['transition-' + state] === 'cubicBezier') {
ports = ports.concat([
{
name: 'cubicBezierP1X-' + state,
editorName: state + '|' + 'P1 X',
displayName: 'P1 X',
group: state + ' Transition',
plug: 'input',
type: 'number',
default: 0
},
{
name: 'cubicBezierP1Y-' + state,
editorName: state + '|' + 'P1 Y',
displayName: 'P1 Y',
group: state + ' Transition',
plug: 'input',
type: 'number',
default: 0
},
{
name: 'cubicBezierP2X-' + state,
editorName: state + '|' + 'P2 X',
displayName: 'P2 X',
group: state + ' Transition',
plug: 'input',
type: 'number',
default: 0
},
{
name: 'cubicBezierP2Y-' + state,
editorName: state + '|' + 'P2 Y',
displayName: 'P2 Y',
group: state + ' Transition',
plug: 'input',
type: 'number',
default: 0
}
]);
}
ports.push({
plug: 'input',
type: 'number',
default: defaultDuration,
displayName: "Duration",
group: state + ' Transition',
name: 'duration-' + state,
});*/
// Go to state port
ports.push({
plug: 'input',
type: { name: 'signal', allowConnectionsOnly: true },
displayName: 'To ' + state,
name: 'to-' + state,
group: 'Go to state'
});
// At state output
ports.push({
plug: 'output',
type: 'boolean',
displayName: 'At ' + state,
name: 'at-' + state,
group: 'Current state'
});
// Has reached state output
ports.push({
plug: 'output',
type: 'signal',
displayName: 'Has Reached ' + state,
name: 'reached-' + state,
group: 'Current state'
});
});
// Add current state port
if (states) {
ports.push({
plug: 'input',
type: { name: 'enum', enums: states },
group: 'States',
displayName: 'State',
name: 'currentState',
default: parameters['startState'] || states[0] // This is kept for backwards compatability, the startState port does no longer exist
});
}
// Detect state and value rename
var stateRenamed = detectRename(previousStates[nodeId], states);
previousStates[nodeId] = states;
var valueRenamed = detectRename(previousValues[nodeId], values);
previousValues[nodeId] = values;
let renamed;
if (stateRenamed) {
renamed = {
plug: 'input',
before: stateRenamed.before,
after: stateRenamed.after,
patterns: [
'transition-{{*}}',
/* 'duration-{{*}}',
'cubicBezierP1X-{{*}}',
'cubicBezierP2X-{{*}}',
'cubicBezierP1Y-{{*}}',
'cubicBezierP2Y-{{*}}',*/
'to-{{*}}',
'at-{{*}}',
'reached-{{*}}'
]
};
// A state has been renamed
values &&
values.forEach(function (value) {
renamed.patterns.push('value-{{*}}-' + value);
});
} else if (valueRenamed) {
renamed = [
{
plug: 'output',
before: valueRenamed.before,
after: valueRenamed.after,
patterns: ['{{*}}']
},
{
plug: 'input',
before: valueRenamed.before,
after: valueRenamed.after,
patterns: ['type-{{*}}']
},
{
plug: 'input',
before: valueRenamed.before,
after: valueRenamed.after,
patterns: states
? states.map(function (s) {
return 'value-' + s + '-' + '{{*}}';
})
: undefined
}
];
}
editorConnection.sendDynamicPorts(nodeId, ports, { renamed: renamed });
}
module.exports = {
node: StatesNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
graphModel.on('nodeAdded.States', function (node) {
if (node.parameters.states) {
updatePorts(node.id, node.parameters, context.editorConnection);
}
node.on('parameterUpdated', function (event) {
if (
event.name === 'useTransitions' ||
event.name === 'states' ||
event.name === 'values' ||
event.name.startsWith('transition') ||
event.name.startsWith('type-')
) {
updatePorts(node.id, node.parameters, context.editorConnection);
}
});
});
}
};