/* eslint-disable max-classes-per-file */
import Ayva from '../ayva.js';
import GeneratorBehavior from './generator-behavior.js';
import { has, validNumber } from '../util/util.js';
import StrokeParameterProvider from '../util/stroke-parameter-provider.js';
/**
* So named for its timelessness. The OG stroke. Simple up and down movement with some (optional) variation on a few parameters
* such as speed, positions, shape, and twist.
*
* A classic stroke consists of a single function action that computes and inserts a move to either the top or the bottom of the stroke based on
* where the device is currently located, and what the most recent movement along the stroke axis was.
*
* See the [Classic Stroke Tutorial]{@link https://ayvajs.github.io/ayvajs-docs/tutorial-behavior-api-classic-stroke.html}.
*/
class ClassicStroke extends GeneratorBehavior {
#top;
#bottom;
#speed;
#duration;
#config;
get speed () {
return this.#speed;
}
get top () {
return this.#top;
}
get bottom () {
return this.#bottom;
}
get duration () {
return this.#duration;
}
static get DEFAULT_CONFIG () {
return {
top: 1,
bottom: 0,
speed: 1,
shape: Ayva.RAMP_COS,
relativeSpeeds: [1, 1],
suck: null,
twist: null,
pitch: null,
};
}
/**
* Create a new ClassicStroke.
*
* @example
* // Bounce stroke.
* ayva.do(new ClassicStroke(0, 1, 1, [ Ayva.RAMP_NEGATIVE_PARABOLIC, Ayva.RAMP_PARABOLIC ]));
*
* @param {Number|Array|Function} bottom - bottom of the stroke, array of bottoms, or a function that computes the bottom for each down stroke
* @param {Number|Array|Function} top - top of the stroke, array of tops, or a function that computes the top for each up stroke
* @param {Number|Array|Function} speed - speed of the stroke, array of speeds, or a function that computes the speed for each stroke
* @param {Function|Array} shape - a value provider for the shape or an even-lengthed array of value providers
*//**
* Create a new ClassicStroke.
*
* @example
* ayva.do(new ClassicStroke({
* bottom: 0,
* top: 1,
* speed: 1,
* }));
*
* @param {Object} config - stroke configuration
*/
constructor (bottom = 0, top = 1, speed = 1, shape = Ayva.RAMP_COS) {
super();
let config;
if (typeof bottom === 'object' && !(bottom instanceof Array)) {
config = bottom;
} else {
config = {
top,
bottom,
speed,
shape,
};
}
this.#init(config);
}
* generate (ayva) {
const { value, lastValue } = ayva.$.stroke;
const {
target, shape, direction, relativeSpeed,
} = this.#getTargetShape(value, lastValue);
const speed = this.#speed * relativeSpeed;
const strokeMove = {
to: target,
value: shape,
};
if (this.#config.speed !== undefined) {
strokeMove.speed = speed;
this.#speed = this.#config.speed.next();
} else {
strokeMove.duration = this.#duration / relativeSpeed;
this.#duration = this.#config.duration.next();
}
const moves = [strokeMove];
if (this.#config.twist) {
moves.push(this.#computeAxisMove('twist', {
direction,
value,
target,
ayva,
speed: this.#config.speed !== undefined ? speed : Math.abs(target - value) / strokeMove.duration,
}));
}
if (this.#config.pitch) {
moves.push(this.#computeAxisMove('pitch', {
direction,
value,
target,
ayva,
speed: this.#config.speed !== undefined ? speed : Math.abs(target - value) / strokeMove.duration,
}));
}
if (validNumber(this.#config.suck, 0, 1)) {
ayva.$.suck.value = this.#config.suck;
}
yield moves;
}
#init (config) {
const defaultConfig = ClassicStroke.DEFAULT_CONFIG;
if (has(config, 'duration')) {
delete defaultConfig.speed;
}
const strokeConfig = {
...defaultConfig,
...config,
};
this.#validate(strokeConfig);
strokeConfig.top = StrokeParameterProvider.createFrom(strokeConfig.top);
strokeConfig.bottom = StrokeParameterProvider.createFrom(strokeConfig.bottom);
strokeConfig.relativeSpeeds = StrokeParameterProvider.createFrom(strokeConfig.relativeSpeeds);
if (has(strokeConfig, 'duration')) {
strokeConfig.duration = StrokeParameterProvider.createFrom(strokeConfig.duration);
} else {
strokeConfig.speed = StrokeParameterProvider.createFrom(strokeConfig.speed);
}
const { shape } = strokeConfig;
if (shape instanceof Array) {
strokeConfig.shape = new StrokeParameterProvider((index) => shape[index % shape.length]);
} else {
strokeConfig.shape = new StrokeParameterProvider(() => shape);
}
this.#config = strokeConfig;
// Compute initial stroke values.
this.#top = this.#config.top.next();
this.#bottom = this.#config.bottom.next();
if (this.#config.speed !== undefined) {
this.#speed = this.#config.speed.next();
} else {
this.#duration = this.#config.duration.next();
}
}
#computeAxisMove (axis, { direction, value, target, speed, ayva }) { // eslint-disable-line object-curly-newline
const { frequency } = ayva;
const phase = (direction === 'up' ? 0 : 2) + (this.#config[axis].phase || 0);
const ecc = this.#config[axis].ecc || 0;
const distance = Math.abs(value - target);
const bpm = (speed * 60) / (2 * distance);
const motion = Ayva.tempestMotion(this.#config[axis].from, this.#config[axis].to, phase, ecc, bpm);
const expectedValue = motion({ index: -1, frequency });
if (Math.abs(expectedValue - ayva.$[axis].value) > 0.05) {
// I'm starting off axis. Just do a smooth move to the next position rather than jerking back.
const nextMotion = Ayva.tempestMotion(this.#config[axis].from, this.#config[axis].to, phase + 2, ecc, bpm);
const targetValue = nextMotion({ index: -1, frequency });
return {
axis,
to: targetValue,
value: Ayva.RAMP_COS,
};
}
return {
axis,
value: motion,
};
}
/**
* Decide where to stroke next based on the current position.
* Applies some common sense so there are smoother transitions between patterns.
*/
#getTargetShape (currentValue, lastValue) {
const lastStrokeWasUp = (currentValue - lastValue) >= 0;
const nextShapeDirection = this.#config.shape.index % 2 === 0 ? 'up' : 'down';
const nextRelativeSpeedDirection = this.#config.relativeSpeeds.index % 2 === 0 ? 'up' : 'down';
let target;
let direction;
if (currentValue <= this.#bottom || (currentValue < this.#top && !lastStrokeWasUp)) {
direction = 'up';
target = this.#top;
this.#top = this.#config.top.next();
if (nextShapeDirection === 'down') {
this.#config.shape.next(); // Skip to the next up shape.
}
if (nextRelativeSpeedDirection === 'down') {
this.#config.relativeSpeeds.next(); // Skip to the next up speed
}
} else {
direction = 'down';
target = this.#bottom;
this.#bottom = this.#config.bottom.next();
if (nextShapeDirection === 'up') {
this.#config.shape.next(); // Skip to the next down shape.
}
if (nextRelativeSpeedDirection === 'up') {
this.#config.relativeSpeeds.next(); // Skip to the next down speed.
}
}
return {
target, shape: this.#config.shape.next(), direction, relativeSpeed: this.#config.relativeSpeeds.next(),
};
}
// TODO: We really need to standardize / generalize validation... :(
#validate (config) {
const fail = (parameter, value) => {
throw new Error(`Invalid stroke ${parameter}: ${value}`);
};
if (!validNumber(config.bottom, 0, 1) && typeof config.bottom !== 'function' && !(config.bottom instanceof Array)) {
fail('bottom', config.bottom);
}
if (!validNumber(config.top, 0, 1) && typeof config.top !== 'function' && !(config.top instanceof Array)) {
fail('top', config.top);
}
if (config.bottom === config.top) {
throw new Error(`Invalid stroke range specified: (${config.bottom}, ${config.top})`);
}
if (has(config, 'speed') && has(config, 'duration')) {
throw new Error('Cannot specify both a speed and duration');
}
if (has(config, 'speed')
&& (!validNumber(config.speed) || config.speed <= 0)
&& typeof config.speed !== 'function' && !(config.speed instanceof Array)) {
fail('speed', config.speed);
}
if (has(config, 'duration') && (!validNumber(config.duration) || config.duration <= 0)
&& typeof config.duration !== 'function'
&& !(config.duration instanceof Array)) {
fail('duration', config.duration);
}
if (typeof config.shape !== 'function' && !(config.shape instanceof Array)) {
fail('shape', config.shape);
}
if (has(config, 'relativeSpeeds') && !(config.relativeSpeeds instanceof Array)) {
fail('relative speeds', config.relativeSpeeds);
}
const otherAxes = ['twist', 'pitch'];
otherAxes.forEach((axis) => {
if (typeof config[axis] !== 'object') {
fail(axis, config[axis]);
}
if (config[axis]) {
if (!validNumber(config[axis].from, 0, 1)) {
fail(`${axis} from`, config[axis].from);
}
if (!validNumber(config[axis].to, 0, 1)) {
fail(`${axis} to`, config[axis].to);
}
if (has(config[axis], 'phase') && !validNumber(config[axis].phase)) {
fail(`${axis} phase`, config[axis].phase);
}
}
});
if (config.shape instanceof Array) {
if (!config.shape.length) {
throw new Error('Missing stroke shape.');
}
if (config.shape.length % 2 !== 0) {
throw new Error('Must specify an even number of stroke shapes.');
}
config.shape.forEach((shape) => {
if (typeof shape !== 'function') {
fail('shape', shape);
}
});
}
if (config.relativeSpeeds instanceof Array) {
if (config.relativeSpeeds.length % 2 !== 0) {
throw new Error('Must specify an even number of relative speeds.');
}
config.relativeSpeeds.forEach((relativeSpeed) => {
if (!validNumber(relativeSpeed) || relativeSpeed <= 0) {
fail('relative speed', relativeSpeed);
}
});
}
if (has(config, 'suck') && !validNumber(config.suck, 0, 1) && config.suck !== null) {
fail('suck', config.suck);
}
}
}
export default ClassicStroke;
Source