base/Layer.js

/*
 * 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/>.
 */

/**
 * Represents a Wick Layer.
 */
Wick.Layer = class extends Wick.Base {
    /**
     * Called when creating a Wick Layer.
     * @param {boolean} locked - Is the layer locked?
     * @param {boolean} hideen - Is the layer hidden?
     */
    constructor (args) {
        if(!args) args = {};
        super(args);

        this.locked = args.locked === undefined ? false : args.locked;
        this.hidden = args.hidden === undefined ? false : args.hidden;
        this.name = args.name || null;
    }

    _serialize (args) {
        var data = super._serialize(args);

        data.locked = this.locked;
        data.hidden = this.hidden;

        return data;
    }

    _deserialize (data) {
        super._deserialize(data);

        this.locked = data.locked;
        this.hidden = data.hidden;
    }

    get classname () {
        return 'Layer';
    }

    /**
     * The frames belonging to this layer.
     * @type {Wick.Frame[]}
     */
    get frames () {
        return this.getChildren('Frame');
    }

    /**
     * The order of the Layer in the timeline.
     * @type {number}
     */
    get index () {
        return this.parent && this.parent.layers.indexOf(this);
    }

    /**
     * Set this layer to be the active layer in its timeline.
     */
    activate () {
        this.parent.activeLayerIndex = this.index;
    }

    /**
     * True if this layer is the active layer in its timeline.
     * @type {boolean}
     */
    get isActive () {
        return this.parent && this === this.parent.activeLayer;
    }

    /**
     * The length of the layer in frames.
     * @type {number}
     */
    get length () {
        var end = 0;
        this.frames.forEach(function (frame) {
            if(frame.end > end) {
                end = frame.end;
            }
        });
        return end;
    }

    /**
     * The active frame on the layer.
     * @type {Wick.Frame}
     */
    get activeFrame () {
        if(!this.parent) return null;
        return this.getFrameAtPlayheadPosition(this.parent.playheadPosition);
    }

    /**
     * Moves this layer to a different position, inserting it before/after other layers if needed.
     * @param {number} index - the new position to move the layer to.
     */
    move (index) {
        this.parentTimeline.moveLayer(this, index);
    }

    /**
     * Remove this layer from its timeline.
     */
    remove () {
        this.parentTimeline.removeLayer(this);
    }

    /**
     * Adds a frame to the layer.
     * @param {Wick.Frame} frame - The frame to add to the Layer.
     */
    addFrame (frame) {
        this.addChild(frame);
        this.resolveOverlap([frame]);
        this.resolveGaps([frame]);
    }

    /**
     * Adds a tween to the active frame of this layer (if one exists).
     * @param {Wick.Tween} tween - the tween to add
     */
    addTween (tween) {
        this.activeFrame && this.activeFrame.addChild(tween);
    }

    /**
     * Adds a frame to the layer. If there is an existing frame where the new frame is
     * inserted, then the existing frame will be cut, and the new frame will fill the
     * gap created by that cut.
     * @param {number} playheadPosition - Where to add the blank frame.
     */
    insertBlankFrame (playheadPosition) {
        if(!playheadPosition) {
            throw new Error('insertBlankFrame: playheadPosition is required');
        }

        var frame = new Wick.Frame({start: playheadPosition});
        this.addChild(frame);

        // If there is is overlap with an existing frame
        var existingFrame = this.getFrameAtPlayheadPosition(playheadPosition);
        if (existingFrame) {
            // Make sure the new frame fills the empty space
            frame.end = existingFrame.end;
        }

        this.resolveOverlap([frame]);
        this.resolveGaps([frame]);

        return frame;
    }

    /**
     * Removes a frame from the Layer.
     * @param  {Wick.Frame} frame Frame to remove.
     */
    removeFrame (frame) {
        this.removeChild(frame);
        this.resolveGaps();
    }

    /**
     * Gets the frame at a specific playhead position.
     * @param {number} playheadPosition - Playhead position to search for frame at.
     * @return {Wick.Frame} The frame at the given playheadPosition.
     */
    getFrameAtPlayheadPosition (playheadPosition) {
        return this.frames.find(frame => {
            return frame.inPosition(playheadPosition);
        }) || null;
    }

    /**
     * Gets all frames in the layer that are between the two given playhead positions.
     * @param {number} playheadPositionStart - The start of the range to search
     * @param {number} playheadPositionEnd - The end of the range to search
     * @return {Wick.Frame[]} The frames in the given range.
     */
    getFramesInRange (playheadPositionStart, playheadPositionEnd) {
        return this.frames.filter(frame => {
            return frame.inRange(playheadPositionStart, playheadPositionEnd);
        });
    }

    /**
     * Gets all frames in the layer that are contained within the two given playhead positions.
     * @param {number} playheadPositionStart - The start of the range to search
     * @param {number} playheadPositionEnd - The end of the range to search
     * @return {Wick.Frame[]} The frames contained in the given range.
     */
    getFramesContainedWithin (playheadPositionStart, playheadPositionEnd) {
        return this.frames.filter(frame => {
            return frame.containedWithin(playheadPositionStart, playheadPositionEnd);
        });
    }

    /**
     * Prevents frames from overlapping each other by removing pieces of frames that are touching.
     * @param {Wick.Frame[]} newOrModifiedFrames - the frames that should take precedence when determining which frames should get "eaten".
     */
    resolveOverlap (newOrModifiedFrames) {
        newOrModifiedFrames = newOrModifiedFrames || [];

        // Ensure that frames never go beyond the beginning of the timeline
        newOrModifiedFrames.forEach(frame => {
            if(frame.start <= 1) {
                frame.start = 1;
            }
        });

        var isEdible = existingFrame => {
            return newOrModifiedFrames.indexOf(existingFrame) === -1;
        };

        newOrModifiedFrames.forEach(frame => {
            // "Full eat"
            // The frame completely eats the other frame.
            var containedFrames = this.getFramesContainedWithin(frame.start, frame.end);
            containedFrames.filter(isEdible).forEach(existingFrame => {
                existingFrame.remove();
            });

            // "Right eat"
            // The frame takes a chunk out of the right side of another frame.
            this.frames.filter(isEdible).forEach(existingFrame => {
                if(existingFrame.inPosition(frame.start) && existingFrame.start !== frame.start) {
                    existingFrame.end = frame.start - 1;
                }
            });

            // "Left eat"
            // The frame takes a chunk out of the left side of another frame.
            this.frames.filter(isEdible).forEach(existingFrame => {
                if(existingFrame.inPosition(frame.end) && existingFrame.end !== frame.end) {
                    existingFrame.start = frame.end + 1;
                }
            });
        });
    }

    /**
     * Prevents gaps between frames by extending frames to fill empty space between themselves.
     */
    resolveGaps (newOrModifiedFrames) {
        if(this.parentTimeline && this.parentTimeline.waitToFillFrameGaps) return;

        newOrModifiedFrames = newOrModifiedFrames || [];

        var fillGapsMethod = this.parentTimeline && this.parentTimeline.fillGapsMethod;
        if(!fillGapsMethod) fillGapsMethod = 'blank_frames';

        this.findGaps().forEach(gap => {
            // Method 1: Use the frame on the left (if there is one) to fill the gap
            if(fillGapsMethod === 'auto_extend') {
                var frameOnLeft = this.getFrameAtPlayheadPosition(gap.start-1);
                if(!frameOnLeft || newOrModifiedFrames.indexOf(frameOnLeft) !== -1 || gap.start === 1) {
                    // If there is no frame on the left, create a blank one
                    var empty = new Wick.Frame({
                        start: gap.start,
                        end: gap.end,
                    });
                    this.addFrame(empty);
                } else {
                    // Otherwise, extend the frame to the left to fill the gap
                    frameOnLeft.end = gap.end;
                }
            }

            // Method 2: Always create empty frames to fill gaps
            if(fillGapsMethod === 'blank_frames') {
                var empty = new Wick.Frame({
                    start: gap.start,
                    end: gap.end,
                });
                this.addFrame(empty);
            }
        });
    }

    /**
     * Generate a list of positions where there is empty space between frames.
     * @returns {Object[]} An array of objects with start/end positions describing gaps.
     */
    findGaps () {
        var gaps = [];

        var currentGap = null;
        for(var i = 1; i <= this.length; i++) {
            var frame = this.getFrameAtPlayheadPosition(i);

            // Found the start of a gap
            if(!frame && !currentGap) {
                currentGap = {};
                currentGap.start = i;
            }

            // Found the end of a gap
            if(frame && currentGap) {
                currentGap.end = i-1;
                gaps.push(currentGap);
                currentGap = null;
            }
        }

        return gaps;
    }
}