/*
* Copyright 2020 WICKLETS LLC
*
* This file is part of Wick Engine.
*
* Wick Engine is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Wick Engine is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Wick Engine. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* A class representing a frame.
*/
Wick.Frame = class extends Wick.Tickable {
/**
* Create a new frame.
* @param {number} start - The start of the frame. Optional, defaults to 1.
* @param {number} end - The end of the frame. Optional, defaults to be the same as start.
*/
constructor(args) {
if (!args) args = {};
super(args);
this.start = args.start || 1;
this.end = args.end || this.start;
this._soundAssetUUID = null;
this._soundID = null;
this._soundVolume = 1.0;
this._soundLoop = false;
this._soundStart = 0;
this._originalLayerIndex = -1;
}
_serialize(args) {
var data = super._serialize(args);
data.start = this.start;
data.end = this.end;
data.sound = this._soundAssetUUID;
data.soundVolume = this._soundVolume;
data.soundLoop = this._soundLoop;
data.soundStart = this._soundStart;
data.originalLayerIndex = this.layerIndex !== -1 ? this.layerIndex : this._originalLayerIndex;
return data;
}
_deserialize(data) {
super._deserialize(data);
this.start = data.start;
this.end = data.end;
this._soundAssetUUID = data.sound;
this._soundVolume = data.soundVolume === undefined ? 1.0 : data.soundVolume;
this._soundLoop = data.soundLoop === undefined ? false : data.soundLoop;
this._soundStart = data.soundStart === undefined ? 0 : data.soundStart;
this._originalLayerIndex = data.originalLayerIndex;
}
get classname() {
return 'Frame';
}
/**
* The length of the frame.
* @type {number}
*/
get length() {
return this.end - this.start + 1;
}
set length(length) {
length = Math.max(1, length);
var diff = length - this.length;
this.end += diff;
}
/**
* The midpoint of the frame.
* @type {number}
*/
get midpoint() {
return this.start + (this.end - this.start) / 2;
}
/**
* Is true if the frame is currently visible.
* @type {boolean}
*/
get onScreen() {
if (!this.parent) return true;
return this.inPosition(this.parentTimeline.playheadPosition) && this.parentClip.onScreen;
}
/**
* The sound attached to the frame.
* @type {Wick.SoundAsset}
*/
get sound() {
var uuid = this._soundAssetUUID;
return uuid ? this.project.getAssetByUUID(uuid) : null;
}
set sound(soundAsset) {
if (!soundAsset) {
this.removeSound();
return;
}
this._soundAssetUUID = soundAsset.uuid;
}
/**
* The volume of the sound attached to the frame.
* @type {number}
*/
get soundVolume() {
return this._soundVolume
}
set soundVolume(soundVolume) {
this._soundVolume = soundVolume;
}
/**
* Whether or not the sound loops.
* @type {boolean}
*/
get soundLoop() {
return this._soundLoop;
}
set soundLoop(soundLoop) {
this._soundLoop = soundLoop;
}
/**
* True if this frame should currently be onion skinned.
*/
get onionSkinned () {
if (!this.project || !this.project.onionSkinEnabled) {
return false;
}
// Don't onion skin if we're in the playhead's position.
var playheadPosition = this.project.focus.timeline.playheadPosition;
if (this.inPosition(playheadPosition)) {
return false;
}
// Determine if we're in onion skinning range.
var onionSkinSeekBackwards = this.project.onionSkinSeekBackwards;
var onionSkinSeekForwards = this.project.onionSkinSeekForwards;
return this.inRange(playheadPosition - onionSkinSeekBackwards,
playheadPosition + onionSkinSeekForwards);
}
/**
* Removes the sound attached to this frame.
*/
removeSound() {
this._soundAssetUUID = null;
}
/**
* Plays the sound attached to this frame.
*/
playSound() {
if (!this.sound) {
return;
}
var options = {
seekMS: this.playheadSoundOffsetMS + this.soundStart,
volume: this.soundVolume,
loop: this.soundLoop,
frame: this,
};
this._soundID = this.project.playSoundFromAsset(this.sound, options);
}
/**
* Stops the sound attached to this frame.
*/
stopSound() {
if (this.sound) {
this.sound.stop(this._soundID);
this._soundID = null;
}
}
/**
* Check if the sound on this frame is playing.
* @returns {boolean} true if the sound is playing
*/
isSoundPlaying() {
return this._soundID !== null;
}
/**
* The amount of time, in milliseconds, that the frame's sound should play before stopping.
* @type {number}
*/
get playheadSoundOffsetMS() {
var offsetFrames = this.parentTimeline.playheadPosition - this.start;
var offsetMS = (1000 / this.project.framerate) * offsetFrames;
return offsetMS;
}
/**
* The amount of time the sound playing should be offset, in milliseconds. If this is 0,
* the sound plays normally. A negative value means the sound should start at a later point
* in the track. THIS DOES NOT DETERMINE WHEN A SOUND PLAYS.
* @type {number}
*/
get soundStart() {
return this._soundStart;
}
set soundStart(val) {
this._soundStart = val;
}
/**
* When should the sound start, in milliseconds.
* @type {number}
*/
get soundStartMS() {
return (1000 / this.project.framerate) * (this.start - 1);
}
/**
* When should the sound end, in milliseconds.
* @type {number}
*/
get soundEndMS() {
return (1000 / this.project.framerate) * this.end;
}
/**
* Returns the frame's start position in relation to the root timeline.
*/
get projectFrameStart () {
if (this.parentClip.isRoot) {
return this.start;
} else {
let val = this.start + this.parentClip.parentFrame.projectFrameStart - 1;
return val;
}
}
/**
* The paths on the frame.
* @type {Wick.Path[]}
*/
get paths() {
return this.getChildren('Path');
}
/**
* The paths that are text and have identifiers, for dynamic text.
* @type {Wick.Path[]}
*/
get dynamicTextPaths() {
return this.paths.filter(path => {
return path.isDynamicText;
});
}
/**
* The clips on the frame.
* @type {Wick.Clip[]}
*/
get clips() {
return this.getChildren(['Clip', 'Button']);
}
/**
* The drawable objectson the frame.
* @type {Wick.Base[]}
*/
get drawable() {
return this.getChildren(['Clip', 'Button', 'Path']);
}
/**
* The tweens on this frame.
* @type {Wick.Tween[]}
*/
get tweens() {
// Ensure no tweens are outside of this frame's length.
var tweens = this.getChildren('Tween')
tweens.forEach(tween => {
tween.restrictToFrameSize();
});
return this.getChildren('Tween');
}
/**
* True if there are clips or paths on the frame.
* @type {boolean}
*/
get contentful () {
return this.paths.filter(path => {
return !path.view.item.data._isPlaceholder;
}).length > 0 || this.clips.length > 0;
}
/**
* The index of the parent layer.
* @type {number}
*/
get layerIndex() {
return this.parentLayer ? this.parentLayer.index : -1;
}
/**
* The index of the layer that this frame last belonged to. Used when copying and pasting frames.
* @type {number}
*/
get originalLayerIndex() {
return this._originalLayerIndex;
}
/**
* Removes this frame from its parent layer.
*/
remove() {
this.parent.removeFrame(this);
}
/**
* True if the playhead is on this frame.
* @param {number} playheadPosition - the position of the playhead.
* @return {boolean}
*/
inPosition(playheadPosition) {
return this.start <= playheadPosition &&
this.end >= playheadPosition;
}
/**
* True if the frame exists within the given range.
* @param {number} start - the start of the range to check.
* @param {number} end - the end of the range to check.
* @return {boolean}
*/
inRange(start, end) {
return this.inPosition(start) ||
this.inPosition(end) ||
(this.start >= start && this.start <= end) ||
(this.end >= start && this.end <= end);
}
/**
* True if the frame is contained fully within a given range.
* @param {number} start - the start of the range to check.
* @param {number} end - the end of the range to check.
* @return {boolean}
*/
containedWithin(start, end) {
return this.start >= start && this.end <= end;
}
/**
* The number of frames that this frame is from a given playhead position.
* @param {number} playheadPosition
*/
distanceFrom(playheadPosition) {
// playhead position is inside frame, distance is zero.
if (this.start <= playheadPosition && this.end >= playheadPosition) {
return 0;
}
// otherwise, find the distance from the nearest end
if (this.start >= playheadPosition) {
return this.start - playheadPosition;
} else if (this.end <= playheadPosition) {
return playheadPosition - this.end;
}
}
/**
* Add a clip to the frame.
* @param {Wick.Clip} clip - the clip to add.
*/
addClip(clip) {
if (clip.parent) {
clip.remove();
}
this.addChild(clip);
// Pre-render the clip's frames
// (this fixes an issue where clips created from ClipAssets would be "missing" frames)
clip.timeline.getAllFrames(true).forEach(frame => {
frame.view.render();
});
}
/**
* Remove a clip from the frame.
* @param {Wick.Clip} clip - the clip to remove.
*/
removeClip(clip) {
this.removeChild(clip);
}
/**
* Add a path to the frame.
* @param {Wick.Path} path - the path to add.
*/
addPath(path) {
if (path.parent) {
path.remove();
}
this.addChild(path);
}
/**
* Remove a path from the frame.
* @param {Wick.Path} path - the path to remove.
*/
removePath(path) {
this.removeChild(path);
}
/**
* Add a tween to the frame.
* @param {Wick.Tween} tween - the tween to add.
*/
addTween(tween) {
// New tweens eat existing tweens.
var otherTween = this.getTweenAtPosition(tween.playheadPosition);
if (otherTween) {
otherTween.remove();
}
this.addChild(tween);
tween.restrictToFrameSize();
}
/**
* Automatically creates a tween at the current playhead position. Converts all objects into one clip if needed.
*/
createTween() {
// Don't make a tween if one already exits
var playheadPosition = this.getRelativePlayheadPosition();
if (this.getTweenAtPosition(playheadPosition)) {
return;
}
// If more than one object exists on the frame, or if there is only one path, create a clip from those objects
var clips = this.clips;
var paths = this.paths;
if ((clips.length === 0 && paths.length === 1) || (clips.length + paths.length) > 1) {
var allDrawables = paths.concat(clips);
var center = this.project.selection.view._getObjectsBounds(allDrawables).center;
var clip = new Wick.Clip({
transformation: new Wick.Transformation({
x: center.x,
y: center.y,
}),
});
this.addClip(clip);
clip.addObjects(allDrawables);
}
// Create the tween (if there's not already a tween at the current playhead position)
var clip = this.clips[0];
this.addTween(new Wick.Tween({
playheadPosition: playheadPosition,
transformation: clip ? clip.transformation.copy() : new Wick.Transformation(),
}));
}
/**
* Remove a tween from the frame.
* @param {Wick.Tween} tween - the tween to remove.
*/
removeTween(tween) {
this.removeChild(tween);
}
/**
* Remove all tweens from this frame.
*/
removeAllTweens(tween) {
this.tweens.forEach(tween => {
tween.remove();
});
}
/**
* Get the tween at the given playhead position. Returns null if there is no tween.
* @param {number} playheadPosition - the playhead position to look for tweens at.
* @returns {Wick.Tween || null} the tween at the given playhead position.
*/
getTweenAtPosition(playheadPosition) {
return this.tweens.find(tween => {
return tween.playheadPosition === playheadPosition;
}) || null;
}
/**
* Returns the tween at the current playhead position, if one exists on the frame. Null otherwise.
* @returns {Wick.Tween || null}
*/
getTweenAtCurrentPlayheadPosition() {
let playheadPosition = this.getRelativePlayheadPosition();
return this.getTweenAtPosition(playheadPosition);
}
/**
* The tween being used to transform the objects on the frame.
* @returns {Wick.Tween || null} tween - the active tween. Null if there is no active tween.
*/
getActiveTween() {
if (!this.parentTimeline) return null;
var playheadPosition = this.getRelativePlayheadPosition();
var tween = this.getTweenAtPosition(playheadPosition);
if (tween) {
return tween;
}
var seekBackwardsTween = this.seekTweenBehind(playheadPosition);
var seekForwardsTween = this.seekTweenInFront(playheadPosition);
if (seekBackwardsTween && seekForwardsTween) {
return Wick.Tween.interpolate(seekBackwardsTween, seekForwardsTween, playheadPosition);
} else if (seekForwardsTween) {
return seekForwardsTween;
} else if (seekBackwardsTween) {
return seekBackwardsTween;
} else {
return null;
}
}
/**
* Applies the transformation of current tween to the objects on the frame.
*/
applyTweenTransforms() {
var tween = this.getActiveTween();
if (tween) {
this.clips.forEach(clip => {
tween.applyTransformsToClip(clip);
});
}
}
/**
* Applies single frame positions to timelines if necessary.
*/
applyClipSingleFramePositions () {
this.clips.forEach(clip => {
clip.applySingleFramePosition();
});
}
/**
* Update all clip timelines for their animation type.
*/
updateClipTimelinesForAnimationType () {
this.clips.forEach(clip => {
clip.updateTimelineForAnimationType();
})
}
/**
* The asset of the sound attached to this frame, if one exists
* @returns {Wick.Asset[]}
*/
getLinkedAssets() {
var linkedAssets = [];
if (this.sound) {
linkedAssets.push(this.sound);
}
return linkedAssets;
}
/**
* Cut this frame in half using the parent timeline's playhead position.
*/
cut() {
// Can't cut a frame that doesn't beolong to a timeline + layer
if (!this.parentTimeline) return;
// Can't cut a frame with length 1
if (this.length === 1) return;
// Can't cut a frame that isn't under the playhead
var playheadPosition = this.parentTimeline.playheadPosition;
if (!this.inPosition(playheadPosition)) return;
// Create right half (leftover) frame
var rightHalf = this.copy();
rightHalf.identifier = null;
rightHalf.removeSound();
rightHalf.removeAllTweens();
rightHalf.start = playheadPosition = playheadPosition;
// Cut this frame shorter
this.end = playheadPosition - 1;
// Add right frame
this.parentLayer.addFrame(rightHalf);
}
/**
* Extend this frame by one and push all frames right of this frame to the right.
*/
extendAndPushOtherFrames() {
this.parentLayer.getFramesInRange(this.end + 1, Infinity).forEach(frame => {
frame.start += 1;
frame.end += 1;
});
this.end += 1;
}
/**
* Shrink this frame by one and pull all frames left of this frame to the left.
*/
shrinkAndPullOtherFrames() {
if (this.length === 1) return;
this.parentLayer.getFramesInRange(this.end + 1, Infinity).forEach(frame => {
frame.start -= 1;
frame.end -= 1;
});
this.end -= 1;
}
/**
* Import SVG data into this frame. SVGs containing mulitple paths will be split into multiple Wick Paths.
* @param {string} svg - the SVG data to parse and import.
*/
/*
importSVG (svg) {
this.view.importSVG(svg);
}
*/
/**
* Get the position of this frame in relation to the parent timeline's playhead position.
* @returns {number}
*/
getRelativePlayheadPosition() {
return this.parentTimeline.playheadPosition - this.start + 1;
}
/**
* Find the first tween on this frame that exists behind the given playhead position.
* @returns {Wick.Tween}
*/
seekTweenBehind(playheadPosition) {
var seekBackwardsPosition = playheadPosition;
var seekBackwardsTween = null;
while (seekBackwardsPosition > 0) {
seekBackwardsTween = this.getTweenAtPosition(seekBackwardsPosition);
seekBackwardsPosition--;
if (seekBackwardsTween) break;
}
return seekBackwardsTween;
}
/**
* Find the first tween on this frame that exists past the given playhead position.
* @returns {Wick.Tween}
*/
seekTweenInFront(playheadPosition) {
var seekForwardsPosition = playheadPosition;
var seekForwardsTween = null;
while (seekForwardsPosition <= this.end) {
seekForwardsTween = this.getTweenAtPosition(seekForwardsPosition);
seekForwardsPosition++;
if (seekForwardsTween) break;
}
return seekForwardsTween;
}
_onInactive() {
super._onInactive();
this._tickChildren();
}
_onActivated() {
super._onActivated();
this.playSound();
this._tickChildren();
}
_onActive() {
super._onActive();
this._tickChildren();
}
_onDeactivated() {
super._onDeactivated();
this.stopSound();
this._tickChildren();
}
_tickChildren() {
this.clips.forEach(clip => {
clip.tick();
});
}
_attachChildClipReferences() {
this.clips.forEach(clip => {
if (clip.identifier) {
this[clip.identifier] = clip;
clip._attachChildClipReferences();
}
});
}
}