Prodigy Tree Viewer

View gameobject trees within Prodigy Game.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Prodigy Tree Viewer
// @namespace    https://hipposgrumm.dev/
// @version      1.1.0
// @description  View gameobject trees within Prodigy Game.
// @author       Hipposgrumm
// @license      gpl-3.0
// @match        *://*.prodigygame.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=prodigygame.com
// @grant        none
// @run-at       document-end
// ==/UserScript==

/*jshint esversion: 11 */
(function() {
    let gametype = 0;
    if (window.Boot) gametype = 1; // Math
    else if (window.GameFramework?.FrameworkApp) gametype = 2; // English
    else return; // Can't do anything since there's no game world.

    let gameaccess;

    let css = `
<link rel="stylesheet" href="https://code.prodigygame.com/assets/Font-Awesome/css/font-awesome-bb53ad7bff.min.css">
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,300,400,500,600,700" rel="stylesheet" type="text/css" async>
<style>
/* https://www.w3schools.com/howto/howto_js_treeview.asp */
.tree ul {
    font-size: 14px;
    list-style-type: none;
    padding-inline-start: 20px;
    user-select: none; /* Prevent text selection */
}
.treename {
    cursor: pointer;
    display: inline-block;
    min-width: 10em;
    padding: 3px;
    border-radius: 5px;
}
.treename.hasgameobj {
    color: #DB0;
}
.treename.onlygameobj {
    color: #B90;
}
.treename:hover {
    background-color: #8884;
}
.treename:active {
    background-color: #8882;
}
.selected > .treename {
    background-color: #8888;
}
.caret {
    cursor: pointer;
}
.caret::before {
    content: "\u25B6";
    display: inline-block;
    margin-right: 6px;
}
.makeinvisible::before {
    color: transparent;
    cursor: auto;
}
.caret-down::before {
    transform: rotate(90deg);
}
</style>
<style>
::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}
::-webkit-scrollbar-thumb {
  background: #5F606A;
}
::-webkit-scrollbar-thumb:hover {
  background: #4F505A;
  cursor: pointer;
}
body {
    display: flex;
    font-family: "Open Sans";
    color: #DDD;
    background-color: #1A1A1E;
}
input,textarea {
    font-family: "Open Sans";
    color: #E5E5E5;
    background-color: #363638;
    border-color: #444;
    border-radius: 5px;
    resize: vertical;
}
.hidden {
  display: none;
}
div.tree {
    height: calc(100vh - 40px);
    background-color: #242429;
    overflow: auto;
    white-space: nowrap;
    padding: 10px;
    border-radius: 10px;
}
div.splitter {
    width: 10px;
    cursor: col-resize;
}
div.dataview {
    height: calc(100vh - 40px);
    background-color: #202023;
    overflow: auto;
    white-space: nowrap;
    flex: 1;
    padding: 10px;
    border-radius: 10px;
}
div.dataitem {
    display: grid;
    grid-template-columns: 150px 1fr;
    padding: 2px 10px;
    height: 25px;
}
.dataitem span {
    vertical-align: bottom;
    user-select: none;
}
.dataitem > span {
    vertical-align: middle;
    width: 150px;
    min-width: 50px;
}
.dataview div.sub {
    background-color: #242427;
    padding: 10px;
    border-radius: 10px;
}
button.degtoggle {
    background-color: #363638;
    border-color: #444;
    aspect-ratio: 1 / 1;
    height: 25px;
}
button.degtoggle.rad {
    background-image: url("data:image/svg+xml;base64,`+btoa(`<svg xmlns="http://www.w3.org/2000/svg" fill="#DDD" viewBox="0 0 500 500">
	<path d="M 71.235 164.76 L 55.596 164.695 C 55.581 164.696 68.346 113.835 89.175 88.947 C 106.336 68.108 131.312 61.924 149.8 61.7 L 444.154 61.724 L 444.2 114.6 L 354.67 114.631 C 354.669 120.296 343.483 203.929 339.545 264.172 C 336.436 311.736 334.283 367.164 380.158 371.445 C 418.882 373.364 427.109 335.185 427.683 322.619 L 442.8 322.6 C 439.3 365.492 419.567 433.28 357.153 432.362 C 308.314 429.455 293.292 398.763 287.689 363.431 C 278.473 329.546 306.818 121.136 304.62 114.733 L 206.6 114.7 C 207.422 115.05 193.43 261.761 187.564 293.505 C 183.956 324.61 174.05 370.806 161.744 395.17 C 145.189 429.984 121.953 435.333 105.175 428.222 C 77.642 414.754 81.181 388.386 90.763 374.381 C 96.317 367.136 123.771 328.393 131.818 309.928 C 142.793 288.981 151.542 249.678 153.494 236.773 C 159.835 216 167.064 115.489 166.297 115.387 L 130.092 115.44 C 111.64 115.885 97.442 126.385 88.196 139.247 C 87.33 138.9 71.158 164.683 71.2 164.7 L 71.235 164.76 Z" />
</svg>`)+`")
}
button.degtoggle.deg {
    background-image: url("data:image/svg+xml;base64,`+btoa(`<svg xmlns="http://www.w3.org/2000/svg" fill="#DDD" viewBox="0 0 500 500">
	<path d="M 159.139 277.589 C 210.158 294.834 247.443 340.673 243.385 399.989 L 288.465 398.242 C 291.281 358.175 261.569 271.201 184.684 242.627 L 159.139 277.589 Z" />
	<path d="M 250.213 80.675 L 60.809 408.222 L 60.829 419.278 L 439.19 419.325 L 439.1 377.9 L 127.222 377.894 L 290.819 106.63 L 250.213 80.675 Z" />
</svg>`)+`")
}
</style>
`;
    let EXPORT_MIMETYPES = {
        "html":  "text/html",
        "css":   "text/css",
        "js":    "application/javascript",
        "woff":  "application/font-woff",
        "woff2": "application/font-woff2",
        "png":   "image/png",
        "jpg":   "image/jpeg",
        "jpeg":  "image/jpeg",
        "gif":   "image/gif",
        "txt":   "text/plain",
        "json":  "application/json",
        "mp4":   "video/mp4",
        "ogg":   "application/ogg",
        "zip":   "application/zip"
    };

    let DEG2RAD = Math.PI / 180;
    let RAD2DEG = 180 / Math.PI;

    let PIXI_CLASSES = [];
    let PIXI_CLASSES_SPINE = [];
    for (let key in window.PIXI) {
        let val = window.PIXI[key];
        if (typeof(val) == 'function' && val.hasOwnProperty("prototype")) PIXI_CLASSES[key] = val;
    }
    for (let key in window.PIXI.spine) {
        let val = window.PIXI.spine[key];
        if (typeof(val) == 'function' && val.hasOwnProperty("prototype")) PIXI_CLASSES_SPINE[key] = val;
    }

    {
        let fun;
        fun = (e) => {
            window.EXTRACTED_CORS_POLICY = {};
            e.originalPolicy.split(new RegExp('; ?')).forEach(v => {
                if (!v) return;
                let vs = [];
                let name;
                v.split(' ').every((e,i) => {
                    if (i == 0) name = e;
                    else {
                        let val;
                        if (e.charAt(0) != "'") val = e;
                        else if (e == "'self'") {
                            val = document.location.origin;
                        } else if (e == "'none'") {
                            vs = [];
                            return false;
                        }
                        if (val) vs.push(new RegExp(val.replaceAll(':', '\\:').replaceAll('/', '\\/').replaceAll('*', '(.*)')));
                    }
                    return true;
                });
                window.EXTRACTED_CORS_POLICY[name] = vs;
            });
            document.removeEventListener("securitypolicyviolation", fun);
        };
        document.addEventListener("securitypolicyviolation", fun);
    }

    // PROBABLY EXPENSIVE, USE SPARINGLY
    async function checkCORSAllow(url, type) {
        if (!window.EXTRACTED_CORS_POLICY) {
            let img = new Image();
            img.crossOrigin = "anonymous";
            img.src = "//cors-test-please-ignore"; // triggers the function above

            // Source - https://stackoverflow.com/a/52652681
            // Posted by Lightbeard
            // Retrieved 2026-04-21, License - CC BY-SA 4.0
            await new Promise(resolve => {
                let loop;
                loop = setInterval(() => {
                    if (window.EXTRACTED_CORS_POLICY) {
                        clearInterval(loop);
                        resolve();
                    }
                }, 10);
            });
        }
        let origin = url.split(new RegExp('(?<![\:\/])\/'))[0]; // Doesn't match ':/' properly but we really don't care.
        return Boolean((window.EXTRACTED_CORS_POLICY[type] ?? []).find(e => origin.match(e)));
    }

    let PIXITextureShader = new window.PIXI.Filter(null, `
// nocache: ${Date.now() /* Cached shaders sometimes simply refuse to work at all. */}
precision mediump float;

varying vec2 vTextureCoord;

uniform sampler2D uSampler;
uniform bool doMatte;

void main(void) {
    vec4 fg = texture2D(uSampler, vTextureCoord);
    if (doMatte) fg.r = fg.a;
    fg.a = 1.0;
    gl_FragColor = fg;
}
`, {doMatte: false});
    function renderPIXITexture(textures) {
        if (!gameaccess) throw new Error("Attempted to render PIXI texture before viewer window was created.");
        let single = !Array.isArray(textures);
        if (single) textures = [textures];
        let outs = [];
        let outcanvas = document.createElement('canvas');
        let outctx = outcanvas.getContext('2d');
        let gl = gameaccess.renderer.gl;
        textures.forEach(tex => {
            outctx.clearRect(0, 0, outcanvas.width, outcanvas.height);
            outcanvas.width = tex.width;
            outcanvas.height = tex.height;
            let sprite = new window.PIXI.Sprite(tex);
            sprite.filters = [PIXITextureShader];
            for (;sprite.x>-tex.width;sprite.x-=gameaccess.width) {
                for (;sprite.y>-tex.height;sprite.y-=gameaccess.height) {
                    let width = Math.min(tex.width+sprite.x, gameaccess.width);
                    let height = Math.min(tex.height+sprite.y, gameaccess.height);
                    let vOff = gameaccess.height - height;
                    let pixels = new Uint8Array(width * height * 4);
                    PIXITextureShader.uniforms.doMatte = false;
                    gameaccess.renderer.render(sprite);
                    gl.readPixels(0, vOff, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
                    {
                        let red = new Uint8Array(pixels.length);
                        PIXITextureShader.uniforms.doMatte = true;
                        gameaccess.renderer.render(sprite);
                        gl.readPixels(0, vOff, width, height, gl.RGBA, gl.UNSIGNED_BYTE, red);
                        for (let i=0;i<pixels.length;i+=4) {
                            pixels[i+3] = red[i];
                        }
                    }
                    pixels = new Uint8ClampedArray(pixels, width, height);
                    let wid = width*4;
                    for (let r=0;r<(((pixels.length/wid)/2)*wid);r+=wid) {
                        let ir = pixels.length-r-wid;
                        for (let i=0;i<wid;i++) {
                            let iri=ir+i, ri=r+i;
                            let t = pixels[iri];
                            pixels[iri] = pixels[ri];
                            pixels[ri] = t;
                        }
                    }
                    outctx.putImageData(new ImageData(pixels, width, height), -sprite.x, -sprite.y);
                }
            }
            outs.push(outcanvas.toDataURL());
        });
        if (single) return outs[0];
        else return outs;
    }

    /// An abstract container that can be used to modify objects that ignores the descrepancies between GameObject types
    class ManipulatableObject {
        /// Creates inner classes for this class
        static createInnerClasses() {
            /// Wrapper class for XY coordinates
            ManipulatableObject.Coord = class {
                constructor(data) {
                    this.data = data;
                }

                get x() { return this.data.x; }
                set x(val) { this.data.x = val; }
                get y() { return this.data.y; }
                set y(val) { this.data.y = val; }
            };
            /// Contains all data for this object's management in the DOM.
            ManipulatableObject.ElementData = class {
                constructor(obj, list) {
                    this._obj = obj;
                    this._list = list;
                    this._item = document.createElement('li');

                    this._caret = document.createElement('span');
                    this._caret.classList.add("caret");
                    this._caret.addEventListener("click", () => this.doToggle());
                    this._caret.classList.add("makeinvisible");
                    this._caretHidden = true;
                    this._item.appendChild(this._caret);

                    if (obj.active != null) {
                        this._checkbox = document.createElement('input');
                        this._checkbox.type = "checkbox";
                        this._checkbox.addEventListener("change", () => {
                            obj.active = this._checkbox.checked;
                        });
                        this._checkbox.checked = obj.active;
                        this._item.appendChild(this._checkbox);
                    }

                    this._nametext = " ";
                    this._txt = document.createElement('span');
                    this._txt.classList.add("treename");
                    if (!obj.treeObject) this._txt.classList.add("onlygameobj");
                    else if (obj.pixiObject) this._txt.classList.add("hasgameobj");
                    this._txt.addEventListener("click", () => openObject(obj));
                    this._item.appendChild(this._txt);

                    this._childList = document.createElement('ul');
                    this._childList.classList.add("hidden");
                    this._item.appendChild(this._childList);
                    this._hideChildren = true;
                }

                /// Expands/collapses this item's child list.
                doToggle() {
                    if (!this._childList) return;
                    this._hideChildren = !this._hideChildren;
                    if (this._hideChildren) {
                        this._childList.classList.add("hidden");
                        this._caret.classList.remove("caret-down");
                        this.updateChildren();
                    } else {
                        this._childList.classList.remove("hidden");
                        this._caret.classList.add("caret-down");
                    }
                }

                updateChildren(addition=null) {
                    if (this._caretHidden) {
                        if (this._obj.node.children.length > 0) {
                            this._caret.classList.remove("makeinvisible");
                            this._caretHidden = false;
                            return;
                        }
                    } else if (this._obj.node.children.length == 0) {
                        this._caret.classList.add("makeinvisible");
                        this._caretHidden = true;
                    }
                    if (!this.isChildrenVisible()) return; // if the children are hidden we don't need to worry about them right now
                    this._obj.node.sortChildren(addition);
                    if (!this._obj.node.sortingDirtyAccept()) return;
                    // https://stackoverflow.com/q/34685316
                    var elements = document.createDocumentFragment();
                    this._obj.node.children.forEach(child => {
                        elements.appendChild(child.object.element._item);
                    });
                    this._childList.innerHTML = null;
                    this._childList.appendChild(elements);
                }

                /// @returns {bool} Whether this item's list of children is collapsed or not
                get childrenHidden() { return this._hideChildren; }

                /// @returns {Object} List Element containing this object
                get list() { return this._list; }

                /// @returns {Object} ListItem Element representing this object
                get item() { return this._item; }

                /// @returns {Object} List Element for this object's children
                get childList() { return this._childList; }

                /// @returns {Object} Checkbox that determines if the GameObject is visible/active
                get checkbox() { return this._checkbox; }

                get txt() { return this._txt; }
                get txt_inner() { return this._nametext; }
                set txt_inner(val) {
                    this._nametext = val;
                    if (!val?.trim()) this._txt.innerHTML = "\u00A0"; // This prevents it from becoming flat.
                    else this._txt.innerHTML = val;
                }

                isChildrenVisible() {
                    let par = this._obj.node;
                    while (par) {
                        if (par.object.element._hideChildren) return false;
                        par = par.parent;
                    }
                    return true;
                }
            };
            delete ManipulatableObject.createInnerClasses;
        }

        constructor(data, list) {
            if (!data) throw new Error("Value passed to 'data' parameter of ManipulatableObject.new() is null or empty.");
            if (data instanceof ManipulatableObject) {
                this._treeobject = data._treeobject;
                this._pixiobject = data._pixiobject;
                this._data = data._data;
                this._typename = data._typename;
                this._pixiTransform = data._pixiTransform;
            } else {
                if (data.hasOwnProperty("_legacyTransform")) { // PIXI GameObject
                    this._treeobject = data.legacyTransform; // potential reference to Tree GameObject
                    this._pixiobject = data;
                } else if (data.transform) { // Tree GameObject
                    this._treeobject = data;
                    this._pixiobject = data.gameObjectRef; // potential reference to PIXI GameObject
                } else {
                    throw new Error("Object passed to 'data' parameter of ManipulatableObject.new() doesn't appear to be a GameObject.");
                }
                this._data = data; // Useful for referencing names common to both GameObject types
                this._typename = null;
                for (let name in PIXI_CLASSES) {
                    let clazz = PIXI_CLASSES[name];
                    if (clazz == data.constructor) {
                        this._typename = "pixi/"+name;
                        break;
                    }
                }
                for (let name in PIXI_CLASSES_SPINE) {
                    let clazz = PIXI_CLASSES_SPINE[name];
                    if (clazz == data.constructor) {
                        this._typename = "pixi/spine/"+name;
                        break;
                    }
                }
                this._pixiTransform = this._treeobject ? this._treeobject.transform : this._pixiobject.transform?.pixiTransform;
            }
            if (this._pixiTransform) {
                this._transPos = new ManipulatableObject.Coord(this._pixiTransform.position);
                this._transScl = new ManipulatableObject.Coord(this._pixiTransform.scale);
                this._transSkw = new ManipulatableObject.Coord(this._pixiTransform.skew);
                this._transPiv = new ManipulatableObject.Coord(this._pixiTransform.pivot);
            }
            this._element = new ManipulatableObject.ElementData(this, list);
        }

        compare(other) {
            return this._treeobject == other._treeobject &&
                this._pixiobject == other._pixiobject;
        }

        get exists() {
            if (this._pixiobject) return this._pixiobject.isDestroyed != true; // can be false or null
            else if (this._treeobject) return this._treeobject.exists;
            else return false;
        }
        get treeObject() { return this._treeobject; }
        get pixiObject() { return this._pixiobject; }
        get typename() { return this._typename; }
        get name() {
            if (this._data.name != null) return this._data.name;
            if (!this._pixiobject && !this._data.hasOwnProperty("name")) return null;
            else return "";
        }
        get active() {
            if (this._pixiobject) return this._pixiobject.active;
            else if (this._treeobject?.hasOwnProperty("visible")) {
                return this._treeobject.visible;
            } else return null;
        }
        set active(val) {
            if (this._pixiobject) this._pixiobject.active = val;
            else if (this._treeobject?.hasOwnProperty("visible")) {
                this._treeobject.visible = val;
            }
        }
        get pos() { return this._transPos; }
        get rot() { // Rotation in radians.
            return this._pixiTransform ? this._pixiTransform.rotation : null;
        }
        set rot(val) {
            if (this._pixiTransform) this._pixiTransform.rotation = val;
        }
        get scl() { return this._transScl; }
        get skw() { return this._transSkw; } // Skew in radians.
        get piv() { return this._transPiv; }

        get alpha() {
            if (!this._treeobject) return null;
            return this._treeobject.alpha;
        }
        set alpha(val) {
            if (this._treeobject) this._treeobject.alpha = val;
        }

        get data() { return this._data; }
        get element() { return this._element; }
        get node() { return this._node; }
    } ManipulatableObject.createInnerClasses();
    class ObjectHeirarchy {
        static createInnerClasses() {
            ObjectHeirarchy.Node = class {
                constructor(obj, heirarchy, parent) {
                    obj._node = this;
                    this._obj = obj;
                    this._heirarchy = heirarchy;
                    this._parent = parent;
                    this._siblingUp = null;
                    this._siblingDown = null;
                    this._children = [];
                    this._sortingDirty = true;
                    this._listTreeObjsLast = [];
                    this._listPixiObjsLast = [];
                }

                get object() { return this._obj; }
                get parent() { return this._parent; }
                get siblingUp() { return this._siblingUp; }
                get siblingDown() { return this._siblingDown; }
                get children() { return this._children; }
                sortingDirtyAccept() {
                    if (!this._sortingDirty) return false;
                    this._sortingDirty = false;
                    return true;
                }

                sortChildren(addition=null) {
                    let listTreeObjs = this._obj.treeObject?.children ?? [];
                    let listPixiObjs = this._obj.pixiObject?.children ?? [];
                    if (addition) {
                        if (addition._obj.treeObject) this._listTreeObjsLast.push(addition._obj.treeObject);
                        if (addition._obj.pixiObject) this._listPixiObjsLast.push(addition._obj.pixiObject);
                        this._obj.element.childList.appendChild(addition._obj.element.item);
                    }
                    if (
                        this._listTreeObjsLast.length == listTreeObjs.length &&
                        this._listTreeObjsLast.every((e,i) => e == listTreeObjs[i]) &&
                        this._listPixiObjsLast.length == listPixiObjs.length &&
                        this._listPixiObjsLast.every((e,i) => e == listPixiObjs[i])
                    ) return;
                    this._listTreeObjsLast = Array.from(listTreeObjs);
                    this._listPixiObjsLast = Array.from(listPixiObjs);

                    let items = [], extras = [];
                    this._children.forEach(e => {
                        let added = false;
                        e._tmp_treeInd = e._obj.treeObject ? listTreeObjs.indexOf(e._obj.treeObject) : -1;
                        if (e._tmp_treeInd >= 0) {
                            items.push(e);
                            added = true;
                        }
                        e._tmp_pixiInd = e._obj.pixiObject ? listPixiObjs.indexOf(e._obj.pixiObject) : -1;
                        if (!added) {
                            if (e._tmp_pixiInd >= 0) {
                                items.push(e);
                            } else extras.push(e);
                        }
                    });

                    items.sort((a, b) => {
                        let ind1 = a._tmp_pixiInd;
                        let ind2 = b._tmp_pixiInd;
                        if (ind1 < 0 || ind2 < 0) return 0;
                        else return ind1-ind2;
                    });
                    items.sort((a, b) => {
                        let ind1 = a._tmp_treeInd;
                        let ind2 = b._tmp_treeInd;
                        if (ind1 < 0 || ind2 < 0) return 0;
                        else return ind1-ind2;
                    });
                    let newChildren = items.concat(extras);
                    newChildren.forEach((e, i) => {
                        e._siblingUp = (i > 0) ? newChildren[i-1] : null;
                        e._siblingDown = (i < (newChildren.length-1)) ? newChildren[i+1] : null;
                        delete e._tmp_treeInd; delete e._tmp_pixiInd;
                    });
                    let sorted = this._children.length != newChildren.length ||
                        !this._children.every((e,i) => e == newChildren[i]);

                    this._children = newChildren;
                    if (sorted) this._sortingDirty = true;
                }

                add(obj) {
                    let node = new ObjectHeirarchy.Node(obj, this._heirarchy, this);
                    if (obj.treeObject) {
                        this._heirarchy._treeObjectsKeys.push(obj.treeObject);
                        this._heirarchy._treeObjectsVals.push(node);
                    }
                    if (obj.pixiObject) {
                        this._heirarchy._pixiObjectsKeys.push(obj.pixiObject);
                        this._heirarchy._pixiObjectsVals.push(node);
                    }
                    this._heirarchy._allnodes.push(node);
                    if (this._children.length > 0) {
                        let lastChild = this._children[this._children.length-1];
                        lastChild._siblingDown = node;
                        node._siblingUp = lastChild;
                    }
                    this._children.push(node);
                    this._obj.element.updateChildren(node);
                }

                removeSelf(fromRecursion=false) {
                    this._children.forEach(c=>c.removeSelf(true));
                    let ind = this._heirarchy._allnodes.indexOf(this);
                    if (ind < 0) return;

                    if (this._siblingUp) this._siblingUp._siblingDown = this._siblingDown;
                    if (this._siblingDown) this._siblingDown._siblingUp = this._siblingUp;

                    this._heirarchy._allnodes.splice(ind, 1);
                    ind = this._heirarchy._treeObjectsVals.indexOf(this);
                    if (ind >= 0) {
                        this._heirarchy._treeObjectsKeys.splice(ind, 1);
                        this._heirarchy._treeObjectsVals.splice(ind, 1);
                    }
                    ind = this._heirarchy._pixiObjectsVals.indexOf(this);
                    if (ind >= 0) {
                        this._heirarchy._pixiObjectsKeys.splice(ind, 1);
                        this._heirarchy._pixiObjectsVals.splice(ind, 1);
                    }
                    if (!fromRecursion) {
                        this._obj.element.item.remove();
                        this._parent._children.splice(this._parent._children.indexOf(this), 1);
                    }
                }
            };
            delete ObjectHeirarchy.createInnerClasses;
        }

        constructor(obj) {
            this.add(obj, null);
        }

        add(obj, parent) {
            if (parent != null && !(parent instanceof ObjectHeirarchy.Node)) {
                if (!(parent instanceof ManipulatableObject)) throw new Error("Unusable 'parent' for adding object to heirarchy.");
                if (parent.treeObject) parent = this._treeObjectsVals[this._treeObjectsKeys.indexOf(parent.treeObject)];
                else if (parent.pixiObject) parent = this._pixiObjectsVals[this._pixiObjectsKeys.indexOf(parent.pixiObject)];
                else throw new Error("This ManipulatableObject is incapable of being a 'parent'.");
            }
            if (parent) parent.add(obj);
            else {
                this._root = new ObjectHeirarchy.Node(obj, this, null);
                if (obj.treeObject) {
                    this._treeObjectsKeys = [obj.treeObject];
                    this._treeObjectsVals = [this._root];
                } else {
                    this._treeObjectsKeys = [];
                    this._treeObjectsVals = [];
                }
                if (obj.pixiObject) {
                    this._pixiObjectsKeys = [obj.pixiObject];
                    this._pixiObjectsVals = [this._root];
                } else {
                    this._pixiObjectsKeys = [];
                    this._pixiObjectsVals = [];
                }
                this._allnodes = [this._root];
                obj.element.list.appendChild(obj.element.item);
            }
        }

        remove(obj) {
            if (!(obj instanceof ObjectHeirarchy.Node)) {
                if (obj instanceof ManipulatableObject) {
                    obj = this._allnodes.find(o => o.object == obj);
                } else throw new Error("Value of 'obj' is not a node, cannot remove it.");
            }
            obj.removeSelf();
        }
    } ObjectHeirarchy.createInnerClasses();

    let createDataitem = function(nameHTML, elementType, elementSetup, elementLayout="auto") {
        let div = document.createElement('div');
        let label = document.createElement('span');
        let vals = document.createElement('div');
        div.classList.add("dataitem");
        div.appendChild(label);
        div.appendChild(vals);
        label.innerHTML = nameHTML;
        vals.style.display = "inline-grid";
        vals.style["grid-template-columns"] = elementLayout;
        if (!Array.isArray(elementType)) {
            let val = document.createElement(elementType);
            if (!Array.isArray(elementSetup)) {
                if (elementSetup) elementSetup(val, [val]);
            } else if (elementSetup.length > 0) {
                if (elementSetup[0]) elementSetup[0](val, [val]);
            }
            vals.appendChild(val);
            return div;
        } else if (elementType.length == 0) return div;
        let elements = [];
        elementType.forEach(e => {
            let val = document.createElement(e);
            elements.push(val);
            vals.appendChild(val);
        });
        if (!Array.isArray(elementSetup)) {
            if (elementSetup) elementSetup(elements[0], Array.from(elements));
            return div;
        }
        elements.every((e, i) => {
            if (i >= elementSetup.length) return false;
            if (elementSetup[i]) elementSetup[i](e, Array.from(elements));
            return true;
        });
        return div;
    };
    let dataItemDegRad = function(dataviewData, modename, onSetDegrees, onSetRadians) {
        return (radDeg, elements) => {
            radDeg.classList.add("degtoggle");
            radDeg.addEventListener("click", () => {
                var mode = !dataviewData[modename];
                dataviewData[modename] = mode;
                if (mode) {
                    radDeg.title = "Mode: Degrees";
                    radDeg.classList.remove("rad");
                    radDeg.classList.add("deg");
                    onSetDegrees(dataviewData, elements);
                } else {
                    radDeg.title = "Mode: Radians";
                    radDeg.classList.add("rad");
                    radDeg.classList.remove("deg");
                    onSetRadians(dataviewData, elements);
                }
            });
            radDeg.title = "Mode: Degrees";
            radDeg.classList.add("deg");
            dataviewData[modename] = true;
        };
    };

    let treeDiv, dataview;
    let treeData = null, dataviewData = null, dataviewUpdaters = null;

    let selected = null;
    function openObject(obj) {
        if (obj instanceof ObjectHeirarchy.Node) obj = obj.object;

        if (selected) selected.element.item.classList.remove("selected");
        selected = obj;
        while (dataview.firstChild) dataview.firstChild.remove();
        dataviewData = {"extra":{},"current":{"extra":{}}};
        dataviewUpdaters = [];
        if (!selected) return;
        selected.element.item.classList.add("selected");

        {
            let par = obj.node.parent;
            while (par) {
                if (par.object.element.childrenHidden) par.object.element.doToggle();
                par = par.parent;
            }
        }

        {
            let treeRect = treeDiv.getBoundingClientRect();
            let itemRect = selected.element.txt.getBoundingClientRect();
            let scrollX = treeDiv.scrollLeft, scrollY = treeDiv.scrollTop;
            if (itemRect.left < treeRect.left) scrollX -= treeRect.left-itemRect.left;
            else if (itemRect.right > treeRect.right) scrollX += itemRect.right-treeRect.right;
            if (itemRect.top < treeRect.top) scrollY -= treeRect.top-itemRect.top;
            else if (itemRect.bottom > treeRect.bottom) scrollY += itemRect.bottom-treeRect.bottom;
            treeDiv.scroll(scrollX, scrollY);
        }

        dataview.appendChild(createDataitem("Class ID", 'input', text => {
            text.type = "text";
            text.readOnly = true;
            let clazz = Object.getPrototypeOf(obj.data);
            let path = clazz.constructor.name;
            while (true) {
                let par = Object.getPrototypeOf(clazz);
                if (!par) break;
                path = par.constructor.name+' '+path;
                clazz = par;
            }
            text.value = path;
        }));
        if (obj.name != null) {
            dataview.appendChild(createDataitem("Name", 'input', text => {
                text.type = "text";
                text.value = obj.name;
                text.readOnly = true;
            }));
        }
        if (obj.active != null) {
            dataview.appendChild(createDataitem("Is Active", 'input', check => {
                check.type = "checkbox";
                check.addEventListener("change", () => {
                    obj.element.checkbox.checked = obj.active = check.checked;
                });
                check.checked = obj.active;
                dataviewData.active = check;
            }));
            dataviewUpdaters.push(() => {
                dataviewData.active.checked = selected.active;
            });
        }
        if (obj._pixiTransform) {
            let transformDiv = document.createElement('div');
            transformDiv.classList.add("sub");
            transformDiv.appendChild(createDataitem("Position", ['span', 'input', 'span', 'input'], [
                label => { label.innerHTML = "X:"; },
                input => {
                    input.type = "number";
                    input.addEventListener("change", () => {
                        dataviewData.current.posX = obj.pos.x = input.valueAsNumber;
                    });
                    input.value = dataviewData.current.posX = obj.pos.x;
                    dataviewData.posX = input;
                },
                label => { label.innerHTML = ", Y:"; },
                input => {
                    input.type = "number";
                    input.addEventListener("change", () => {
                        dataviewData.current.posY = obj.pos.y = input.valueAsNumber;
                    });
                    input.value = dataviewData.current.posY = obj.pos.y;
                    dataviewData.posY = input;
                }
            ], "auto minmax(0, 1fr) auto minmax(0, 1fr)"));
            dataviewUpdaters.push(() => {
                if (dataviewData.posX.value == dataviewData.current.posX) {
                    dataviewData.posX.value = dataviewData.current.posX = selected.pos.x;
                }
                if (dataviewData.posY.value == dataviewData.current.posY) {
                    dataviewData.posY.value = dataviewData.current.posY = selected.pos.y;
                }
            });
            transformDiv.appendChild(createDataitem("Rotation", ['input', 'button'], [
                input => {
                    input.type = "number";
                    input.addEventListener("change", () => {
                        let val = input.valueAsNumber;
                        if (dataviewData.rot_degmode) val *= DEG2RAD;
                        dataviewData.current.rot = obj.rot = val;
                    });
                    input.value = dataviewData.current.rot = obj.rot * RAD2DEG;
                    dataviewData.rot = input;
                }, dataItemDegRad(dataviewData, "rot_degmode", (dataviewData, elements) => {
                    elements[0].value = dataviewData.current.rot = obj.rot * RAD2DEG;
                    dataviewData.skwX.step = dataviewData.skwY.step = 1;
                }, (dataviewData, elements) => {
                    elements[0].value = dataviewData.current.rot = obj.rot;
                    dataviewData.skwX.step = dataviewData.skwY.step = DEG2RAD;
                })
            ], "minmax(0, 1fr) auto"));
            dataviewUpdaters.push(() => {
                if (dataviewData.rot.value != dataviewData.current.rot) return;
                let val = selected.rot;
                if (dataviewData.rot_degmode) val *= RAD2DEG;
                dataviewData.rot.value = dataviewData.current.rot = val;
            });
            transformDiv.appendChild(createDataitem("Scale", ['span', 'input', 'span', 'input'], [
                label => { label.innerHTML = "X:"; },
                input => {
                    input.type = "number";
                    input.addEventListener("change", () => {
                        dataviewData.current.sclX = obj.scl.x = input.valueAsNumber;
                    });
                    input.value = dataviewData.current.sclX = obj.scl.x;
                    dataviewData.sclX = input;
                },
                label => { label.innerHTML = ", Y:"; },
                input => {
                    input.type = "number";
                    input.addEventListener("change", () => {
                        dataviewData.current.sclY = obj.scl.y = input.valueAsNumber;
                    });
                    input.value = dataviewData.current.sclY = obj.scl.y;
                    dataviewData.sclY = input;
                }
            ], "auto minmax(0, 1fr) auto minmax(0, 1fr)"));
            dataviewUpdaters.push(() => {
                if (dataviewData.sclX.value == dataviewData.current.sclX) {
                    dataviewData.sclX.value = dataviewData.current.sclX = selected.scl.x;
                }
                if (dataviewData.sclY.value == dataviewData.current.sclY) {
                    dataviewData.sclY.value = dataviewData.current.sclY = selected.scl.y;
                }
            });
            transformDiv.appendChild(createDataitem("Skew", ['span', 'input', 'span', 'input', 'button'], [
                label => { label.innerHTML = "X:"; },
                input => {
                    input.type = "number";
                    input.addEventListener("change", () => {
                        let val = input.valueAsNumber;
                        if (dataviewData.skw_degmode) val *= DEG2RAD;
                        dataviewData.current.skwX = obj.skw.x = val;
                    });
                    input.step = 1;
                    input.value = dataviewData.current.skwX = obj.skw.x * RAD2DEG;
                    dataviewData.skwX = input;
                },
                label => { label.innerHTML = ", Y:"; },
                input => {
                    input.type = "number";
                    input.addEventListener("change", () => {
                        let val = input.valueAsNumber;
                        if (dataviewData.skw_degmode) val *= DEG2RAD;
                        dataviewData.current.skwY = obj.skw.y = val;
                    });
                    input.step = 1;
                    input.value = dataviewData.current.skwY = obj.skw.y * RAD2DEG;
                    dataviewData.skwY = input;
                }, dataItemDegRad(dataviewData, "skw_degmode", (dataviewData, elements) => {
                    elements[1].value = dataviewData.current.skwX = obj.skw.x * RAD2DEG;
                    elements[3].value = dataviewData.current.skwY = obj.skw.y * RAD2DEG;
                    dataviewData.skwX.step = dataviewData.skwY.step = 1;
                }, (dataviewData, elements) => {
                    elements[1].value = dataviewData.current.skwX = obj.skw.x;
                    elements[3].value = dataviewData.current.skwY = obj.skw.y;
                    dataviewData.skwX.step = dataviewData.skwY.step = DEG2RAD;
                })
            ], "auto minmax(0, 1fr) auto minmax(0, 1fr) auto"));
            dataviewUpdaters.push(() => {
                if (dataviewData.skwX.value == dataviewData.current.skwX) {
                    let val = selected.skw.x;
                    if (dataviewData.skw_degmode) val *= RAD2DEG;
                    dataviewData.skwX.value = dataviewData.current.skwX = val;
                }
                if (dataviewData.skwY.value == dataviewData.current.skwY) {
                    let val = selected.skw.y;
                    if (dataviewData.skw_degmode) val *= RAD2DEG;
                    dataviewData.skwY.value = dataviewData.current.skwY = val;
                }
            });
            transformDiv.appendChild(createDataitem("Pivot", ['span', 'input', 'span', 'input'], [
                label => { label.innerHTML = "X:"; },
                input => {
                    input.type = "number";
                    input.addEventListener("change", () => {
                        dataviewData.current.pivX = obj.piv.x = input.valueAsNumber;
                    });
                    input.value = dataviewData.current.pivX = obj.piv.x;
                    dataviewData.pivX = input;
                },
                label => { label.innerHTML = ", Y:"; },
                input => {
                    input.type = "number";
                    input.addEventListener("change", () => {
                        dataviewData.current.pivY = obj.piv.y = input.valueAsNumber;
                    });
                    input.value = dataviewData.current.pivY = obj.piv.y;
                    dataviewData.pivY = input;
                }
            ], "auto minmax(0, 1fr) auto minmax(0, 1fr)"));
            dataviewUpdaters.push(() => {
                if (dataviewData.pivX.value == dataviewData.current.pivX) {
                    dataviewData.pivX.value = dataviewData.current.pivX = selected.piv.x;
                }
                if (dataviewData.pivY.value == dataviewData.current.pivY) {
                    dataviewData.pivY.value = dataviewData.current.pivY = selected.piv.y;
                }
            });
            dataview.appendChild(transformDiv);
        }
        if (obj.alpha != null) {
            dataview.appendChild(createDataitem("Opacity", ['input', 'input'], [
                (slider, elements) => {
                    slider.type = "range";
                    slider.min = 0;
                    slider.max = 1;
                    slider.step = 0.001;
                    slider.value = obj.alpha;
                    slider.addEventListener("input", () => {
                        let val = slider.valueAsNumber;
                        obj.alpha = val;
                        elements[1].value = dataviewData.current.alphaslider = dataviewData.current.alpha = val;
                    });
                    dataviewData.current.alphaslider = Math.min(Math.max(obj.alpha, 0), 1);
                    slider.value = dataviewData.current.alphaslider;
                    dataviewData.alphaslider = slider;
                    slider.style["min-width"] = "50px";
                },
                (input, elements) => {
                    input.type = "number";
                    input.step = 0.1;
                    input.addEventListener("change", () => {
                        let val = dataviewData.current.alpha = input.valueAsNumber;
                        obj.alpha = val;
                        elements[0].value = Math.min(Math.max(val, 0), 1);
                    });
                    input.value = dataviewData.current.alpha = obj.alpha;
                    dataviewData.alpha = input;
                    input.style["min-width"] = 0;
                }
            ], "auto 75px"));
            dataviewUpdaters.push(() => {
                if (dataviewData.alpha.value != dataviewData.current.alpha) return;
                let val = dataviewData.current.alpha = selected.alpha;
                dataviewData.alphaslider.value = val;
                dataviewData.alpha.value = val;
            });
        }
        if (obj.data.hasOwnProperty("_text")) {
            let item = createDataitem("Text", 'textarea', [
                (text, elements) => {
                    text.addEventListener("change", () => {
                        obj.data.text = dataviewData.current.extra.text = text.value;
                    });
                    text.value = dataviewData.current.extra.text = obj.data.text;
                    dataviewData.extra.text = text;
                    text.style["min-width"] = 0;
                }
            ]);
            item.style["min-height"] = item.style.height;
            item.style.height = "auto";
            dataview.appendChild(item);
            dataviewUpdaters.push(() => {
                if (dataviewData.extra.text.value != dataviewData.current.extra.text) return;
                dataviewData.extra.text.value = dataviewData.current.extra.text = selected.data.text;
            });
        }
        if (obj.data.hasOwnProperty("_texture")) {
            let item = createDataitem("Texture", ['div', 'a', 'a', 'button'], [
                container => {
                    container.style["max-height"] = "200px";
                    container.style["border-style"] = "inset";
                    container.style["grid-column"] = "span 3";
                    let img = document.createElement('img');
                    let errMsg = document.createElement('p');
                    img.style.width = "100%";
                    img.style.height = "100%";
                    img.style["object-fit"] = "contain";
                    errMsg.style.position = "relative";
                    errMsg.style["text-align"] = "center";
                    errMsg.style.margin = 0;
                    errMsg.style.display = "none";
                    errMsg.style.color = "#F00";
                    // https://www.w3schools.com/howto/howto_css_center-vertical.asp
                    errMsg.style.top = "50%";
                    errMsg.style.left = "50%";
                    errMsg.style["-ms-transform"] = "translate(-50%, -50%)";
                    errMsg.style.transform = "translate(-50%, -50%)";
                    container.appendChild(img);
                    container.appendChild(errMsg);
                    dataviewData.extra.texture = img;
                    dataviewData.extra.texturefailure = errMsg;
                },
                btncont => {
                    btncont.target = "_blank";
                    btncont.rel = "noopener noreferrer";
                    let btn = document.createElement('button');
                    btn.innerHTML = "<i class=\"fa fa-external-link\"></i> Open";
                    btn.style.width = "100%";
                    btncont.appendChild(btn);
                    dataviewData.extra.textureOpenBtn = btn;
                    btncont.style["min-width"] = btn.style["min-width"] = 0;
                },
                btncont => {
                    let btn = document.createElement('button');
                    btn.innerHTML = "<i class=\"fa fa-download\"></i> Export";
                    btn.style.width = "100%";
                    btncont.appendChild(btn);
                    dataviewData.extra.textureExportBtn = btn;
                    btncont.style["min-width"] = btn.style["min-width"] = 0;
                },
                btn => {
                    let filecont = document.createElement('label');
                    filecont.innerHTML = "<i class=\"fa fa-files-o\"></i> Change";
                    // Source - https://stackoverflow.com/a/27165977
                    // Posted by nkron, modified by community. See post 'Timeline' for change history
                    // Retrieved 2026-04-23, License - CC BY-SA 4.0
                    let fileInp = document.createElement('input');
                    fileInp.style.display = "none";
                    fileInp.type = "file";
                    fileInp.accept = "image/*";
                    let reader = new FileReader();
                    reader.onload = () => {
                        selected.data.texture = window.PIXI.Texture.from(reader.result);
                    };
                    fileInp.addEventListener("change", evt => {
                        reader.readAsDataURL(evt.target.files[0]);
                    });
                    btn.style["min-width"] = 0;
                    filecont.appendChild(fileInp);
                    btn.appendChild(filecont);
                }
            ], "1fr 1fr 1fr");
            item.style["min-height"] = item.style.height;
            item.style.height = "auto";
            dataview.appendChild(item);
            dataviewUpdaters.push(() => {
                if (selected.data.texture == dataviewData.current.extra.texture) return;
                dataviewData.extra.texture.style.display = "block";
                dataviewData.extra.texturefailure.style.display = "none";
                let url = selected.data.texture.baseTexture.resource?.url;
                dataviewData.extra.textureOpenBtn.disabled = !Boolean(url);
                dataviewData.extra.textureOpenBtn.parentElement.href = url;
                try {
                    let srcImgData = renderPIXITexture(selected.data.texture);
                    dataviewData.extra.textureExportBtn.parentElement.href = srcImgData;
                    let filename = selected.data.texture.baseTexture.cacheId;
                    if (!filename) {
                        if (url) {
                            filename = url.substring(url.lastIndexOf('/')+1); // If there is no slash, it will be -1, which when 1 is added makes 0. This works out perfectly.
                            let lastDot = filename.lastIndexOf('.');
                            filename = filename.substring(0, lastDot);
                        } else filename = "texture";
                    }
                    dataviewData.extra.textureExportBtn.parentElement.download = filename+".png";
                    dataviewData.extra.texture.src = srcImgData;
                } catch (e) {
                    console.error(e);
                    if (!dataviewData.extra.texture) return;
                    dataviewData.extra.texture.style.display = "none";
                    dataviewData.extra.texturefailure.style.display = "block";
                    dataviewData.extra.texturefailure.innerHTML = "Something went wrong.";
                }
                dataviewData.current.extra.texture = selected.data.texture;
            });
        }
    }

    function updateTreeObject(obj) {
        if (obj.name != null && obj.name.trim() != "") {
            if (obj.element.txt_inner != obj.name) {
                obj.element.txt_inner = obj.name;
            }
        } else if (obj.element.txt_inner != null) {
            obj.element.txt_inner = null;
            obj.element.txt.innerHTML = `<i> ${obj.typename ? obj.typename : "Unknown"}</i>`;
        }
        let activeVal = obj.active;
        if (obj.element.checkbox && obj.element.checkbox.checked != activeVal) {
            obj.element.checkbox.checked = activeVal;
        }

        obj.element.updateChildren();

        let updatedChildren = [];
        ["treeObject", "pixiObject"].forEach(type => { // Probably a horrendous way of going about iterating this, but it lets me so it makes my brain happy.
            if (obj[type]?.children != null) {
                obj[type].children.forEach(childData => { // Finds and adds new children.
                    let child = obj.node.children.find(o => o.object[type] == childData)?.object;
                    if (!child) {
                        child = new ManipulatableObject(childData, obj.element.childList);
                        obj.node.add(child);
                    }
                    updateTreeObject(child);
                    updatedChildren.push(child);
                });
            }
        });
        obj.node.children.forEach(child => { // Removes old children.
            if (!updatedChildren.includes(child.object)) {
                child.removeSelf();
            }
        });
    }

    // needed because can't open popup until there is an interaction
    let createprodigytreeviewermodbutton = document.createElement('button');
    document.body.appendChild(createprodigytreeviewermodbutton);
    createprodigytreeviewermodbutton.innerText = "Open Tree View";
    createprodigytreeviewermodbutton.style = "position: fixed; top: 10px; left: 10px;";
    createprodigytreeviewermodbutton.onclick = () => {
        switch (gametype) {
            case 1: // Math
                gameaccess = window.Boot?.prototype.game;
                break;
            case 2: // English
                gameaccess = window.GameFramework?.FrameworkApp?.instance?.game;
                break;
        }
        if (!gameaccess) {
            createprodigytreeviewermodbutton.remove();
            console.log(`No game detected (type=${gametype}).

  ____________________
< How did we get here? >
  --------------------
         \\   ^__^
          \\  (oo)\\_______
             (__)\\       )\\/\\\\
                 ||----w |
                 ||     ||
`); // https://github.com/cowsay-org/cowsay
            return;
        }

        // Source - https://stackoverflow.com/a/16992521
        // Posted by Vadim
        // Retrieved 2026-03-18, License - CC BY-SA 3.0
        var menu = window.open("", "_blank", "height=500,width=900,status=yes,toolbar=0,menubar=0,location=0");
        if (!menu.document.body || !menu.document.body.innerHTML) {
            menu.document.write(css);
            menu.document.write(`
<!-- https://www.w3schools.com/howto/howto_js_treeview.asp -->
<span style="display:none">load-bearing element; the script crashes without this</span>
`);
            treeDiv = document.createElement('div');
            treeDiv.classList.add("tree");
            menu.document.body.appendChild(treeDiv);
            menu.window.addEventListener("keydown", function (evt) {
                if (!selected) return;
                if (['INPUT', 'TEXTAREA'].includes(menu.document.activeElement.tagName.toUpperCase())) return;
                switch (evt.key) {
                    case ' ': {
                        evt.preventDefault();
                        if (!selected.element.checkbox) return;
                        let val = !selected.active;
                        selected.active = val;
                        selected.element.checkbox.checked = val;
                        if (dataviewData.active) dataviewData.active.checked = val;
                    } break;
                    case "ArrowUp":
                        evt.preventDefault();
                        if (selected.node.siblingUp) {
                            let up = selected.node.siblingUp;
                            while (!up.object.element.childrenHidden && up.children.length > 0) up = up.children[up.children.length-1];
                            openObject(up);
                        } else if (selected.node.parent) openObject(selected.node.parent);
                        else return;
                        break;
                    case "ArrowLeft":
                        evt.preventDefault();
                        if (!selected.element.childrenHidden) selected.element.doToggle();
                        else if (selected.node.parent) openObject(selected.node.parent);
                        else return;
                        break;
                    case "ArrowDown":
                        evt.preventDefault();
                        if (!selected.element.childrenHidden && selected.node.children.length > 0) openObject(selected.node.children[0]);
                        else if (selected.node.siblingDown) openObject(selected.node.siblingDown);
                        else {
                            let par = selected.node.parent;
                            while (par && !par.siblingDown) par = par.parent;
                            if (par && par.siblingDown) openObject(par.siblingDown);
                            else return;
                        }
                        break;
                    case "ArrowRight":
                        evt.preventDefault();
                        if (selected.node.children.length == 0) return;
                        if (selected.element.childrenHidden) selected.element.doToggle();
                        else openObject(selected.node.children[0]);
                        break;
                }
            });

            let drag = false; let moveX;
            let splitter = document.createElement('div');
            splitter.classList.add("splitter");
            menu.document.body.appendChild(splitter);

            dataview = document.createElement('div');
            dataview.classList.add("dataview");
            menu.document.body.appendChild(dataview);

            treeDiv.style.width = "350px";
            let treeDivResize = x => {
                let bodyStyle = menu.window.getComputedStyle(menu.document.body);
                let treeStyle = menu.window.getComputedStyle(treeDiv);
                treeDiv.style.width = (x-32)+"px";
            };
            splitter.addEventListener("mousedown", function (evt) {
                drag = true;
                treeDivResize(evt.x);
            });
            menu.window.addEventListener("mouseup", function(evt) {
                drag = false;
            });
            menu.window.addEventListener("mousemove", function (evt) {
                if (drag) treeDivResize(evt.x);
            });

            menu.updateTree = setInterval(() => {
                if (!gameaccess) {
                    menu.document.write(`
<p style="color: #F00; font-size: 20px;">Prodigy instance lost.</p>
`);
                    clearInterval(menu.updateTree);
                    return;
                }
                if (menu.closed) {
                    clearInterval(menu.updateTree);
                    treeData = null;
                    return;
                }
                let root = gameaccess.stage;
                let pathUp = [];
                /*while (root.parent != null) {
                    pathUp.push(root.parent.children.indexOf(root));
                    root = root.parent; // The stage might not be the root object.
                }*/ // but tbf it's not much of interest anyway
                if (treeData != null && treeData.data != root) {
                    openObject(null);
                    treeData.element.list.remove();
                    treeData = null;
                }
                let newTree = !treeData;
                if (newTree) {
                    let treeBase = document.createElement('ul');
                    treeBase.style = "margin: 0; padding: 0;";
                    treeDiv.appendChild(treeBase);
                    treeData = new ManipulatableObject(root, treeBase);
                    new ObjectHeirarchy(treeData);
                }
                updateTreeObject(treeData);
                if (newTree) {
                    treeData.element.doToggle();
                    pathUp.forEach(num => treeData.node.children[num].element.doToggle());
                }
            }, 100);

            menu.updateData = setInterval(() => {
                if (!gameaccess || menu.closed) {
                    clearInterval(menu.updateData);
                    return;
                }
                if (!selected) return;

                dataviewUpdaters.forEach(u => u());
            }, 5);
        }
    };
})();