BitBurner settings.common.js

 avatar
unknown
javascript
2 years ago
18 kB
29
Indexable
import { SettingsDomElement } from "settings.react.common.js"

// This script uses globalThis["document"] to get the Document instance, which we use to create HTML elements.
// We do it this way, because BitBurner already has the capability to create DOM elements through React (see React.createElement and ns.printRaw).
// If you consider this as cheating just uncomment the next line:
//document;

// Set this to 0 to trigger instant reloads without a setInterval FileWatcher
// This will use the globalThis["document"] Document instance to dispatch an event.
const autoLoadCheckInterval = 100;

export class SettingsInfo {
  constructor(name, schema) {
    this._name = name;
    this._schema = schema;
  }

  get schema() { return this._schema; }
  get name() { return this._name; }

  create(ns) {
    return Settings.fromSchema(this.schema, `settingsData/${this.name}.txt`, ns);
  }
}

export class Settings extends EventTarget {
  /** @param {NS} ns */
  static fromSchema(schema, settingsFilePathOrNs, ns) {
    let settingsFilePath;
    if (typeof settingsFilePathOrNs === "object")
      ns = settingsFilePathOrNs;
    else
      settingsFilePath = settingsFilePathOrNs;
    settingsFilePath = settingsFilePath ?? schema.settingsFilePath;

    const result = new Settings({
      properties: schema,
    });

    if (ns) {
      result._ns = ns;
      result.getLastChangeTimeCallback = (settingsName) => {
        const date = ns.fileExists(settingsName + ".date.txt") ? parseInt(ns.read(settingsName + ".date.txt")) : 0;
        return date;
      };
      result.loadSettingsCallback = (settingsName) => {
        return ns.fileExists(settingsName) ? JSON.parse(ns.read(settingsName)) : {};
      };
      result.saveSettingsCallback = (settingsName, data) => {
        ns.write(settingsName, JSON.stringify(data), "w");
        ns.write(settingsName + ".date.txt", Date.now().toString(), "w");
      };
    }

    if (settingsFilePath)
      result.startAutoLoadSave(settingsFilePath);

    return result;
  }

  descriptor;
  name;
  parent;
  _value = null;
  _descriptionElement = null;
  _properties = [];
  _proxy;
  _lastLoaded = 0;
  _autoLoadHandle;
  _save;
  _onValueChangedInternal = () => { };

  constructor(descriptor, name, parent) {
    super();

    this.descriptor = descriptor;
    this.name = name;
    this.parent = parent;
    this._type = {
      get hasValue() {
        return this.valueType !== undefined;
      },
      get valueType() {
        if (this.isButton)
          return "number"; // because we increment the value to indicate the button was clicked
        else if (this.isLabel)
          return "string";
        return descriptor.default === undefined ? undefined : typeof descriptor.default;
      },

      get isButton() { return typeof descriptor.default === "function"; },
      get isObject() { return "properties" in descriptor; },
      get isRange() { return "min" in descriptor || "max" in descriptor || "step" in descriptor; },
      get isSelect() { return "options" in descriptor; },
      get isLabel() { return descriptor.default === undefined && !this.isObject; },

      get isBoolean() { return this.valueType === "boolean"; },
      get isNumber() { return this.valueType === "number"; },
      get isString() { return this.valueType === "string"; },

      get isHidden() { return !!descriptor.hidden; },
      get isReadonly() { return !!descriptor.readonly; },

      get typeName() {
        if (this.isButton)
          return "button";
        else if (this.isObject)
          return "object";
        else if (this.isRange)
          return "range";
        else if (this.isSelect)
          return "select";
        else if (this.isLabel)
          return "label";
        else
          return "value";
      },

      get range() {
        if (!this.isRange)
          return undefined;
        return {
          min: descriptor.min ?? 0,
          max: descriptor.max ?? 1,
          step: descriptor.step,
        };
      },

      get options() {
        if (!this.isSelect)
          return undefined;
        return descriptor.options;
      }
    };

    this._value = this._type.isButton ? 0 : descriptor.default;
    if (this.type.isObject)
      this._properties = Object.entries(descriptor.properties)
        .map(([name, descriptor]) => new Settings(descriptor, name, this));
  }

  get ns() {
    return this.root._ns;
  }

  getLastChangeTimeCallback = (settingsName) => {
    return parseInt(localStorage.getItem(settingsName + "_date") ?? "0");
  };

  loadSettingsCallback = (settingsName) => {
    return JSON.parse(localStorage.getItem(settingsName) ?? "{}");
  };

  saveSettingsCallback = (settingsName, data) => {
    localStorage.setItem(settingsName, JSON.stringify(data));
    localStorage.setItem(settingsName + "_date", Date.now().toString());
  };

  get type() {
    return this._type;
  }

  get root() {
    return this.parent?.root ?? this;
  }

  get value() {
    return this._value;
  }

  set value(value) {
    if (!this.root._isLoading && this.root._load)
      this.root._load();

    if (this._value === value)
      return;

    if (this.type.isButton) {
      const times = value - this._value;
      for (let i = 0; i < times; ++i)
        this._onTriggered([], this.name);
    }

    this._value = value;
    this._onValueChanged([], this.name, value, this.type.isButton);
    this._onValueChangedInternal(value);
  }

  get proxy() {
    return this._createProxy();
  }

  get reactElement() {
    return this.createReactElement();
  }

  createReactElement(labelOverride = undefined) {
    this._reactElement = this._reactElement ?? SettingsDomElement.create(this, labelOverride);
    return this._reactElement;
  }

  startAutoSave(filePath) {
    if (!this.type.isObject || this._save !== undefined)
      return;

    this._save = () => {
      this.saveSettingsCallback(filePath, this.toObject());
      if (autoLoadCheckInterval === 0) {
        globalThis["document"].dispatchEvent(new BitBurnerSettingsChangedEvent(filePath));
      }
    };
    this.addEventListener("internal-change", this._save);
  }

  stopAutoSave() {
    if (this._save === undefined) return;

    this.removeEventListener("internal-change", this._save);
    this._save = undefined;
  }

  startAutoLoad(filePath) {
    if (!this.type.isObject || this._load !== undefined)
      return;

    this._load = (e) => {
      if (e && e.settingsFilePath !== filePath)
          return;
      const lastLoaded = this.getLastChangeTimeCallback(filePath);
      if (lastLoaded > this._lastLoaded)
        this.fromObject(this.loadSettingsCallback(filePath), lastLoaded);
    };

    if (autoLoadCheckInterval === 0) {
      globalThis["document"].addEventListener("BitBurnerSettingsChanged", this._load);
    }
    else {
      this._autoLoadHandle = setInterval(this._load, autoLoadCheckInterval);
    }
    this._load();
  }

  stopAutoLoad() {
    if (this._load === undefined) return;

    if (this._autoLoadHandle) {
      clearInterval(this._autoLoadHandle);
      this._autoLoadHandle = undefined;
    }
    else {
      globalThis["document"].removeEventListener("BitBurnerSettingsChanged", this._load);
    }
    this._load = undefined;
  }

  startAutoLoadSave(filePath) {
    this.startAutoLoad(filePath);
    this.startAutoSave(filePath);
  }

  stopAutoLoadSave() {
    this.stopAutoLoad();
    this.stopAutoSave();
  }

  get _path() {
    if (!this.name)
      return [];
    if (!this.parent)
      return [this.name];
    return this.parent._path.concat([this.name]);
  }

  _onTriggered(path, name) {
    this.dispatchEvent(new ClickEvent(path.join(".")));
    this.parent?._onTriggered([this.name, ...path], name);
  }

  _onValueChanged(path, name, value, wasFromButton) {
    this.dispatchEvent(new InternalChangeEvent(path.join("."), value));

    if (!wasFromButton)
      this.dispatchEvent(new ChangeEvent(path.join("."), value));
    this.parent?._onValueChanged([this.name, ...path], name, value, wasFromButton);
    this._updateDescriptionText();
    this._updateText();
    this._updateHint();
  }

  get label() {
    return this._getDynamicPropertyValue("label") ?? (this.name ? toHumanReadableName(this.name) : undefined);

    function toHumanReadableName(name) {
      return name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (str) => str.toUpperCase());
    }
  }

  get hint() {
    return this._getDynamicPropertyValue("hint");
  }

  get description() {
    return this._getDynamicPropertyValue("description");
  }

  get text() {
    return this._getDynamicPropertyValue("text");
  }

  _getDynamicPropertyValue(propertyName) {
    const reboundGetter = Object.getOwnPropertyDescriptor(this.descriptor, propertyName)?.get?.bind(this);
    if (reboundGetter)
      return reboundGetter.call();

    const result = this.descriptor[propertyName];
    if (result === undefined)
      return undefined;
    if (typeof result === "function")
      return result.bind(this)(this.value, this.ns);
    return result;
  }

  _updateHint() {
    if (!this._control) return;
    const hint = this.hint;
    if (hint !== undefined)
      this._control.title = hint.toString();
  }

  _updateDescriptionText() {
    if (!this._descriptionElement) return;
    const text = this.description ?? this.value;
    if (text !== undefined)
      this._descriptionElement.textContent = text.toString();
  }

  _updateLabel() {
    if (!this._labelElement) return;
    const label = this.label;
    if (label !== undefined)
      this._labelElement.textContent = label;
  }

  _updateText() {
    if (!this._textElement) return;
    const text = this.text;
    if (text !== undefined)
      this._textElement.textContent = text;
    else if (this.type.isLabel)
      this._textElement.textContent = this.value;
  }

  _createProxy() {
    if (this._proxy)
      return this._proxy;

    if (!this.type.isButton && !this.type.isObject)
      throw new Error("Can only create a proxy for an object or button");

    this._proxy = new Proxy({}, {
      get: (target, name) => {
        if (name === "addEventListener")
          return this.addEventListener.bind(this);
        else if (name === "removeEventListener")
          return this.removeEventListener.bind(this);
        else if (this.type.isObject && name === "toObject")
          return this.toObject.bind(this);
        else if (this.type.isObject && name === "fromObject")
          return this.fromObject.bind(this);
        const property = this._properties?.find((property) => property.name === name);
        if (!property)
          throw new Error(`Property ${name} not found`);
        return property.type.isObject || property.type.isButton ? property.proxy : property.value;
      },
      set: (target, name, value) => {
        const property = this._properties?.find((property) => property.name === name);
        if (!property)
          throw new Error(`Property ${name} not found`);
        property.value = value;
        return true;
      },
    });
    return this._proxy;
  }

  fromObject(data, timeStamp = Date.now()) {
    this._lastLoaded = timeStamp;
    this._isLoading = true;
    if (!this.type.isObject)
      throw new Error("Can only load an object");
    this._properties.forEach((property) => {
      if (property.type.isObject) {
        property.fromObject(data[property.name]);
      }
      else if (property.type.hasValue) {
        property._isLoading = true;
        property.value = data[property.name] ?? property.value;
        property._isLoading = false;
      }
    });
    this._isLoading = false;
  }

  toObject() {
    if (!this.type.isObject)
      throw new Error("Can only save an object");
    const result = {};
    this._properties.forEach((property) => {
      if (property.type.isObject) {
        result[property.name] = property.toObject();
        return;
      }
      else if (property.type.hasValue) {
        result[property.name] = property.value;
      }
    });
    return result;
  }

  createDom(labelOverride = undefined) {
    if (this._controlElement)
      return this._controlElement;

    let containerElement; // only for type object
    let valueContainerElement; // contains an input element and maybe a description span

    const doc = globalThis["document"];
    const hasLabel = labelOverride !== undefined || this.label !== undefined;
   
    if (this.type.isObject && hasLabel) {
      this._controlElement = doc.createElement("details");
      this._controlElement.open = true;
    }
    else
      this._controlElement = doc.createElement("div");

    if (this.type.isObject) {
      containerElement = doc.createElement("div");
      containerElement.classList.add("container");
      this._controlElement.appendChild(containerElement);
    }

    if (this.descriptor.hint)
      this._controlElement.title = this.descriptor.hint;

    (containerElement ?? this._controlElement).toggleAttribute("disabled", !!this.type.isReadonly);

    this._controlElement.classList.add("control");
    this._controlElement.classList.add(this.type.typeName);
    if (this.hasValue && !this.type.isButton)
    this._controlElement.classList.add(this.type.valueType);

    if (!this.type.isButton && hasLabel) {
      this._labelElement = doc.createElement(this.type.isObject ? "summary" : "label");
      if (labelOverride !== undefined)
        this._labelElement.textContent = labelOverride;
      else
        this._labelElement.textContent = this.label;
        this._controlElement.appendChild(this._labelElement);
    }

    let input;
    if (this.type.isRange) {
      input = doc.createElement("input");
      input.type = "range";
      input.min = this.type.range.min.toString();
      input.max = this.type.range.max.toString();
      if (this.type.range.step !== undefined)
        input.step = this.descriptor.step.toString();
    }
    else if (this.type.isSelect) {
      input = doc.createElement("select");
      this.type.options.forEach((option) => {
        const optionElement = doc.createElement("option");
        optionElement.value = option;
        optionElement.textContent = option;
        input.appendChild(optionElement);
      });
    }
    else if (this.type.isButton) {
      input = doc.createElement("button");
      this._textElement = this._labelElement = input;
    }
    else if (this.type.isLabel) {
      this._textElement = input = doc.createElement("span");
       //nop
    }
    else if (this.type.isObject) {
      // no input, but more controls
      this._properties.filter(p => !p.type.isHidden).forEach((property) => {
        containerElement.appendChild(property.createDom());
      });
    }
    else if (this.type.isBoolean) {
      input = doc.createElement("input");
      input.type = "checkbox";
    }
    else if (this.type.isNumber) {
      input = doc.createElement("input");
      input.type = "number";
    }
    else if (this.type.isString) {
      input = doc.createElement("input");
      input.type = "text";
    }

    if (input) {
      valueContainerElement = doc.createElement("div");
      valueContainerElement.classList.add("valueContainer");
      valueContainerElement.appendChild(input);
      this._controlElement.appendChild(valueContainerElement);
      
      if (this.description !== undefined || this.type.isRange) {
        this._descriptionElement = doc.createElement("span");
        this._descriptionElement.classList.add("description");
        valueContainerElement.appendChild(this._descriptionElement);
      }

      if (this.type.isButton) {
        input.addEventListener("click", () => {
          if (typeof this.descriptor.default === "function" && this.descriptor.default !== Function)
            this.descriptor.default.call(this);
          this.value++;
        });
      }
      else if (this.type.isLabel) {
       // nop
      }
      else if (this.type.isBoolean) {
        input.checked = this.value;
        this._onValueChangedInternal = (newValue) => {
          input.checked = newValue;
        };
        input.addEventListener("change", () => {
          if (!this.root._isLoading)
            this.value = input.checked;
        });
      }
      else if (this.type.isNumber) {
        input.value = this.value;
        this._onValueChangedInternal = (newValue) => {
          input.value = newValue;
        };
        input.addEventListener(this.type.isRange ? "input" : "change", () => {
          if (!this.root._isLoading)
            this.value = parseFloat(input.value);
        });
      }
      else if (this.type.isString) {
        input.value = this.value;
        this._onValueChangedInternal = (newValue) => {
          input.value = newValue;
        };
        input.addEventListener("change", () => {
          if (!this.root._isLoading)
            this.value = input.value;
        });
      }
      this._updateDescriptionText();
    }

    this._updateLabel();
    this._updateText();
    this._updateHint();

    return this._controlElement;
  }
}

class ChangeEvent extends Event {
  constructor(path, value) {
    super('change', {
      bubbles: false,
      cancelable: false,
      composed: false
    });
    this.path = path;
    this.value = value;
  }
}

class InternalChangeEvent extends Event {
  constructor(path, value) {
    super('internal-change', {
      bubbles: false,
      cancelable: false,
      composed: false
    });
    this.path = path;
    this.value = value;
  }
}

class ClickEvent extends Event {
  constructor(path) {
    super('click', {
      bubbles: false,
      cancelable: false,
      composed: false
    });
    this.path = path;
  }
}

class BitBurnerSettingsChangedEvent extends Event {
  constructor(settingsFilePath) {
    super('BitBurnerSettingsChanged', {
      bubbles: false,
      cancelable: false,
      composed: false
    });
    this.settingsFilePath = settingsFilePath;
  }
}

Editor is loading...
Leave a Comment