code:
// Based on https://github.com/goessner/parseSvgPathData
// Path2Ds are javascript types for handling bezier curves
// But once created, they can not be edited, so this helper class aims to be an editable Path2D
// TODO some of these modifier functions return a modified copy, while others alter the original: make this consistent
class Path2DHelper {
    // Don't call this directly, use parseSvgPathData instead
    constructor() {
        this.ctx = [];
        this.x0 = 0;
        this.y0 = 0;
        this.x = 0;
        this.y = 0;
        this.x1 = 0;
        this.y1 = 0;
        this.x2 = 0;
        this.y2 = 0;
    }

    // Simplify segments to minimal absolute set [A,M,L,C] as JSON array
    // A stands for Arc, which is not supported and used
    // M stands for Move, used only at the beginning for defining initial offset
    // L stands for Line, simple line
    // C stands for Curve, squiggly bezeier lines
    A(rx,ry,rot,fA,fS,x,y) { this.ctx.push({type:'A',x:(this.x=x),y:(this.y=y),rx,ry,rot,fA,fS}) }; // A is not supported further
    M(x,y) { this.ctx.push({type:'M',x:(this.x=this.x0=this.x1=this.x2=x),y:(this.y=this.y0=this.y1=this.y2=y)}) };
    L(x,y) { this.ctx.push({type:'L',x:(this.x=x),y:(this.y=y)}) };
    H(x) { this.ctx.push({type:'L',x:(this.x=x),y:this.y}) };
    V(y) { this.ctx.push({type:'L',x:this.x,y:(this.y=y)}) };
    C(x1,y1,x2,y2,x,y) {
        this.ctx.push({type:'C',x:(this.x=x),y:(this.y=y),
                                x1:(this.x1=x1),y1:(this.y1=y1),
                                x2:(this.x2=x2),y2:(this.y2=y2)});
    };
    S(x2,y2,x,y) {
        this.ctx.push({type:'C',x:(this.x=x),y:(this.y=y),
                                x1:(this.x1=2*this.x-this.x2),y1:(this.y1=2*this.y-this.y2),
                                x2:(this.x2=x2),y2:(this.y2=y2)});
    };
    Q(x1,y1,x,y) {
        this.ctx.push({type:'C',x:(this.x=x),y:(this.y=y),
                                x1:(this.x1=x1),y1:(this.y1=y1),
                                x2:(this.x2=x1),y2:(this.y2=y1)});
    };
    T(x,y) {
        this.ctx.push({type:'C',x:(this.x=x),y:(this.y=y),
                                x1:(this.x1+=2*(this.x-this.x1)),y1:(this.y1+=2*(this.y-this.y1)),
                                x2:(this.x1),y2:(this.y1)});
    };
    Z() { this.ctx.push({type:'L',x:(this.x=this.x0),y:(this.y=this.y0)}) };
    a(rx,ry,rot,fA,fS,x,y) { this.A(rx,ry,rot,fA,fS,this.x+x,this.y+y) };
    m(x,y) { this.M(this.x+x,this.y+y) };
    l(x,y) { this.L(this.x+x,this.y+y) };
    h(x) { this.H(this.x+x) };
    v(y) { this.V(this.y+y) };
    c(x1,y1,x2,y2,x,y) { this.C(this.x+x1,this.y+y1,this.x+x2,this.y+y2,this.x+x,this.y+y) };
    s(x2,y2,x,y) { this.S(this.x+x2,this.y+y2,this.x+x,this.y+y) };
    q(x1,y1,x,y) { this.Q(this.x+x1,this.y+y1,this.x+x,this.y+y) };
    t(x,y) { this.T(this.x+x,this.y+y) };
    z() { this.Z() };

    // Calculates the average of the points
    centerOfMass() {
        let xsum = 0;
        let ysum = 0;
        for (let i = 1; i < this.ctx.length; i++) { // Skip first, as its the same as the last, and would weight doubly (for closed paths)
            let entry = this.ctx[i];
            xsum += entry.x;
            ysum += entry.y;
        }
        return {x: xsum/(this.ctx.length-1), y: ysum/(this.ctx.length-1)};
    }

    // Moves the path and alters it
    translate(dx, dy) {
        for (const entry of this.ctx) {
            if (entry.type == "C") {
                entry.x1 += dx;
                entry.y1 += dy;
                entry.x2 += dx;
                entry.y2 += dy;
            }
            entry.x += dx;
            entry.y += dy;
        }
    }

    // Rotates the path and alters it
    rotate(angle) {
        for (const entry of this.ctx) {
            if (entry.type == "C") {
                let newx1 = entry.x1 * Math.cos(angle) - entry.y1 * Math.sin(angle);
                let newy1 = entry.x1 * Math.sin(angle) + entry.y1 * Math.cos(angle);
                let newx2 = entry.x2 * Math.cos(angle) - entry.y2 * Math.sin(angle);
                let newy2 = entry.x2 * Math.sin(angle) + entry.y2 * Math.cos(angle);
                entry.x1 = newx1;
                entry.y1 = newy1;
                entry.x2 = newx2;
                entry.y2 = newy2;
            }
            let newx = entry.x * Math.cos(angle) - entry.y * Math.sin(angle);
            let newy = entry.x * Math.sin(angle) + entry.y * Math.cos(angle);
            entry.x = newx;
            entry.y = newy;
        }
    }

    // Scales the path and alters it
    // Same scaling amound for x and y axes
    scale(amount) {
        for (const entry of this.ctx) {
            if (entry.type == "C") {
                entry.x1 *= amount;
                entry.y1 *= amount;
                entry.x2 *= amount;
                entry.y2 *= amount;
            }
            entry.x *= amount;
            entry.y *= amount;
        }
    }

    // Converts the path to string which can be fed to Path2D
    // p is the precision, number of decimal digits to display
    toString(p=4) {
        let ret = "";
        for (const entry of this.ctx) {
            ret += entry.type;
            if (entry.type == "C") {
                ret += entry.x1.toFixed(p) + " ";
                ret += entry.y1.toFixed(p) + " ";
                ret += entry.x2.toFixed(p) + " ";
                ret += entry.y2.toFixed(p) + " ";
            }
            ret += entry.x.toFixed(p) + " ";
            ret += entry.y.toFixed(p);

        }
        return ret;
    }

    // Generates the morph from this path and the target path via a single subtraction
    // The result is a path object, but would probably look weird if displayed
    getMorph(other) {
        let ret = new Path2DHelper()
        for (let i = 0; i < this.ctx.length; i++) {
            let e = this.ctx[i];
            let o = other.ctx[i];
            switch(e.type) {
                case "C":
                    // If the morph has a different C-L set, then the rest has to be filled with dummy values to avoid NaNs later on
                    if (!o.x1) {
                        o.x1 = 0;
                        o.x2 = 0;
                        o.y1 = 0;
                        o.y2 = 0;
                    }
                    ret.C(o.x1 - e.x1, o.y1 - e.y1, o.x2 - e.x2, o.y2 - e.y2, o.x - e.x, o.y - e.y);
                    break;
                case "M":
                    ret.M(o.x - e.x, o.y - e.y);
                    break;
                case "L":
                    ret.L(o.x - e.x, o.y - e.y);
                    break;
            }
        }
        return ret;
    }

    /**
     * Applies a morph to a copy of this path
     * @param {*} morph The morph to apply (usually generated by getMorph)
     * @param {*} strength Interpolation strength between 0 and 1. Other values result in extrapolation, which might look ugly
     * @returns The morphed copy path
     */
    makeMorph(morph, strength) {
        let ret = new Path2DHelper()
        for (let i = 0; i < this.ctx.length; i++) {
            let e = this.ctx[i];
            let m = morph.ctx[i];
            let s = strength;
            switch(e.type) {
                case "C":
                    ret.C(e.x1 + m.x1*s, e.y1 + m.y1*s, e.x2 + m.x2*s, e.y2 + m.y2*s, e.x + m.x*s, e.y + m.y*s);
                    break;
                case "M":
                    ret.M(e.x + m.x*s, e.y + m.y*s);
                    break;
                case "L":
                    ret.L(e.x + m.x*s, e.y + m.y*s);
                    break;
            }
        }
        return ret;
    }

    // Flips the path around the x axis and return itself
    flipX() {
        for (const entry of this.ctx) {
            if (entry.type == "C") {
                entry.x1 *= -1;
                entry.x2 *= -1;
            }
            entry.x *= -1;
        }
        return this;
    }

    copy() {
        return parseSvgPathData(this.toString()); // TODO might be slow (and inaccurate)
    }
}

let parseSvgPathData = function (data) {
    const rex = /([achlmqstvz])([^achlmqstvz]*)/ig;
    const seg = { A:{n:7,f:'A'}, C:{n:6,f:'C'}, H:{n:1,f:'H'},
                  L:{n:2,f:'L'}, M:{n:2,f:'L'}, Q:{n:4,f:'Q'},
                  S:{n:4,f:'S'}, T:{n:2,f:'T'}, V:{n:1,f:'V'},
                  Z:{n:0},
                  a:{n:7,f:'a'}, c:{n:6,f:'c'}, h:{n:1,f:'h'},
                  l:{n:2,f:'l'}, m:{n:2,f:'l'}, q:{n:4,f:'q'},
                  s:{n:4,f:'s'}, t:{n:2,f:'t'}, v:{n:1,f:'v'},
                  z:{n:0} };

    const segment = (pathObj, type, args) => {
        if (type in seg) {
            if (args.length === seg[type].n) {
                pathObj[type](...args);
            }
            else if (args.length > seg[type].n) {
                pathObj[type](...args);
                args.splice(0,seg[type].n);
                segment(pathObj,seg[type].f,args);
            }
            else
                console.error(`invalid # of path segment '${type}' arguments: ${args.length} of ${seg[type].n}: '${args}'`)
        }
    };
    let match;

    let pathObj = new Path2DHelper();
    // for each explicit named segment ...
    while (match = rex.exec(data)) {
        segment(pathObj, match[1], match[2].replace(/^\s+|\s+$/g,'')  // trim whitespace at both ends (str.trim .. !)
                                       .replace(/(\d)\-/g,'$1 -') // insert blank between digit and '-'
                                       .split(/[, \t\n\r]+/g)     // as array
                                       .map(Number))
    }
    return pathObj;
}


code:
// The main scene of the game
class PlayScene {
    constructor(rm, params) {
        this.rm = rm;
        this.params = params;
        this.dom = jQuery("#game")[0];
        this.$canvas = jQuery("#canvas");
        this.canvas = this.$canvas[0];
        this.ctx = this.canvas.getContext("2d");
        this.mouseDown = false;

        // Cursor position
        this.mx = 0;
        this.my = 0;

        params.lockCursor = false;
        params.volume = 1;
        params.tick = 0; // Elapsed time

        // Create b2World
        this.world = new b2World(
            new b2Vec2(0, 10),    // gravity=10 (this should be revisited as our current system is probably not given in meters)
            false                 // not allow sleep
        );
        params.world = this.world;
        params.items = [];

        // Initializing tools, map and characters
        // Step 0: Initialize empty character holding array in params, as tools and maps reference it
        // Step 1: Init tools, since it is required by map construction
        // Step 2: Create map and get character poses from it
        // Step 3: Create characters with appropriate poses
        // Step 4: Init map which probably applies a few tools on characters
        params.characters = Array(this.params.characterIDs.length);
        this.characters = params.characters;

        this.tools = {};
        for (const id of toolIDs) {
            this.tools[id] = eval("new "+ id +"(params, rm)");
        }
        params.tools = this.tools;

        // Setting characters and IDs for later use
        this.map = eval("new " + params.mapID + "(params, rm)");
        params.map = this.map;

        // Various canvases
        this.shadowCanvas = createCanvas(this.map.width, this.map.height);
        this.shadowCtx = this.shadowCanvas.getContext("2d");
        this.tempCanvas = createCanvas(this.map.width, this.map.height); // Temporary canvas used to render the main contents for faster shadow rendering
        this.tempCtx = this.tempCanvas.getContext("2d");
        this.foregroundCanvas = createCanvas(this.map.width, this.map.height);
        this.foregroundCtx    = this.foregroundCanvas.getContext("2d");
        this.frontEffectCanvas = createCanvas(this.map.width, this.map.height);
        this.frontEffectCtx    = this.frontEffectCanvas.getContext("2d");
        this.backEffectCanvas  = createCanvas(this.map.width, this.map.height);
        this.backEffectCtx     = this.backEffectCanvas.getContext("2d");
        this.cursorCanvas = createCanvas(this.map.width, this.map.height)
        this.cursorCtx = this.cursorCanvas.getContext("2d");
        // Particle rendering with Proton
        this.particleEffectCanvas  = createCanvas(this.map.width, this.map.height);
        this.particleManager = new Proton();
        this.particleManager.addRenderer(new Proton.CanvasRenderer(this.particleEffectCanvas));
        params.particleManager = this.particleManager;
        params.canvas = {
            "foregroundCtx": this.foregroundCtx,
            "frontEffectCtx": this.frontEffectCtx,
            "backEffectCtx": this.backEffectCtx,
            "cursorCtx": this.cursorCtx,
        };

        // Must be done after the particle manager is initialized
        for (const toolID in this.tools) {
            this.tools[toolID].initializeParticleEffects();
        }

        let poseInfo = this.map.getPoseInfo();
        for (let chidx = 0; chidx < this.characters.length; chidx++) {
            let pose = poseInfo[chidx];
            this.characters[chidx] = eval("new " + params.characterIDs[chidx] + "(params, rm, params.styles[chidx], pose)");
            this.characters[chidx].ID = chidx;
        }

        refreshConnectedBodies(params);

        this.map.init();

        // Ingame UI
        this.ingame_gui = preact.createElement(IngameUI, {
            characters: this.characters,
            tools: this.tools,
            changeTool: this.changeTool,
            refresh: () => this.dom.dispatchEvent(createChangeSceneEvent("play")),
            title: () => this.dom.dispatchEvent(createChangeSceneEvent("title")),
            params: params,
        }, null);
        jQuery("#ingame_gui").removeClass("transparent");
        preact.render(this.ingame_gui, document.getElementById("ingame_gui"));

        // Mouse event processing
        this.$canvas.mousemove(this.onMouseMoveHandler);
        this.$canvas.mousedown(this.onMouseDownHandler);
        this.$canvas.mouseup(this.onMouseUpHandler);
        this.$canvas.addClass("noCursor");
        // TODO touch event support

        // Set starting tool
        this.tool = undefined;
        this.changeTool(toolIDs[0]);
    }

    changeTool = (id) => {
        this.tool = this.tools[id];
        clearCtx(this.cursorCtx);
        this.cursorCtx.drawImage(this.tool.cursorImage,
            this.mx-this.tool.cursorImage.width/2 + this.tool.cursorImageShiftX,
            this.my-this.tool.cursorImage.height/2 + this.tool.cursorImageShiftY );
    }

    onMouseMoveHandler = (e) => {
        let cOffset = this.$canvas.offset();
        this.mx = e.pageX - cOffset.left;
        this.my = e.pageY - cOffset.top;
        this.tool.mouseMove(this.mx, this.my, this.mouseDown);
    }

    onMouseDownHandler = (e) => {
        this.mouseDown = true;
        let cOffset = this.$canvas.offset();
        let mx = e.pageX - cOffset.left;
        let my = e.pageY - cOffset.top;
        this.tool.mouseDown(mx, my);
    }

    onMouseUpHandler = (e) => {
        this.mouseDown = false;
        let cOffset = this.$canvas.offset();
        let mx = e.pageX - cOffset.left;
        let my = e.pageY - cOffset.top;
        this.tool.mouseUp(mx, my);
    }

    // Renders shadows from the current contents of tempCanvas
    // Uses shadowCanvas for a temporal drawing canvas
    renderShadow(ctx) {
        clearCtx(this.shadowCtx);
        this.shadowCtx.drawImage(this.tempCanvas, 0, 0);
        this.shadowCtx.save();
        this.shadowCtx.globalCompositeOperation = "source-atop";
        this.shadowCtx.fillStyle = "#000000";
        this.shadowCtx.fillRect(0, 0, this.map.width, this.map.height);
        this.shadowCtx.restore();

        ctx.save();
        ctx.globalAlpha = 0.4;
        ctx.drawImage(this.shadowCanvas, 0, I2W(30)); // Shift the shadow downwards
        ctx.restore();
    }

    renderTempCanvas(ctx) {
        ctx.drawImage(this.tempCanvas, 0, 0);
    }

    // Renders back effects (displayed behind the characters but above their back hair)
    renderBackEffect(ctx) {
        ctx.drawImage(this.backEffectCanvas, 0, 0);
    }

    renderForegroundCanvas(ctx) {
        ctx.save();
        ctx.globalCompositeOperation = "source-atop";
        ctx.drawImage(this.foregroundCanvas, 0, 0);
        ctx.restore();
    }

    // Renders front and particle effects
    renderFrontEffect(ctx) {
        ctx.drawImage(this.frontEffectCanvas, 0, 0);
        ctx.drawImage(this.particleEffectCanvas, 0, 0);
    }

    renderCursor(ctx) {
        ctx.drawImage(this.cursorCanvas, 0, 0);
    }

    renderDebugInfo(ctx) {
        for (let b = this.world.GetBodyList(); b; b = b.GetNext()) {
            let bx = b.GetPosition().x*SCALE;
            let by = b.GetPosition().y*SCALE;
            let bang = b.GetAngle();
            ctx.save();
            ctx.translate(bx,by);
            ctx.rotate(bang);
            for (let f = b.GetFixtureList(); f; f= f.GetNext()) {
                let s= f.GetShape();

                if (s.GetType()==b2Shape.e_polygonShape) {
                    let vs = s.GetVertices();
                    let x = vs[0].x*SCALE;
                    let y = vs[0].y*SCALE;
                    ctx.lineWidth = 2;

                    if (f.GetFilterData().categoryBits == 0) {
                        ctx.setLineDash([2, 2]); // no collision
                    } else {
                        ctx.setLineDash([]); // collision
                    }
                    switch (f.GetFilterData().categoryBits) {
                        case 0b00000001: ctx.strokeStyle = "#0000AF"; break;
                        case 0b00000010: ctx.strokeStyle = "#00AF00"; break;
                        case 0b00000100: ctx.strokeStyle = "#AF0000"; break;
                        case 0b00001000: ctx.strokeStyle = "#00AFAF"; break;
                        case 0b00010000: ctx.strokeStyle = "#AF00AF"; break;
                        case 0b00100000: ctx.strokeStyle = "#AFAF00"; break;
                        default: ctx.strokeStyle = "#000000"; break;
                    }
                    ctx.fillStyle = ctx.strokeStyle + "3F"; // Add semi-transparent fill for static bodies
                    ctx.beginPath();
                    ctx.moveTo(x,y);
                    for (let k=1; k < vs.length; k++) {
                        x = vs[k].x*SCALE;
                        y = vs[k].y*SCALE;
                        ctx.lineTo(x,y);
                    }
                    ctx.closePath();
                    ctx.stroke();
                    if (b.GetType()==b2Body.b2_staticBody) {
                        ctx.fill();
                    }
                }
            }
            ctx.restore();
        }

        // Rendering joints with red-blue circles
        // Ideally the overlap and look purple-ish
        // If they are far apart that indicates something is wrong with the physics engine
        for (let j = this.world.GetJointList(); j; j = j.GetNext()) {
            let x1 = j.GetAnchorA().x*SCALE;
            let y1 = j.GetAnchorA().y*SCALE;
            let x2 = j.GetAnchorB().x*SCALE;
            let y2 = j.GetAnchorB().y*SCALE;
            ctx.fillStyle = '#ff000080';
            ctx.beginPath();
            ctx.arc(x1, y1, 5, 0, 2 * Math.PI);
            ctx.fill();
            ctx.fillStyle = '#0000ff80';
            ctx.beginPath();
            ctx.arc(x2, y2, 5, 0, 2 * Math.PI);
            ctx.fill();
        }
    }

    update() {
        for (const chara of this.characters) chara.breath();
        for (const chara of this.characters) chara.updateHeadCanvas();
        for (const chara of this.characters) chara.updateHeadPosition();
        this.world.Step(1/30, 100, 75); // frame-rate, velocity-iteration, position-iteration
        for (const item of this.params.items) item.update();
        this.tool.updateCursor(this.mx, this.my);
        this.particleManager.update();
        this.map.update();

        this.map.renderBackround(this.ctx);

        clearCtx(this.tempCtx);
        for (const chara of this.characters) chara.renderBackHair(this.tempCtx);
        this.renderBackEffect(this.tempCtx);
        for (const chara of this.characters) chara.renderBody(this.tempCtx);
        this.map.renderBetweenBodyAndHead(this.tempCtx);
        for (const chara of this.characters) chara.renderHead(this.tempCtx);
        this.renderForegroundCanvas(this.tempCtx);

        if (this.map.renderShadow)
            this.renderShadow(this.ctx); // Uses content from tempCanvas to generate the shadow
        this.renderTempCanvas(this.ctx); // Renders the actual content rendered previously on tempCanvas

        this.map.renderForeground(this.ctx);
        if (DEBUG) this.renderDebugInfo(this.ctx);
        this.renderFrontEffect(this.ctx);
        this.renderCursor(this.ctx);
        this.params.tick++;
    }

    start() { }

    stop() {
        // Item disposal
        for (const item of this.params.items) {
            item.delete();
        }

        // Body disposal
        for (let b = this.world.GetBodyList(); b; b = b.GetNext()) {
            this.world.DestroyBody(b);
        }

        // Character disposal
        for (const chara of this.characters) {
            chara.delete();
        }

        // Map disposal
        this.map.delete();

        // Destroy particle system
        this.particleManager.destroy();

        // Restore cursor
        this.$canvas.removeClass("noCursor");

        // Remove event handlers
        this.$canvas.unbind("mousemove", this.onMouseMoveHandler);
        this.$canvas.unbind("mousedown", this.onMouseDownHandler);
        this.$canvas.unbind("mouseup", this.onMouseUpHandler);

        // Disable ingame ui
        jQuery("#ingame_gui").addClass("transparent");
    }
}

repo_name: Tomajc/torture

license: mit


code:
// Represents a regular body part (everything excluding head), encompasses physics, render and drawing
class RegularBodyPart extends BodyPart {

    constructor(name, parent, bodyType, collision, def, world, chara) {
        super(name, chara, def, world);
        this.parent = parent;

        this.parent.children.push(this);
        this.hasOrganLayer = name == "neck" || name == "breast" || name == "belly" || name == "waist"; // Not the most elegant solution

        this.jointXP = def[parent.name + cap1st(name) + "JointXPos"];
        this.jointYP = def[parent.name + cap1st(name) + "JointYPos"];
        this.jointXC = def[name + cap1st(parent.name) + "JointXPos"];
        this.jointYC = def[name + cap1st(parent.name) + "JointYPos"];

        this.jointMINA = def[parent.name.toUpperCase() + "_" + name.toUpperCase() + "_MIN_ANGLE"];
        this.jointMAXA = def[parent.name.toUpperCase() + "_" + name.toUpperCase() + "_MAX_ANGLE"];

        this.width = def[name + "Width"];
        this.height = def[name + "Height"];
        this.top = 0;
        this.bottom = this.height;

        // Initializing physics body
        this.box2dBody = createHumanPartFK(world, bodyType, collision, [
            new b2Vec2(this.width / 2 / SCALE, I2W(0) / SCALE),
            new b2Vec2(this.width / 2 / SCALE, this.height / SCALE),
            new b2Vec2(-this.width / 2 / SCALE, this.height / SCALE),
            new b2Vec2(-this.width / 2 / SCALE, I2W(0) / SCALE),
        ], parent.box2dBody,
            this.jointXP, this.jointYP, this.jointXC, this.jointYC,
            def[name + cap1st(parent.name) + "JointAngle"]
        );

        this.contourCanvas = createCanvas(def[name + "ContImage"]);
        this.skinCanvas = createCanvas(def[name + "SkinImage"]);
        this.meatCanvas = createCanvas(def[name + "MeatImage"]);
        if (this.hasOrganLayer) this.organsCanvas = createCanvas(def[name + "OrgansImage"]);
        this.boneCanvas = createCanvas(def[name + "BoneImage"]);

        this.contourCtx = this.contourCanvas.getContext("2d");
        this.skinCtx = this.skinCanvas.getContext("2d");
        this.meatCtx = this.meatCanvas.getContext("2d");
        if (this.hasOrganLayer) this.organsCtx = this.organsCanvas.getContext("2d");
        this.boneCtx = this.boneCanvas.getContext("2d");

        // Initalizing physics joint to parent
        this.parentJoint = new Joint(world, parent, this,
            this.jointXP / SCALE,
            this.jointYP / SCALE,
            this.jointXC / SCALE,
            this.jointYC / SCALE,
            this.jointMINA,
            this.jointMAXA
        );
        this.joints.push(this.parentJoint);
        this.parent.joints.push(this.parentJoint);
    }

    /**
     * Splits a body part into two
     * @param {*} lp Cut position (only y coordinate is used)
     * @returns The two newly created BodyPart objects
     */
    split(lp) {
        let b = this.box2dBody;
        let s = b.GetFixtureList().GetShape();
        let ang = b.GetAngle();
        let pos = b.GetPosition();

        let vs = s.GetVertices();
        let vs1 = new Array();
        let vs2 = new Array();
        for(let i=0; i<vs.length; i++){
            if (i==0 || i==3) { // Top right, top left
                vs1[i] = new b2Vec2(vs[i].x, vs[i].y);
                vs2[i] = new b2Vec2(vs[i].x, lp.y);
            } else {            // Bottom right, Bottom left
                vs1[i] = new b2Vec2(vs[i].x, lp.y);
                vs2[i] = new b2Vec2(vs[i].x, vs[i].y);
            }
        }

        let newBodyPart1 = Object.assign(Object.create(Object.getPrototypeOf(this)), this);
        let newBodyPart2 = Object.assign(Object.create(Object.getPrototypeOf(this)), this);

        // Making sure that bodyparts have unique names
        newBodyPart1.name = newBodyPart1.name + "1";
        newBodyPart2.name = newBodyPart2.name + "2";

        newBodyPart1.bottom = lp.y * SCALE;
        newBodyPart2.top = lp.y * SCALE;

        let f = this.box2dBody.GetFixtureList(); // Gets the first element actually
        let newCollision;
        switch (f.GetFilterData().categoryBits) {
            case 0b000000001: case 0b001000000: newCollision = BODYPART_1M; break;
            case 0b000000010: case 0b010000000: newCollision = BODYPART_2M; break;
            case 0b000000100: case 0b100000000: newCollision = BODYPART_3M; break;
            case 0b000001000: newCollision = BODYPART_BREAST; break;
            default: newCollision = BODYPART_3M;
        }
        let b1 = createHumanPart(this.world, b2Body.b2_dynamicBody, newCollision, vs1, pos.x, pos.y, ang);
        let b2 = createHumanPart(this.world, b2Body.b2_dynamicBody, newCollision, vs2, pos.x, pos.y, ang);
        newBodyPart1.box2dBody = b1;
        newBodyPart2.box2dBody = b2;

        // Copies joints and splits them between the new body parts properly
        newBodyPart1.joints = [];
        newBodyPart2.joints = [];
        this.copyJoints(newBodyPart1, newBodyPart2, lp);

        // Splits items
        newBodyPart1.items = [];
        newBodyPart2.items = [];
        for (const item of [...this.items]) { // Loop over a copy, cause items will get removed in the loop body
            if (item.lp.y < lp.y) {
                this.transferItemTo(newBodyPart1, item);
            } else {
                this.transferItemTo(newBodyPart2, item);
            }
        }

        newBodyPart1.clothes = new Array(BodyPart.CLOTH_COUNT);
        newBodyPart2.clothes = new Array(BodyPart.CLOTH_COUNT);
        for (let i = 0; i < BodyPart.CLOTH_COUNT; i++) {
            if (this.clothes[i]) {
                let cloth1 = Object.assign(Object.create(Object.getPrototypeOf(this.clothes[i])), this.clothes[i]);
                let cloth2 = Object.assign(Object.create(Object.getPrototypeOf(this.clothes[i])), this.clothes[i]);
                cloth1.bottom = newBodyPart1.bottom;
                cloth1.bodyPart = newBodyPart1;
                cloth1.height = newBodyPart1.height;
                cloth2.top = newBodyPart2.top;
                cloth2.bodyPart = newBodyPart2;
                cloth2.height = newBodyPart2.height;
                newBodyPart1.clothes[i] = cloth1;
                newBodyPart2.clothes[i] = cloth2;
            }
        }
        this.destroyed = true;

        return [newBodyPart1, newBodyPart2];
    }

    // Called when rendering the scene, renders skin/meat/organs/bone onto the main canvas
    render(ctx, type, extra=false) {
        let bx = this.box2dBody.GetPosition().x * SCALE;
        let by = this.box2dBody.GetPosition().y * SCALE;
        let bang = this.box2dBody.GetAngle();

        let canvas = undefined;
        if (extra) {
            switch (type) {
                case BodyPart.CONTOUR:      canvas = this.contourExtraCanvas; break;
                case BodyPart.SKIN:         canvas = this.skinExtraCanvas; break;
                case BodyPart.MEAT:         canvas = this.meatExtraCanvas; break;
                case BodyPart.ORGANS:       canvas = this.organsExtraCanvas; break;
                case BodyPart.BONE:         canvas = this.boneExtraCanvas; break;
            }
        } else {
            switch (type) {
                case BodyPart.CONTOUR:      canvas = this.contourCanvas; break;
                case BodyPart.SKIN:         canvas = this.skinCanvas; break;
                case BodyPart.MEAT:         canvas = this.meatCanvas; break;
                case BodyPart.ORGANS:       canvas = this.organsCanvas; break;
                case BodyPart.BONE:         canvas = this.boneCanvas; break;
            }
        }

        if (canvas) {
            ctx.save();
            ctx.translate(bx, by);
            ctx.rotate(bang);
            ctx.drawImage(
                canvas,                                  // src
                0, (canvas.height-this.height)/2+this.top,   // sx, sy
                canvas.width, this.bottom-this.top,          // sw, sh
                -canvas.width/2, this.top,                 // dx, dy
                canvas.width, this.bottom-this.top           // dw, dh
            );
            ctx.restore();
        }
    }

    /**
     * Called when a tool is drawing effects on the body part
     * @param {image} image The image to draw
     * @param {*} type Which layer to draw on (SKIN, CONT, MEAT, etc)
     * @param {*} lp Local position (x,y) of hit relative to the body part
     * @param {*} ang Angle of the image to draw
     * @param {*} scale Scale of the image to draw
     * @param {*} calculate_mask If true, returns a mask which has a value where there were NO bodyparts present
     * @param {*} mask Limits where to draw an effect
     * @param {*} gco Global composite operation
     * @param {*} alpha Alpha of the image (1 means opaque, 0 means completely transparent)
     */
    draw(image, type, lp, ang, {scale=1, calculate_mask=false, mask=undefined, gco="source-atop", alpha=1} = {}) {
        // Handles the case when both MEAT and ORGANS layers should be drawn upon
        // If needed, mask is calculated from the result of the meat layer
        if (type == BodyPart.MEAT_ORGANS) {
            if (this.hasOrganLayer) this.draw(image, BodyPart.ORGANS, lp, ang, {scale: scale, calculate_mask: false, mask: mask, gco: gco, alpha: alpha});
            return this.draw(image, BodyPart.MEAT, lp, ang, {scale: scale, calculate_mask: calculate_mask, mask: mask, gco: gco, alpha: alpha});
        }
        if (type == BodyPart.ORGANS && !this.hasOrganLayer) return;

        if (!mask) { // Default mask, if the one provided is undefined
            mask = createCanvas(image.width, image.height);
            let maskCtx = mask.getContext("2d");
            maskCtx.fillStyle = BodyPart.MASK_COLOR;
            maskCtx.fillRect(0, 0, mask.width, mask.height);
        }
        let maskCtx = mask.getContext("2d");
        maskCtx.globalCompositeOperation = "source-in";
        maskCtx.drawImage(image, 0, 0);
        image = mask;

        let ctx = undefined, canvas = undefined;
        switch (type) {
            case BodyPart.CONTOUR:      ctx = this.contourCtx;      canvas = this.contourCanvas;    break;
            case BodyPart.SKIN:         ctx = this.skinCtx;         canvas = this.skinCanvas;       break;
            case BodyPart.MEAT:         ctx = this.meatCtx;         canvas = this.meatCanvas;       break;
            case BodyPart.ORGANS:       ctx = this.organsCtx;       canvas = this.organsCanvas;     break;
            case BodyPart.BONE:         ctx = this.boneCtx;         canvas = this.boneCanvas;       break;
        }
        let bang = this.box2dBody.GetAngle();
        ctx.save();
        ctx.globalCompositeOperation = gco;
        ctx.globalAlpha = alpha;
        ctx.translate(
            lp.x*SCALE + canvas.width/2,
            lp.y*SCALE + (canvas.height-this.height)/2
        );
        ctx.rotate(-bang+ang);
        ctx.drawImage(image,
            0, 0, image.width, image.height,
            -image.width/2 * scale, -image.height/2 * scale,
            image.width * scale, image.height * scale);
        ctx.restore();

        if (this.extra) {
            let extraCanvas = undefined;
            switch (type) {
                case BodyPart.CONTOUR:      extraCanvas = this.contourExtraCanvas; break;
                case BodyPart.SKIN:         extraCanvas = this.skinExtraCanvas; break;
                case BodyPart.MEAT:         extraCanvas = this.meatExtraCanvas; break;
                case BodyPart.ORGANS:       extraCanvas = this.organsExtraCanvas;  break;
                case BodyPart.BONE:         extraCanvas = this.boneExtraCanvas; break;
            }
            let extraCtx = extraCanvas.getContext("2d");
            extraCtx.save();
            extraCtx.globalCompositeOperation = gco;
            extraCtx.globalAlpha = alpha;
            extraCtx.translate(
                lp.x*SCALE + canvas.width/2,
                lp.y*SCALE + (canvas.height-this.height)/2
            );
            extraCtx.rotate(-bang+ang);
            extraCtx.drawImage(image,
                0, 0, image.width, image.height,
                -image.width/2 * scale, -image.height/2 * scale,
                image.width * scale, image.height * scale);
            extraCtx.restore();
        }

        if (calculate_mask) {
            let newMask = createCanvas(image.width, image.height);
            let newMaskCtx = newMask.getContext("2d");
            newMaskCtx.fillStyle = BodyPart.MASK_COLOR;
            newMaskCtx.fillRect(0, 0, newMask.width, newMask.height);
            newMaskCtx.save();

            // Two choices here: either draw the effect on the bodypart with 'source-out'
            // Or draw the bodypart to the effect with 'destination-out' (TODO check which one is faster)
            newMaskCtx.globalCompositeOperation = "destination-out";

            newMaskCtx.translate( // Image translate
                image.width/2,
                image.height/2
            );
            newMaskCtx.scale(1/scale, 1/scale); // Inverse scale
            newMaskCtx.rotate(bang-ang); // Inverse rotation
            newMaskCtx.translate( // Inverse translate
                -(lp.x*SCALE),
                -(lp.y*SCALE)
            );

            newMaskCtx.drawImage(canvas, -canvas.width/2, -(canvas.height-this.height)/2);
            newMaskCtx.restore();
            return newMask;
        }
    }

    /**
     * Sets the angle of a bodypart, whilist recalculating its and its children's positions and angles as well
     * Should only be used in an environment where physics is turned off (for example modding tools)
     * @param {number} angle The relative (to parent) angle in radians
     */
    bodySetAngle(angle) {
        let parentAnchorX = this.jointXP;
        let parentAnchorY = this.jointYP;
        let anchorX = this.jointXC;
        let anchorY = this.jointYC;

        let oldA = this.box2dBody.GetAngle();

        let parX = this.parent.box2dBody.GetPosition().x;
        let parY = this.parent.box2dBody.GetPosition().y;
        let parA = this.parent.box2dBody.GetAngle();
        let posx = SCALE * parX
                + parentAnchorX * Math.cos(parA) - parentAnchorY * Math.sin(parA)
                - anchorX * Math.cos(parA+angle) + anchorY * Math.sin(parA+angle);
        let posy = SCALE * parY
                + parentAnchorY * Math.cos(parA) + parentAnchorX * Math.sin(parA)
                - anchorY * Math.cos(parA+angle) - anchorX * Math.sin(parA+angle);
        posx /= SCALE;
        posy /= SCALE;

        this.box2dBody.SetPosition(new b2Vec2(posx, posy));
        this.box2dBody.SetAngle(parA + angle);
        for (const child of this.children) {
            child.bodySetAngle(child.box2dBody.GetAngle() - oldA);
        }
    }
}


code:
// Manages external resources like images and sound files
class ResourceManager {
    // Change this to use the new loader
    #useFiles = window.location.search === "?newLoader";

    // For accessing resources
    images = {};
    sounds = {};

    // Only change this for modding tools which are not located in the main game folder, but still reference this file
    gamePath = "";

    totalResourceCount = 0; // imageList.length + soundDefs.length
    loadedResourceCount = 0; // To check progress

    // Private array of filenames
    #imageList = [];
    #soundList = [];

    #loadedImageCount = 0;
    #loadedSoundCount = 0;

    // Async option related variables
    #files = new Map();
    #loadThreads = new Semaphore(128);
    #pool = workerpool.pool({ maxQueueSize: this.#loadThreads.getPermits() });

    #dom = jQuery("#game")[0];

    constructor() {
        if (!this.#useFiles) {
            jQuery("#game-files")[0].disabled = true;
        }
    }

    usesThreads() {
        return this.#useFiles;
    }

    init() {
        if (this.totalResourceCount !== 0) {
            return
        }

        // Check if .mp3 playback is supported
        let audio = new Audio();
        let canPlayMP3 = ("" != audio.canPlayType("audio/mpeg"));
        if (!canPlayMP3) {
            alert("Your browser does not support .mp3 files");
        }

        // Getting all image filenames to load
        // System images
        for (const imageType in systemImageDefs) {
            this.images[imageType] = new Array();
            for (const image of systemImageDefs[imageType]) {
                this.#imageList.push([imageType, image]);
            }
        }

        // Image resources added by mods
        const modImages = [
            ["characters", characterIDs],
            ["tools", toolIDs],
            ["collections", collectionIDs],
            ["maps", mapIDs],
        ];
        for (const [type, IDs] of modImages) {
            this.images[type] = new Object();
            for (const id of IDs) {
                this.images[type][id] = new Array(); // TODO Array?
                const list = eval(id).Resources.images;
                if (list !== undefined) {
                    for (const image of list) {
                        this.#imageList.push([type, id, image]);
                    }
                }
            }
        }

        // Getting all sound filenames to load
        // System sounds
        for (const soundType in systemSoundDefs) {
            this.sounds[soundType] = new Array();
            for (const sound of systemSoundDefs[soundType]) {
                this.#soundList.push([soundType, sound]);
            }
        }

        // Sounds added by mods
        const modSounds = [
            ["characters", characterIDs],
            ["tools", toolIDs],
            ["collections", collectionIDs],
            ["maps", mapIDs],
        ];
        for (const [type, IDs] of modSounds) {
            this.sounds[type] = new Object();
            for (const id of IDs) {
                this.sounds[type][id] = new Array();
                const list = eval(id).Resources.sounds;
                if (list !== undefined) {
                    for (const sound of list) {
                        this.#soundList.push([type, id, sound]);
                    }
                }
            }
        }

        this.totalResourceCount = this.#imageList.length + this.#soundList.length;
    }

    async #loadImageFromFile(src, isMod, imageInfo) {
        let file;
        let future = this.#files.get(src);
        if (future === undefined || typeof future === "function") {
            file = await new Promise((resolve, reject) => this.#files.set(src, resolve));
            if (future !== undefined) {
                future(file);
            }
        } else {
            file = future;
        }

        try {
            await this.#loadThreads.acquire();
            let bitmap = await this.#pool.exec(async (image, imageInfo, TEXTURE_SCALE) => {
                image = await createImageBitmap(image);
                let bitmap = await createImageBitmap(image, 0, 0, image.width, image.height, {
                    resizeWidth: image.width * TEXTURE_SCALE,
                    resizeHeight: image.height * TEXTURE_SCALE,
                    resizeQuality: "high"
                });
                image.close();
                return bitmap;
            }, [file, imageInfo, TEXTURE_SCALE]);
            this.#storeImage(isMod, imageInfo, bitmap);
        } finally {
            this.#loadThreads.release();
        }
    }

    #storeImage(isMod, imageInfo, bitmap) {
        const imageType = imageInfo[0];
        if (isMod) {
            let groupName = imageInfo[1];
            let resourceName = imageInfo[2];
            this.images[imageType][groupName][resourceName] = bitmap;
        } else { // ui or base
            let resourceName = imageInfo[1];
            this.images[imageType][resourceName] = bitmap;
        }

        this.#loadedImageCount++;
        this.#markProgress();
    }

    #markProgress() {
        this.loadedResourceCount++;
        this.#dom.dispatchEvent(new Event('load_resources_progress', {
            bubbles: true,
            cancelable: true
        }));

        if (this.loadedResourceCount != this.totalResourceCount) {
            return;
        }

        this.#dom.dispatchEvent(new Event('load_resources_complete', {
            bubbles: true,
            cancelable: true
        }));
        this.#pool.terminate();
    }

    // Load image files in order
    async #loadImages() {
        for (const imageInfo of this.#imageList) {
            const imageType = imageInfo[0];
            let src;
            let resourceName;
            const isMod = (imageType == "characters" || imageType == "tools" || imageType == "collections" || imageType == "maps");
            if (isMod) {
                let groupName = imageInfo[1];
                resourceName = imageInfo[2];
                src = "mods/" + imageType + "/" + groupName + "/images/" + getImageWithExtension(resourceName);
            } else { // ui or base
                resourceName = imageInfo[1];
                src = "system/images/" + imageType + "/" + getImageWithExtension(resourceName);
            }

            if (this.#useFiles) {
                this.#loadImageFromFile("game/" + src, isMod, imageInfo);
                continue;
            }

            src = this.gamePath + src;

            const image = new Image();
            image.onerror = function () {
                alert("Failed to read image " + image.src + ". Please contact the developers if the problem persists.");
            };
            image.onload = async () => {
                let bitmap;
                try {
                    bitmap = await createImageBitmap(image, 0, 0, image.width, image.height, {
                        resizeWidth: image.width * TEXTURE_SCALE,
                        resizeHeight: image.height * TEXTURE_SCALE,
                        resizeQuality: "high"
                    });
                } finally {
                    this.#loadThreads.release();
                }
                this.#storeImage(isMod, imageInfo, bitmap);
            };
            await this.#loadThreads.acquire();
            image.src = src;
        }
    }

    // Load music files in order
    #loadSounds() {
        for (const soundInfo of this.#soundList) {
            let soundType = soundInfo[0];
            const sound = new Audio();
            sound.oncanplaythrough = () => {
                this.#loadedSoundCount++;
                this.#markProgress();

                // Remove previously assigned event handler
                sound.oncanplaythrough = undefined;
            };
            sound.onerror = function () {
                alert("Failed to read sound " + sound.src + ". Please contact the developers if the problem persists.");
            };
            let src;
            if (soundType == "tools") {
                let groupName = soundInfo[1];
                let resourceName = soundInfo[2];
                this.sounds[soundType][groupName][resourceName] = sound;
                src = this.gamePath + "mods/" + soundType + "/" + groupName + "/sounds/" + resourceName + ".mp3";
            } else {
                let resourceName = soundInfo[1];
                this.sounds[soundType][resourceName] = sound;
                src = this.gamePath + "system/sounds" + soundType + "/" + resourceName + ".mp3";
            }
            sound.src = src;
        }
    }

    /**
     * The list of files to load for the async case must be passed explicitly by user input
     * and can not be hardcoded
     * @param {*} files List of every file as File object in the chosen directory
     */
    setFiles(files) {
        let check = false;
        for (const file of files) {
            // This can't be enforced in the folder selection dialog, so we have to check afterwards
            if (file.webkitRelativePath.startsWith("game/mods")) {
                check = true;
                break;
            }
        }
        if (!check) {
            alert("Uploaded files did not contain mods folder. Please select the 'game' folder.");
            return;
        }

        for (const file of files) {
            let promise = this.#files.get(file.webkitRelativePath);
            this.#files.set(file.webkitRelativePath, file);
            if (promise !== undefined) {
                promise(file);
            }
        }
    }

    // Start loading (called from the outside)
    load() {
        this.init();
        this.#loadImages();
        this.#loadSounds();
    }
}


code:
// Dynamically loads a list of javascript files based on their paths
class ScriptLoader {
    constructor(scriptList, load_async=true) {
        this.dom = jQuery("#game")[0];
        this.head = document.getElementsByTagName('head')[0];
        this.scriptList = scriptList;

        this.loadedScriptCount = 0;
        this.totalScriptCount = scriptList.length;
        this.load_async = load_async;
    }

    // Handler when loading progresses
    // Dispatches a load_scripts_progress event (for UI processing)
    // Upon finishing with every script, a load_scripts_complete event is dispatched
    loadScriptHandler() {
        this.loadedScriptCount++;
        this.dom.dispatchEvent(
            new Event('load_scripts_progress', {
                bubbles: true,
                cancelable: true
            })
        );

        if (this.loadedScriptCount >= this.totalScriptCount) {
            this.dom.dispatchEvent(
                new Event('load_scripts_complete', {
                    bubbles: true,
                    cancelable: true
                })
            );
        }
    }

    // Loads every script in a loop and attaches a callback to loadScriptHandler when loading is finished
    // The function returns quickly, but the actual loading might take some time -- please use event listeners
    loadScripts() {
        for (let i = 0; i < this.totalScriptCount; i++) {
            let script = document.createElement('script');
            script.type = "text/javascript";
            script.addEventListener("load", this.loadScriptHandler.bind(this));
            script.async = this.load_async; // Set this to false when every script (collection, chara, tool, map) is loaded at the same time
            let src = this.scriptList[i];
            script.onerror = function () {
                alert("Failed to read script " + src + ". Please contact the developers if the problem persists.");
            };
            script.src = src;
            this.head.appendChild(script);
        }

        if (this.totalScriptCount == 0) { // loadScriptHandler wouldn't be called otherwise if the list were empty
            this.loadScriptHandler();
        }
    }

    // Start loading (called from the outside)
    load() {
        this.loadScripts();
    }
}


code:
// Main menu
class TitleScene {
    #ctx;
    #renderTitleElements = true; // Render title logo, start button, or just simply the background
    #uiImages;
    #versionCanvas = createTextImage("Torture Chamber ver. " + VERSION + " @ granony, zmod", WIDTH, 30, "bold 28px sans-serif", "#000000", "left");
    #startButtonWidth = I2W(900);
    #startButtonHeight = I2W(500);
    #startButton;
    #menu_gui;
    #clickStartCallback; // preact callback

    constructor(rm, params) {
        this.$canvas = jQuery("#canvas");
        this.#ctx = this.$canvas[0].getContext("2d");
        setResolution(WIDTH, HEIGHT);

        this.#uiImages = rm.images.ui;
        this.#createBackground();

        // Creating a start button (this isn't really a button as the onClick is handled somewhere completely else)
        (() => {
            let startButtonCanvas = createCanvas(this.#startButtonWidth, this.#startButtonHeight);
            let startCtx = startButtonCanvas.getContext("2d");
            let nStep = 0;

            this.#startButton = {
                step: () => {
                    clearCtx(startCtx);
                    // Fade in and fade out animation
                    nStep = (++nStep) % 70;
                    if (nStep <= 35) {
                        startCtx.globalAlpha = nStep / 35;
                    } else {
                        startCtx.globalAlpha = 1 + (35 - nStep) / 35;
                    }
                    startCtx.drawImage(this.#uiImages["start_button"], 0, 0);
                },
                render: (ctx, x, y) => { ctx.drawImage(startButtonCanvas, x, y); }
            };
        })();

        // When the button is clicked, go to the map_select scene
        this.$canvas.click((e) => {
            let x = e.pageX - this.$canvas.offset().left;
            let y = e.pageY - this.$canvas.offset().top;
            if (x >= WIDTH / 2 - this.#startButtonWidth / 2 &&
                x <= WIDTH / 2 + this.#startButtonWidth / 2 &&
                y >= HEIGHT / 3 * 2 &&
                y <= HEIGHT / 3 * 2 + this.#startButtonHeight) {

                // Hide alternate resource loader if present as the files are already loaded
                jQuery("#game-files")[0].disabled = true;

                requestFullscreen();
                this.#renderTitleElements = false;
                this.#clickStartCallback();
            }
        });

        this.#menu_gui = preact.createElement(MenuGUI, {
            params: params,
            titleScene: this,
            onTitle: () => { this.#renderTitleElements = true; } // Return to title from gui
        }, null);
        preact.render(this.#menu_gui, document.getElementById("menu_gui"));
    }

    start() { }

    update() {
        this.#startButton.step();
        this.#ctx.drawImage(this.#uiImages["background"], 0, 0);
        if (this.#renderTitleElements) {
            this.#ctx.drawImage(this.#uiImages["title_logo"], WIDTH / 2 - this.#uiImages["title_logo"].width / 2, 0);
            this.#ctx.drawImage(this.#versionCanvas, 0, HEIGHT - 30);
            this.#startButton.render(this.#ctx, WIDTH / 2 - this.#startButtonWidth / 2, HEIGHT / 3 * 2);
        }
    }

    stop() {
        this.$canvas.unbind("click");
    }

    // Only the MenuGUI preact component should call this function
    setClickStartCallback(clickStartCallback) {
        this.#clickStartCallback = clickStartCallback;
    }

    // Tiles a smaller background image
    #createBackground() {
        let tileImage = this.#uiImages["background_tile"];
        this.#uiImages["background"] = createCanvas(WIDTH, HEIGHT);
        let ctx = this.#uiImages["background"].getContext("2d");
        for (let i = 0; i < Math.ceil(WIDTH / tileImage.width); i++) {
            for (let j = 0; j < Math.ceil(HEIGHT / tileImage.height); j++) {
                ctx.drawImage(this.#uiImages["background_tile"], i * tileImage.width, j * tileImage.height);
            }
        }
    }
}


code:
// Tool base class
// Extend your custom tool from this
class Tool {
    constructor(params, rm) {
        this.gui = false;
        this.params = params;
        this.characters = params.characters;
        this.images = rm.images.tools[this.constructor.name];
        this.sounds = rm.sounds.tools[this.constructor.name];
        this.IDENTITY = new b2Transform(); // Identity matrix cached in order to not create empty transforms every time

        this.cursorImage = undefined;
        // Where the tip of the cursor image is
        this.cursorImageShiftX = I2W(100);
        this.cursorImageShiftY = I2W(100);

        this.canTargetClothAlone = true;
        this.itemRemovalStrength = 0;

        this.collisionShape = undefined;
    }

    initializeParticleEffects() {}

    collision(part, mx, my) {
        if (this.collisionShape) {
            return shapeCollisionWithBody(part.box2dBody, mx, my, this.collisionShape);
        }
        return pointCollisionWithBody(part.box2dBody, mx, my);
    }

    regionCollision(region, lp) {
        if (this.collisionShape) {
            return shapeShapeCollision(this.IDENTITY, region, this.collisionShape, lp);
        }
        return shapePointCollision(this.IDENTITY, region, lp);
    }

    // Reset tool state on user event
    reset(mx, my, shortcut=false) {
        this.validHit = false; // Any body parts or characters were hit
        this.characterFlags = {}; // Storing body part flags for each character separately
        this.onlyCloth = false; // Whether only clothes are hit (this flag should probably go to character specific flags)
        this.stoppedBodies = []; // Temporarily stores the body parts whose movements is frozen during tool animation
        this.animateStep = 0; // Elapsed time for cursor animation
        this.hitParts = []; // Array of bodyparts with their local hit point coordinates: [[part, {x, y}], ...]
        this.hitItems = [];
        this.mx = mx; // Mouse coordinate X on the canvas
        this.my = my; // Mouse coordinate Y on the canvas
        this.shortcut = shortcut; // Skip animation (for example when applying tools as part of map initialization)
    }

    lockCursor() {
        this.params.lockCursor = true;
    }

    finalize() {
        this.releaseStoppedBodies();
        this.params.lockCursor = false;
    }

    // Turns of physics processing for hit body parts temporally
    stopBodies() {
        for (const [part, _] of this.hitParts) {
            let b = part.box2dBody;
            if (b.GetType() == b2Body.b2_dynamicBody) {
                let origPosition = b.GetPosition();
                b.SetType(b2Body.b2_staticBody);
                b.SetPosition(origPosition);
                this.stoppedBodies.push(b);
            }
        }
    }

    // Resume physics processing for stopped bodies (sets body type to dynamicBody)
    releaseStoppedBodies() {
        for (const b of this.stoppedBodies) {
            let origPosition = b.GetPosition();
            b.SetType(b2Body.b2_dynamicBody);
            b.SetPosition(origPosition);
        }
    }

    setFlags(part, lp) {
        let character = part.character;
        if (!(character.ID in this.characterFlags)) {
            this.characterFlags[character.ID] = {
                // Default flags
                head: false, neck: false, breast: false, belly: false, waist: false,
                lbutt: false, rbutt: false, lleg: false, rleg: false, lfoot: false, rfoot: false,
                luparm: false, ruparm: false, lloarm: false, rloarm: false, lhand: false, rhand: false,
                direction: "center",
                feeling: false, // Whether any hit part is connected to the head
                leye: false, reye: false, mouth: false, nose: false,
                lnipple: false, rnipple: false, pussy: false,
                heart: false,
            };
        }

        // Applying flags based on cursor position on parts
        let flags = this.characterFlags[character.ID];
        let name = part.name;
        flags[part.name] = true;

        if (name == "waist" && lp.y * SCALE <= character.waist.height * 0.3)
            flags.belly = true;

        if (name == "breast" && lp.y * SCALE <= character.breast.height * 0.75)
            flags.heart = true;

        if (name[0] == "l")
            flags.direction = "left";
        if (name[0] == "r")
            flags.direction = "right";
        if (name == "head") {
            if (lp.x > 0)
                flags.direction = "left";
            if (lp.x < 0)
                flags.direction = "right";
            flags.leye = this.regionCollision(character.leyeRegion, lp);
            flags.reye = this.regionCollision(character.reyeRegion, lp);
            flags.mouth = this.regionCollision(character.mouthRegion, lp);
            flags.nose = this.regionCollision(character.noseRegion, lp);
        }
        if (name == "breast") {
            flags.lnipple = this.regionCollision(character.lnippleRegion, lp);
            flags.rnipple = this.regionCollision(character.rnippleRegion, lp);
        }
        if (name == "waist") {
            flags.pussy = this.regionCollision(character.pussyRegion, lp);
        }
        if (!flags.feeling)
            flags.feeling = connectedWith(character.head, part);

        for (const item of part.items) {
            if (this.regionCollision(circleRegion(item.lp.x * SCALE, item.lp.y * SCALE, I2W(10)), lp)) {
                this.hitItems.push(item);
            }
        }
        dbgLog(this.name, "is applied to", name, ", (", lp.x * SCALE, ",", lp.y * SCALE, ")");
    }

    checkCollision(mx, my) {
        // Process each character in the scene
        for (const character of this.characters) {
            for (const part of character.bodyParts) {
                let b = part.box2dBody;
                if (this.collision(part, mx, my)) {
                    //let ud = b.GetUserData();
                    //if (ud.type!="cloth") this.onlyCloth = false;
                    // onlyCloth support is temporarily removed from all tools
                    let lp = b.GetLocalPoint(new b2Vec2(mx / SCALE, my / SCALE));

                    this.hitParts.unshift([part, lp]); // Parts are processed in a reverse order as they are rendered
                    this.setFlags(part, lp);
                }
            }
        }

        if (this.hitParts.length == 0) {
            this.validHit = false;
        } else if (this.onlyCloth && !this._canTargetClothAlone) {
            this.validHit = false;
        } else {
            this.validHit = true;
        }
    }

    // Updates cursor image
    // Overriden by subclasses for when the tool is currently animated
    updateCursor(mx, my) {
        clearCtx(this.params.canvas.cursorCtx);
        this.params.canvas.cursorCtx.drawImage(this.cursorImage,
            mx - this.cursorImageShiftX,
            my - this.cursorImageShiftY);
    }

    // Filters out non feeling and dead characters
    // Used for emotions, blushes, temporal shadows and sweats
    doReactionAll() {
        for (let chidx in this.characterFlags) {
            let flags = this.characterFlags[chidx];
            let character = this.characters[chidx];
            let direction = flags.direction;
            if (flags.feeling && character.alive()) {
                this.doReaction(character, flags, direction);
            }
        }
    }

    // Filters out non feeling characters
    damageAll() {
        for (let chidx in this.characterFlags) {
            let flags = this.characterFlags[chidx];
            let character = this.characters[chidx];
            if (flags.feeling) {
                this.damage(character, flags);
            }
        }
    }

    removeHitItems() {
        for (const item of this.hitItems) {
            if (item.attachStrength < this.itemRemovalStrength) {
                item.parent.removeItem(item);
            }
        }
    }

    // Single character methods to be overridden by concrete tools
    doReaction(character, flags, direction) {}
    damage(character, flags) {}

    mouseDown(mx, my) {}

    mouseUp(mx, my) {}

    mouseMove(mx, my, mouseDown) {}
}

class ClickTool extends Tool {
    constructor(params, rm) {
        super(params, rm);

        this.allowInvalidHit = false; // Animate effect even when nothing was hit (shoot wall for example)
        this.animation = false;
    }

    mouseUp(mx, my) {
        this.click(mx, my);
    }

    finalize() {
        super.finalize();
        this.animation = false;
    }

    updateCursor(mx, my) {
        if (!this.animation) {
            super.updateCursor(mx, my);
        }
    }

    animate() {
        if (this.params.lockCursor) {
            this.animateStep++;
            setTimeout(() => { this.animate(); }, TIMESTEP);
        }
    }

    click(mx, my, shortcut=false) {
        if (this.params.lockCursor)
            return;

        this.reset(mx, my, shortcut);
        this.lockCursor();
        this.checkCollision(mx, my);

        if (this.validHit) { // If there is a valid target (either body part or single character)
            this.stopBodies(this.hitParts);
            this.animation = true;
            this.animate();
        } else if (this.allowInvalidHit) {
            this.animation = true;
            this.animate();
        } else { // If there is no valid target
            this.finalize();
        }
    }
}

class SingleTargetTool extends ClickTool {
    constructor(params, rm) {
        super(params, rm);
        this.singleCharacterTarget = undefined;
        this.singleCharacterFeeling = false;
    }

    reset(mx, my, shortcut=false) {
        super.reset(mx, my, shortcut);
        this.singleCharacterTarget = undefined;
        this.singleCharacterFeeling = false;
    }

    checkCollision(mx, my) { // For single character
        // Process each character in the scene
        for (const character of this.characters) {
            for (const part of character.bodyParts) {
                if (this.collision(part, mx, my)) {
                    // First hit is valid, but might belong to a detached body part and we also hit a connected one as well
                    this.singleCharacterTarget = character;
                    this.validHit = true;
                    if (!this.singleCharacterFeeling)
                        this.singleCharacterFeeling = connectedWith(this.singleCharacterTarget.head, part);
                }
            }
            if (this.validHit)
                return;
        }
    }

    doReactionAll() {
        if (this.singleCharacterFeeling) {
            this.doReaction(this.singleCharacterTarget, {}, "center");
        }
    }

    damageAll() {
        if (this.singleCharacterFeeling) {
            this.damage(this.singleCharacterTarget, {});
        }
    }
}

class ChargeTool extends ClickTool {
    constructor(params, rm) {
        super(params, rm);

        this.charging = false;
        this.maxCharge = 0;
        this.currentCharge = 0;
    }

    reset(mx, my, shortcut=false) {
        super.reset(mx, my, shortcut);
        this.currentCharge = 0;
    }

    updateCursor(mx, my) {
        if (this.charging) {
            this.updateChargingCursor(mx, my);
        } else {
            super.updateCursor(mx, my);
        }
    }

    charge() {
        if (!this.charging) return;
        this.currentCharge = Math.min(this.maxCharge, this.currentCharge + 1);
        setTimeout(() => { this.charge(); }, TIMESTEP);
    }

    updateChargingCursor(x, y) {}

    mouseDown(mx, my) {
        if (this.params.lockCursor)
            return;
        this.reset(mx, my);
        this.lockCursor();
        this.charging = true;
        this.charge();
    }

    mouseUp(mx, my) {
        if (this.animation)
            return;
        this.charging = false;

        this.mx = mx;
        this.my = my;
        this.lockCursor();
        this.checkCollision(mx, my);

        if (this.validHit) { // If there is a valid target (either body part or single character)
            this.stopBodies(this.hitParts);
            this.animation = true;
            this.animate();
        } else if (this.allowInvalidHit) {
            this.animation = true;
            this.animate();
        } else { // If there is no valid target
            this.finalize();
        }
    }
}

class DragTool extends Tool {
    constructor(params, rm) {
        super(params, rm);
        this.prevMX = 0;
        this.prevMY = 0;
        this.prevCharacterFlags = undefined;
        this.prevHitParts = undefined;
        this.dragging = false;
    }

    drag() {}

    updateCursor(mx, my) {
        if (this.dragging) {
            this.reset(mx, my);
            this.checkCollision(this.prevMX, this.prevMY);
            this.prevCharacterFlags = this.characterFlags;
            this.prevHitParts = this.hitParts;
            this.reset(mx, my);
            this.checkCollision(mx, my);
            this.drag();
            this.prevMX = this.mx;
            this.prevMY = this.my;
        }
        super.updateCursor(mx, my);
    }

    mouseDown(mx, my) {
        this.prevMX = mx;
        this.prevMY = my;
        this.dragging = true;
    }

    mouseUp(mx, my) {
        this.dragging = false;
    }
}


code:
// Contains global utility functions

// https://api.jquery.com/jquery.noconflict/ this line passes the $ back as some library might use it
jQuery.noConflict();

// box2d function aliases
let b2Vec2         = Box2D.Common.Math.b2Vec2;
let b2BodyDef      = Box2D.Dynamics.b2BodyDef;
let b2Body         = Box2D.Dynamics.b2Body;
let b2FixtureDef   = Box2D.Dynamics.b2FixtureDef;
let b2Fixture      = Box2D.Dynamics.b2Fixture;
let b2World        = Box2D.Dynamics.b2World;
let b2MassData     = Box2D.Collision.Shapes.b2MassData;
let b2PolygonShape = Box2D.Collision.Shapes.b2PolygonShape;
let b2CircleShape  = Box2D.Collision.Shapes.b2CircleShape;
let b2DebugDraw    = Box2D.Dynamics.b2DebugDraw;
let b2Shape        = Box2D.Collision.Shapes.b2Shape;
let b2RevoluteJointDef = Box2D.Dynamics.Joints.b2RevoluteJointDef;
let b2LineJointDef     = Box2D.Dynamics.Joints.b2LineJointDef;
let b2FrictionJointDef = Box2D.Dynamics.Joints.b2FrictionJointDef;
let b2DistanceJointDef = Box2D.Dynamics.Joints.b2DistanceJointDef;
let b2WeldJoint        = Box2D.Dynamics.Joints.b2WeldJoint;
let b2WeldJointDef     = Box2D.Dynamics.Joints.b2WeldJointDef;
let b2TestOverlap      = b2Shape.TestOverlap;
let b2Transform        = Box2D.Common.Math.b2Transform;

// Fast trigonometric functions using LUTs for shadow rendering (TODO profiling to check if this has actual speed gains or not)
let sinTable = (function(){
    let table = [];
    let ang = 0;
    angStep = (Math.PI*2)/4096;
    do {
        table.push(Math.sin(ang));
        ang += angStep;
    } while (ang < Math.PI*2);
    return table;
})();

let cosTable = (function(){
    let table = [];
    let ang = 0;
    angStep = (Math.PI*2)/4096;
    do {
        table.push(Math.cos(ang));
        ang += angStep;
    } while (ang < Math.PI*2);
    return table;
})();

let myFloor = Math.floor; // Eliminate the hassle of name resolution (only used here, might have to remove this)
let myPIInv = 1/Math.PI;

let fastSin = function(ang){
    return sinTable[myFloor(4096*100+4096*ang*myPIInv*0.5)&4095];
}

let fastCos = function(ang){
    return cosTable[myFloor(4096*100+4096*ang*myPIInv*0.5)&4095];
}

// Converts degrees to radians
function D2R(degrees) {
    return degrees * (Math.PI/180);
}

// Converts radians to degrees
// Degrees are also rounded
function R2D(radians) {
    return Math.round(radians / (Math.PI/180));
}

// Creates a scene change event, which will be catched by Game
function createChangeSceneEvent(id) {
    return new CustomEvent('change_scene', {
        bubbles: true,
        cancelable: true,
        detail: { id: id }
    });
}

// Safari compatible fullscreen request
// https://www.w3schools.com/jsref/met_element_requestfullscreen.asp
function requestFullscreen() {
    if (document.documentElement.requestFullscreen) {
        document.documentElement.requestFullscreen();
    } else if (document.documentElement.webkitRequestFullscreen) { /* Safari */
        document.documentElement.webkitRequestFullscreen();
    } else if (document.documentElement.msRequestFullscreen) { /* IE11 */
        document.documentElement.msRequestFullscreen();
    }
}

/**
 * Creates a canvas composed of text
 * @param {string} text The actual text to render
 * @param {number} w Total width of resulting canvas in pixels
 * @param {number} h Total height of resulting canvas in pixels
 * @param {string} font Font type, size, weight, decoration
 * @param {string} color Color of text
 * @param {string} align Horizontal alignment of text (left, right or center)
 * @returns The rendered canvas
 */
function createTextImage(text, w, h, font, color, align) {
    let canvas = createCanvas(w, h);
    let ctx = canvas.getContext("2d");
    ctx.font = font;
    ctx.fillStyle = color;
    ctx.textBaseline = "top";
    ctx.textAlign = align;
    let x = 0;
    switch(align) { // Only horizontal alignment is supported
        case("center"): x=w/2; break;
        case("left")  : x=0;   break;
        case("right") : x=w;   break;
    }
    ctx.fillText(text, x, 0);
    return canvas;
}

// Plays a sound with volume specified
function playSound(sound, volume) {
    sound.volume = volume;
    sound.currentTime = 0;
    sound.play();
}

const BODYPART_1 =      {categoryBits: 0b000000001, maskBits: 0b111111001};
const BODYPART_2 =      {categoryBits: 0b000000010, maskBits: 0b111111010};
const BODYPART_3 =      {categoryBits: 0b000000100, maskBits: 0b111111100};
const BODYPART_BREAST = {categoryBits: 0b000001000, maskBits: 0b111000111};
const BODYPART_NECK =   {categoryBits: 0b000010000, maskBits: 0b111000111};
const BODYPART_HEAD =   {categoryBits: 0b000100000, maskBits: 0b111000111};
const BODYPART_1M =     {categoryBits: 0b001000000, maskBits: 0b001111111};
const BODYPART_2M =     {categoryBits: 0b010000000, maskBits: 0b010111111};
const BODYPART_3M =     {categoryBits: 0b100000000, maskBits: 0b100111111};
const NO_COLL =         {categoryBits: 0b000000000, maskBits: 0b111111111};
const WALL_C =          {categoryBits: 0b111111111, maskBits: 0b111111111};

/**
 * Creates a new body part based on the given absolute coordinates and rotation
 * @param {b2World} world Box2d world which all body parts live in
 * @param {enum} type Whether the body part is static or dynamic
 * @param {dictionary} collision Collision info as bitmaps
 * @param {array} vertices Collision box (polygon) vertices
 * @param {number} posx Absolute x coordinate of the top middle part of the body
 * @param {number} posy Absolute y coordinate of the top middle part of the body
 * @param {number} ang Absolute rotation of the body in radians
 * @returns The created box2dBody
 */
function createHumanPart(world, type, collision, vertices, posx, posy, ang) {
    let fd = new b2FixtureDef;
    fd.density = 10.0;
    fd.friction = 0.1;
    fd.restitution = 0.01;
    fd.shape = new b2PolygonShape();
    fd.shape.SetAsArray(vertices, vertices.length);
    fd.filter.categoryBits = collision.categoryBits;
    fd.filter.maskBits = collision.maskBits;

    let bd = new b2BodyDef();
    bd.type = type;
    bd.position.x = posx;
    bd.position.y = posy;
    bd.linearDamping = 0.5;
    bd.angularDamping = 0.5;
    if (ang!=undefined) {
        bd.angle = ang;
    }
    let body = world.CreateBody(bd);
    body.CreateFixture(fd);
    body.SetLinearDamping(0.5);
    body.SetAngularDamping(10.0);
    return body;
}

/**
 * Creates a body part which should be attached to a parent
 * The exact position of the body part is calculated from the anchor positions and angles
 * NOTE: call createRevoluteJoint afterwards
 * @param {b2World} world b2World, passed through
 * @param {enum} type static or dynamic, passed through
 * @param {dictionary} collision dict of collision info as bitmaps, passed through
 * @param {array} vertices poly collision and detection box, passed through
 * @param {b2Body} parentBody body reference to the parent
 * @param {number} parentAnchorX anchor location relative to the parent position and invariant under parent rotation (x)
 * @param {number} parentAnchorY anchor location relative to the parent position and invariant under parent rotation (y)
 * @param {number} anchorX anchor location relative to the newly created body and invariant under parent rotation (x)
 * @param {number} anchorY anchor location relative to the newly created body and invariant under parent rotation (y)
 * @param {number} angle new body rotation relative to parent in radians
 * @returns The created box2dBody
 */
function createHumanPartFK(world, type, collision, vertices, parentBody, parentAnchorX, parentAnchorY, anchorX, anchorY, angle) {
    let parA = parentBody.GetAngle();
    let parX = parentBody.GetPosition().x;
    let parY = parentBody.GetPosition().y;
    let posx = SCALE * parX
                + parentAnchorX * Math.cos(parA) - parentAnchorY * Math.sin(parA)
                - anchorX * Math.cos(parA+angle) + anchorY * Math.sin(parA+angle);
    let posy = SCALE * parY
                + parentAnchorY * Math.cos(parA) + parentAnchorX * Math.sin(parA)
                - anchorY * Math.cos(parA+angle) - anchorX * Math.sin(parA+angle);
    posx /= SCALE;
    posy /= SCALE;
    return createHumanPart(world, type, collision, vertices, posx, posy, parA+angle);
}

/**
 * Creates a revolute joint between two box2dBodies
 * A revolute joint allows rotation between the two bodies (constrained by possible angle limits) but their position is fixed at the location of the joint
 * @param {b2World} world Box2d world which the joint and the body parts are in
 * @param {b2Body} bA First body to connect
 * @param {b2Body} bB Second body to connect
 * @param {number} ax Local x coordinate of joint location in the first body
 * @param {number} ay Local y coordinate of joint location in the first body
 * @param {number} bx Local x coordinate of joint location in the second body
 * @param {number} by Local y coordinate of joint location in the second body
 * @param {number} ang1 Minimum allowed angle for the joint
 * @param {number} ang2 Maximum allowed angle for the joint
 * @returns The created joint object
 */
function createRevoluteJoint(world, bA, bB, ax, ay, bx, by, ang1, ang2) {
    let jd = new b2RevoluteJointDef();
    jd.Initialize(bA, bB, bA.GetWorldCenter());
    jd.localAnchorA = new b2Vec2(ax, ay);
    jd.localAnchorB = new b2Vec2(bx, by);
    let j = world.CreateJoint(jd);
    if (ang1 !== undefined) {
        let flimit = function(A, B) {
            let aAng = A.GetAngle();
            let bAng = B.GetAngle();
            return [ang1+aAng-bAng, ang2+aAng-bAng];
        };
        j.EnableLimit(true);
        j.SetLimits(flimit(bA, bB)[0], flimit(bA, bB)[1]);
    }
    return j;
}

/**
 * Creates a new canvas by either copying one or creating an entirely new based on the given width and height
 * @param {*} a1 Either a canvas or an image object (anything that has a width and height attribute), or a numeric value which specifies the width of the new canvas
 * @param {*} a2 Either undefined, or a numeric value specifying the height of the new canvas
 * @returns The created canvas
 */
function createCanvas(a1, a2) {
    let canvas = document.createElement("canvas");
    if (a1.width) {
        canvas.setAttribute("width", Math.ceil(a1.width));
        canvas.setAttribute("height", Math.ceil(a1.height));
        canvas.getContext("2d").drawImage(a1, 0, 0);
    } else {
        w = a1;
        h = a2;
        canvas.setAttribute("width", Math.ceil(w));
        canvas.setAttribute("height", Math.ceil(h));
    }
    return canvas;
}

// Fills the whole canvas with transparent color thus erasing it
function clearCtx(ctx) {
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}

// Cache design:
// connectionCache should be storing unordered pairs of body parts
// This is hard to implement without further small helper classes, so a string representation is used instead
// Body parts are encoded by character ID followed by the body part's name (for example '1lbutt' or '2neck121')
// Two body parts are simply concatenated with a separating semicolon, and reverse ordered values are also stored
// For example '1head;1lhand1'. This has a memory overhead, but the overall size of this cache should be insignificantly small
// connectedGroupCache would use a map of (BodyPart => Array<BodyPart>), but since we can't use object as keys,
// the same string representation should be used (for example '1lbutt' or '2neck121')
let connectionCache = new Set();
let connectedGroupCache = {};

/**
 * Implements a DFS to traverse body parts
 * @param {*} part The current part which is checked, changes on recursive calls
 * @param {*} originalPart The original body part, stays constant in subsequential recursive calls
 * @param {*} visited Helper set to keep track of visited nodes (to avoid infinite loops)
 */
function _traverse(part, originalPart, visited) {
    for (const joint of part.joints) {
        let connectedPart;
        if (joint.bodyA == part) {
            connectedPart = joint.bodyB;
        } else {
            connectedPart = joint.bodyA;
        }
        if (visited.has(connectedPart)) {
            continue;
        }
        connectionCache.add(originalPart.character.ID + originalPart.name + ";" + connectedPart.character.ID + connectedPart.name);
        connectedGroupCache[originalPart.character.ID + originalPart.name].push(connectedPart);
        visited.add(connectedPart);
        _traverse(connectedPart, originalPart, visited);
    }
}

/**
 * Refreshes the caches (connectionCache and connectedGroupCache) used for keeping track of connection info
 * This is a slow any costly operation, so it should be used sparingly
 * As a result connectedWith and getConnectedBodies will be fast, and can be used safely in render loops
 * @param {*} params Game params
 */
function refreshConnectedBodies(params) {
    connectionCache = new Set();
    connectedGroupCache = {};
    for (const character of params.characters) {
        for (const part of character.bodyParts) {
            visited = new Set();
            connectedGroupCache[part.character.ID + part.name] = [part];
            connectionCache.add(part.character.ID + part.name + ";" + part.character.ID + part.name);
            visited.add(part);
            _traverse(part, part, visited);
        }
    }
}

/**
 * Checks if two body parts are connected by joints (as in class Joint, not any kind of box2d joints)
 * The actual value is read from a cache which must be refreshed, whenever certain changes happen (splitting body parts into to)
 *   (@see refreshConnectedBodies )
 * @param {BodyPart} part1 The first body part (order does not matter)
 * @param {BodyPart} part2 The second body part
 * @returns True if connected
 */
function connectedWith(part1, part2) {
    return connectionCache.has(part1.character.ID + part1.name + ";" + part2.character.ID + part2.name);
}

/**
 * Retrieves a list of connected body parts to a given body part (connected by joint as in class Joint, not any kind of box2d joints)
 * The actual value is read from a cache which must be refreshed, whenever certain changes happen (splitting body parts into to)
 *   (@see refreshConnectedBodies )
 * The resulting list will also contain part
 * @param {*} part The body part te get connections
 * @returns A list containing the (directly or indirectly) connected body parts
 */
function getConnectedBodies(part) {
    return connectedGroupCache[part.character.ID + part.name];
}

// Taking a screenshot and displaying it in a new tab
// Doesn't work on chrome in a local environment due to tainted canvases
function captureScreenshot() {
    let $canvas = jQuery("#canvas");
    let canvas = $canvas[0];
    let imageData = canvas.toDataURL("image/png");
    let nwin = window.open();
    nwin.document.write("<!DOCTYPE html>");
    nwin.document.write("");
    nwin.document.write("");
    nwin.document.write("<title>Captured Image::Torture Chamber @ granony, zmod</title>");
    nwin.document.write("");
    nwin.document.write("");
    nwin.document.write("");
    nwin.document.write("");
    nwin.document.write("");
}

// If DEBUG is enabled, output any arguments to console
function dbgLog() {
    if (!DEBUG) { return; }
    let msg = "";
    for (let i = 0; i < arguments.length; i++) {
        let subMsg = arguments[i];
        if (subMsg === null) { subMsg = "null" }
        if (subMsg === undefined) { subMsg = "undefined" }
        msg += " " + arguments[i].toString();
    }
    console.log(msg);
}

// See https://stackoverflow.com/questions/29450046/how-to-check-that-es6-variable-is-constant
function isDebugConst() {
    'use strict';
    try {
        const oldValue = DEBUG;
        DEBUG = 'new value';
        DEBUG = oldValue;
    } catch (err) {
        return true;
    }
    return false;
}

// Converts from original asset sizes to scaled texture sizes
// Should be used everywhere, where sizes and absolute positions are specified
function I2W(i) {
    return i * TEXTURE_SCALE;
}

// Fills a number `num` with `size` leading zeroes
function zfill(num, size) {
    return ('000000000' + num).substr(-size);
}

// Capitalizes first letter of a string
function cap1st(s) {
    return s.charAt(0).toUpperCase() + s.slice(1);
}

// Automatically detects or adds image file extensions
// So that you don't have to specify these at resource definition, but can if needed
// TODO more robust checkings for more file types
function getImageWithExtension(filename) {
    if (filename.endsWith(".jpg") || filename.endsWith(".png") || filename.endsWith(".webp")) {
        return filename;
    }
    return filename + ".png";
}

// Opacity is between 0 and 255
function addAlpha(color, opacity) {
    opacity = Math.min(Math.max(opacity, 0), 255);
    return color + opacity.toString(16).toUpperCase();
}

// Sets the resolution of the main canvas (acquired by jquery)
function setResolution(width, height) {
    let gameDiv = jQuery("#game")[0];
    gameDiv.setAttribute("style",
        "width: " + width + "px; height: " + height + "px"); // div and canvas resizing is different
    let canvas = jQuery("#canvas")[0];
    canvas.setAttribute("width", width);
    canvas.setAttribute("height", height);
}

/*
    Collision utils
    TODO documentation and cleanup, as now they have unintuitive names and behaviours
*/

function pointCollisionWithBody(b, mx, my) {
    for (let f = b.GetFixtureList(); f; f = f.GetNext()) { // This for loop should only run once always
        let s = f.GetShape();
        return s.TestPoint(b.GetTransform(), new b2Vec2(mx / SCALE, my / SCALE));
    }
    return false;
}

function shapeCollisionWithBody(b, mx, my, shape) {
    let transform = new b2Transform();
    transform.position.SetV(new b2Vec2(mx / SCALE, my / SCALE));
    for (let f = b.GetFixtureList(); f; f = f.GetNext()) { // This for loop should only run once always
        let s = f.GetShape();
        return b2TestOverlap(s, b.GetTransform(), shape, transform);
    }
    return false;
}

function shapePointCollision(t, localShape, lp) {
    return localShape.TestPoint(t, lp);
}

function shapeShapeCollision(t, localShape, toolShape, lp) {
    let transform = new b2Transform();
    transform.position.SetV(lp);
    return b2TestOverlap(localShape, t, toolShape, transform);
}

function rectShape(w, h, angle) {
    let shape = new b2PolygonShape();
    let vertices = [
        rotateVec2(new b2Vec2(-w / 2 / SCALE, h / 2 / SCALE), angle),
        rotateVec2(new b2Vec2(w / 2 / SCALE, h / 2 / SCALE), angle),
        rotateVec2(new b2Vec2(w / 2 / SCALE, -h / 2 / SCALE), angle),
        rotateVec2(new b2Vec2(-w / 2 / SCALE, -h / 2 / SCALE), angle),
    ];
    shape.SetAsArray(vertices, vertices.length);
    return shape;
}

function circleShape(r) {
    let shape = new b2CircleShape();
    shape.SetRadius(r / SCALE);
    return shape;
}

// Trigger regions for specific areas on body parts for tools
function circleRegion(x, y, r) {
    let region = new b2CircleShape();
    region.SetRadius(r / SCALE);
    region.SetLocalPosition(new b2Vec2(x / SCALE, y / SCALE));
    return region;
}

// Rotates a given vector with a given angle
function rotateVec2(vec, angle) {
    return new b2Vec2(vec.x * Math.cos(angle) - vec.y * Math.sin(angle), vec.y * Math.cos(angle) + vec.x * Math.sin(angle));
}

/*
    Random functions
*/

// Generates a random int between min and max (inclusive)
function randint(min, max) {
    return Math.floor(Math.random() * (max - min + 1) ) + min;
}

// Returns a random element of a list
function randchoice(choices) {
    let index = Math.floor(Math.random() * choices.length);
    return choices[index];
}

// Returns a random real number between min and max
// If only one argument is given, result will be between 0 and param
function randfloat(min, max) {
    if (max == undefined) return Math.random()*min; // Min means max in that case
    return Math.random() * (max - min) + min;
}

// Returns a point uniformly sampled within a circle
function randcircle(radius) {
    let rad = Math.sqrt(Math.random()) * radius;
    let ang = Math.random() * 2 * Math.PI;
    return [rad * Math.cos(ang), rad * Math.sin(ang)];
}


code:
class Button extends preact.Component {
    static defaultProps = {
        label: "",
        onClick: ()=>{},
    };

    constructor(props) {
        super(props);
    }

    onClick = () => {
        this.props.onClick();
    }

    render() {
        return(html`
            <div class="custom_button">
            <button type="button" onClick=${this.onClick}>${this.props.label}</button>
            </div>
        `);
    }
}


code:
/**
 * Contains hp, bleeding and pleasure information about a single character and the state of clothing can be set from here
 */
class CharacterItem extends preact.Component {
    static CLOTH_DISABLED = 0;
    static CLOTH_ON = 1;
    static CLOTH_OFF = 2;

    constructor(props) {
        super(props);
        let clothesState = CharacterItem.getClothesState(this.props.chara);
        this.props.chara.hpCallback = (hp) => this.setState({hp: hp});
        this.props.chara.bleedingCallback = (bleeding, bleedingDeadly) => this.setState({bleeding: bleeding, bleedingDeadly: bleedingDeadly});
        this.props.chara.pleasureCallback = (pleasure) => this.setState({pleasure: pleasure});
        this.state = {
            chara: this.props.chara,
            clothesState: clothesState,
            hp: this.props.chara.hp,
            bleding: this.props.chara.bleedingAmount,
            bleedingDeadly: this.props.chara.bleedingDeadly,
            pleasure: this.props.chara.pleasure,
        };
    }

    // Reinitialising callbacks: this can not be done in getDerivedStateFromProps
    componentDidUpdate(prevProps, prevState) {
        if (this.state.chara !== prevState.chara) {
            this.state.chara.hpCallback = (hp) => this.setState({hp: hp});
            this.props.chara.bleedingCallback = (bleeding, bleedingDeadly) => this.setState({bleeding: bleeding, bleedingDeadly: bleedingDeadly});
            this.props.chara.pleasureCallback = (pleasure) => this.setState({pleasure: pleasure});
        }
    }

    static getClothesState(chara) {
        let clothesState = Array(BodyPart.CLOTH_COUNT).fill(CharacterItem.CLOTH_DISABLED);
        for (const bodyPart of chara.bodyParts) {
            for (let i = 0; i < BodyPart.CLOTH_COUNT; i++) {
                if (bodyPart.clothes[i] !== undefined) {
                    clothesState[i] = CharacterItem.CLOTH_ON;
                }
            }
        }
        return clothesState;
    }

    static getDerivedStateFromProps(nextProps, prevState) {
        if (nextProps.chara != prevState.chara) {
            let clothesState = CharacterItem.getClothesState(nextProps.chara);
            return {
                chara: nextProps.chara,
                clothesState: clothesState,
                hp: nextProps.chara.hp,
                bleding: nextProps.chara.bleedingAmount,
                bleedingDeadly: nextProps.chara.bleedingDeadly,
                pleasure: nextProps.chara.pleasure,
            };
        }
        else return null;
    }

    // Called when a clothing button is pressed
    clothChange(id) {
        let newClothesState = [...this.state.clothesState]; // Recreating the whole state array
        let state = true;
        if (newClothesState[id] == CharacterItem.CLOTH_ON) {
            newClothesState[id] = CharacterItem.CLOTH_OFF;
            state = false;
        } else if (newClothesState[id] == CharacterItem.CLOTH_OFF) {
            newClothesState[id] = CharacterItem.CLOTH_ON;
            state = true;
        }

        // Applying changes to actual character clothes
        for (const bodyPart of this.state.chara.bodyParts) {
            if (bodyPart.clothes[id] !== undefined) {
                bodyPart.clothes[id].on = state;
            }
        }

        this.setState({clothesState: newClothesState});
    }

    autoSkin = () => {
        this.state.chara.autoSkin = !this.state.chara.autoSkin;
    }

    render() {
        const chara = this.state.chara;
        const name = chara.constructor.name;
        const fullName = chara.constructor.fullName;
        return(html`

${fullName}

                ${Array.from({length: BodyPart.CLOTH_COUNT}, (_, index) => index).map(i => {
                    if (this.state.clothesState[i] == CharacterItem.CLOTH_DISABLED) {
                        return (html`
                            <button class="${"state_" + this.state.clothesState[i].toString()}"
                            onClick=${() => this.clothChange(i)}
                            disabled>
                            ${i.toString()}</button>
                        `);
                    } else {
                        return (html`
                            <button class="${"state_" + this.state.clothesState[i].toString()}"
                            onClick=${() => this.clothChange(i)}
                            >
                            ${i.toString()}</button>
                    `);
                    }
                })}

        `);
    }
}


code:
/**
 * Lists every character in the current session
 */
class CharacterList extends preact.Component {
    constructor(props) {
        super(props);
    }

    render() {
        return(html`

Characters

            ${this.props.characters.map((character) => {
                return html`<${CharacterItem} chara=${character}/>`;
            })}

        `);
    }
}


code:
class Checkbox extends preact.Component {
    static defaultProps = {
        value: false,
        label: "",
        onChange: ()=>{},
    };

    constructor(props) {
        super(props);
        this.state = {value: this.props.value};
    }

    onChange = () => {
        this.props.onChange(!this.state.value);
        this.setState({value: !this.state.value});
    }

    render() {
        return(html`

            ${this.props.label}
                <input type="checkbox" checked=${this.state.value} onChange=${this.onChange} />

        `);
    }
}


code:
class ColorPicker extends preact.Component {
    static defaultProps = {
        value: "#000000",
        label: "",
        onChange: ()=>{},
    };

    constructor(props) {
        super(props);
        this.state = {value: this.props.value};
    }

    onChange = (e) => {
        let newValue = e.target.value;
        this.props.onChange(newValue);
        this.setState({value: newValue});
    }

    render() {
        return(html`
            <div class="custom_color">
            <label class="color_label">${this.props.label}
                <input type="color"
                    value=${this.state.value}
                    onChange=${e => this.onChange(e)} />
            </label>
            </div>
        `);
    }
}


code:
/**
 * Main holder class for ingame UI
 * Can be hidden, in that case only a single arrow is displayed
 * Also can be toggled to be on the left side of the screen
 */
class IngameUI extends preact.Component {
    static RIGHT = 0;
    static HIDDEN = 1;
    static LEFT = 2;

    constructor(props) {
        super(props);
        this.state = {display: IngameUI.RIGHT};
    }

    cycleDisplay = () => {
        const prevState = this.state.display;
        this.setState({display: (prevState + 1) % 3});
    }

    render() {
        // Conditional displaying: the ui is never discarded, only hidden by css when minimized
        // This way the items do not have to be remade on each minimization (faster)
        // And the state stays correct (clothing state, currently selected tool)
        return(html`

            <${OptionsList} refresh=${this.props.refresh} title=${this.props.title} params=${this.props.params} hide=${this.cycleDisplay}/>
            <${CharacterList} characters=${this.props.characters}/>
            <${ToolList} tools=${this.props.tools} changeTool=${this.props.changeTool}/>

Torture Chamber ${VERSION} @ granony, zmod

        `);
    }
}


code:
/**
 * Contains buttons for global options:
 * - Hide the gui
 * - Take a screenshot
 * - Mute or unmute game
 * - Back to title screen
 * - Back to index.html
 * - Optional toggle DEBUG button
 */
class OptionsList extends preact.Component {
    constructor(props) {
        super(props);
        this.state = {mute: false, debug: DEBUG};
    }

    hide = () => {
        this.props.hide();
    }

    takeScreenshot = () => {
        captureScreenshot();
    }

    toggleMute = () => {
        this.props.params.volume = this.state.mute ? 1 : 0; // Using the inverse of the old state
        this.setState({mute: !this.state.mute});
    }

    refresh = () => {
        this.props.refresh();
    }

    title = () => {
        this.props.title();
    }

    toggleDebug = () => {
        if (!isDebugConst()) {
            DEBUG = !DEBUG;
            this.setState({debug: DEBUG})
        }
    }

    render() {
        return(html`

            ${!isDebugConst() ?
                html`<img src="system/images/ui/debug_${this.state.debug ? "on" : "off"}.svg" onClick=${this.toggleDebug}/>`
            : null}

        `);
    }
}


code:
class RangeInput extends preact.Component {
    static defaultProps = {
        value: 0,
        label: "",
        min: 0,
        max: 100,
        step: 1,
        onChange: ()=>{},
    };

    constructor(props) {
        super(props);
        this.state = {value: this.props.value};
    }

    onChange = (e) => {
        let newValue = parseFloat(e.target.value);
        newValue = Math.min(this.props.max, Math.max(this.props.min, newValue));
        this.props.onChange(newValue);
        this.setState({value: newValue});
    }

    render() {
        return(html`

            ${this.props.label}

                <input type="range"
                    min=${this.props.min}
                    max=${this.props.max}
                    value=${this.state.value}
                    step=${this.props.step}
                    onInput=${e => this.onChange(e)}
                    style="background-size: ${(this.state.value - this.props.min) * 100 / (this.props.max - this.props.min) + '% 100%'};"/>
                <input type="number"
                    min=${this.props.min}
                    max=${this.props.max}
                    value=${this.state.value}
                    step=${this.props.step}
                    onInput=${e => this.onChange(e)} />

        `);
    }
}


code:
/**
 * Wrapper for tool specific configuration UI
 * Each tool can define its own GUI by creating a ToolnameGUI class extending preact.Component
 *  and setting the field 'gui' to true
 */
class ToolItem extends preact.Component {
    constructor(props) {
        super(props);
    }

    render() {
        if (this.props.tool.gui) { // Check if specific UI exist
            return(html`
                <div class="tool_custom_ui">
                <${eval(this.props.tool.constructor.name + "GUI")} tool=${this.props.tool}/>
                </div>
            `);
        }
        else return null;
    }
}


code:
/**
 * Handles tool selection, and keeps track of the numeric id of the currently selected tool
 */
class ToolList extends preact.Component {
    constructor(props) {
        super(props);
        this.state = {id: toolIDs[0]};
    }

    componentDidUpdate(prevProps) {
        if (this.props.tools !== prevProps.tools) {
            this.toolChange(toolIDs[0]); // Reset current tool on restart
        }
    }

    toolChange(id) {
        this.props.changeTool(id); // Callback to PlayScene
        this.setState({id: id});
    }

    render() {
        return(html`

Tools: ${this.props.tools[this.state.id].name}

            ${toolIDs.map((id) => {
                let tool = this.props.tools[id];
                return html`

                     this.toolChange(id)}/>

                `;
            })}

            <${ToolItem} tool=${this.props.tools[this.state.id]}/>
        `);
    }
}