plugins_uiSystem.js

/**
 * LittleJS User Interface Plugin
 * - Nested Menus
 * - Text
 * - Buttons
 * - Checkboxes
 * - Images
 * @namespace UISystemPlugin
 */

'use strict';

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

// ui defaults

/** Default fill color for UI elements
 *  @type {Color}
 *  @memberof UISystemPlugin */
let uiDefaultColor       = WHITE;

/** Default outline color for UI elements
 *  @type {Color}
 *  @memberof UISystemPlugin */
let uiDefaultLineColor   = BLACK;

/** Default text color for UI elements
 *  @type {Color}
 *  @memberof UISystemPlugin */
let uiDefaultTextColor   = BLACK;

/** Default button color for UI elements
 *  @type {Color}
 *  @memberof UISystemPlugin */
let uiDefaultButtonColor = hsl(0,0,.5);

/** Default hover color for UI elements
 *  @type {Color}
 *  @memberof UISystemPlugin */
let uiDefaultHoverColor  = hsl(0,0,.7);

/** Default line width for UI elements
 *  @type {number}
 *  @memberof UISystemPlugin */
let uiDefaultLineWidth = 4;

/** Default font for UI elements
 *  @type {string}
 *  @memberof UISystemPlugin */
let uiDefaultFont = 'arial';

/** List of all UI elements
 *  @type {Array<UIObject>}
 *  @memberof UISystemPlugin */
let uiObjects = [];

/** Context to render UI elements to
 *  @type {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D}
 *  @memberof UISystemPlugin */
let uiContext;

/** Set up the UI system, typically called in gameInit
 *  @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context=overlayContext]
 *  @memberof UISystemPlugin */
function initUISystem(context=overlayContext)
{
    uiContext = context;
    engineAddPlugin(uiUpdate, uiRender);

    // setup recursive update and render
    function uiUpdate()
    {
        function updateObject(o)
        {
            if (!o.visible)
                return;
            if (o.parent)
                o.pos = o.localPos.add(o.parent.pos);
            o.update();
            for(const c of o.children)
                updateObject(c);
        }
        uiObjects.forEach(o=> o.parent || updateObject(o));
    }
    function uiRender()
    {
        function renderObject(o)
        {
            if (!o.visible)
                return;
            if (o.parent)
                o.pos = o.localPos.add(o.parent.pos);
            o.render();
            for(const c of o.children)
                renderObject(c);
        }
        uiObjects.forEach(o=> o.parent || renderObject(o));
    }
}

/** Draw a rectangle to the UI context
 *  @param {Vector2} pos
 *  @param {Vector2} size
 *  @param {Color}   [color=uiDefaultColor]
 *  @param {number}  [lineWidth=uiDefaultLineWidth]
 *  @param {Color}   [lineColor=uiDefaultLineColor]
 *  @memberof UISystemPlugin */
function drawUIRect(pos, size, color=uiDefaultColor, lineWidth=uiDefaultLineWidth, lineColor=uiDefaultLineColor)
{
    uiContext.fillStyle = color.toString();
    uiContext.beginPath();
    uiContext.rect(pos.x-size.x/2, pos.y-size.y/2, size.x, size.y);
    uiContext.fill();
    if (lineWidth)
    {
        uiContext.strokeStyle = lineColor.toString();
        uiContext.lineWidth = lineWidth;
        uiContext.stroke();
    }
}

/** Draw a line to the UI context
 *  @param {Vector2} posA
 *  @param {Vector2} posB
 *  @param {number}  [lineWidth=uiDefaultLineWidth]
 *  @param {Color}   [lineColor=uiDefaultLineColor]
 *  @memberof UISystemPlugin */
function drawUILine(posA, posB, lineWidth=uiDefaultLineWidth, lineColor=uiDefaultLineColor)
{
    uiContext.strokeStyle = lineColor.toString();
    uiContext.lineWidth = lineWidth;
    uiContext.beginPath();
    uiContext.lineTo(posA.x, posA.y);
    uiContext.lineTo(posB.x, posB.y);
    uiContext.stroke();
}

/** Draw a tile to the UI context
 *  @param {Vector2}  pos
 *  @param {Vector2}  size
 *  @param {TileInfo} tileInfo
 *  @param {Color}    [color=uiDefaultColor]
 *  @param {number}   [angle]
 *  @param {boolean}  [mirror]
 *  @memberof UISystemPlugin */
function drawUITile(pos, size, tileInfo, color=uiDefaultColor, angle=0, mirror=false)
{
    drawTile(pos, size, tileInfo, color, angle, mirror, BLACK, false, true, uiContext);
}

/** Draw text to the UI context
 *  @param {string}  text
 *  @param {Vector2} pos
 *  @param {Vector2} size
 *  @param {Color}   [color=uiDefaultColor]
 *  @param {number}  [lineWidth=uiDefaultLineWidth]
 *  @param {Color}   [lineColor=uiDefaultLineColor]
 *  @param {string}  [align]
 *  @param {string}  [font=uiDefaultFont]
 *  @memberof UISystemPlugin */
function drawUIText(text, pos, size, color=uiDefaultColor, lineWidth=uiDefaultLineWidth, lineColor=uiDefaultLineColor, align='center', font=uiDefaultFont)
{
    drawTextScreen(text, pos, size.y, color, lineWidth, lineColor, align, font, size.x, uiContext);
}

///////////////////////////////////////////////////////////////////////////////
/** 
 * UI Object - Base level object for all UI elements
 */
class UIObject
{
    /** Create a UIObject
     *  @param {Vector2}  [pos=(0,0)]
     *  @param {Vector2}  [size=(1,1)]
     */
    constructor(pos=vec2(), size=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} */
        this.color      = uiDefaultColor;
        /** @property {Color} */
        this.lineColor  = uiDefaultLineColor;
        /** @property {Color} */
        this.textColor  = uiDefaultTextColor;
        /** @property {Color} */
        this.hoverColor = uiDefaultHoverColor;
        /** @property {number} */
        this.lineWidth  = uiDefaultLineWidth;
        /** @property {string} */
        this.font       = uiDefaultFont;
        /** @property {boolean} */
        this.visible    = true;
        /** @property {Array<UIObject>} */
        this.children   = [];
        /** @property {UIObject} */
        this.parent     = undefined;
        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;
    }

    /** Update the object, called automatically by plugin once each frame */
    update()
    {
        // track mouse input
        const mouseWasOver = this.mouseIsOver;
        const mouseDown = mouseIsDown(0);
        if (!mouseDown || isTouchDevice)
        {
            this.mouseIsOver = isOverlapping(this.pos, this.size, mousePosScreen);
            if (!mouseDown && isTouchDevice)
                this.mouseIsOver = false;
            if (this.mouseIsOver && !mouseWasOver)
                this.onEnter();
            if (!this.mouseIsOver && mouseWasOver)
                this.onLeave();
        }
        if (mouseWasPressed(0) && this.mouseIsOver)
        {
            this.mouseIsHeld = true;
            this.onPress();
            if (isTouchDevice)
                this.mouseIsOver = false;
        }
        else if (this.mouseIsHeld && !mouseDown)
        {
            this.mouseIsHeld = false;
            this.onRelease();
        }
    }

    /** Render the object, called automatically by plugin once each frame */
    render()
    {
        if (this.size.x && this.size.y)
            drawUIRect(this.pos, this.size, this.color, this.lineWidth, this.lineColor);
    }

    /** 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 the state of this object changes */
    onChange()  {}
}

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

        /** @property {string} */
        this.text = text;
        /** @property {string} */
        this.align = align;

        this.font = font; // set font
        this.lineWidth = 0; // set text to not be outlined by default
    }
    render()
    {
        drawUIText(this.text, this.pos, this.size, this.textColor, this.lineWidth, this.lineColor, this.align, this.font);
    }
}

///////////////////////////////////////////////////////////////////////////////
/** 
 * UITile - A UI object that displays a tile image
 * @extends UIObject
 */
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);

        /** @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;
        this.color = color;
    }
    render()
    {
        drawUITile(this.pos, this.size, this.tileInfo, this.color, this.angle, this.mirror);
    }
}

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

        /** @property {string} */
        this.text = text;
        this.color = color;
    }
    render()
    {
        const lineColor = this.mouseIsHeld ? this.color : this.lineColor;
        const color = this.mouseIsOver? this.hoverColor : this.color;
        drawUIRect(this.pos, this.size, color, this.lineWidth, lineColor);
        const textSize = vec2(this.size.x, this.size.y*.8);
        drawUIText(this.text, this.pos, textSize, 
            this.textColor, 0, undefined, this.align, this.font);
    }
}

///////////////////////////////////////////////////////////////////////////////
/** 
 * UICheckbox - A UI object that acts as a checkbox
 * @extends UIObject
 */
class UICheckbox extends UIObject
{
    /** Create a UICheckbox object
     *  @param {Vector2} [pos]
     *  @param {Vector2} [size]
     *  @param {boolean} [checked]
     */
    constructor(pos, size, checked=false)
    {
        super(pos, size);

        /** @property {boolean} */
        this.checked = checked;
    }
    onPress()
    {
        this.checked = !this.checked;
        this.onChange();
    }
    render()
    {
        const color = this.mouseIsOver? this.hoverColor : this.color;
        drawUIRect(this.pos, this.size, color, this.lineWidth, this.lineColor);
        if (this.checked)
        {
            // draw an X if checked
            drawUILine(this.pos.add(this.size.multiply(vec2(-.5,-.5))), this.pos.add(this.size.multiply(vec2(.5,.5))), this.lineWidth, this.lineColor);
            drawUILine(this.pos.add(this.size.multiply(vec2(-.5,.5))), this.pos.add(this.size.multiply(vec2(.5,-.5))), this.lineWidth, this.lineColor);
        }
    }
}

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

        /** @property {number} */
        this.value = value;
        /** @property {string} */
        this.text = text;
        this.color = color;
        this.handleColor = handleColor;
    }
    update()
    {
        super.update();
        if (this.mouseIsHeld)
        {
            const handleSize = vec2(this.size.y);
            const handleWidth = this.size.x - handleSize.x;
            const p1 = this.pos.x - handleWidth/2;
            const p2 = this.pos.x + handleWidth/2;
            const oldValue = this.value;
            this.value = percent(mousePosScreen.x, p1, p2);
            this.value == oldValue || this.onChange();
        }
    }
    render()
    {
        const lineColor = this.mouseIsHeld ? this.color : this.lineColor;
        const color = this.mouseIsOver? this.hoverColor : this.color;
        drawUIRect(this.pos, this.size, color, this.lineWidth, lineColor);
    
        const handleSize = vec2(this.size.y);
        const handleWidth = this.size.x - handleSize.x;
        const p1 = this.pos.x - handleWidth/2;
        const p2 = this.pos.x + handleWidth/2;
        const handlePos = vec2(lerp(this.value, p1, p2), this.pos.y);
        const barColor = this.mouseIsHeld ? this.color : this.handleColor;
        drawUIRect(handlePos, handleSize, barColor, this.lineWidth, this.lineColor);

        const textSize = vec2(this.size.x, this.size.y*.8);
        drawUIText(this.text, this.pos, textSize, 
            this.textColor, 0, undefined, this.align, this.font);
    }
}