UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

805 lines (643 loc) • 19.1 kB
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;