From ceaf0a613207f9bcd9a978b899b2ab8892c38d01 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 29 Sep 2023 18:00:12 +0500 Subject: [PATCH] Implemented infrastructure for settings loading + GUI, Implemented settings for Gravity example --- examples/common/common.css | 583 +++++++++++++++++ examples/common/settings/base.js | 602 ++++++++++++++++++ examples/common/settings/enum.js | 7 + examples/common/ui/controllers/base.js | 112 ++++ examples/common/ui/controllers/settings.js | 246 +++++++ .../common/ui/controllers/views/settings.js | 13 + examples/common/ui/controls/base.js | 131 ++++ examples/common/ui/controls/button.js | 15 + examples/common/ui/controls/checkbox.js | 18 + examples/common/ui/controls/dialog.js | 138 ++++ examples/common/ui/controls/frame.js | 7 + examples/common/ui/controls/input.js | 64 ++ examples/common/ui/controls/label.js | 16 + examples/common/ui/controls/popup.js | 162 +++++ examples/common/ui/controls/select.js | 75 +++ examples/common/ui/controls/views/dialog.js | 7 + examples/common/ui/controls/views/popup.js | 5 + examples/common/utils.js | 32 + examples/gravity/index.html | 9 + examples/gravity/index.js | 253 ++------ examples/gravity/physics.js | 120 ++++ examples/gravity/render.js | 36 ++ examples/gravity/settings.js | 96 +++ examples/gravity/style.css | 5 + examples/gravity/world.js | 136 ++++ lib/render/renderer/webgl/misc/texture.js | 19 +- lib/render/renderer/webgl/objects/sprite.js | 4 +- webpack.config.js | 2 + 28 files changed, 2721 insertions(+), 192 deletions(-) create mode 100644 examples/common/common.css create mode 100644 examples/common/settings/base.js create mode 100644 examples/common/settings/enum.js create mode 100644 examples/common/ui/controllers/base.js create mode 100644 examples/common/ui/controllers/settings.js create mode 100644 examples/common/ui/controllers/views/settings.js create mode 100644 examples/common/ui/controls/base.js create mode 100644 examples/common/ui/controls/button.js create mode 100644 examples/common/ui/controls/checkbox.js create mode 100644 examples/common/ui/controls/dialog.js create mode 100644 examples/common/ui/controls/frame.js create mode 100644 examples/common/ui/controls/input.js create mode 100644 examples/common/ui/controls/label.js create mode 100644 examples/common/ui/controls/popup.js create mode 100644 examples/common/ui/controls/select.js create mode 100644 examples/common/ui/controls/views/dialog.js create mode 100644 examples/common/ui/controls/views/popup.js create mode 100644 examples/gravity/physics.js create mode 100644 examples/gravity/render.js create mode 100644 examples/gravity/settings.js create mode 100644 examples/gravity/world.js diff --git a/examples/common/common.css b/examples/common/common.css new file mode 100644 index 0000000..a7be337 --- /dev/null +++ b/examples/common/common.css @@ -0,0 +1,583 @@ +@media screen and (min-width: 1024px) and (max-width: 1440px) { + html { + font-size: 18px; + } +} + +@media screen and (min-width: 768px) and (max-width: 1024px) { + html { + font-size: 16px; + } + + #stats { + font-size: 0.6em; + } +} + +@media screen and (max-width: 768px) { + html { + font-size: 14px; + } + + #stats { + font-size: 0.4em; + } +} + +@media screen and (min-resolution: 280dpi) { + html { + font-size: 16px; + } + + .loading-screen { + font-size: 0.6em; + } + + #stats { + font-size: 0.4em; + } + + .dialog { + font-size: 0.6em; + } + + .dialog button { + font-size: 0.8em; + } + + #hint { + font-size: 1em !important; + } +} + + +body { + position: relative; + padding: 0; + margin: 0; + + overflow: hidden; + font: 13px Helvetica Neue, Lucida Grande, Arial; +} + +button { + min-height: 2em; + border: 0.1em solid white; + font-family: system-ui; + color: rgb(200, 200, 200); + background: transparent; + padding: 0 0.5em; + font-size: 1rem; +} + +button:hover { + background: rgba(255, 255, 255, 0.1); + cursor: pointer; +} + +button:active { + background: rgba(255, 255, 255, 0.2); +} + +button:disabled { + filter: brightness(50%); + pointer-events: none; +} + +select { + user-select: none; + padding: 0.2em; + background: transparent; + color: rgb(41, 127, 255); + outline: none; + border: none; + appearance: none; + font-size: 1em; + font-family: system-ui; +} + +input[type=text] { + text-align: right; + border: none; + border-radius: 0; + border-bottom: 0.1em solid #c8c8c8; + color: #c8c8c8; + background: transparent; + outline: none; + appearance: none; + font-size: 1em; + font-family: system-ui; +} + +input[type=text]:disabled { + border-color: #8a8a8a; + color: #8a8a8a; +} + +input[type=checkbox] { + margin: 0; + display: flex; + justify-content: center; + align-items: center; + width: 2.8em; + height: 1.4em; + border: 0.1em solid #c8c8c8; + border-radius: 0; + background: transparent; + outline: none; + appearance: none; + color: red; +} + +input[type=checkbox]::before { + content: ""; + transform: translateX(-70%); + background: #c8c8c8; + width: 1em; + height: 1em; + transition: transform 100ms, background-color 100ms ease; +} + +input[type=checkbox]:disabled { + border-color: #8a8a8a; +} + +input[type=checkbox]:disabled::before { + background: #8a8a8a !important; +} + + +input[type=checkbox]:checked::before { + transform: translateX(70%); + background: #297fff; +} + +input:focus { + border-color: rgb(41, 127, 255); +} + +input[invalid=true] { + border-color: rgb(255, 41, 41); +} + +.fill { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; +} + +.flex-centered { + display: flex; + justify-content: center; + align-items: center; +} + +.flex-row { + display: flex; + flex-direction: row; +} + +.flex-column { + display: flex; + flex-direction: column; +} + +.flex-centered span { + font-size: 2em; + pointer-events: none; + user-select: none; +} + +.loading-screen { + background: black; + z-index: 1000; + user-select: none; +} + +.loading-screen * { + pointer-events: none; +} + +.popup { + display: none; + position: absolute; + border: 0.1em solid rgba(255, 255, 255, 0.5); +} + +.popup.popup-shown { + display: block; +} + +.dialog-container { + display: none; + align-items: center; + pointer-events: none; +} + +.dialog-container.dialog-modal { + pointer-events: initial; + background: rgba(0, 0, 0, 0.5); +} + +.dialog-container.dialog-shown, +.dialog-container.dialog-fading { + display: flex; +} + +.dialog { + pointer-events: initial; + padding: 1em; + margin: 1em; + min-width: 20vw; + max-width: 80vw; + max-height: 80vh; + overflow: auto; + z-index: 10000; + background: #111111; + border: 0.1em solid rgba(255, 255, 255, 0.5); +} + +.dialog-shown .dialog { + animation: 225ms; + animation-name: dialog-show-center; + animation-timing-function: ease-out; +} + +.dialog-fading .dialog { + animation: 195ms reverse; + animation-timing-function: ease-in; +} + +.dialog .dialog-caption { + display: flex; + justify-content: center; + align-items: center; + padding-bottom: 0.5em; + margin-bottom: 0.2em; + font-size: 2em; + border-bottom: 0.1em solid #2c2c2c; + user-select: none; +} + +.dialog .dialog-actions { + display: flex; + justify-content: flex-end; + align-items: center; + padding-top: 0.5em; + margin-top: 0.2em; + border-top: 0.1em solid #2c2c2c; +} + +.dialog .dialog-actions > *:not(:first-child) { + margin-left: 0.5em; +} + +.dialog button.dialog-close { + border: 0.1em solid rgba(255, 255, 255, 0.5); +} + +.rotating { + animation-duration: 1s; + animation-fill-mode: forwards; + animation-iteration-count: infinite; + animation-name: rotating; +} + +@keyframes rotating { + from { + transform: rotate(0); + } + + to { + transform: rotate(359deg); + } +} + +.icon-btn { + box-sizing: border-box; + border: none; + padding: 0.6em; + margin: 0; +} + +.icon-btn > *:not(div) { + height: 2em; + width: 2em; +} + +.icon-btn > div { + font-size: 2em; + height: 1em; + width: 1em; + transform: translateY(-15%); +} + +.align-corner-end { + position: absolute; + right: 0.6em; + bottom: 0.6em; +} + +.action-panel { + margin: 0; + background: rgba(255, 255, 255, 0.1); + pointer-events: initial; +} + +.action-panel .icon-btn { + padding: 0.8em; +} + +.action-panel-container { + overflow: hidden; + bottom: 4.2em; + pointer-events: none; +} + +.action-panel-container .popup { + display: block; + border: none; + + transform: translateY(100%); + opacity: 0; + transition: transform 300ms, opacity 0ms 300ms; +} + +.action-panel-container .popup.popup-shown { + opacity: 1; + transform: translateY(0); + + transition: transform 300ms 50ms, opacity 0ms; +} + +.control-block .popup-trigger-opened { + background: rgba(255, 255, 255, 0.1); +} + +#hint { + position: absolute; + bottom: 1em; + left: 50%; + transform: translateX(-50%); + font-size: 2em; + color: rgba(255, 255, 255, 0.6); + text-shadow: 0 0 0.2em black; + pointer-events: none; +} + +.fading-out { + animation-duration: 1s; + animation-delay: 2s; + animation-fill-mode: forwards; + animation-name: fading-out; +} + +@keyframes fading-out { + from { + opacity: 100%; + } + + to { + opacity: 0; + } +} + +.control-block { + pointer-events: none; +} + +.control-block > * { + pointer-events: initial; +} + + +.control-block > .icon-btn { + padding: 0.8em; +} + + +.settings-button { + position: absolute; + right: 1em; + bottom: 1em; +} + +.settings-base { + font-size: 1em; + font-family: system-ui; +} + + +.settings-dialog .dialog { + box-sizing: border-box; + max-height: calc(100vh - 2em); +} + +.settings-block { + display: grid; + grid-template-columns: 2fr 1fr; + align-items: center; + justify-content: space-between; + gap: 0 1em; + justify-items: start; +} + +.settings-block .settings-caption { + position: relative; + pointer-events: none; +} + +.settings-block .settings-input { + justify-self: end; + text-align: right; +} + +.settings-block .settings-caption[data-tooltip]:after { + content: "i"; + pointer-events: all; + + display: inline-block; + text-align: center; + vertical-align: super; + + margin-left: 1em; + width: 0.8rem; + height: 0.8rem; + line-height: 0.8rem; + + border-radius: 50%; + font-size: 0.6em; + font-weight: bold; + color: #111111; + background: #efefef; +} + +.settings-dialog .dialog { + padding: 0 1em 1em 1em; + box-sizing: border-box; + max-height: calc(100vh - 1em); + max-width: max(40vw, 400px); +} + +.settings-dialog .dialog-close.settings-close { + position: absolute; + right: 0; + border: none; +} + +.settings-close > img { + width: 1.5em; + height: 1.5em; +} + +.settings-dialog .settings-dialog-caption { + position: sticky; + background: #111111; + top: 0; + z-index: 1; + padding-top: 0.5em; +} + +[data-tooltip]:before { + content: attr(data-tooltip); + white-space: pre-line; + pointer-events: none; + display: none; + + position: absolute; + left: 50%; + bottom: 100%; + margin: 0.2em; + max-width: 20em; + max-height: 8em; + width: max-content; + height: max-content; + z-index: 10; + + background: #1f1f1f; + border: 0.1em solid #2c2c2c; + padding: 0.2em; + opacity: 0; + transition: opacity 100ms 0ms; +} + +[data-tooltip]:hover::before { + display: block; + opacity: 1; + transition: opacity 200ms 300ms; +} + +[data-tooltip]:active::before { + display: block; + opacity: 1; + transition: opacity 200ms 0ms; +} + +@keyframes pulsation { + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(var(--color), 0.9); + } + + 70% { + transform: scale(1); + box-shadow: 0 0 0 0.6em rgba(var(--color), 0); + } + + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(var(--color), 0); + } +} + +@keyframes dialog-show-center { + 0% { + opacity: 0; + transform: scale(1); + } + + 5% { + transform: scale(0.8); + } + + + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes dialog-show-left { + 0% { + opacity: 0; + transform: translateX(0); + } + + 5% { + transform: translateX(-50%); + } + + + 100% { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes dialog-show-right { + 0% { + opacity: 0; + transform: translateX(0); + } + + 5% { + transform: translateX(50%); + } + + 100% { + opacity: 1; + transform: translateX(0); + } +} \ No newline at end of file diff --git a/examples/common/settings/base.js b/examples/common/settings/base.js new file mode 100644 index 0000000..ffbeecb --- /dev/null +++ b/examples/common/settings/base.js @@ -0,0 +1,602 @@ +import * as Utils from "../utils.js"; + +/** + * @enum{string} + */ +export const PropertyType = { + string: "string", + int: "int", + float: "float", + bool: "bool", + enum: "enum", +} + +export class PropertyParser { + static string(prop) { + return (param) => { + const value = param && param.trim(); + if (value && value.length > 0) { + return value; + } + + return prop.defaultValue; + } + } + + static bool(prop) { + return (param) => { + if (typeof param === "boolean") { + return param; + } + + const value = param && param.trim(); + if (value && ["1", "true", "on"].includes(value)) { + return true; + } else if (value && ["0", "false", "off"].includes(value)) { + return false; + } + + return prop.defaultValue; + } + } + + static int(prop) { + return (param) => { + if (Number.isInteger(param)) { + return Math.min(prop.max ?? param, Math.max(prop.min ?? param, param)); + } + + const value = param && param.trim(); + if (value && value.length > 0) { + const parsed = Number.parseInt(value); + if (Number.isFinite(parsed)) { + return Math.min(prop.max ?? parsed, Math.max(prop.min ?? parsed, parsed)); + } + } + + return prop.defaultValue; + } + } + + static float(prop) { + return (param) => { + if (Number.isFinite(param)) { + return Math.min(prop.max ?? param, Math.max(prop.min ?? param, param)); + } + + const value = param && param.trim(); + if (value && value.length > 0) { + const parsed = Number.parseFloat(value); + if (Number.isFinite(parsed)) { + return Math.min(prop.max ?? parsed, Math.max(prop.min ?? parsed, parsed)); + } + } + + return prop.defaultValue; + } + } + + static enum(prop) { + return (param) => { + if (param === undefined) { + return prop.defaultValue + } + + const value = param instanceof String ? param.trim() : param; + const entry = Object.entries(prop.enumType).find(([k, v]) => k === value || v === value); + + return entry?.at(1) ?? prop.defaultValue; + } + } +} + +/** + * @template {Property} T + */ +export class Property { + /** + * @param {string} [key] + * @param {PropertyType} [type=PropertyType.string] + * @param {object} [enumType=null] + * @param {*} [defaultValue=null] + */ + constructor(key, type = PropertyType.string, enumType = null, defaultValue = null) { + this.name = name; + this.key = key; + this.type = type; + this.enumType = enumType; + this.defaultValue = defaultValue; + this.getter = null; + this.setter = null; + + this.exportable = true; + this.affects = []; + this.breaks = []; + this.name = ""; + this._description = ""; + this.min = null; + this.max = null; + + if (this.type === PropertyType.enum) { + if (!this.enumType) { + throw new Error(`Property ${this.name} missing enum type`); + } + + if (!(this.enumType instanceof Object)) { + throw new Error(`Property ${this.name} bad enum type`); + } + } + + const parser = PropertyParser[this.type]; + if (parser) { + this._parser = parser(this); + } else { + throw new Error(`Property ${this.name} has invalid type ${this.type}`); + } + } + + get descriptionText() { + return this._description; + } + + /** + * @return {string|null} + */ + get description() { + let constraints = null; + if (this.min !== null || this.max !== null) { + constraints = `- Constraints (${this.min ?? '-∞'}-${this.max ?? "∞"})`; + } + + let type = null; + if ([PropertyType.string, PropertyType.int, PropertyType.float].includes(this.type)) { + type = `- Type: ${this.type}`; + } + + const parts = [this._description, constraints, type].filter(v => v); + if (parts.length > 0) { + return parts.join("\n"); + } + + return null; + } + + /** + * @template R + * @param {string|R} param + * @return {R} + */ + parse(param) { + return this._parser(param); + } + + /** + * @param {boolean} exportable + * @return {Property} + */ + setExportable(exportable) { + this.exportable = exportable; + return this; + } + + /** + * @param {string} name + * @return {Property} + */ + setName(name) { + this.name = name; + return this; + } + + /** + * @param {string} description + * @return {Property} + */ + setDescription(description) { + this._description = description; + return this; + } + + /** + * @param affects + * @return {Property} + */ + setAffects(...affects) { + this.affects = affects; + return this; + } + + /** + * @param breaks + * @return {Property} + */ + setBreaks(...breaks) { + this.breaks = breaks; + return this; + } + + /** + * + * @param {number|null} min + * @param {number|null} max + * @return {Property} + */ + setConstraints(min, max) { + this.min = min; + this.max = max; + return this; + } + + /** + * @template ArgT + * @param {function (): ArgT} [getter = null] + * @param {function (ArgT): void} [setter = null] + * + * @return {Property} + */ + setImplementation(getter = null, setter = null) { + this.getter = getter; + this.setter = setter; + return this; + } + + static string(key, defaultValue = null) { + return new Property(key, PropertyType.string, null, defaultValue); + } + + static bool(key, defaultValue = null) { + return new Property(key, PropertyType.bool, null, defaultValue); + } + + static int(key, defaultValue = null) { + return new Property(key, PropertyType.int, null, defaultValue); + } + + static float(key, defaultValue = null) { + return new Property(key, PropertyType.float, null, defaultValue); + } + + static enum(key, enumType, defaultValue = null) { + return new Property(key, PropertyType.enum, enumType, defaultValue); + } +} + +/** + * @extends {Property} + */ +export class ReadOnlyProperty extends Property { + constructor(type = PropertyType.string, enumType = null) { + super("", type, enumType); + this.formatter = null; + } + + /** + * @param value + * @return {string} + */ + format(value) { + if (this.formatter) { + return this.formatter(value); + } + + return value + } + + /** + * @param {(value:*) => string} fn + * @return {ReadOnlyProperty} + */ + setFormatter(fn) { + this.formatter = fn; + return this; + } + + get description() { + return this._description; + } + + static string() { + return new ReadOnlyProperty(PropertyType.string); + } + + static bool() { + return new ReadOnlyProperty(PropertyType.bool); + } + + static int() { + return new ReadOnlyProperty(PropertyType.int); + } + + static float() { + return new ReadOnlyProperty(PropertyType.float); + } + + static enum(enumType) { + return new ReadOnlyProperty(PropertyType.enum, enumType); + } +} + +export class DependantProperties { + properties; + options; + + /** + * @param {Array} props + * @param {Object} [options=null] + */ + constructor(props, options = null) { + this.properties = props; + this.options = options ?? {}; + } +} + +export class QueryParameterParser { + static parse(type, defaults) { + const urlSearchParams = new URLSearchParams(window.location.search); + const queryParams = Object.fromEntries(urlSearchParams.entries()); + + const results = {}; + for (const [name, prop] of Object.entries(type.Properties)) { + if (prop.exportable && defaults?.hasOwnProperty(name) && !queryParams.hasOwnProperty(prop.key)) { + results[name] = prop.parse(defaults[name]); + } else { + results[name] = prop.parse(queryParams[prop.key]); + } + } + + return results; + } +} + +export class SettingsBase { + /** + * @abstract + * + * @type {{[string]: Property}} + */ + static Properties = {}; + + /** + * @abstract + * + * @type {{[string]: ReadOnlyProperty}} + */ + static ReadOnlyProperties = {}; + + /** + * @abstract + * + * @type {Map} + */ + static PropertiesDependencies = new Map(); + + isMobile() { + if (globalThis.window) { + return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || window.orientation !== undefined; + } + + return false; + } + + config = {}; + + constructor(values) { + const config = this.constructor.Properties; + for (const [key, value] of Object.entries(values || {})) { + if (config.hasOwnProperty(key)) { + const prop = config[key]; + Object.defineProperty(this, key, { + get: prop.getter?.bind(this) ?? (() => this.config[key]), + set: prop.setter?.bind(this) ?? ((value) => this.config[key] = value) + }) + + this[key] = value; + } + } + } + + toQueryParams() { + const params = new Map(); + for (const [name, prop] of Object.entries(this.constructor.Properties)) { + params.set(prop, this[name]) + } + + for (const [prop, deps] of this.constructor.PropertiesDependencies.entries()) { + const value = params.get(prop); + for (const depProp of deps.properties) { + const invert = deps.options.invert && (deps.options.invert === true || deps.options.invert[depProp.key] === true); + if (invert ? value : !value) { + params.delete(depProp); + } + } + } + + const result = []; + for (const [prop, value] of params.entries()) { + if (prop.exportable && value !== prop.defaultValue) { + if (prop.type === PropertyType.enum) { + result.push({key: prop.key, value: Utils.findKey(prop.enumType, value)}); + } else { + result.push({key: prop.key, value}); + } + } + } + + return result; + } + + static fromQueryParams(defaults = null) { + const values = QueryParameterParser.parse(this, defaults); + return new this(values); + } + + serialize() { + return Object.assign({}, this.config); + } + + + static deserialize(serialized) { + const values = {}; + for (const [name, prop] of Object.entries(this.Properties)) { + values[name] = prop.parse(serialized[name]); + } + + return new this(values); + } + + export() { + const result = {}; + for (const [name, prop] of Object.entries(this.constructor.Properties)) { + if (prop.exportable) { + result[name] = this[name]; + } + } + + return result; + } + + static import(params) { + const values = {}; + for (const [name, prop] of Object.entries(this.Properties)) { + if (prop.exportable) { + values[name] = prop.parse(params[name]); + } else { + values[name] = prop.defaultValue; + } + } + + return new this(values); + } +} + +export class SettingsGroup { + constructor(type) { + this.type = type; + + this.name = name; + } + + setName(name) { + this.name = name; + + return this; + } + + static of(type) { + return new SettingsGroup(type); + } +} + +/** + * @template {AppSettingsBase} T + */ +export class AppSettingsBase { + /** + * @abstract + * @type {{[string]: SettingsGroup}} + */ + static Types = {}; + + config = {}; + constructor() { + for (const key of Object.keys(this.constructor.Types)) { + Object.defineProperty(this, key, { + get: () => this.config[key] + }) + } + } + + /** + * @returns {object} + */ + serialize() { + const result = {}; + for (const [name, _] of Object.entries(this.constructor.Types)) { + result[name] = this.config[name].serialize(); + } + + return result; + } + + /** + * @returns {T} + */ + static deserialize(data) { + const instance = new this(); + for (const [name, group] of Object.entries(this.Types)) { + instance.config[name] = group.type.deserialize(data[name]); + } + + return /** @type {T} */ instance; + } + + toQueryParams() { + const params = []; + for (const [name, _] of Object.entries(this.constructor.Types)) { + params.push(...this.config[name].toQueryParams()); + } + + return params; + } + + /** + * @returns {T} + */ + static fromQueryParams(defaults = null) { + const instance = new this(); + for (const [name, group] of Object.entries(this.Types)) { + instance.config[name] = group.type.fromQueryParams(defaults); + } + + return /** @type {T} */ instance; + } + + export() { + const result = {}; + for (const [name, _] of Object.entries(this.constructor.Types)) { + Object.assign(result, this.config[name].export()); + } + + return result; + } + + /** + * @returns {T} + */ + static import(data) { + const instance = new this(); + for (const [name, group] of Object.entries(this.Types)) { + instance.config[name] = group.type.import(data); + } + + return /** @type {*} */ instance; + } + + /** + * @template C + * + * @param {SettingsGroup} newSettings + * @returns {{breaks: Set, affects: Set}} + */ + compare(newSettings) { + const affects = new Set(); + const breaks = new Set(); + for (const [groupName, group] of Object.entries(this.constructor.Types)) { + for (const [name, prop] of Object.entries(group.type.Properties)) { + if (this[groupName][name] !== newSettings[groupName][name]) { + for (const component of prop.affects) { + affects.add(component); + } + for (const component of prop.breaks) { + breaks.add(component); + } + } + } + } + + return { + affects: affects, + breaks: breaks + } + } +} diff --git a/examples/common/settings/enum.js b/examples/common/settings/enum.js new file mode 100644 index 0000000..911b2b0 --- /dev/null +++ b/examples/common/settings/enum.js @@ -0,0 +1,7 @@ +/** + * @enum(string) + */ +export const ComponentType = { + solver: "solver", + renderer: "renderer" +} diff --git a/examples/common/ui/controllers/base.js b/examples/common/ui/controllers/base.js new file mode 100644 index 0000000..0709aea --- /dev/null +++ b/examples/common/ui/controllers/base.js @@ -0,0 +1,112 @@ +import {Frame} from "../controls/frame.js"; + +export class IEventEmitter { + /** + * @abstract + * + * @param {string} type + * @param {*|null} data + */ + emitEvent(type, data = null) { + } +} + +export class ControllerBase extends IEventEmitter { + subscribers = new Map(); + + /** + * @param {HTMLElement} root + * @param {ControllerBase|null} [parentCtrl=null] + */ + constructor(root, parentCtrl = null) { + super(); + + this.root = root; + this.parentCtrl = parentCtrl; + this.frame = new Frame(this.root); + } + + /** + * @param {string} type + * @param {*|null} data + */ + emitEvent(type, data = null) { + for (let subscriptions of this.subscribers.values()) { + const handler = subscriptions[type]; + if (handler) { + handler(this, data); + } + } + } + + /** + * @param {object} subscriber + * @param {string} type + * @param {function} handler + */ + subscribe(subscriber, type, handler) { + if (!this.subscribers.has(subscriber)) { + this.subscribers.set(subscriber, {}); + } + + const subscription = this.subscribers.get(subscriber); + subscription[type] = handler; + } + + /** + * @param {object} subscriber + * @param {string} type + */ + unsubscribe(subscriber, type) { + const subscription = this.subscribers.has(subscriber) ? this.subscribers.get(subscriber) : null; + if (subscription && subscription.hasOwnProperty(type)) { + delete subscription[type]; + } + } +} + +/** + * @template T + */ +export class StateControllerBase extends ControllerBase { + static UnsetState = /** @type {T} */ -1; + static STATE_EVENT_NAME = "state"; + + /** + * @type {T} + */ + currentState = StateControllerBase.UnsetState; + + /** + * @param {HTMLElement} root + * @param {ControllerBase|null} [parentCtrl=null] + */ + constructor(root, parentCtrl = null) { + super(root, parentCtrl); + this.parentCtrl?.subscribe(this, StateControllerBase.STATE_EVENT_NAME, (sender, state) => this.setState(state)); + } + + /** + * @param {T} state + */ + setState(state) { + if (this.currentState === state) { + return; + } + + const oldState = this.currentState; + this.currentState = state; + + this.emitEvent(StateControllerBase.STATE_EVENT_NAME, this.currentState); + this.onStateChanged(this, oldState, this.currentState); + } + + /** + * + * @param {object} sender + * @param {T} oldState + * @param {T} newState + */ + onStateChanged(sender, oldState, newState) { + } +} \ No newline at end of file diff --git a/examples/common/ui/controllers/settings.js b/examples/common/ui/controllers/settings.js new file mode 100644 index 0000000..19130aa --- /dev/null +++ b/examples/common/ui/controllers/settings.js @@ -0,0 +1,246 @@ +import {ControllerBase} from "./base.js"; +import {View} from "../controls/base.js"; +import {PropertyType, ReadOnlyProperty} from "../../settings/base.js"; +import {Select} from "../controls/select.js"; +import {Input, InputType} from "../controls/input.js"; +import {Checkbox} from "../controls/checkbox.js"; +import {Label} from "../controls/label.js"; + +import view from "./views/settings.js"; + +/** + * @template {AppSettingsBase} SettingsT + */ +export class SettingsController extends ControllerBase { + static RECONFIGURE_EVENT = "start_recording"; + + /** @type {SettingsT} */ + settings; + /** @type {{[string]: {[string]: InputControl}}} */ + config; + /** @type {Map} */ + propData; + + constructor(root, parentCtrl) { + const viewControl = new View(root, view) + super(viewControl.element, parentCtrl); + + this.content = this.root.getElementsByClassName("settings-content")[0]; + } + + /** + * @param {SettingsT} settings + */ + configure(settings) { + this.settings = settings; + this.config = {}; + this.propData = new Map(); + + while (this.content.firstChild) { + this.content.removeChild(this.content.lastChild); + } + + for (const [key, group] of Object.entries(this.settings.constructor.Types)) { + if (group.name) { + this.config[key] = {}; + this._createBlock(this.config[key], key, group, this.settings[key]); + } + } + + for (const prop of this.propData.keys()) { + const {key, groupKey} = this.propData.get(prop); + const deps = this.settings[groupKey].constructor.PropertiesDependencies.get(prop); + if (deps && deps.length > 0) { + for (const depProp of deps) { + if (!(depProp instanceof ReadOnlyProperty)) { + const value = this.settings[groupKey][key]; + this.propData.get(depProp).control.setEnabled(!!value); + } + } + } + } + } + + onParameterChanged(prop, suppressEvent = false) { + const config = this.getConfig(); + const {control, key, groupKey} = this.propData.get(prop); + const value = config[groupKey][key]; + + if (prop instanceof ReadOnlyProperty) { + control.setText(prop.format(value)); + } else { + control.setValue(value); + if (!suppressEvent) { + this.emitEvent(SettingsController.RECONFIGURE_EVENT, config); + } + + const deps = config[groupKey].constructor.PropertiesDependencies.get(prop); + if (deps && deps.properties.length > 0) { + for (const depProp of deps.properties) { + this.onParameterChanged(depProp, true); + + if (!(depProp instanceof ReadOnlyProperty)) { + const invert = deps.options.invert && (deps.options.invert === true || deps.options.invert[depProp.key] === true); + this.propData.get(depProp).control.setEnabled(invert ? !value : !!value); + } + } + } + } + } + + getConfig() { + const config = {}; + + for (const [blockKey, block] of Object.entries(this.config)) { + const blockConfig = {} + for (const [key, control] of Object.entries(block)) { + let value = control.getValue(); + if (value instanceof String) { + value = value && value !== "null" && value.trim() !== "" ? value.trim() : null; + } + + blockConfig[key] = value; + } + + config[blockKey] = blockConfig; + } + + return this.settings.constructor.deserialize(config); + } + + _createBlock(config, groupKey, group, value) { + const h3 = document.createElement("h3"); + h3.innerText = group.name; + this.content.appendChild(h3); + + const block = document.createElement("div"); + block.classList.add("settings-block"); + this._createBlockEntries(config, groupKey, group, block, value); + this.content.appendChild(block); + } + + /** + * @param {object} config + * @param {string} groupKey + * @param {SettingsGroup} group + * @param {HTMLElement} parent + * @param {SettingsBase} groupValue + * @private + */ + _createBlockEntries(config, groupKey, group, parent, groupValue) { + let count = 0; + for (const [key, prop] of Object.entries(groupValue.constructor.Properties)) { + const caption = document.createElement("div"); + caption.innerText = prop.name || key; + caption.classList.add("settings-caption") + if (prop.description) { + caption.setAttribute("data-tooltip", prop.description); + } + parent.appendChild(caption); + + + const control = this._createBlockInput(prop, groupValue[key]); + control.addClass("settings-input"); + control.setOnChange(() => this.onParameterChanged(prop)); + + this.propData.set(prop, { + key, + groupKey, + group, + control + }); + + parent.appendChild(control.element); + + config[key] = control; + count += 1; + } + + for (const [key, prop] of Object.entries(groupValue.constructor.ReadOnlyProperties)) { + const caption = document.createElement("div"); + caption.innerText = prop.name || key; + caption.classList.add("settings-caption") + if (prop.description) { + caption.setAttribute("data-tooltip", prop.description); + } + parent.appendChild(caption); + + const label = new Label(document.createElement("div")); + label.setText(prop.format(groupValue[key])); + label.addClass("settings-input"); + parent.appendChild(label.element); + + this.propData.set(prop, { + key, + groupKey, + group, + control: label + }); + + count += 1; + } + + parent.style.gridTemplateRows = `repeat(${count}, 2em)`; + } + + /** + * @param {Property} property + * @param {*} value + * @returns {InputControl} + * @private + */ + _createBlockInput(property, value) { + let control; + switch (property.type) { + case PropertyType.enum: + control = this._createSelect(property.enumType, value); + break; + + case PropertyType.int: + control = this._createInput(value, InputType.int) + break; + + case PropertyType.float: + control = this._createInput(value, InputType.float) + break; + + case PropertyType.bool: + control = this._createCheckbox(value); + break; + + default: + case PropertyType.string: + control = this._createInput(value, InputType.text) + break; + } + + return control; + } + + _createInput(value, type) { + const input = new Input(document.createElement("input"), type); + input.setValue(value); + + return input; + } + + _createCheckbox(value) { + const e = document.createElement("input"); + const input = new Checkbox(e); + input.setValue(value); + + return input; + } + + _createSelect(type, value) { + const select = new Select(document.createElement("select")); + select.setOptions(Object.keys(type)); + + const entry = value && Object.entries(type).find(([k, v]) => v === value); + if (entry) { + select.select(entry[0]); + } + + return select; + } +} \ No newline at end of file diff --git a/examples/common/ui/controllers/views/settings.js b/examples/common/ui/controllers/views/settings.js new file mode 100644 index 0000000..88b25bc --- /dev/null +++ b/examples/common/ui/controllers/views/settings.js @@ -0,0 +1,13 @@ +// language=HTML +export default ` +
+
+ Settings + +
+
+
+
+` \ No newline at end of file diff --git a/examples/common/ui/controls/base.js b/examples/common/ui/controls/base.js new file mode 100644 index 0000000..d4c6dd2 --- /dev/null +++ b/examples/common/ui/controls/base.js @@ -0,0 +1,131 @@ +/** + * @class + * @template T + */ +export class Control { + /** + * @param {HTMLElement} element + */ + constructor(element) { + this.element = element; + } + + /** + * + * @param {string} id + * @param {...*} params + * @return {T} + */ + static byId(id, ...params) { + const e = document.getElementById(id); + if (!e) { + throw new Error(`Unable to fined element ${id}`); + } + + return /** @type {T} */ new this(e, ...params); + } + + setVisibility(show) { + this.element.style.display = show ? null : "none"; + } + + setEnabled(enabled) { + if (enabled) { + this.element.removeAttribute("disabled"); + } else { + this.element.setAttribute("disabled", ""); + } + + } + + setTooltip(text) { + this.element.setAttribute("title", text); + } + + setInteractions(enable) { + this.element.style.pointerEvents = enable ? null : "none"; + } + + addClass(className) { + this.element.classList.add(className); + } + + removeClass(className) { + this.element.classList.remove(className); + } +} + +export class View { + /** + * @param {HTMLElement} element + * @param {string} view + */ + constructor(element, view) { + if (!element) { + throw new Error("Element is missing"); + } + + if (!view) { + throw new Error("View is missing"); + } + + this.element = element; + this._replaceElement(view); + } + + _replaceElement(outerHTML) { + let parent = false; + let ref; + + if (this.element.previousElementSibling !== null) { + ref = this.element.previousElementSibling; + } else { + ref = this.element.parentElement; + parent = true; + } + + const originalClasses = this.element.classList; + + this.element.outerHTML = outerHTML; + this.element = parent ? ref.firstElementChild : ref.nextElementSibling; + + this.element.classList.add(...originalClasses); + } + +} + + +export class InputControl extends Control { + _onChangeFn = null; + + /** + * @param {HTMLInputElement} element + */ + constructor(element) { + super(element); + } + + /** + * @abstract + */ + getValue() { + } + + setValue(value) { + this.element.value = value; + } + + setOnChange(fn) { + this._onChangeFn = fn; + } + + /** + * @param {*} value + * @protected + */ + _emitChanged(value) { + if (this._onChangeFn) { + this._onChangeFn(value); + } + } +} \ No newline at end of file diff --git a/examples/common/ui/controls/button.js b/examples/common/ui/controls/button.js new file mode 100644 index 0000000..b533a87 --- /dev/null +++ b/examples/common/ui/controls/button.js @@ -0,0 +1,15 @@ +import {Control} from "./base.js"; + +/** + * @class + * @extends Control + +
+
+
\ No newline at end of file diff --git a/examples/gravity/index.js b/examples/gravity/index.js index 3d39929..15c50b3 100644 --- a/examples/gravity/index.js +++ b/examples/gravity/index.js @@ -1,223 +1,104 @@ -import {Vector2} from "../../lib/utils/vector.js"; -import {BoundaryBox} from "../../lib/physics/common/boundary.js"; -import {CircleBody} from "../../lib/physics/body/circle.js"; -import {WebglRenderer} from "../../lib/render/renderer/webgl/renderer.js"; -import {SpriteObject} from "../../lib/render/renderer/webgl/objects/sprite.js"; -import {ImageTexture} from "../../lib/render/renderer/webgl/misc/texture.js"; -import {m4} from "../../lib/render/renderer/webgl/utils/m4.js"; - import {Bootstrap} from "../common/bootstrap.js"; import * as Params from "../common/params.js"; -import * as Utils from "../common/utils.js"; import {ResistanceForce} from "../../lib/physics/force.js"; import {InsetConstraint} from "../../lib/physics/constraint.js"; +import {GravityComponentType, GravityExampleSettings} from "./settings.js"; +import {SettingsController} from "../common/ui/controllers/settings.js"; +import {Dialog, DialogPositionEnum, DialogTypeEnum} from "../common/ui/controls/dialog.js"; +import {Button} from "../common/ui/controls/button.js"; +import {GravityPhysics} from "./physics.js"; +import {Label} from "../common/ui/controls/label.js"; +import {GravityWorld} from "./world.js"; +import {GravityRender} from "./render.js"; +import {updateUrl} from "../common/utils.js"; +import * as Utils from "../common/utils.js"; const options = Params.parse({ resistance: 1, restitution: 0.2, friction: 0.5 -}) - -const renderer = new WebglRenderer(document.getElementById("canvas"), options); -const BootstrapInstance = new Bootstrap( - renderer, - Object.assign({solverBias: 0.5, solverBeta: 1}, options) -); - -const { - count, minSize, maxSize, gravity, particleScale, particleOpacity, worldScale, minInteractionDistance, - particleTextureUrl, particleBlending, particleColoring -} = Params.parseSettings({ - count: {parser: Params.Parser.int, param: "count", default: 300}, - minSize: {parser: Params.Parser.int, param: "min_size", default: 20}, - maxSize: {parser: Params.Parser.int, param: "max_size", default: 40}, - gravity: {parser: Params.Parser.float, param: "gravity", default: 1000}, - particleScale: {parser: Params.Parser.float, param: "p_scale", default: 20}, - particleOpacity: {parser: Params.Parser.float, param: "opacity", default: 1}, - worldScale: {parser: Params.Parser.float, param: "w_scale", default: 40}, - minInteractionDistance: {parser: Params.Parser.float, param: "scale", default: 0.01 ** 2}, - - particleBlending: {parser: Params.Parser.bool, param: "blend", default: true}, - particleColoring: {parser: Params.Parser.bool, param: "color", default: true}, - particleTextureUrl: { - parser: Params.Parser.string, - param: "tex", - default: new URL("./sprites/particle.png", import.meta.url) - } }); +let Settings = GravityExampleSettings.fromQueryParams(); +const settingsCtrl = new SettingsController(document.getElementById("settings-content"), this); +const settingsDialog = Dialog.byId("settings", settingsCtrl.root); -const WorldRect = new BoundaryBox( - -worldScale * renderer.canvasWidth / 2, - worldScale * renderer.canvasWidth / 2, - -worldScale * renderer.canvasHeight / 2, - worldScale * renderer.canvasHeight / 2 -); +settingsDialog.type = DialogTypeEnum.popover; +settingsDialog.position = DialogPositionEnum.right; -let projMatrix = m4.projection(renderer.canvasWidth, renderer.canvasHeight, 2); -projMatrix = m4.translate(projMatrix, renderer.canvasWidth / 2, renderer.canvasHeight / 2, 0); -projMatrix = m4.scale(projMatrix, 1 / worldScale, 1 / worldScale, 1); +settingsCtrl.subscribe(this, SettingsController.RECONFIGURE_EVENT, (sender, data) => reconfigure(data)); +settingsCtrl.configure(Settings); -BootstrapInstance.renderer.setProjectionMatrix(projMatrix) -BootstrapInstance.addConstraint(new InsetConstraint(WorldRect)) -BootstrapInstance.addForce(new ResistanceForce(options.resistance)); +const bSettings = Button.byId("settings-button"); +bSettings.setOnClick(() => { + bSettings.setEnabled(false); + settingsDialog.show(); +}) -if (particleBlending) { - BootstrapInstance.renderer.setBlending(WebGL2RenderingContext.SRC_COLOR, WebGL2RenderingContext.ONE); -} +settingsDialog.setOnDismissed(() => { + bSettings.setEnabled(true); +}); -const particleTexture = new ImageTexture(particleTextureUrl); -particleTexture.glWrapS = WebGL2RenderingContext.CLAMP_TO_EDGE; -particleTexture.glWrapT = WebGL2RenderingContext.CLAMP_TO_EDGE; -particleTexture.glMin = WebGL2RenderingContext.LINEAR_MIPMAP_LINEAR; - -await particleTexture.wait(); - -const minRadius = Math.min(WorldRect.width, WorldRect.height) / 2 * 0.6 -const maxRadius = Math.min(WorldRect.width, WorldRect.height) / 2 * 0.8; - -for (let i = 0; i < count; i++) { - const angle = Math.random() * Math.PI * 2; - const size = minSize + Math.random() * Math.max(0, maxSize - minSize); - - const radius = minRadius + Math.random() * (maxRadius - minRadius); - const position = Vector2.fromAngle(angle) - .scale(radius) - .add(WorldRect.center); - - Utils.clampBodyPosition(position, WorldRect, size); - const body = new CircleBody(position.x, position.y, size) - .setMass(size) - .setVelocity(Vector2.fromAngle(Math.random() * Math.PI * 2).scale(gravity)) - .setFriction(options.friction) - .setRestitution(options.restitution) - .setTag("particle"); - - const renderer = new SpriteObject(body); - renderer.texture = particleTexture; - if (particleColoring) { - renderer.color = Utils.randomColor(170, 255); +const lblPause = Label.byId("pause-label"); + +document.addEventListener("visibilitychange", function () { + if (document.hidden) { + BootstrapInstance.pause(); + } else { + BootstrapInstance.play(); } - renderer.opacity = particleOpacity; - renderer.scale = particleScale; - BootstrapInstance.addRigidBody(body, renderer); + lblPause.setVisibility(document.hidden); +}); -} +const RenderInstance = new GravityRender(document.getElementById("canvas"), Settings, options) +const BootstrapInstance = new Bootstrap( + RenderInstance.renderer, + Object.assign({solverBias: 0.5, solverBeta: 1}, options) +); + +RenderInstance.initialize(); +const GravityInstance = new GravityPhysics(BootstrapInstance, Settings); +const WorldInstance = new GravityWorld(BootstrapInstance, Settings, options); +await WorldInstance.initialize(); + +BootstrapInstance.addConstraint(new InsetConstraint(WorldInstance.worldRect)) +BootstrapInstance.addForce(new ResistanceForce(options.resistance)); BootstrapInstance.enableHotKeys(); BootstrapInstance.run(); +async function reconfigure(newSettings) { + const diff = Settings.compare(newSettings); -let stepDelta = 0; + Settings = newSettings; + updateUrl(Settings); -function gravityStep(delta) { - const tree = BootstrapInstance.solver.stepInfo.tree; - stepDelta = delta; + Utils.updateUrl(Settings); - if (tree) { - calculateTree(tree); + if (diff.affects.has(GravityComponentType.physics)) { + GravityInstance.reconfigure(Settings); } -} -/** - * @param {SpatialTree} tree - */ -function calculateTree(tree) { - return calculateLeaf(tree.root, new Vector2()); -} - -/** - * @param {SpatialLeaf} leaf - * @param {Vector2} pForce - */ -function calculateLeaf(leaf, pForce) { - const blocks = leaf.leafs; - if (blocks.length > 0) { - calculateLeafBlock(blocks, pForce); - } else { - calculateLeafData(leaf, pForce); + if (diff.affects.has(GravityComponentType.renderer)) { + RenderInstance.reconfigure(Settings); } -} - -/** - * - * @param {SpatialLeaf[]} blocks - * @param {Vector2} pForce - */ -function calculateLeafBlock(blocks, pForce) { - for (let i = 0; i < blocks.length; i++) { - const blockCenter = blocks[i].boundary.center; - const iForce = pForce.copy(); - - for (let j = 0; j < blocks.length; j++) { - if (i === j) continue; - - const mass = blocks[j].items - .filter(b => b.tag === "particle") - .reduce((p, c) => p + c.mass, 0); - - const g = gravity * mass; - calculateForce(blockCenter, blocks[j].boundary.center, g, iForce); - } - - calculateLeaf(blocks[i], iForce); - } -} -/** - * - * @param {SpatialLeaf} leaf - * @param {Vector2} pForce - */ -function calculateLeafData(leaf, pForce) { - for (let i = 0; i < leaf.items.length; i++) { - const attractor = leaf.items[i]; - attractor.velocity.add(pForce.scaled(stepDelta)); - - for (let j = 0; j < leaf.items.length; j++) { - if (i === j) continue; - - const particle = leaf.items[j]; - calculateForce(particle.position, attractor.position, gravity * attractor.mass, particle); + await WorldInstance.reconfigure(Settings, { + affected: { + sizing: diff.affects.has(GravityComponentType.particleSizing), + look: diff.affects.has(GravityComponentType.particleLook) + }, + broken: { + world: diff.breaks.has(GravityComponentType.world), + sprite: diff.breaks.has(GravityComponentType.particleLook), + particles: diff.breaks.has(GravityComponentType.particleInitialization) } - } -} + }); -/** - * @param {Vector2} p1 - * @param {Vector2} p2 - * @param {number} g - * @param {Vector2|Body} out - */ -function calculateForce(p1, p2, g, out) { - const dx = p1.x - p2.x, - dy = p1.y - p2.y; - - const distSquare = dx * dx + dy * dy; - if (distSquare < minInteractionDistance) return; - - const force = -g / distSquare; - if (out.velocity !== undefined) { - out.velocity.x += dx * force * stepDelta; - out.velocity.y += dy * force * stepDelta; - } else { - out.x += dx * force; - out.y += dy * force; - } } -document.addEventListener("visibilitychange", function () { - if (document.hidden) { - BootstrapInstance.pause(); - } else { - BootstrapInstance.play(); - } - - document.getElementById("pause-label").style.display = document.hidden ? "block" : "none"; -}); - // noinspection InfiniteLoopJS while (true) { - await BootstrapInstance.requestPhysicsFrame(gravityStep); + await BootstrapInstance.requestPhysicsFrame(GravityInstance.gravityStep.bind(GravityInstance)); } \ No newline at end of file diff --git a/examples/gravity/physics.js b/examples/gravity/physics.js new file mode 100644 index 0000000..937aad1 --- /dev/null +++ b/examples/gravity/physics.js @@ -0,0 +1,120 @@ +import {Vector2} from "../../lib/utils/vector.js"; + +export class GravityPhysics { + bootstrap; + settings; + #stepDelta = 0; + + + /** + * @param {Bootstrap} bootstrap + * @param {GravityExampleSettings} settings + */ + constructor(bootstrap, settings) { + this.bootstrap = bootstrap; + this.settings = settings; + } + + reconfigure(settings) { + this.settings = settings; + } + + gravityStep(delta) { + const tree = this.bootstrap.solver.stepInfo.tree; + this.#stepDelta = delta; + + if (tree) { + this.#calculateTree(tree); + } + } + + /** + * @param {SpatialTree} tree + */ + #calculateTree(tree) { + return this.#calculateLeaf(tree.root, new Vector2()); + } + + /** + * @param {SpatialLeaf} leaf + * @param {Vector2} pForce + */ + #calculateLeaf(leaf, pForce) { + const blocks = leaf.leafs; + if (blocks.length > 0) { + this.#calculateLeafBlock(blocks, pForce); + } else { + this.#calculateLeafData(leaf, pForce); + } + } + + /** + * + * @param {SpatialLeaf[]} blocks + * @param {Vector2} pForce + */ + #calculateLeafBlock(blocks, pForce) { + for (let i = 0; i < blocks.length; i++) { + const blockCenter = blocks[i].boundary.center; + const iForce = pForce.copy(); + + for (let j = 0; j < blocks.length; j++) { + if (i === j) continue; + + const mass = blocks[j].items + .filter(b => b.tag === "particle") + .reduce((p, c) => p + c.mass, 0); + + const g = this.settings.simulation.gravity * mass; + this.#calculateForce(blockCenter, blocks[j].boundary.center, g, iForce); + } + + this.#calculateLeaf(blocks[i], iForce); + } + } + + /** + * + * @param {SpatialLeaf} leaf + * @param {Vector2} pForce + */ + #calculateLeafData(leaf, pForce) { + const g = this.settings.simulation.gravity; + + for (let i = 0; i < leaf.items.length; i++) { + const attractor = leaf.items[i]; + attractor.velocity.add(pForce.scaled(this.#stepDelta)); + + for (let j = 0; j < leaf.items.length; j++) { + if (i === j) continue; + + const particle = leaf.items[j]; + this.#calculateForce(particle.position, attractor.position, g * attractor.mass, particle); + } + } + } + + /** + * @param {Vector2} p1 + * @param {Vector2} p2 + * @param {number} g + * @param {Vector2|Body} out + */ + function + #calculateForce(p1, p2, g, out) { + const dx = p1.x - p2.x, + dy = p1.y - p2.y; + + const distSquare = dx * dx + dy * dy; + if (distSquare < this.settings.simulation.minInteractionDistanceSq) return; + + const force = -g / distSquare; + if (out.velocity !== undefined) { + out.velocity.x += dx * force * this.#stepDelta; + out.velocity.y += dy * force * this.#stepDelta; + } else { + out.x += dx * force; + out.y += dy * force; + } + } +} \ No newline at end of file diff --git a/examples/gravity/render.js b/examples/gravity/render.js new file mode 100644 index 0000000..24e6122 --- /dev/null +++ b/examples/gravity/render.js @@ -0,0 +1,36 @@ +import {WebglRenderer} from "../../lib/render/renderer/webgl/renderer.js"; +import {m4} from "../../lib/render/renderer/webgl/utils/m4.js"; + +export class GravityRender { + renderer; + settings; + + /** + * @param {HTMLCanvasElement} element + * @param {GravityExampleSettings} settings + * @param {*} options + */ + constructor(element, settings, options) { + this.settings = settings; + this.renderer = new WebglRenderer(element, options); + } + + initialize() { + let projMatrix = m4.projection(this.renderer.canvasWidth, this.renderer.canvasHeight, 2); + projMatrix = m4.translate(projMatrix, this.renderer.canvasWidth / 2, this.renderer.canvasHeight / 2, 0); + projMatrix = m4.scale(projMatrix, 1 / this.settings.world.worldScale, 1 / this.settings.world.worldScale, 1); + + this.renderer.setProjectionMatrix(projMatrix) + + if (this.settings.render.particleBlending) { + this.renderer.setBlending(WebGL2RenderingContext.SRC_COLOR, WebGL2RenderingContext.ONE); + } else { + this.renderer.setBlending(WebGL2RenderingContext.ONE, WebGL2RenderingContext.ONE_MINUS_SRC_ALPHA); + } + } + + reconfigure(settings) { + this.settings = settings; + this.initialize(); + } +} \ No newline at end of file diff --git a/examples/gravity/settings.js b/examples/gravity/settings.js new file mode 100644 index 0000000..60a458b --- /dev/null +++ b/examples/gravity/settings.js @@ -0,0 +1,96 @@ +import {AppSettingsBase, SettingsBase, SettingsGroup, Property} from "../common/settings/base.js"; +import {ComponentType} from "../common/settings/enum.js"; + +/** + * @enum {string} + */ +export const GravityComponentType = { + world: "world", + particleLook: "particleLook", + particleSizing: "particleSizing", + physics: "physics", + particleInitialization: "particleInitialization", + ...ComponentType +} + +class ParticleSettings extends SettingsBase { + static Properties = { + count: Property.int("count", 300) + .setName("Particle count") + .setConstraints(2, 10000) + .setBreaks(GravityComponentType.particleInitialization), + minSize: Property.float("min_size", 20) + .setName("Particle min size") + .setConstraints(0.1, 1000) + .setAffects(GravityComponentType.particleSizing), + maxSize: Property.float("max_size", 40) + .setName("Particle max size") + .setConstraints(0.1, 1000) + .setAffects(GravityComponentType.particleSizing), + } +} + +class RenderSettings extends SettingsBase { + static Properties = { + particleScale: Property.float("p_scale", 20) + .setName("Particle Scale").setDescription("Particle sprite scale") + .setConstraints(0.1, 1000) + .setAffects(GravityComponentType.particleLook), + particleOpacity: Property.float("opacity", 1) + .setName("Particle Opacity").setDescription("Particle sprite opacity") + .setConstraints(0, 1) + .setAffects(GravityComponentType.particleLook), + particleBlending: Property.bool("blend", true) + .setName("Particle Blending") + .setAffects(GravityComponentType.renderer), + particleColoring: Property.bool("color", true) + .setName("Particle Coloring") + .setAffects(GravityComponentType.particleLook), + particleTextureUrl: Property.string("tex", new URL("./sprites/particle.png", import.meta.url)) + .setName("Particle texture url") + .setBreaks(GravityComponentType.particleLook), + } +} + +class DisplaySettings extends SettingsBase { + static Properties = { + particleScale: Property.float("p_scale", 20) + .setName("Particle Scale").setDescription("Particle sprite scale") + .setConstraints(0.1, 1000) + .setAffects(GravityComponentType.particleLook), + } +} + +class SimulationSettings extends SettingsBase { + static Properties = { + gravity: Property.float("gravity", 1000) + .setName("Gravity force") + .setConstraints(0, 10000) + .setAffects(GravityComponentType.physics), + minInteractionDistance: Property.float("min_distance", 0.01) + .setName("Min interaction distance") + .setConstraints(0, 100) + .setAffects(GravityComponentType.physics), + } + + get minInteractionDistanceSq() {return this.minInteractionDistance ** 2;} +} + +class WorldSettings extends SettingsBase { + static Properties = { + worldScale: Property.float("w_scale", 40) + .setName("World distance scale") + .setConstraints(1, 10000) + .setAffects(GravityComponentType.renderer) + .setBreaks(GravityComponentType.world), + } +} + +export class GravityExampleSettings extends AppSettingsBase { + static Types = { + particle: SettingsGroup.of(ParticleSettings).setName("Particles"), + simulation: SettingsGroup.of(SimulationSettings).setName("Simulation"), + render: SettingsGroup.of(RenderSettings).setName("Render"), + world: SettingsGroup.of(WorldSettings).setName("Display"), + } +} \ No newline at end of file diff --git a/examples/gravity/style.css b/examples/gravity/style.css index 76c13c1..c72de1f 100644 --- a/examples/gravity/style.css +++ b/examples/gravity/style.css @@ -7,6 +7,11 @@ body { margin: 0; padding: 1rem; background: black; + color: rgb(200, 200, 200); +} + +.stats-block { + color: black; } #canvas { diff --git a/examples/gravity/world.js b/examples/gravity/world.js new file mode 100644 index 0000000..579248b --- /dev/null +++ b/examples/gravity/world.js @@ -0,0 +1,136 @@ +import {Vector2} from "../../lib/utils/vector.js"; +import * as Utils from "../common/utils.js"; +import {CircleBody} from "../../lib/physics/body/circle.js"; +import {SpriteObject} from "../../lib/render/renderer/webgl/objects/sprite.js"; +import {BoundaryBox} from "../../lib/physics/common/boundary.js"; +import {ImageTexture} from "../../lib/render/renderer/webgl/misc/texture.js"; + +export class GravityWorld { + bootstrap; + settings; + worldRect; + + particleTexture; + + + /** + * @param {Bootstrap} bootstrap + * @param {GravityExampleSettings} settings + * @param options + */ + constructor(bootstrap, settings, options) { + this.bootstrap = bootstrap; + this.settings = settings; + this.options = options; + + this.worldRect = new BoundaryBox( + -settings.world.worldScale * bootstrap.renderer.canvasWidth / 2, + settings.world.worldScale * bootstrap.renderer.canvasWidth / 2, + -settings.world.worldScale * bootstrap.renderer.canvasHeight / 2, + settings.world.worldScale * bootstrap.renderer.canvasHeight / 2 + ); + } + + async initialize() { + this.particleTexture = new ImageTexture(this.settings.render.particleTextureUrl); + this.particleTexture.glWrapS = WebGL2RenderingContext.CLAMP_TO_EDGE; + this.particleTexture.glWrapT = WebGL2RenderingContext.CLAMP_TO_EDGE; + this.particleTexture.glMin = WebGL2RenderingContext.LINEAR_MIPMAP_LINEAR; + + await this.particleTexture.wait(); + + this.#initParticles(); + } + + async reconfigure(settings, { + affected: {sizing = false, look = false}, + broken: {world = false, sprite = false, particles = false} + }) { + this.settings = settings; + + if (sprite) { + await this.#updateTexture(); + } + + if (world) { + this.worldRect.update( + -settings.world.worldScale * this.bootstrap.renderer.canvasWidth / 2, + settings.world.worldScale * this.bootstrap.renderer.canvasWidth / 2, + -settings.world.worldScale * this.bootstrap.renderer.canvasHeight / 2, + settings.world.worldScale * this.bootstrap.renderer.canvasHeight / 2 + ); + } + + if (particles) { + for (const rigidBody of this.bootstrap.rigidBodies.slice()) { + if (rigidBody.tag !== "particle") continue; + this.bootstrap.destroyBody(rigidBody); + } + + this.#initParticles(); + } else { + this.#reconfigureImpl(sizing, look, sprite); + } + } + + async #updateTexture() { + this.particleTexture.updateSource(this.settings.render.particleTextureUrl); + await this.particleTexture.wait(); + } + + #initParticles() { + const minRadius = Math.min(this.worldRect.width, this.worldRect.height) / 2 * 0.6 + const maxRadius = Math.min(this.worldRect.width, this.worldRect.height) / 2 * 0.8; + + for (let i = 0; i < this.settings.particle.count; i++) { + const angle = Math.random() * Math.PI * 2; + + const radius = minRadius + Math.random() * (maxRadius - minRadius); + const position = Vector2.fromAngle(angle) + .scale(radius) + .add(this.worldRect.center); + + const body = new CircleBody(position.x, position.y, 1) + .setVelocity(Vector2.fromAngle(Math.random() * Math.PI * 2).scale(this.settings.simulation.gravity)) + .setFriction(this.options.friction) + .setRestitution(this.options.restitution) + .setTag("particle"); + + const renderer = new SpriteObject(body); + renderer.texture = this.particleTexture; + + this.bootstrap.addRigidBody(body, renderer); + } + + this.#reconfigureImpl(true, true, false); + } + + #reconfigureImpl(updateSizing, updateLook, updateSprite) { + const maxSize = Math.max(0, this.settings.particle.maxSize - this.settings.particle.minSize); + for (const particle of this.bootstrap.rigidBodies) { + if (particle.tag !== "particle") continue; + + if (updateSizing) { + const size = this.settings.particle.minSize + Math.random() * maxSize; + particle.radius = size; + particle.setMass(size); + } + + const renderer = this.bootstrap.getRenderObject(particle); + if (updateLook) { + if (this.settings.render.particleColoring) { + renderer.color = Utils.randomColor(170, 255); + } else { + renderer.color = "#ffffff"; + } + + renderer.opacity = this.settings.render.particleOpacity; + renderer.scale = this.settings.render.particleScale; + } + + if (updateSprite) { + renderer.texture = this.particleTexture; + } + } + } +} \ No newline at end of file diff --git a/lib/render/renderer/webgl/misc/texture.js b/lib/render/renderer/webgl/misc/texture.js index d79c98a..9ea93dd 100644 --- a/lib/render/renderer/webgl/misc/texture.js +++ b/lib/render/renderer/webgl/misc/texture.js @@ -15,6 +15,7 @@ export class ITextureSource { /** @type {WebGLTexture|null} */ glTexture = null; + uploaded = false; glFormat = GL.RGBA; glInternalFormat = GL.RGBA; @@ -77,15 +78,23 @@ export class ImageTexture extends ITextureSource { this.#loaded = false; this.#loadPromise = new Promise((resolve, reject) => { this.#image.onload = () => { - this.#image.onload = null; - this.#image.onerror = null; - this.#loadPromise = null; - + this.#resetLoading(); + this.uploaded = false; this.#loaded = true; + resolve() }; - this.#image.onerror = (e) => reject(new Error(e.message ?? "Unable to load image")); + this.#image.onerror = (e) => { + this.#resetLoading(); + reject(new Error(e.message ?? "Unable to load image")) + }; }); } + + #resetLoading() { + this.#image.onload = null; + this.#image.onerror = null; + this.#loadPromise = null; + } } \ No newline at end of file diff --git a/lib/render/renderer/webgl/objects/sprite.js b/lib/render/renderer/webgl/objects/sprite.js index d0d8bcd..5535ec9 100644 --- a/lib/render/renderer/webgl/objects/sprite.js +++ b/lib/render/renderer/webgl/objects/sprite.js @@ -66,7 +66,7 @@ export class SpriteObjectLoader { ] }]); - if (texture.glTexture === null) { + if (texture.glTexture === null || !texture.uploaded) { WebglUtils.loadTextures(gl, configuration, Shader.program, [{ name: "u_texture", @@ -85,6 +85,8 @@ export class SpriteObjectLoader { }, }] ); + + texture.uploaded = true; } else { WebglUtils.assignTextures(gl, configuration, Geometry.key, texture.glTexture); } diff --git a/webpack.config.js b/webpack.config.js index d00a115..9a5ac98 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -57,7 +57,9 @@ export default { ...glob.sync("./lib/misc/**/*.js"), ...glob.sync("./lib/physics/**/*.js"), ...glob.sync("./lib/render/**/*.js"), + ...glob.sync("./examples/common/**/*.js"), + ...glob.sync("./examples/common/**/*.css"), ] }, ...exampleEntries,