View gameobject trees within Prodigy Game.
// ==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);
}
};
})();