@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
805 lines (643 loc) • 19.1 kB
JavaScript
import { assert } from "../core/assert.js";
import Signal from "../core/events/signal/Signal.js";
import { SignalBinding } from "../core/events/signal/SignalBinding.js";
import AABB2 from "../core/geom/2d/aabb/AABB2.js";
import { m3_cm_compose_transform } from "../core/geom/mat3/m3_cm_compose_transform.js";
import Vector1 from "../core/geom/Vector1.js";
import Vector2 from "../core/geom/Vector2.js";
import { epsilonEquals } from "../core/math/epsilonEquals.js";
import { FLT_EPSILON_32 } from "../core/math/FLT_EPSILON_32.js";
import { setElementVisibility } from "./setElementVisibility.js";
import { writeCssTransformMatrix } from "./writeCssTransformMatrix.js";
/**
*
* @enum {number}
*/
export const ViewFlags = {
Linked: 1,
Destroyed: 2,
Visible: 4
};
/**
* @readonly
* @type {ViewFlags|number}
*/
const INITIAL_FLAGS = ViewFlags.Visible;
/**
* Building block for UI elements.
* Implements a DOM model, built around HTML elements (svg and XML is allowed as well)
* Base View class.
* @class
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
class View {
/**
* Signal bindings, these will be linked and unlinked along with the view
* @private
* @readonly
* @type {SignalBinding[]}
*/
bindings = [];
/**
*
* @type {ViewFlags|number}
*/
flags = INITIAL_FLAGS;
/**
* @readonly
* @type {Vector2}
*/
position = new Vector2(0, 0);
/**
* @readonly
* @type {Vector1}
*/
rotation = new Vector1(0);
/**
* @readonly
* @type {Vector2}
*/
scale = new Vector2(1, 1);
/**
* @readonly
* @type {Vector2}
*/
size = new Vector2(0, 0);
/**
* Origin from which rotation and scaling is applied
* @readonly
* @type {Vector2}
*/
transformOrigin = new Vector2(0.5, 0.5);
/**
* @readonly
*/
on = {
/**
* @readonly
*/
linked: new Signal(),
/**
* @readonly
*/
unlinked: new Signal()
};
/**
*
* @type {View[]}
* @readonly
*/
children = [];
/**
*
* @type {View|null}
*/
parent = null;
#transform_written = new Float32Array(9);
#transform_current = new Float32Array(9);
/**
*
* @type {Element|NodeDescription|null}
*/
el = null;
constructor() {
this.position.onChanged.add(this.__updateTransform, this);
this.scale.onChanged.add(this.__updateTransform, this);
this.rotation.onChanged.add(this.__updateTransform, this);
this.size.onChanged.add(this.__setDimensions, this);
this.transformOrigin.onChanged.add(this.__setTransformOrigin, this);
}
/**
* @returns {boolean}
*/
get isLinked() {
return this.getFlag(ViewFlags.Linked);
}
/**
*
* @return {boolean}
*/
get isDestroyed() {
return this.getFlag(ViewFlags.Destroyed);
}
/**
*
* @param {boolean} v
*/
set visible(v) {
if (v === this.getFlag(ViewFlags.Visible)) {
//do nothing
return;
}
this.writeFlag(ViewFlags.Visible, v);
setElementVisibility(this.el, v);
}
/**
*
* @return {boolean}
*/
get visible() {
return this.getFlag(ViewFlags.Visible);
}
/**
*
* @param {number|ViewFlags} flag
* @returns {void}
*/
setFlag(flag) {
this.flags |= flag;
}
/**
*
* @param {number|ViewFlags} flag
* @returns {void}
*/
clearFlag(flag) {
this.flags &= ~flag;
}
/**
*
* @param {number|ViewFlags} flag
* @param {boolean} value
*/
writeFlag(flag, value) {
if (value) {
this.setFlag(flag);
} else {
this.clearFlag(flag);
}
}
/**
*
* @param {number|ViewFlags} flag
* @returns {boolean}
*/
getFlag(flag) {
return (this.flags & flag) === flag;
}
/**
*
* @param {number} x
* @param {number} y
* @private
*/
__setTransformOrigin(x, y) {
const style = this.el.style;
style.transformOrigin = `${x * 100}% ${y * 100}%`;
}
/**
*
* @param {number} x
* @param {number} y
* @private
*/
__setDimensions(x, y) {
const style = this.el.style;
style.width = x + "px";
style.height = y + "px";
}
/**
*
* @private
*/
__updateTransform() {
const position = this.position;
const scale = this.scale;
const rotation = this.rotation.getValue();
m3_cm_compose_transform(this.#transform_current, position.x, position.y, scale.x, scale.y, 0, 0, rotation);
this.#tryWriteTransform();
}
#tryWriteTransform() {
const current = this.#transform_current;
const written = this.#transform_written;
for (let i = 0; i < 9; i++) {
const a = current[i];
const b = written[i];
if (epsilonEquals(a, b, FLT_EPSILON_32)) {
// common path
continue;
}
this.#writeTransform();
return true;
}
return false;
}
#writeTransform() {
writeCssTransformMatrix(this.#transform_current, this.el);
this.#transform_written.set(this.#transform_current);
}
/**
* intended as initialization point when view becomes linked to the visible tree
*/
link() {
if (this.isLinked) {
//do nothing
return;
}
assert.notOk(this.isDestroyed, 'view is destroyed and may not be re-used');
this.setFlag(ViewFlags.Linked);
const signalBindings = this.bindings;
const bindingCount = signalBindings.length;
let i;
for (i = 0; i < bindingCount; i++) {
const binding = signalBindings[i];
binding.link();
}
//link all children also
const children = this.children;
const childCount = children.length;
for (i = 0; i < childCount; i++) {
const child = children[i];
child.link();
}
this.on.linked.send0();
}
/**
* Finalization point, release all used resources and cleanup listeners
*/
unlink() {
if (!this.isLinked) {
//do nothing
return;
}
this.clearFlag(ViewFlags.Linked);
const signalBindings = this.bindings;
const bindingCount = signalBindings.length;
let i;
for (i = 0; i < bindingCount; i++) {
const binding = signalBindings[i];
binding.unlink();
}
//unlink all children also
const children = this.children;
const childCount = children.length;
for (i = 0; i < childCount; i++) {
const child = children[i];
child.unlink();
}
this.on.unlinked.send0();
}
/**
*
* @param {View[]} children
*/
addChildren(children) {
for (let i = 0; i < children.length; i++) {
this.addChild(children[i]);
}
}
/**
*
* @param {View} child
* @returns {View} this
*/
addChild(child) {
assert.defined(child, 'child');
assert.notNull(child, 'child');
assert.isInstanceOf(child, View, 'child');
assert.equal(child.isLinked, false, 'child is already linked somewhere');
assert.notEqual(child, this, 'cannot add self as a child');
assert.notEqual(this.parent, child, 'cannot add existing parent as a child')
child.parent = this;
if (this.isLinked && !child.isLinked) {
child.link();
}
this.children.push(child);
this.el.appendChild(child.el);
return this;
}
/**
*
* @param {Vector2} size
* @param {number} targetX normalized horizontal position for setting child's position. 0 represents left-most , 1 right-most
* @param {number} targetY normalized vertical position for setting child's position. 0 represents top-most , 1 bottom-most
* @param {number} alignmentX
* @param {number} alignmentY
* @param {Vector2} result
*/
computePlacement(size, targetX, targetY, alignmentX, alignmentY, result) {
const p = new Vector2(targetX, targetY);
p.multiply(this.size);
p.add(this.position);
p._sub(size.x * alignmentX, size.y * alignmentY);
result.copy(p);
}
/**
*
* @param {View} child
* @param {number} targetX normalized horizontal position for setting child's position. 0 represents left-most , 1 right-most
* @param {number} targetY normalized vertical position for setting child's position. 0 represents top-most , 1 bottom-most
* @param {number} alignmentX
* @param {number} alignmentY
*/
addChildAt(child, targetX, targetY, alignmentX, alignmentY) {
assert.equal(typeof targetX, "number", `targetX must be of type "number", instead was "${typeof targetX}"`);
assert.equal(typeof targetY, "number", `targetY must be of type "number", instead was "${typeof targetY}"`);
assert.equal(typeof alignmentX, "number", `alignmentX must be of type "number", instead was "${typeof alignmentX}"`);
assert.equal(typeof alignmentY, "number", `alignmentY must be of type "number", instead was "${typeof alignmentY}"`);
this.computePlacement(child.size, targetX, targetY, alignmentX, alignmentY, child.position);
this.addChild(child);
//need to resize the container to ensure future calls work as expected
this.resizeToFitChildren();
}
/**
*
* @param {View} child
* @returns {boolean}
*/
removeChild(child) {
const children = this.children;
const i = children.indexOf(child);
if (i === -1) {
//console.warn('Child not found. this:', this, 'child:', child);
return false;
}
children.splice(i, 1);
this.el.removeChild(child.el);
child.unlink();
child.parent = null;
return true;
}
/**
*
* @param {View} child
* @returns {boolean}
*/
hasChild(child) {
return this.children.indexOf(child) !== -1;
}
removeAllChildren() {
const children = this.children;
const numChildren = children.length;
for (let i = 0; i < numChildren; i++) {
const child = children.pop();
this.el.removeChild(child.el);
child.unlink();
child.parent = null;
}
}
/**
*
* @param {AABB2} aabb
* @param {number} offsetX
* @param {number} offsetY
*/
expandToFit(aabb, offsetX, offsetY) {
const oX = this.position.x + offsetX;
const oY = this.position.y + offsetY;
aabb._expandToFit(oX, oY, oX + this.size.x, oY + this.size.y);
const children = this.children;
let i = 0;
const l = children.length;
for (; i < l; i++) {
const child = children[i];
child.expandToFit(aabb, oX, oY);
}
}
/**
*
* @param {AABB2} [result]
* @returns {AABB2}
*/
computeBoundingBox(result) {
if (result === undefined) {
result = new AABB2();
result.setNegativelyInfiniteBounds();
}
this.expandToFit(result, 0, 0);
return result;
}
resizeToFitChildren() {
const box = new AABB2(0, 0, this.size.x, this.size.y);
const children = this.children;
let i = 0;
const l = children.length;
for (; i < l; i++) {
const child = children[i];
child.expandToFit(box, 0, 0);
}
this.size.set(box.x1, box.y1);
}
/**
*
* @param {Vector2} input
* @param {Vector2} result
* @returns {Vector2} result, same as parameter
*/
positionLocalToGlobal(input, result) {
result.copy(input);
let v = this;
while (v !== null) {
result.add(v.position);
v = v.parent;
}
return result;
}
/**
*
* @param {Vector2} input
* @param {Vector2} result
* @returns {Vector2} result, same as parameter
*/
positionGlobalToLocal(input, result) {
result.copy(input);
let v = this;
while (v !== null) {
result.sub(v.position);
v = v.parent;
}
return result;
}
/**
*
* @param {Vector2} result
*/
computeGlobalScale(result) {
let v = this;
let x = 1;
let y = 1;
while (v !== null) {
x *= v.scale.x;
y *= v.scale.y;
v = v.parent;
}
result.set(x, y);
}
destroy() {
assert.ok(this instanceof View, 'this is not a View');
assert.notOk(this.isLinked, 'view is linked, linked view may not be destroyed');
const children = this.children;
const childrenCount = children.length;
for (let i = 0; i < childrenCount; i++) {
const child = children[i];
//cascade destruction
child.destroy();
}
this.setFlag(ViewFlags.Destroyed);
}
/**
* Will create signal binding that is automatically linked/unlinked along with the view
* Useful for observing state changes when the view is live
* @param {Signal} signal
* @param {function} handler
* @param {*} [context]
* @returns {View} returns self, for call chaining
*/
bindSignal(signal, handler, context) {
const binding = new SignalBinding(signal, handler, context);
this.bindings.push(binding);
if (this.isLinked) {
binding.link();
}
return this;
}
/**
*
* @param {Signal} signal
* @param {function} handler
* @param {*} [context]
* @returns {boolean} true if binding existed and was removed, false otherwise
*/
unbindSignal(signal, handler, context) {
const bindings = this.bindings;
const numBindings = bindings.length;
for (let i = 0; i < numBindings; i++) {
const signalBinding = bindings[i];
if (
signalBinding.signal === signal
&& signalBinding.handler === handler
&& (context === undefined || signalBinding.context === context)
) {
bindings.splice(i, 1);
signalBinding.unlink();
return true;
}
}
// no match found
return false;
}
/**
* Add CSS class to View's dom element
*
* NOTE: Idempotent
*
* @param {string} name
*/
addClass(name) {
assert.isString(name, 'name');
this.el.classList.add(name);
}
/**
* Add multiple CSS calsses to the View's dom element
* @param {string[]} names
*/
addClasses(names) {
const classList = this.el.classList;
const n = names.length;
for (let i = 0; i < n; i++) {
const name = names[i];
assert.isString(name, 'name');
classList.add(name);
}
}
/**
* Remove CSS class from View's dom element
*
* NOTE: Idempotent
*
* @param {string} name
*/
removeClass(name) {
this.el.classList.remove(name);
}
/**
* Remove classes that match the given regular expression
* @param {RegExp} rx
* @returns {string[]} removed classes
*/
removeClassesByPattern(rx) {
const classList = this.el.classList;
const classesToRemove = [];
const initial_class_count = classList.length;
for (let i = 0; i < initial_class_count; i++) {
const className = classList[i];
if (className.search(rx) !== -1) {
classesToRemove.push(className);
}
}
const n = classesToRemove.length;
for (let i = 0; i < n; i++) {
const className = classesToRemove[i];
classList.remove(className);
}
return classesToRemove;
}
/**
* Toggle CSS class of the View's dom element ON or OFF
*
* NOTE: Idempotent
*
* @param {string} name
* @param {boolean} flag if true, will add class, if false will remove it
* @returns {View}
*/
setClass(name, flag) {
const classList = this.el.classList;
classList.toggle(name, flag);
return this;
}
/**
*
* @param {Object} hash
*/
css(hash) {
for (let propertyName in hash) {
if (hash.hasOwnProperty(propertyName)) {
this.el.style[propertyName] = hash[propertyName];
}
}
}
/**
*
* @param {Object} hash
*/
attr(hash) {
for (let propertyName in hash) {
if (hash.hasOwnProperty(propertyName)) {
this.el.setAttribute(propertyName, hash[propertyName]);
}
}
}
/**
*
* @param {Vector2} size
* @param {Vector2} [padding]
*/
followSize({ size, padding = Vector2.zero }) {
assert.defined(size, 'size');
assert.equal(size.isVector2, true, 'size.isVector2 !== true');
assert.defined(padding, 'padding');
assert.equal(padding.isVector2, true, 'padding.isVector2 !== true');
const copy = () => {
this.size.set(size.x - padding.x * 2, size.y - padding.y * 2);
};
const link = () => {
copy();
size.onChanged.add(copy);
}
const unlink = () => {
size.onChanged.remove(copy);
};
this.on.linked.add(link);
this.on.unlinked.add(unlink);
copy();
}
}
/**
* @readonly
* @type {boolean}
*/
View.prototype.isView = true;
export default View;