littlejsengine
Version:
LittleJS - Tiny and Fast HTML5 Game Engine
966 lines (872 loc) • 36.7 kB
JavaScript
/**
* LittleJS User Interface Plugin
* - call new UISystemPlugin() to setup the UI system
* - Nested Menus
* - Text
* - Buttons
* - Checkboxes
* - Images
* @namespace UISystem
*/
'use strict';
///////////////////////////////////////////////////////////////////////////////
/** Global UI system plugin object
* @type {UISystemPlugin}
* @memberof UISystem */
let uiSystem;
///////////////////////////////////////////////////////////////////////////////
/**
* 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.defaultTextScale = .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);
// 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 {number} - If set ui coords will be renormalized to this canvas height */
this.nativeHeight = 0;
engineAddPlugin(uiUpdate, uiRender);
// setup recursive update and render
// update in reverse order to detect mouse enter/leave
function uiUpdate()
{
function updateInvisibleObject(o)
{
// update invisible objects
for (const c of o.children)
updateInvisibleObject(c);
o.updateInvisible();
}
function updateObject(o)
{
if (o.visible)
{
// set position in parent space
if (o.parent)
o.pos = o.localPos.add(o.parent.pos);
// update in reverse order to detect mouse enter/leave
for (let i=o.children.length; i--;)
updateObject(o.children[i]);
o.update();
}
else
updateInvisibleObject(o);
}
// reset hover object at start of update
uiSystem.lastHoverObject = uiSystem.hoverObject;
uiSystem.hoverObject = undefined;
// 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 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;
if (o.parent)
o.pos = o.localPos.add(o.parent.pos);
o.render();
for (const c of o.children)
renderObject(c);
}
uiSystem.uiObjects.forEach(o=> o.parent || renderObject(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)
{
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] */
drawTile(pos, size, tileInfo, color=uiSystem.defaultColor, angle=0, mirror=false)
{
drawTile(pos, size, tileInfo, color, angle, mirror, CLEAR_BLACK, false, true, uiSystem.uiContext);
}
/** 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]
*/
drawText(text, pos, size, color=uiSystem.defaultColor, lineWidth=uiSystem.defaultLineWidth, lineColor=uiSystem.defaultLineColor, align='center', font=uiSystem.defaultFont, fontStyle='', applyMaxWidth=true, textShadow=undefined)
{
if (textShadow)
drawTextScreen(text, pos.add(textShadow), size.y, BLACK, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, uiSystem.uiContext);
drawTextScreen(text, pos, size.y, color, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, uiSystem.uiContext);
}
/**
* @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] - continously 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);
}
}
///////////////////////////////////////////////////////////////////////////////
/**
* 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.textScale = uiSystem.defaultTextScale;
/** @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();
uiSystem.uiObjects.push(this);
/** @property {Vector2} - How much to offset the text shadow or undefined */
this.textShadow = undefined;
}
/** 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();
const wasHover = uiSystem.lastHoverObject === this;
const isActive = this.isActiveObject();
const mouseDown = mouseIsDown(0);
const mousePress = this.dragActivate ? mouseDown : mouseWasPressed(0);
if (this.canBeHover)
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();
if (this.soundPress)
this.soundPress.play();
if (uiSystem.activeObject && !isActive)
uiSystem.activeObject.onRelease();
uiSystem.activeObject = this;
}
}
if (!mouseDown && this.isActiveObject() && this.interactive)
{
this.onClick();
if (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();
if (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()
{
if (!this.size.x || !this.size.y) return;
const lineColor = this.interactive && this.isActiveObject() && !this.disabled ? this.color : this.lineColor;
const color = this.disabled ? this.disabledColor : this.interactive ? this.isActiveObject() ? this.activeColor || this.color : this.isHoverObject() ? this.hoverColor : this.color : this.color;
uiSystem.drawRect(this.pos, this.size, color, this.lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor, this.shadowBlur, this.shadowOffset);
}
/** Special update when object is not visible */
updateInvisible()
{
// reset input state when not visible
if (this.isActiveObject())
uiSystem.activeObject = undefined;
}
/** Get the size for text with overrides and scale
* @return {Vector2}
*/
getTextSize()
{
return vec2(
this.textWidth || this.textScale * this.size.x,
this.textHeight || this.textScale * this.size.y);
}
/** @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; }
/** Called each frame when object updates */
onUpdate() {}
/** 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;
// make text not outlined by default
this.lineWidth = 0;
// text can not be a hover object by default
this.canBeHover = false;
}
render()
{
// only render the text
const textSize = this.getTextSize();
uiSystem.drawText(this.text, this.pos, textSize, this.textColor, this.lineWidth, this.lineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
}
}
///////////////////////////////////////////////////////////////////////////////
/**
* 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();
}
render()
{
uiSystem.drawTile(this.pos, this.size, this.tileInfo, this.color, this.angle, this.mirror);
}
}
///////////////////////////////////////////////////////////////////////////////
/**
* 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');
// 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, textSize,
this.textColor, 0, undefined, 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, 0, undefined, '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.isActiveObject() && this.interactive)
{
// 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 oldValue = this.value;
const p = uiSystem.screenToNative(mousePosScreen);
this.value = isHorizontal ?
percent(p.x, p1, p2) :
percent(p.y, p2, p1);
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, 0, undefined, this.align, this.font, this.fontStyle, true, this.textShadow);
}
}
///////////////////////////////////////////////////////////////////////////////
/**
* 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 {float} - 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} */
isLoadng()
{ 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.isLoadng(); }
/** 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.isLoadng())
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();
}
}