plugins_uiSystem.js

/**
 * LittleJS User Interface Plugin
 * - call new UISystemPlugin() to setup the UI system
 * - Gamepad and keyboard navigation support
 * - Nested Menus
 * - Text
 * - Buttons
 * - Checkboxes
 * - Images
 * - Scrollbars
 * - Video
 * @namespace UISystem
 */

'use strict';

///////////////////////////////////////////////////////////////////////////////

/** Global UI system plugin object
 *  @type {UISystemPlugin}
 *  @memberof UISystem */
let uiSystem;

/** Enable UI system debug drawing
 *  0=off, 1=normal, 2=show invisible
 *  @type {number}
 *  @default
 *  @memberof UISystem */
let uiDebug = 0;

/** Enable UI system debug drawing
 *  0=off, 1=normal, 2=show invisible
 *  @param {number|boolean} enable
 *  @memberof UISystem */
function uiSetDebug(debugMode)
{ uiDebug = typeof debugMode === 'boolean' ? (debugMode ? 1 : 0) : debugMode; }

///////////////////////////////////////////////////////////////////////////////
/** 
 * UI System Global Object
 * @memberof UISystem
 */
class UISystemPlugin
{
    /** Create the global UI system object
     *  @param {CanvasRenderingContext2D} [context]
     *  @example
     *  // create the ui plugin object
     *  new UISystemPlugin;
     */
    constructor(context=overlayContext)
    {
        ASSERT(!uiSystem, 'UI system already initialized');
        uiSystem = this;

        // default settings
        /** @property {Color} - Default fill color for UI elements */
        this.defaultColor = WHITE;
        /** @property {Color} - Default outline color for UI elements */
        this.defaultLineColor = BLACK;
        /** @property {Color} - Default text color for UI elements */
        this.defaultTextColor = BLACK;
        /** @property {Color} - Default button color for UI elements */
        this.defaultButtonColor = hsl(0,0,.7);
        /** @property {Color} - Default hover color for UI elements */
        this.defaultHoverColor = hsl(0,0,.9);
        /** @property {Color} - Default color for disabled UI elements */
        this.defaultDisabledColor = hsl(0,0,.3);
        /** @property {Color} - Uses a gradient fill combined with color */
        this.defaultGradientColor = undefined;
        /** @property {number} - Default line width for UI elements */
        this.defaultLineWidth = 4;
        /** @property {number} - Default rounded rect corner radius for UI elements */
        this.defaultCornerRadius = 0;
        /** @property {number} - Default scale to use for fitting text to object */
        this.defaultTextFitScale = .8;
        /** @property {string} - Default font for UI elements */
        this.defaultFont = fontDefault;
        /** @property {Sound} - Default sound when interactive UI element is pressed */
        this.defaultSoundPress = undefined;
        /** @property {Sound} - Default sound when interactive UI element is released */
        this.defaultSoundRelease = undefined;
        /** @property {Sound} - Default sound when interactive UI element is clicked */
        this.defaultSoundClick = undefined;
        /** @property {Color} - Color for shadow */
        this.defaultShadowColor = CLEAR_BLACK;
        /** @property {number} - Size of shadow blur */
        this.defaultShadowBlur = 5;
        /** @property {Vector2} - Offset of shadow blur */
        this.defaultShadowOffset = vec2(5);
        /** @property {number} - If set ui coords will be renormalized to this canvas height */
        this.nativeHeight = 0;

        // navigation properties
        /** @property {UIObject} - Object currently selected by navigation (gamepad or keyboard) */
        this.navigationObject = undefined;
        /** @property {Timer} - Cooldown timer for navigation inputs */
        this.navigationTimer = new Timer(undefined, true);
        /** @property {number} - Time between navigation inputs in seconds */
        this.navigationDelay = .2;
        /** @property {boolean} - should the navigation be horizontal, vertical, or both? */
        this.navigationDirection = 1;
        /** @property {boolean} - True if user last used navigation instead of mouse */
        this.navigationMode = false;

        // system state
        /** @property {Array<UIObject>} - List of all UI elements */
        this.uiObjects = [];
        /** @property {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} - Context to render UI elements to */
        this.uiContext = context;
        /** @property {UIObject} - Object user is currently interacting with */
        this.activeObject = undefined;
        /** @property {UIObject} - Top most object user is over */
        this.hoverObject = undefined;
        /** @property {UIObject} - Hover object at start of update */
        this.lastHoverObject = undefined;
        /** @property {UIObject} - Current confirm menu being shown */
        this.confirmDialog = undefined;

        engineAddPlugin(uiUpdate, uiRender);

        // set object position in parent space
        function updateTransforms(o)
        {
            if (!o.parent) return;
            o.pos.x = o.localPos.x + o.parent.pos.x;
            o.pos.y = o.localPos.y + o.parent.pos.y;
        }

        // setup recursive update and render
        // update in reverse order to detect mouse enter/leave
        function uiUpdate()
        {
            if (uiSystem.activeObject && !uiSystem.activeObject.visible)
                uiSystem.activeObject = undefined;

            // reset hover object at start of update
            uiSystem.lastHoverObject = uiSystem.hoverObject;
            uiSystem.hoverObject = undefined;

            if (mouseWasPressed(0))
            {
                uiSystem.navigationMode = false;
                uiSystem.navigationObject = undefined;
            }

            // navigation with gamepad/keyboard
            const navigableObjects = uiSystem.getNavigableObjects();
            if (!navigableObjects.length)
                uiSystem.navigationObject = undefined;
            else
            {
                // unselect object if it is no longer navigable
                if (!navigableObjects.includes(uiSystem.navigationObject))
                    uiSystem.navigationObject = undefined;

                if (!isTouchDevice)
                if (uiSystem.navigationMode && !uiSystem.navigationObject)
                {
                    // select first auto focus object
                    uiSystem.navigationObject = navigableObjects.find(o=>o.navigationAutoSelect);
                }
                
                // navigate with dpad or left stick
                if (!uiSystem.navigationTimer.active())
                {
                    // navigate through list with gamepad or keyboard
                    const direction = sign(uiSystem.getNavigationDirection());
                    if (direction)
                    {
                        let newNavigationObject;
                        if (!uiSystem.navigationObject)
                        {
                            // use auto select object
                            newNavigationObject = navigableObjects.find(o=>o.navigationAutoSelect);

                            if (!newNavigationObject)
                            {
                                // try first or last object
                                const newIndex = direction > 0 ? 0 : navigableObjects.length-1;
                                newNavigationObject = navigableObjects[newIndex];
                            }
                        }
                        else
                        {
                            const currentIndex = navigableObjects.indexOf(uiSystem.navigationObject);
                            const newIndex = mod(currentIndex + direction, navigableObjects.length);
                            newNavigationObject = navigableObjects[newIndex];
                        }
                        
                        if (uiSystem.navigationObject !== newNavigationObject)
                        {
                            uiSystem.navigationMode = true;
                            uiSystem.hoverObject = undefined;
                            uiSystem.navigationObject = newNavigationObject;
                            uiSystem.navigationTimer.set(uiSystem.navigationDelay);
                            newNavigationObject.soundPress &&
                                newNavigationObject.soundPress.play();
                        }
                    }
                }

                // activate the navigation object when pressed
                if (uiSystem.navigationObject)
                if (uiSystem.getNavigationWasPressed())
                    uiSystem.navigationObject.navigatePressed();
            }

            // update in reverse order so topmost objects get priority
            for (let i = uiSystem.uiObjects.length; i--;)
            {
                const o = uiSystem.uiObjects[i];
                o.parent || updateObject(o);
            }

            // remove destroyed objects
            uiSystem.uiObjects = uiSystem.uiObjects.filter(o=>!o.destroyed);

            function updateObject(o)
            {
                if (!o.visible) return;

                // update in reverse order to detect mouse enter/leave
                updateTransforms(o);
                for (let i=o.children.length; i--;)
                    updateObject(o.children[i]);
                o.update();
            }
        }
        function uiRender()
        {
            const context = uiSystem.uiContext;
            context.save();
            if (uiSystem.nativeHeight)
            {
                // convert to native height
                const s = mainCanvasSize.y / uiSystem.nativeHeight;
                context.translate(-s*mainCanvasSize.x/2,0);
                context.scale(s,s);
                context.translate(mainCanvasSize.x/2/s,0);
            }

            function renderObject(o)
            {
                if (!o.visible) return;

                // render object and children
                updateTransforms(o);
                o.render();
                for (const c of o.children)
                    renderObject(c);
            }
            uiSystem.uiObjects.forEach(o=> o.parent || renderObject(o));

            if (uiDebug > 0)
            {
                // debug render all objects
                function renderDebug(o, visible=true)
                {
                    visible &&= !!o.visible;
                    updateTransforms(o);
                    o.renderDebug(visible);
                    for (const c of o.children)
                        renderDebug(c, visible);
                }
                uiSystem.uiObjects.forEach(o=> o.parent || renderDebug(o));
            }
            context.restore();
        }
    }

    /** Draw a rectangle to the UI context
    *  @param {Vector2} pos
    *  @param {Vector2} size
    *  @param {Color}   [color]
    *  @param {number}  [lineWidth]
    *  @param {Color}   [lineColor]
    *  @param {number}  [cornerRadius]
    *  @param {Color}   [gradientColor]
    *  @param {Color}   [shadowColor]
    *  @param {number}  [shadowBlur]
    *  @param {Color}   [shadowOffset] */
    drawRect(pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, cornerRadius=0, gradientColor, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
    {
        ASSERT(isVector2(pos), 'pos must be a vec2');
        ASSERT(isVector2(size), 'size must be a vec2');
        ASSERT(isColor(color), 'color must be a color');
        ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
        ASSERT(isColor(lineColor), 'lineColor must be a color');
        ASSERT(isNumber(cornerRadius), 'cornerRadius must be a number');
        
        const context = uiSystem.uiContext;
        if (gradientColor)
        {
            const g = context.createLinearGradient(
                pos.x, pos.y-size.y/2, pos.x, pos.y+size.y/2);
            const c = color.toString();
            g.addColorStop(0, c);
            g.addColorStop(.5, gradientColor.toString());
            g.addColorStop(1, c);
            context.fillStyle = g;
        }
        else
            context.fillStyle = color.toString();
        if (shadowBlur || shadowOffset.x || shadowOffset.y)
        if (shadowColor.a > 0)
        {
            // setup shadow
            context.shadowColor = shadowColor.toString();
            context.shadowBlur = shadowBlur;
            context.shadowOffsetX = shadowOffset.x;
            context.shadowOffsetY = shadowOffset.y;
        }
        context.beginPath();
        if (cornerRadius && context['roundRect'])
            context['roundRect'](pos.x-size.x/2, pos.y-size.y/2, size.x, size.y, cornerRadius);
        else
            context.rect(pos.x-size.x/2, pos.y-size.y/2, size.x, size.y);
        context.fill();
        context.shadowColor = '#0000';
        if (lineWidth && lineColor.a > 0)
        {
            context.strokeStyle = lineColor.toString();
            context.lineWidth = lineWidth;
            context.stroke();
        }
    }

    /** Draw a line to the UI context
    *  @param {Vector2} posA
    *  @param {Vector2} posB
    *  @param {number}  [lineWidth=uiSystem.defaultLineWidth]
    *  @param {Color}   [lineColor=uiSystem.defaultLineColor] */
    drawLine(posA, posB, lineWidth=uiSystem.defaultLineWidth, lineColor=uiSystem.defaultLineColor)
    {
        ASSERT(isVector2(posA), 'posA must be a vec2');
        ASSERT(isVector2(posB), 'posB must be a vec2');
        ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
        ASSERT(isColor(lineColor), 'lineColor must be a color');

        const context = uiSystem.uiContext;
        context.strokeStyle = lineColor.toString();
        context.lineWidth = lineWidth;
        context.beginPath();
        context.lineTo(posA.x, posA.y);
        context.lineTo(posB.x, posB.y);
        context.stroke();
    }

    /** Draw a tile to the UI context
    *  @param {Vector2}  pos
    *  @param {Vector2}  size
    *  @param {TileInfo} tileInfo
    *  @param {Color}    [color=uiSystem.defaultColor]
    *  @param {number}   [angle]
    *  @param {boolean}  [mirror]
    *  @param {Color}    [shadowColor]
    *  @param {number}   [shadowBlur]
    *  @param {Color}    [shadowOffset] */
    drawTile(pos, size, tileInfo, color=uiSystem.defaultColor, angle=0, mirror=false, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
    {
        const context = uiSystem.uiContext;
        if (shadowBlur || shadowOffset.x || shadowOffset.y)
        if (shadowColor.a > 0)
        {
            // setup shadow
            context.shadowColor = shadowColor.toString();
            context.shadowBlur = shadowBlur;
            context.shadowOffsetX = shadowOffset.x;
            context.shadowOffsetY = shadowOffset.y;
        }
        drawTile(pos, size, tileInfo, color, angle, mirror, CLEAR_BLACK, false, true, context);
        context.shadowColor = '#0000';
    }

    /** Draw text to the UI context
    *  @param {string}  text
    *  @param {Vector2} pos
    *  @param {Vector2} size
    *  @param {Color}   [color=uiSystem.defaultColor]
    *  @param {number}  [lineWidth=uiSystem.defaultLineWidth]
    *  @param {Color}   [lineColor=uiSystem.defaultLineColor]
    *  @param {string}  [align]
    *  @param {string}  [font=uiSystem.defaultFont]
    *  @param {string}  [fontStyle]
    *  @param {boolean} [applyMaxWidth=true]
    *  @param {Vector2} [textShadow]
    *  @param {Color}   [shadowColor]
    *  @param {number}  [shadowBlur]
    *  @param {Color}   [shadowOffset] */
    drawText(text, pos, size, color=uiSystem.defaultColor, lineWidth=uiSystem.defaultLineWidth, lineColor=uiSystem.defaultLineColor, align='center', font=uiSystem.defaultFont, fontStyle='', applyMaxWidth=true, textShadow=undefined, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
    {
        const context = uiSystem.uiContext;
        if (shadowColor.a > 0)
        {
            if (textShadow)
                drawTextScreen(text, pos.add(textShadow), size.y, shadowColor, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context);
            if (shadowBlur || shadowOffset.x || shadowOffset.y)
            {
                // setup shadow
                context.shadowColor = shadowColor.toString();
                context.shadowBlur = shadowBlur;
                context.shadowOffsetX = shadowOffset.x;
                context.shadowOffsetY = shadowOffset.y;
            }
        }
        drawTextScreen(text, pos, size.y, color, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context);
        context.shadowColor = '#0000';
    }

    /**
     * @callback DragAndDropCallback - Callback for drag and drop events
     * @param {DragEvent} event - The drag event
     * @memberof UISystem
     */

    /** Setup drag and drop event handlers
    *  Automatically prevents defaults and calls the given functions
    *  @param {DragAndDropCallback} [onDrop] - when a file is dropped
    *  @param {DragAndDropCallback} [onDragEnter] - when a file is dragged onto the window
    *  @param {DragAndDropCallback} [onDragLeave] - when a file is dragged off the window
    *  @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
    setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
    {
        function setCallback(callback, listenerType)
        {
            function listener(e) { e.preventDefault(); callback && callback(e); }
            document.addEventListener(listenerType, listener);
        }
        setCallback(onDrop,      'drop');
        setCallback(onDragEnter, 'dragenter');
        setCallback(onDragLeave, 'dragleave');
        setCallback(onDragOver,  'dragover');
    }

    /** Convert a screen space position to native UI position
     *  @param {Vector2} pos
     *  @return {Vector2} */
    screenToNative(pos)
    {
        if (!uiSystem.nativeHeight)
            return pos;
    
        const s = mainCanvasSize.y / uiSystem.nativeHeight;
        const sInv = 1/s;
        const p = pos.copy();
        p.x += s*mainCanvasSize.x/2;
        p.x *= sInv;
        p.y *= sInv;
        p.x -= sInv*mainCanvasSize.x/2;
        return p;
    }

    /** Destroy and remove all objects
    *  @memberof Engine */
    destroyObjects()
    {
        for (const o of this.uiObjects)
            o.parent || o.destroy();
        this.uiObjects = this.uiObjects.filter(o=>!o.destroyed);
        this.activeObject = undefined;
        this.hoverObject = undefined;
        this.lastHoverObject = undefined;
    }

    /** Get all navigable UI objects sorted by navigationIndex
     *  @return {Array<UIObject>} */
    getNavigableObjects()
    {
        function getNavigableRecursive(o)
        {
            if (!o.visible || o.disabled)
                return; // skip children if parent is invisible or disabled

            if (o.isInteractive() && o.navigationIndex !== undefined)
                objects.push(o);
            for (let i=o.children.length; i--;)
                getNavigableRecursive(o.children[i]);
        }

        // get all the valid navigable objects recursively
        let objects = [];
        for (let i = uiSystem.uiObjects.length; i--;)
        {
            const o = uiSystem.uiObjects[i];
            if (uiSystem.confirmDialog && o !== uiSystem.confirmDialog)
                continue;
            o.parent || getNavigableRecursive(o);
        }

        // sort by navigationIndex (lower numbers first)
        objects.sort((a, b)=> a.navigationIndex - b.navigationIndex);
        return objects;
    }

    /** Get navigation direction from gamepad or keyboard
     *  @return {number} */
    getNavigationDirection()
    {
        const vertical = uiSystem.navigationDirection === 1;
        const both = uiSystem.navigationDirection === 2;
        if (isUsingGamepad)
        {
            const stick = gamepadStick(0, gamepadPrimary);
            const dpad = gamepadDpad(gamepadPrimary);
            if (both)
                return -(stick.y || dpad.y) || (stick.x || dpad.x);
            return vertical ? -(stick.y || dpad.y) : (stick.x || dpad.x);
        }
        const up = 'ArrowUp', down = 'ArrowDown', left = 'ArrowLeft', right = 'ArrowRight';
        if (both)
        {
            return keyIsDown(up) || keyIsDown(left) ? -1 : 
                keyIsDown(down) || keyIsDown(right) ? 1 : 0;
        }
        const back = vertical ? up : left;
        const forward = vertical ? down : right;
        return keyIsDown(back) ? -1 : keyIsDown(forward) ? 1 : 0;
    }

    /** Get other axis navigation direction from gamepad or keyboard
     *  @return {Vector2} */
    getNavigationOtherDirection()
    {
        if (uiSystem.navigationDirection === 2)
            return 0; // other direction disabled

        const vertical = uiSystem.navigationDirection === 1;
        if (isUsingGamepad)
        {
            const stick = gamepadStick(0, gamepadPrimary);
            const dpad = gamepadDpad(gamepadPrimary);
            return !vertical ? (stick.y || dpad.y) : (stick.x || dpad.x);
        }
        const back = !vertical ? 'ArrowUp' : 'ArrowLeft';
        const forward = !vertical ? 'ArrowDown' : 'ArrowRight';
        return keyIsDown(back) ? -1 : keyIsDown(forward) ? 1 : 0;
    }

    /** Get if navigation button was pressed from gamepad or keyboard
     *  @return {boolean} */
    getNavigationWasPressed()
    {
        return isUsingGamepad ? gamepadWasPressed(0, gamepadPrimary) : 
            keyWasPressed('Space') || keyWasPressed('Enter');
    }
        
    /** Show a confirmation dialog with Yes/No buttons
     *  Centers the dialog on the screen with darkened background
     *  @param {string} [text] - The message to display
     *  @param {Function} [yesCallback] - Called when Yes is clicked
     *  @param {Function} [noCallback] - Called when No is clicked
     *  @param {Vector2} [size] - Size of the confirmation dialog
     *  @param {string} [exitKey] - Key that can exit the menu
     *  @return {UIObject} The confirmation menu object
     */
    showConfirmDialog(text='Are you sure?', yesCallback, noCallback, size=vec2(500,250), exitKey='Escape')
    {
        ASSERT(!uiSystem.confirmDialog);

        const savedNavigationDirection = uiSystem.navigationDirection;

        // allow both axies for navigation
        uiSystem.navigationDirection = 2;

        // confirm menu
        const confirmMenu = new UIObject(vec2(), size);
        uiSystem.confirmDialog = confirmMenu;
        confirmMenu.onRender = ()=> 
        {
            confirmMenu.pos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
            const backgroundColor = hsl(0,0,0,.7);
            uiSystem.drawRect(vec2(), vec2(1e9), backgroundColor);
        }
        confirmMenu.onUpdate = ()=>
        {
            if (keyWasPressed(exitKey))
                closeMenu();
        }
        confirmMenu.isMouseOverlapping = ()=> true; // always hover
        
        // title text
        const gap = 50;
        const textTitle = new UIText(vec2(0,-50), vec2(size.x-gap,70), text);
        confirmMenu.addChild(textTitle);
        
        // yes button
        const buttonYes = new UIButton(vec2(-80,50), vec2(120,70), 'Yes');
        buttonYes.textHeight = 40;
        buttonYes.navigationIndex = 1;
        buttonYes.hoverColor = hsl(0,1,.5);
        buttonYes.onClick = ()=> { closeMenu(); yesCallback && yesCallback(); }; 
        confirmMenu.addChild(buttonYes);
        
        // no button
        const buttonNo = new UIButton(vec2(80,50), vec2(120,70), 'No');
        buttonNo.textHeight = 40;
        buttonNo.navigationIndex = 2;
        buttonNo.navigationAutoSelect = true;
        buttonNo.onClick = ()=> { closeMenu(); noCallback && noCallback(); };
        confirmMenu.addChild(buttonNo);

        // close menu and return to normal navigation
        function closeMenu()
        {
            ASSERT(uiSystem.confirmDialog === confirmMenu);
            confirmMenu.destroy();
            uiSystem.confirmDialog = undefined;
            uiSystem.navigationDirection = savedNavigationDirection;
            inputClear();
        }
    }
}

///////////////////////////////////////////////////////////////////////////////
/** 
 * UI Object - Base level object for all UI elements
 * @memberof UISystem */
class UIObject
{
    /** Create a UIObject
     *  @param {Vector2}  [pos=(0,0)]
     *  @param {Vector2}  [size=(1,1)]
     */
    constructor(pos=vec2(), size=vec2())
    {
        ASSERT(isVector2(pos), 'ui object pos must be a vec2');
        ASSERT(isVector2(size), 'ui object size must be a vec2');

        /** @property {Vector2} - Local position of the object */
        this.localPos = pos.copy();
        /** @property {Vector2} - Screen space position of the object */
        this.pos = pos.copy();
        /** @property {Vector2} - Screen space size of the object */
        this.size = size.copy();
        /** @property {Color} - Color of the object */
        this.color = uiSystem.defaultColor.copy();
        /** @property {Color} - Color of the object when active, uses color if undefined */
        this.activeColor = undefined;
        /** @property {string} - Text for this ui object */
        this.text = undefined;
        /** @property {Color} - Color when disabled */
        this.disabledColor = uiSystem.defaultDisabledColor.copy();
        /** @property {boolean} - Is this object disabled? */
        this.disabled = false;
        /** @property {Color} - Color for text */
        this.textColor = uiSystem.defaultTextColor.copy();
        /** @property {Color} - Color used when hovering over the object */
        this.hoverColor = uiSystem.defaultHoverColor.copy();
        /** @property {Color} - Color for line drawing */
        this.lineColor = uiSystem.defaultLineColor.copy();
        /** @property {Color} - Uses a gradient fill combined with color */
        this.gradientColor = uiSystem.defaultGradientColor ? uiSystem.defaultGradientColor.copy() : undefined;
        /** @property {number} - Width for line drawing */
        this.lineWidth = uiSystem.defaultLineWidth;
        /** @property {number} - Corner radius for rounded rects */
        this.cornerRadius = uiSystem.defaultCornerRadius;
        /** @property {string} - Font for this objecct */
        this.font = uiSystem.defaultFont;
        /** @property {string} - Font style for this object or undefined */
        this.fontStyle = undefined;
        /** @property {number} - Override for text width */
        this.textWidth = undefined;
        /** @property {number} - Override for text height */
        this.textHeight = undefined;
        /** @property {number} - Scale text to fit in the object */
        this.textFitScale = uiSystem.defaultTextFitScale;
        /** @property {Vector2} - How much to offset the text shadow or undefined */
        this.textShadow = undefined;
        /** @property {number} - Color for text line drawing  */
        this.textLineColor = uiSystem.defaultLineColor.copy();
        /** @property {number} - Width for text line drawing */
        this.textLineWidth = 0;
        /** @property {boolean} - Should this object be drawn */
        this.visible  = true;
        /** @property {Array<UIObject>} - A list of this object's children */
        this.children = [];
        /** @property {UIObject} - This object's parent, position is in parent space */
        this.parent = undefined;
        /** @property {number} - Added size to make small buttons easier to touch on mobile devices */
        this.extraTouchSize = 0;
        /** @property {Sound} - Sound when interactive element is pressed */
        this.soundPress = uiSystem.defaultSoundPress;
        /** @property {Sound} - Sound when interactive element is released */
        this.soundRelease = uiSystem.defaultSoundRelease;
        /** @property {Sound} - Sound when interactive element is clicked */
        this.soundClick = uiSystem.defaultSoundClick;
        /** @property {boolean} - Is this element interactive */
        this.interactive = false;
        /** @property {boolean} - Activate when dragged over with mouse held down */
        this.dragActivate = false;
        /** @property {boolean} - True if this can be a hover object */
        this.canBeHover = true;
        /** @property {Color} - Color for shadow, undefined if no shadow */
        this.shadowColor = uiSystem.defaultShadowColor?.copy();
        /** @property {number} - Size of shadow blur */
        this.shadowBlur = uiSystem.defaultShadowBlur;
        /** @property {Vector2} - Offset of shadow blur */
        this.shadowOffset = uiSystem.defaultShadowOffset?.copy();
        /** @property {number} - Optional navigation order index, lower values are selected first */
        this.navigationIndex = undefined;
        /** @property {boolean} - Should this be auto selected by navigation? Must also have valid navigation index. */
        this.navigationAutoSelect = false;
        
        uiSystem.uiObjects.push(this);
    }

    /** Add a child UIObject to this object
     *  @param {UIObject} child */
    addChild(child)
    {
        ASSERT(!child.parent && !this.children.includes(child));
        this.children.push(child);
        child.parent = this;
    }

    /** Remove a child UIObject from this object
     *  @param {UIObject} child */
    removeChild(child)
    {
        ASSERT(child.parent === this && this.children.includes(child));
        this.children.splice(this.children.indexOf(child), 1);
        child.parent = undefined;
    }

    /** Destroy this object, destroy its children, detach its parent, and mark it for removal */
    destroy()
    {
        if (this.destroyed)
            return;

        // disconnect from parent and destroy children
        this.destroyed = 1;
        this.parent && this.parent.removeChild(this);
        for (const child of this.children)
        {
            child.parent = 0;
            child.destroy();
        }
    }

    /** Check if the mouse is overlapping a box in screen space
     *  @return {boolean} - True if overlapping */
    isMouseOverlapping()
    {
        if (!mouseInWindow) return false;

        const size = !isTouchDevice ? this.size :
                this.size.add(vec2(this.extraTouchSize || 0));
        const pos = uiSystem.screenToNative(mousePosScreen);
        return isOverlapping(this.pos, size, pos);
    }

    /** Update the object, called automatically by plugin once each frame */
    update()
    {
        // call the custom update callback
        this.onUpdate();

        // unset active if disabled
        if (this.disabled && this == uiSystem.activeObject)
            uiSystem.activeObject = undefined;

        const wasHover = uiSystem.lastHoverObject === this;
        const isActive = this.isActiveObject();
        const mouseDown = mouseIsDown(0);
        const mousePress = this.dragActivate ? mouseDown : mouseWasPressed(0);
        if (this.canBeHover)
        if (!uiSystem.navigationMode) // no mouse hover in navigation mode
        if (mousePress || isActive || (!mouseDown && !isTouchDevice))
        if (!uiSystem.hoverObject && this.isMouseOverlapping())
            uiSystem.hoverObject = this;
        if (this.isHoverObject())
        {
            if (!this.disabled)
            {
                if (mousePress)
                {
                    if (this.interactive)
                    {
                        if (!this.dragActivate || (!wasHover || mouseWasPressed(0)))
                            this.onPress();
                        this.soundPress && this.soundPress.play();
                        if (uiSystem.activeObject && !isActive)
                            uiSystem.activeObject.onRelease();
                        uiSystem.activeObject = this;
                    }
                }
                if (!mouseDown && this.isActiveObject() && this.interactive)
                {
                    this.onClick();
                    this.soundClick && this.soundClick.play();
                }
            }

            // clear mouse was pressed state even when disabled
            mousePress && inputClearKey(0,0,0,1,0);
        }
        if (isActive)
        if (!mouseDown || (this.dragActivate && !this.isHoverObject()))
        {
            this.onRelease();
            this.soundRelease && this.soundRelease.play();
            uiSystem.activeObject = undefined;
        }

        // call enter/leave events
        if (this.isHoverObject() !== wasHover)
            this.isHoverObject() ? this.onEnter() : this.onLeave();
    }

    /** Render the object, called automatically by plugin once each frame */
    render()
    {
        // call the custom render callback
        this.onRender();

        if (!this.size.x || !this.size.y) return;

        const isNavigationObject = this.isNavigationObject();
        const lineColor = isNavigationObject ? this.color :
            this.interactive && this.isActiveObject() && !this.disabled ?
            this.color : this.lineColor;
        const color = isNavigationObject ? this.hoverColor :
            this.disabled ? this.disabledColor : 
            this.interactive ? 
                this.isHoverObject() ? this.hoverColor : 
                this.isActiveObject() ? this.activeColor || this.color : 
                this.color : this.color;
        const lineWidth = this.lineWidth * (isNavigationObject ? 1.5 : 1);
        
        uiSystem.drawRect(this.pos, this.size, color, lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor, this.shadowBlur, this.shadowOffset);
    }

    /** Get the size for text with overrides and scale
     *  @return {Vector2} */
    getTextSize()
    {
        return vec2(
            this.textWidth  || this.textFitScale * this.size.x, 
            this.textHeight || this.textFitScale * this.size.y);
    }

    /** Called when the navigation button is pressed on this object */
    navigatePressed()
    {
        this.onClick();
        this.soundClick && this.soundClick.play();
    }

    /** @return {boolean} - Is the mouse hovering over this element */
    isHoverObject() { return uiSystem.hoverObject === this; }

    /** @return {boolean} - Is the mouse held onto this element */
    isActiveObject() { return uiSystem.activeObject === this; }

    /** @return {boolean} - Is the gamepad or keyboard navigation object */
    isNavigationObject() { return uiSystem.navigationObject === this; }

    /** @return {boolean} - Can it be interacted with */
    isInteractive() { return this.interactive && this.visible && !this.disabled;}

    /** Returns string containing info about this object for debugging
     *  @return {string} */
    toString()
    {
        if (!debug) return;
        
        let text = 'type = ' + this.constructor.name;
        if (this.text)
            text += '\ntext = ' + this.text;
        if (this.pos.x || this.pos.y)
            text += '\npos = ' + this.pos;
        if (this.localPos.x || this.localPos.y)
            text += '\localPos = ' + this.localPos;
        if (this.size.x || this.size.y)
            text += '\nsize = ' + this.size;
        if (this.color)
            text += '\ncolor = ' + this.color;
        return text;
    }

    /** Called if uiDebug is enabled
     *  @param {boolean} visible */
    renderDebug(visible=true)
    {
        // apply color based on state
        const color = 
            !visible ? GREEN :
            this.isHoverObject() ? YELLOW : 
            this.disabled ? PURPLE :
            this.interactive ? RED : BLUE;
        uiSystem.drawRect(this.pos, this.size, CLEAR_BLACK, 4, color);
    }

    /** Called each frame before object updates */
    onUpdate() {}

    /** Called each frame before object renders */
    onRender() {}

    /** Called when the mouse enters the object */
    onEnter() {}

    /** Called when the mouse leaves the object */
    onLeave() {}

    /** Called when the mouse is pressed while over the object */
    onPress() {}

    /** Called when the mouse is released while over the object */
    onRelease() {}

    /** Called when user clicks on this object */
    onClick() {}

    /** Called when the state of this object changes */
    onChange() {}
};

///////////////////////////////////////////////////////////////////////////////
/** 
 * UIText - A UI object that displays text
 * @extends UIObject
 * @memberof UISystem
 */
class UIText extends UIObject
{
    /** Create a UIText object
     *  @param {Vector2} [pos]
     *  @param {Vector2} [size]
     *  @param {string}  [text]
     *  @param {string}  [align]
     *  @param {string}  [font=uiSystem.defaultFont]
     */
    constructor(pos, size, text='', align='center', font=uiSystem.defaultFont)
    {
        super(pos, size);

        ASSERT(isString(text), 'ui text must be a string');
        ASSERT(['left','center','right'].includes(align), 'ui text align must be left, center, or right');
        ASSERT(isString(font), 'ui text font must be a string');

        // set properties
        this.text = text;
        this.align = align;
        this.font = font;

        // text can not be a hover object by default
        this.canBeHover = false;
        
        // no background by default
        this.color = CLEAR_BLACK;
        this.shadowColor = CLEAR_BLACK;
        this.gradientColor = undefined;
        this.lineWidth = 0;

        // use max fit scale by default
        this.textFitScale = 1;
    }
    render()
    {
        super.render();

        // render the text
        const textSize = this.getTextSize();
        uiSystem.drawText(this.text, this.pos, textSize, this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow, this.shadowColor, this.shadowBlur, this.shadowOffset);
    }
}

///////////////////////////////////////////////////////////////////////////////
/** 
 * UITile - A UI object that displays a tile image
 * @extends UIObject
 * @memberof UISystem
 */
class UITile extends UIObject
{
    /** Create a UITile object
     *  @param {Vector2}  [pos]
     *  @param {Vector2}  [size]
     *  @param {TileInfo} [tileInfo]
     *  @param {Color}    [color=WHITE]
     *  @param {number}   [angle]
     *  @param {boolean}  [mirror]
     */
    constructor(pos, size, tileInfo, color=WHITE, angle=0, mirror=false)
    {
        super(pos, size);

        ASSERT(tileInfo instanceof TileInfo, 'ui tile tileInfo must be a TileInfo');
        ASSERT(isColor(color), 'ui tile color must be a color');
        ASSERT(isNumber(angle), 'ui tile angle must be a number');

        /** @property {TileInfo} - Tile image to use */
        this.tileInfo = tileInfo;
        /** @property {number} - Angle to rotate in radians */
        this.angle = angle;
        /** @property {boolean} - Should it be mirrored? */
        this.mirror = mirror;
        // set properties
        this.color = color.copy();

        // no shadow by default
        this.shadowColor = CLEAR_BLACK;
    }
    render()
    {
        uiSystem.drawTile(this.pos, this.size, this.tileInfo, this.color, this.angle, this.mirror, this.shadowColor, this.shadowBlur, this.shadowOffset);
    }
}

///////////////////////////////////////////////////////////////////////////////
/** 
 * UIButton - A UI object that acts as a button
 * @extends UIObject
 * @memberof UISystem
 */
class UIButton extends UIObject
{
    /** Create a UIButton object
     *  @param {Vector2} [pos]
     *  @param {Vector2} [size]
     *  @param {string}  [text]
     *  @param {Color}   [color=uiSystem.defaultButtonColor]
     */
    constructor(pos, size, text='', color=uiSystem.defaultButtonColor)
    {
        super(pos, size);

        ASSERT(isString(text), 'ui button must be a string');
        ASSERT(isColor(color), 'ui button color must be a color');

        /** @property {Vector2} - Text offset for the button */
        this.textOffset = vec2();

        // set properties
        this.text = text;
        this.color = color.copy();
        this.interactive = true;
    }
    render()
    {
        super.render();
        
        // draw the text scaled to fit
        const textSize = this.getTextSize();
        uiSystem.drawText(this.text, this.pos.add(this.textOffset), textSize, 
            this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
    }
}

///////////////////////////////////////////////////////////////////////////////
/** 
 * UICheckbox - A UI object that acts as a checkbox
 * @extends UIObject
 * @memberof UISystem
 */
class UICheckbox extends UIObject
{
    /** Create a UICheckbox object
     *  @param {Vector2} [pos]
     *  @param {Vector2} [size]
     *  @param {boolean} [checked]
     *  @param {string}  [text]
     *  @param {Color}   [color=uiSystem.defaultButtonColor]
     */
    constructor(pos, size, checked=false, text='', color=uiSystem.defaultButtonColor)
    {
        super(pos, size);

        ASSERT(isString(text), 'ui checkbox must be a string');
        ASSERT(isColor(color), 'ui checkbox color must be a color');

        /** @property {boolean} - Current percentage value of this scrollbar 0-1 */
        this.checked = checked;
        // set properties
        this.text = text;
        this.color = color.copy();
        this.interactive = true;
    }
    onClick()
    {
        this.checked = !this.checked;
        this.onChange();
    }
    render()
    {
        super.render();
        if (this.checked)
        {
            const p = this.cornerRadius / min(this.size.x, this.size.y) * 2;
            const length = lerp(1, 2**.5/2, p) / 2;
            let s = this.size.scale(length);
            uiSystem.drawLine(this.pos.add(s.multiply(vec2(-1))), this.pos.add(s.multiply(vec2(1))), this.lineWidth, this.lineColor);
            uiSystem.drawLine(this.pos.add(s.multiply(vec2(-1,1))), this.pos.add(s.multiply(vec2(1,-1))), this.lineWidth, this.lineColor);
        }
        
        // draw the text next to the checkbox
        const textSize = this.getTextSize();
        const pos = this.pos.add(vec2(this.size.x,0));
        uiSystem.drawText(this.text, pos, textSize, 
            this.textColor, this.textLineWidth, this.textLineColor, 'left', this.font, this.fontStyle, false, this.textShadow);
    }
}

///////////////////////////////////////////////////////////////////////////////
/** 
 * UIScrollbar - A UI object that acts as a scrollbar
 * @extends UIObject
 * @memberof UISystem
 */
class UIScrollbar extends UIObject
{
    /** Create a UIScrollbar object
     *  @param {Vector2} [pos]
     *  @param {Vector2} [size]
     *  @param {number}  [value]
     *  @param {string}  [text]
     *  @param {Color}   [color=uiSystem.defaultButtonColor]
     *  @param {Color}   [handleColor=WHITE]
     */
    constructor(pos, size, value=.5, text='', color=uiSystem.defaultButtonColor, handleColor=WHITE)
    {
        super(pos, size);

        ASSERT(isNumber(value), 'ui scrollbar value must be a number');
        ASSERT(isString(text), 'ui scrollbar must be a string');
        ASSERT(isColor(color), 'ui scrollbar color must be a color');
        ASSERT(isColor(handleColor), 'ui scrollbar handleColor must be a color');

        /** @property {number} - Current percentage value of this scrollbar 0-1 */
        this.value = value;
        /** @property {Color} - Color for the handle part of the scrollbar */
        this.handleColor = handleColor.copy();

        // set properties
        this.text = text;
        this.color = color.copy();
        this.interactive = true;
    }
    update()
    {
        super.update();
        if (!this.interactive)
            return;

        const oldValue = this.value;
        if (this.isActiveObject())
        {
            // handle horizontal or vertical scrollbar
            const isHorizontal = this.size.x > this.size.y;
            const handleSize = isHorizontal ? this.size.y : this.size.x;
            const barSize = isHorizontal ? this.size.x : this.size.y;
            const centerPos = isHorizontal ? this.pos.x : this.pos.y;

            // check if value changed
            const handleWidth = barSize - handleSize;
            const p1 = centerPos - handleWidth/2;
            const p2 = centerPos + handleWidth/2;
            const p = uiSystem.screenToNative(mousePosScreen);
            this.value = isHorizontal ? 
                percent(p.x, p1, p2) :
                percent(p.y, p2, p1);
        }
        else if (this.isNavigationObject())
        {
            // gamepad/keyboard navigation adjustment
            const direction = uiSystem.getNavigationOtherDirection();
            if (!uiSystem.navigationTimer.active())
                this.value = clamp(this.value + direction*.01);
        }
        this.value === oldValue || this.onChange();
    }
    render()
    {
        super.render();

        // handle horizontal or vertical scrollbar
        const isHorizontal = this.size.x > this.size.y;
        const handleSize = isHorizontal ? this.size.y : this.size.x;
        const barSize = isHorizontal ? this.size.x : this.size.y;
        const centerPos = isHorizontal ? this.pos.x : this.pos.y;
        
        // draw the scrollbar handle
        const handleWidth = barSize - handleSize;
        const p1 = centerPos - handleWidth/2;
        const p2 = centerPos + handleWidth/2;
        const handlePos = isHorizontal ? 
            vec2(lerp(p1, p2, this.value), this.pos.y) :
            vec2(this.pos.x, lerp(p2, p1, this.value))
        const handleColor = this.disabled ? this.disabledColor : this.handleColor;
        uiSystem.drawRect(handlePos, vec2(handleSize), handleColor, this.lineWidth, this.lineColor, this.cornerRadius, this.gradientColor);

        // draw the text scaled to fit on the scrollbar
        const textSize = this.getTextSize();
        uiSystem.drawText(this.text, this.pos, textSize, 
            this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
    }
    navigatePressed()
    {
        // toggle value between 0 and 1
        this.value = this.value ? 0 : 1;
        this.onRelease();
        super.navigatePressed();
    }
}

///////////////////////////////////////////////////////////////////////////////
/** 
 * VideoPlayerUIObject - A UI object that plays video
 * @extends UIObject
 * @example
 * // Create a video player UI object
 * const video = new VideoPlayerUIObject(vec2(400, 300), vec2(320, 240), 'cutscene.mp4', true);
 * video.play();
 * @memberof UISystem
 */
class UIVideo extends UIObject
{
    /** Create a video player UI object
     *  @param {Vector2} [pos]
     *  @param {Vector2} [size]
     *  @param {string} src - Video file path or URL
     *  @param {boolean} [autoplay=false] - Start playing immediately?
     *  @param {boolean} [loop=false] - Loop the video?
     *  @param {number} [volume=1] - Volume percent scaled by global volume (0-1)
     */
    constructor(pos, size, src, autoplay=false, loop=false, volume=1)
    {
        super(pos, size || vec2());
        
        ASSERT(isString(src), 'video src must be a string');
        ASSERT(isNumber(volume), 'video volume must be a number');

        this.color = BLACK; // default to black background
        this.cornerRadius = 0; // default to no corner radius

        /** @property {number} - The video volume */
        this.volume = volume;

        // create video element
        /** @property {HTMLVideoElement} - The video player */
        this.video = document.createElement('video');
        this.video.loop = loop;
        this.video.volume = clamp(volume * soundVolume);
        this.video.muted = !soundEnable;
        this.video.style.display = 'none';
        this.video.src = src;
        document.body.appendChild(this.video);
        autoplay && this.play();
    }
    
    /** Play or resume the video
     *  @return {Promise} Promise that resolves when playback starts */
    play()
    {
        // try to play the video, catch any errors (autoplay may be blocked)
        const promise = this.video.play();
        promise?.catch(()=>{});
        return promise;
    }
    
    /** Pause the video */
    pause() { this.video.pause(); }
    
    /** Stop and reset the video */
    stop() { this.video.pause(); this.video.currentTime = 0; }
    
    /** Check if video is currently loading
     *  @return {boolean} */
    isLoading()
    { return this.video.readyState < this.video.HAVE_CURRENT_DATA; }
    
    /** Check if video is currently paused
     *  @return {boolean} */
    isPaused() { return this.video.paused; }
    
    /** Check if video is currently playing
     *  @return {boolean} */
    isPlaying()
    { return !this.isPaused() && !this.hasEnded() && !this.isLoading(); }
    
    /** Check if video has ended playing
     *  @return {boolean} */
    hasEnded() { return this.video.ended; }
    
    /** Set volume (0-1)
     *  @param {number} volume - Volume level (0-1) */
    setVolume(volume)
    {
        this.volume = volume;
        this.video.volume = clamp(volume * soundVolume);
    }
    
    /** Set playback speed
     *  @param {number} rate - Playback rate multiplier */
    setPlaybackRate(rate) { this.video.playbackRate = rate; }
    
    /** Get current time in seconds
     *  @return {number} Current playback time */
    getCurrentTime() { return this.video.currentTime || 0; }
    
    /** Get duration in seconds
     *  @return {number} Total video duration */
    getDuration() { return this.video.duration || 0; }
    
    /** Get the native video dimensions 
     *  @return {Vector2} Video dimensions (may be 0,0 if metadata not loaded) */
    getVideoSize()
    { return vec2(this.video.videoWidth, this.video.videoHeight); }
    
    /** Seek to time in seconds
     *  @param {number} time - Time in seconds to seek to */
    setTime(time)
    { this.video.currentTime = clamp(time, 0, this.getDuration()); }

    update()
    {
        super.update();

        // update volume based on global sound volume
        this.video.volume = clamp(this.volume * soundVolume);
    }
    
    /** Render video to UI canvas */
    render()
    {
        super.render();

        if (this.isLoading())
            return;
        const context = uiSystem.uiContext;
        const s = this.size;
        context.save();
        context.translate(this.pos.x, this.pos.y);
        context.drawImage(this.video, -s.x/2, -s.y/2, s.x, s.y);
        context.restore();
    }
    
    /** Clean up video on destroy */
    destroy()
    {
        if (this.destroyed)
            return;

        this.video.pause();
        this.video.remove();
        super.destroy();
    }
}