diff --git a/.travis.yml b/.travis.yml
index 5c69a2899..c60abcf5f 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -14,12 +14,13 @@ stages:
   - name: lint
   - name: build
   - name: pack
-    if: tag IS present
-  - name: deploy
-    if: tag IS present
+    if: (tag IS present) AND (type != cron)
+  - name: packNightly
+    if: (branch = develop) AND (type = cron)
 
 jobs:
   include:
+
     - stage: lint
       script:
         - gulp lint
@@ -28,6 +29,7 @@ jobs:
       script:
         - gulp build
       skip_cleanup: true
+
     - stage: pack
       script:
         - gulp -f devSetup.gulpfile.js
@@ -48,5 +50,18 @@ jobs:
         - provider: script
           script: gulp deployOnly
           skip_cleanup: true
+          on:
+            all_branches: true
+
+    - stage: packNightly
+      script:
+        - gulp -f devSetup.gulpfile.js
+        - gulp packages --nightly --buildNum=$TRAVIS_BUILD_NUMBER
+      skip_cleanup: true
+      deploy:
+        # Push to comigo.itch.io/ct
+        - provider: script
+          script: gulp deployOnly --nightly --buildNum=$TRAVIS_BUILD_NUMBER
+          skip_cleanup: true
           on:
             all_branches: true
\ No newline at end of file
diff --git a/README.md b/README.md
index a4cce1568..599f4ced8 100644
--- a/README.md
+++ b/README.md
@@ -24,9 +24,17 @@ If you are willing to participate in ct.js' future, contact me at Discord (CoMiG
 * [Like ct.js on AlternativeTo](https://alternativeto.net/software/ct-js/)
 * [Rate ct.js on Slant in different questions](https://www.slant.co/options/30242/~ct-js-review)
 
+# Production builds
+
+See the [releases page](https://github.com/ct-js/ct-js/releases) or [jump to itch.io page](https://comigo.itch.io/ct). Available for Windows, Mac and Linux.
+
+# Nightly builds
+
+We have [daily builds at itch.io](https://comigo.itch.io/ct-nightly). This page will have nightly versions that are built from the `develop` branch from our repository. It means that you will get the latest features, improvement, bug fixes, and new bugs daily, out of the oven. [Itch.io app](https://itch.io/app) is strongly recommended.
+
 # Repo structure & tools
 
-* `app` — an [Electron app](https://electronjs.org/), with its configs and static files.
+* `app` — an [NW.js app](https://nwjs.io/), with its configs and static files.
     * `data`
         * `ct.release` — the ct.js game library, aka its "core"
         * `ct.libs` — catmods (modules) that ship with ct.js. Feel free to create a pull request with your module!
diff --git a/app/Changelog.md b/app/Changelog.md
index bb2b57c29..da432ddbb 100644
--- a/app/Changelog.md
+++ b/app/Changelog.md
@@ -1,3 +1,86 @@
+## v1.4.2
+
+*Sat Aug 29 2020*
+
+### ✨ New Features
+
+* Add `tag` catmod, for adding tags for individual copies and rooms.
+* Add a properties panel for tweaking parameters of an individual copy.
+* Add `PIXI.MultiStyleText` module.
+* Add support for moddable extensions for individual copies.
+* Add texture generator for placeholders.
+* Background color control for rooms (finally!)
+* Code completions now suggest names of types, rooms, sounds, actions, and emitters.
+* `ct.place.moveByAxes` and `this.moveContinuousByAxes` for easy movement at platformers and top-down games.
+* `ct.place.moveAlong` now checks against tiles too
+* Fast integer scaling mode for `ct.fittoscreen`, for purely pixelart projects.
+* Hide default cursor at Project -> Render Options -> Hide system cursor
+* Import a texture by pasting it from a clipboard. Will update an existing opened texture as well!
+* In the room editor, Shift+Click now selects the nearest copy or tile.
+* New `select` input type for catmods, as an alternative to `radio`
+* Nightly builds at comigo.itch.io/ct-nightly.
+* Seeded random for `ct.random` module
+* `slider`, `sliderAndNumber` input types for extensions, and additional settings for them and `number` inputs.
+* Sort copies or tiles inside a room with two new buttons at the top-left corner of the room editor. Extremely handy for isometric games!
+* Toggle UI sounds in the Main menu -> Settings
+
+### ⚡️ General Improvements
+
+* A popup to quickly fix backgrounds at the room editor if their texture is not marked for tiled use.
+* Add .itch.toml to simplify run dialog on Linux.
+* Add `dnd-processor` tag that solves edge cases with drag-and-drop behavior and allows dropping any supported files on any tab.
+* Add icons that highlight deprecated and preview modules more clearly.
+* Better zooming controls for room, texture, and emitter editors.
+* Change build, projects', export folders to be stored under the `~/ct.js/` directory.
+* Change `ct.fs` to use app data directories for Linux, Windows, macOS (#226 by @JulianWebb).
+* Decrease threshold that differentiated clicks and drags in room editor, improving placing behavior of multiple tiles/copies.
+* Improve preview making process for textures.
+* Improve tile positioning algorithm for the room editor.
+* Minor UI improvements for the texture viewer.
+* Position context menus so that they don't exceed viewport's size.
+* Rename "Author" field at settings into "Developer" (i18n strings only).
+* Scale smaller tilesets to fit the tile picker, at the room editor.
+* Update Russian UI translation.
+
+### 🐛 Bug Fixes
+
+* A workaround for 'oncancel' not being fired on `input(type="file")` tags. Fixes an issue with invisible inputs overlaying the main menu.
+* Add the missing CSS directive for pixelated projects.
+* Fix checkboxes at extensions and module settings not showing the actual value's state
+* Fix `ct.mouse` returning old coordinates if a camera has moved, but a cursor hasn't.
+* Fix incorrect drawing of scaled copies in the room editor.
+* Fix issues with camera movement at room editor with extreme zooming factors.
+* Fix modules' extensions being parsed at the exporter if they have undefined or unset (equal to -1) secondary keys.
+* Fix overflow issues and wrong initial values for bitmap font generator.
+* Fix regression from v1.4 with blurry particle editor and room view when pixelart rendering was enabled.
+* Fix `user-select` CSS parameter on modules' docs panel.
+* Hotfix: fix font import issues on Windows, as well as fix potential similar issues for other asset types
+
+### 🍱 Demos, Dependencies and Stuff
+
+* Add the missing link to the bitmap fonts page in the navigation panel.
+* At the platformer tutorial, fix a typo in collectibles title.
+* Bump various catmods' versions.
+* Fix small error in describing key input in the asteroid shooter tutorial.
+* Fixed bitmap fonts docs. The `font` in the constructor should be an object.
+* Specify the tab for enemy/asteroid generation code at space shooter tutorial.
+* Update electron-packager to v15.0.0. Fixes build issues for Windows.
+
+### 📝 Docs
+
+* Add info about moddable copies' extensions
+* Document new input types `slider` and `sliderAndNumber`, as well as additional settings for them
+
+### 🌐 Website
+
+* :sparkles: Presskit
+
+### 🌚 Misc
+
+* :fire: Remove keymage.js, as it is not used anymore
+* :fire: Remove keymaster.js, as it is not used anymore
+
+
 ## v1.4.1
 
 *Sun Aug 10 2020*
diff --git a/app/ct_ide.png b/app/ct_ide.png
index 38bbc1450..b02a3795e 100644
Binary files a/app/ct_ide.png and b/app/ct_ide.png differ
diff --git a/app/data/ct.libs/3d/README.md b/app/data/ct.libs/3d/README.md
new file mode 100644
index 000000000..78cea4eb0
--- /dev/null
+++ b/app/data/ct.libs/3d/README.md
@@ -0,0 +1,41 @@
+# 3D Projection
+
+> Warning: this module is in its early stages, it is more a proof of concept than a thing to be used.
+> It does render stuff, but doesn't hide items behind the camera.
+> It also has a drawback: as ct.js v1.x uses AnimatedSprite for everything and the underlying projection module
+> doesn't support them, all the copies are transfromed as parallelograms.
+
+> Oh, and there is still no `ct.place3d` or such.
+
+> And no support for tiles. Yet.
+
+## The Axes
+
+Initially, the camera looks along the Z axis.
+
+* X points to the right;
+* Y points downwards;
+* Z points forwards.
+
+## The Idea
+
+Rooms are still designed in 2D space. Making a full-featured room editor would be a pain.
+The module needs to transform these 2D rooms into 3D space. A number of rules are applied:
+
+* X coordinate is remained as is.
+* If the 2D room is a side-view level, Y is left as is, and Z coordinate is set by the Depth property.
+* If the 2D room is a top-down level, 2D Y axis becomes Z, and Y in 3D is set by the negated Depth property.
+
+You can set whether a room is a side-view or a top-down level in a room's settings tab.
+
+## 3D Camera
+
+A new object `ct.camera3d` is added. Use it to position the camera in the 3D world. It has all the properties listed below.
+
+## 3D transforms
+
+`this.x`, `this.y`, `this.position`, `this.rotation` and `this.scale` still exist, but they should not be used. Instead,
+
+* Use `this.position3d` with `x`, `y` and `z` parameters to position objects.
+* Use `this.euler` with `pitch`, `yaw` and `roll` parameters to rotate them.
+* Use `this.scale3d` with `x`, `y` and `z` parameters to scale stuff.
\ No newline at end of file
diff --git a/app/data/ct.libs/3d/includes/pixi.projection.js b/app/data/ct.libs/3d/includes/pixi.projection.js
new file mode 100644
index 000000000..15cdc03aa
--- /dev/null
+++ b/app/data/ct.libs/3d/includes/pixi.projection.js
@@ -0,0 +1,3753 @@
+var pixi_projection;
+(function (pixi_projection) {
+    var utils;
+    (function (utils) {
+        function getIntersectionFactor(p1, p2, p3, p4, out) {
+            var A1 = p2.x - p1.x, B1 = p3.x - p4.x, C1 = p3.x - p1.x;
+            var A2 = p2.y - p1.y, B2 = p3.y - p4.y, C2 = p3.y - p1.y;
+            var D = A1 * B2 - A2 * B1;
+            if (Math.abs(D) < 1e-7) {
+                out.x = A1;
+                out.y = A2;
+                return 0;
+            }
+            var T = C1 * B2 - C2 * B1;
+            var U = A1 * C2 - A2 * C1;
+            var t = T / D, u = U / D;
+            if (u < (1e-6) || u - 1 > -1e-6) {
+                return -1;
+            }
+            out.x = p1.x + t * (p2.x - p1.x);
+            out.y = p1.y + t * (p2.y - p1.y);
+            return 1;
+        }
+        utils.getIntersectionFactor = getIntersectionFactor;
+        function getPositionFromQuad(p, anchor, out) {
+            out = out || new PIXI.Point();
+            var a1 = 1.0 - anchor.x, a2 = 1.0 - a1;
+            var b1 = 1.0 - anchor.y, b2 = 1.0 - b1;
+            out.x = (p[0].x * a1 + p[1].x * a2) * b1 + (p[3].x * a1 + p[2].x * a2) * b2;
+            out.y = (p[0].y * a1 + p[1].y * a2) * b1 + (p[3].y * a1 + p[2].y * a2) * b2;
+            return out;
+        }
+        utils.getPositionFromQuad = getPositionFromQuad;
+    })(utils = pixi_projection.utils || (pixi_projection.utils = {}));
+})(pixi_projection || (pixi_projection = {}));
+PIXI.projection = pixi_projection;
+var pixi_projection;
+(function (pixi_projection) {
+    var AbstractProjection = (function () {
+        function AbstractProjection(legacy, enable) {
+            if (enable === void 0) { enable = true; }
+            this._enabled = false;
+            this.legacy = legacy;
+            if (enable) {
+                this.enabled = true;
+            }
+            this.legacy.proj = this;
+        }
+        Object.defineProperty(AbstractProjection.prototype, "enabled", {
+            get: function () {
+                return this._enabled;
+            },
+            set: function (value) {
+                this._enabled = value;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        AbstractProjection.prototype.clear = function () {
+        };
+        return AbstractProjection;
+    }());
+    pixi_projection.AbstractProjection = AbstractProjection;
+    var TRANSFORM_STEP;
+    (function (TRANSFORM_STEP) {
+        TRANSFORM_STEP[TRANSFORM_STEP["NONE"] = 0] = "NONE";
+        TRANSFORM_STEP[TRANSFORM_STEP["BEFORE_PROJ"] = 4] = "BEFORE_PROJ";
+        TRANSFORM_STEP[TRANSFORM_STEP["PROJ"] = 5] = "PROJ";
+        TRANSFORM_STEP[TRANSFORM_STEP["ALL"] = 9] = "ALL";
+    })(TRANSFORM_STEP = pixi_projection.TRANSFORM_STEP || (pixi_projection.TRANSFORM_STEP = {}));
+})(pixi_projection || (pixi_projection = {}));
+var __extends = (this && this.__extends) || (function () {
+    var extendStatics = function (d, b) {
+        extendStatics = Object.setPrototypeOf ||
+            ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
+            function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
+        return extendStatics(d, b);
+    };
+    return function (d, b) {
+        extendStatics(d, b);
+        function __() { this.constructor = d; }
+        d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
+    };
+})();
+var pixi_projection;
+(function (pixi_projection) {
+    function transformHack(parentTransform) {
+        var proj = this.proj;
+        var ta = this;
+        var pwid = parentTransform._worldID;
+        var lt = ta.localTransform;
+        var scaleAfterAffine = proj.scaleAfterAffine && proj.affine >= 2;
+        if (ta._localID !== ta._currentLocalID) {
+            if (scaleAfterAffine) {
+                lt.a = ta._cx;
+                lt.b = ta._sx;
+                lt.c = ta._cy;
+                lt.d = ta._sy;
+                lt.tx = ta.position._x;
+                lt.ty = ta.position._y;
+            }
+            else {
+                lt.a = ta._cx * ta.scale._x;
+                lt.b = ta._sx * ta.scale._x;
+                lt.c = ta._cy * ta.scale._y;
+                lt.d = ta._sy * ta.scale._y;
+                lt.tx = ta.position._x - ((ta.pivot._x * lt.a) + (ta.pivot._y * lt.c));
+                lt.ty = ta.position._y - ((ta.pivot._x * lt.b) + (ta.pivot._y * lt.d));
+            }
+            ta._currentLocalID = ta._localID;
+            proj._currentProjID = -1;
+        }
+        var _matrixID = proj._projID;
+        if (proj._currentProjID !== _matrixID) {
+            proj._currentProjID = _matrixID;
+            proj.updateLocalTransform(lt);
+            ta._parentID = -1;
+        }
+        if (ta._parentID !== pwid) {
+            var pp = parentTransform.proj;
+            if (pp && !pp._affine) {
+                proj.world.setToMult(pp.world, proj.local);
+            }
+            else {
+                proj.world.setToMultLegacy(parentTransform.worldTransform, proj.local);
+            }
+            var wa = ta.worldTransform;
+            proj.world.copyTo(wa, proj._affine, proj.affinePreserveOrientation);
+            if (scaleAfterAffine) {
+                wa.a *= ta.scale._x;
+                wa.b *= ta.scale._x;
+                wa.c *= ta.scale._y;
+                wa.d *= ta.scale._y;
+                wa.tx -= ((ta.pivot._x * wa.a) + (ta.pivot._y * wa.c));
+                wa.ty -= ((ta.pivot._x * wa.b) + (ta.pivot._y * wa.d));
+            }
+            ta._parentID = pwid;
+            ta._worldID++;
+        }
+    }
+    var LinearProjection = (function (_super) {
+        __extends(LinearProjection, _super);
+        function LinearProjection() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this._projID = 0;
+            _this._currentProjID = -1;
+            _this._affine = pixi_projection.AFFINE.NONE;
+            _this.affinePreserveOrientation = false;
+            _this.scaleAfterAffine = true;
+            return _this;
+        }
+        LinearProjection.prototype.updateLocalTransform = function (lt) {
+        };
+        Object.defineProperty(LinearProjection.prototype, "affine", {
+            get: function () {
+                return this._affine;
+            },
+            set: function (value) {
+                if (this._affine == value)
+                    return;
+                this._affine = value;
+                this._currentProjID = -1;
+                this.legacy._currentLocalID = -1;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(LinearProjection.prototype, "enabled", {
+            set: function (value) {
+                if (value === this._enabled) {
+                    return;
+                }
+                this._enabled = value;
+                if (value) {
+                    this.legacy.updateTransform = transformHack;
+                    this.legacy._parentID = -1;
+                }
+                else {
+                    this.legacy.updateTransform = PIXI.Transform.prototype.updateTransform;
+                    this.legacy._parentID = -1;
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        LinearProjection.prototype.clear = function () {
+            this._currentProjID = -1;
+            this._projID = 0;
+        };
+        return LinearProjection;
+    }(pixi_projection.AbstractProjection));
+    pixi_projection.LinearProjection = LinearProjection;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var TYPES = PIXI.TYPES;
+    var premultiplyTint = PIXI.utils.premultiplyTint;
+    var shaderVert = "precision highp float;\nattribute vec3 aVertexPosition;\nattribute vec2 aTextureCoord;\nattribute vec4 aColor;\nattribute float aTextureId;\n\nuniform mat3 projectionMatrix;\n\nvarying vec2 vTextureCoord;\nvarying vec4 vColor;\nvarying float vTextureId;\n\nvoid main(void){\n\tgl_Position.xyw = projectionMatrix * aVertexPosition;\n\tgl_Position.z = 0.0;\n\n\tvTextureCoord = aTextureCoord;\n\tvTextureId = aTextureId;\n\tvColor = aColor;\n}\n";
+    var shaderFrag = "\nvarying vec2 vTextureCoord;\nvarying vec4 vColor;\nvarying float vTextureId;\nuniform sampler2D uSamplers[%count%];\n\nvoid main(void){\nvec4 color;\n%forloop%\ngl_FragColor = color * vColor;\n}";
+    var Batch3dGeometry = (function (_super) {
+        __extends(Batch3dGeometry, _super);
+        function Batch3dGeometry(_static) {
+            if (_static === void 0) { _static = false; }
+            var _this = _super.call(this) || this;
+            _this._buffer = new PIXI.Buffer(null, _static, false);
+            _this._indexBuffer = new PIXI.Buffer(null, _static, true);
+            _this.addAttribute('aVertexPosition', _this._buffer, 3, false, TYPES.FLOAT)
+                .addAttribute('aTextureCoord', _this._buffer, 2, false, TYPES.FLOAT)
+                .addAttribute('aColor', _this._buffer, 4, true, TYPES.UNSIGNED_BYTE)
+                .addAttribute('aTextureId', _this._buffer, 1, true, TYPES.FLOAT)
+                .addIndex(_this._indexBuffer);
+            return _this;
+        }
+        return Batch3dGeometry;
+    }(PIXI.Geometry));
+    pixi_projection.Batch3dGeometry = Batch3dGeometry;
+    var Batch2dPluginFactory = (function () {
+        function Batch2dPluginFactory() {
+        }
+        Batch2dPluginFactory.create = function (options) {
+            var _a = Object.assign({
+                vertex: shaderVert,
+                fragment: shaderFrag,
+                geometryClass: Batch3dGeometry,
+                vertexSize: 7,
+            }, options), vertex = _a.vertex, fragment = _a.fragment, vertexSize = _a.vertexSize, geometryClass = _a.geometryClass;
+            return (function (_super) {
+                __extends(BatchPlugin, _super);
+                function BatchPlugin(renderer) {
+                    var _this = _super.call(this, renderer) || this;
+                    _this.shaderGenerator = new PIXI.BatchShaderGenerator(vertex, fragment);
+                    _this.geometryClass = geometryClass;
+                    _this.vertexSize = vertexSize;
+                    return _this;
+                }
+                BatchPlugin.prototype.packInterleavedGeometry = function (element, attributeBuffer, indexBuffer, aIndex, iIndex) {
+                    var uint32View = attributeBuffer.uint32View, float32View = attributeBuffer.float32View;
+                    var p = aIndex / this.vertexSize;
+                    var uvs = element.uvs;
+                    var indices = element.indices;
+                    var vertexData = element.vertexData;
+                    var vertexData2d = element.vertexData2d;
+                    var textureId = element._texture.baseTexture._batchLocation;
+                    var alpha = Math.min(element.worldAlpha, 1.0);
+                    var argb = alpha < 1.0 && element._texture.baseTexture.alphaMode ? premultiplyTint(element._tintRGB, alpha)
+                        : element._tintRGB + (alpha * 255 << 24);
+                    if (vertexData2d) {
+                        var j = 0;
+                        for (var i = 0; i < vertexData2d.length; i += 3, j += 2) {
+                            float32View[aIndex++] = vertexData2d[i];
+                            float32View[aIndex++] = vertexData2d[i + 1];
+                            float32View[aIndex++] = vertexData2d[i + 2];
+                            float32View[aIndex++] = uvs[j];
+                            float32View[aIndex++] = uvs[j + 1];
+                            uint32View[aIndex++] = argb;
+                            float32View[aIndex++] = textureId;
+                        }
+                    }
+                    else {
+                        for (var i = 0; i < vertexData.length; i += 2) {
+                            float32View[aIndex++] = vertexData[i];
+                            float32View[aIndex++] = vertexData[i + 1];
+                            float32View[aIndex++] = 1.0;
+                            float32View[aIndex++] = uvs[i];
+                            float32View[aIndex++] = uvs[i + 1];
+                            uint32View[aIndex++] = argb;
+                            float32View[aIndex++] = textureId;
+                        }
+                    }
+                    for (var i = 0; i < indices.length; i++) {
+                        indexBuffer[iIndex++] = p + indices[i];
+                    }
+                };
+                return BatchPlugin;
+            }(PIXI.AbstractBatchRenderer));
+        };
+        return Batch2dPluginFactory;
+    }());
+    pixi_projection.Batch2dPluginFactory = Batch2dPluginFactory;
+    PIXI.Renderer.registerPlugin('batch2d', Batch2dPluginFactory.create({}));
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var AbstractBatchRenderer = PIXI.AbstractBatchRenderer;
+    var premultiplyBlendMode = PIXI.utils.premultiplyBlendMode;
+    var UniformBatchRenderer = (function (_super) {
+        __extends(UniformBatchRenderer, _super);
+        function UniformBatchRenderer() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this.forceMaxTextures = 0;
+            _this.defUniforms = {};
+            return _this;
+        }
+        UniformBatchRenderer.prototype.getUniforms = function (sprite) {
+            return this.defUniforms;
+        };
+        UniformBatchRenderer.prototype.syncUniforms = function (obj) {
+            if (!obj)
+                return;
+            var sh = this._shader;
+            for (var key in obj) {
+                sh.uniforms[key] = obj[key];
+            }
+        };
+        UniformBatchRenderer.prototype.buildDrawCalls = function (texArray, start, finish) {
+            var thisAny = this;
+            var _a = this, elements = _a._bufferedElements, _attributeBuffer = _a._attributeBuffer, _indexBuffer = _a._indexBuffer, vertexSize = _a.vertexSize;
+            var drawCalls = AbstractBatchRenderer._drawCallPool;
+            var dcIndex = this._dcIndex;
+            var aIndex = this._aIndex;
+            var iIndex = this._iIndex;
+            var drawCall = drawCalls[dcIndex];
+            drawCall.start = this._iIndex;
+            drawCall.texArray = texArray;
+            for (var i = start; i < finish; ++i) {
+                var sprite = elements[i];
+                var tex = sprite._texture.baseTexture;
+                var spriteBlendMode = premultiplyBlendMode[tex.alphaMode ? 1 : 0][sprite.blendMode];
+                var uniforms = this.getUniforms(sprite);
+                elements[i] = null;
+                if (start < i && (drawCall.blend !== spriteBlendMode || drawCall.uniforms !== uniforms)) {
+                    drawCall.size = iIndex - drawCall.start;
+                    start = i;
+                    drawCall = drawCalls[++dcIndex];
+                    drawCall.texArray = texArray;
+                    drawCall.start = iIndex;
+                }
+                this.packInterleavedGeometry(sprite, _attributeBuffer, _indexBuffer, aIndex, iIndex);
+                aIndex += sprite.vertexData.length / 2 * vertexSize;
+                iIndex += sprite.indices.length;
+                drawCall.blend = spriteBlendMode;
+                drawCall.uniforms = uniforms;
+            }
+            if (start < finish) {
+                drawCall.size = iIndex - drawCall.start;
+                ++dcIndex;
+            }
+            thisAny._dcIndex = dcIndex;
+            thisAny._aIndex = aIndex;
+            thisAny._iIndex = iIndex;
+        };
+        UniformBatchRenderer.prototype.drawBatches = function () {
+            var dcCount = this._dcIndex;
+            var _a = this.renderer, gl = _a.gl, stateSystem = _a.state, shaderSystem = _a.shader;
+            var drawCalls = AbstractBatchRenderer._drawCallPool;
+            var curUniforms = null;
+            var curTexArray = null;
+            for (var i = 0; i < dcCount; i++) {
+                var _b = drawCalls[i], texArray = _b.texArray, type = _b.type, size = _b.size, start = _b.start, blend = _b.blend, uniforms = _b.uniforms;
+                if (curTexArray !== texArray) {
+                    curTexArray = texArray;
+                    this.bindAndClearTexArray(texArray);
+                }
+                if (curUniforms !== uniforms) {
+                    curUniforms = uniforms;
+                    this.syncUniforms(uniforms);
+                    shaderSystem.syncUniformGroup(this._shader.uniformGroup);
+                }
+                this.state.blendMode = blend;
+                stateSystem.set(this.state);
+                gl.drawElements(type, size, gl.UNSIGNED_SHORT, start * 2);
+            }
+        };
+        UniformBatchRenderer.prototype.contextChange = function () {
+            if (!this.forceMaxTextures) {
+                _super.prototype.contextChange.call(this);
+                this.syncUniforms(this.defUniforms);
+                return;
+            }
+            var gl = this.renderer.gl;
+            var thisAny = this;
+            thisAny.MAX_TEXTURES = this.forceMaxTextures;
+            this._shader = thisAny.shaderGenerator.generateShader(this.MAX_TEXTURES);
+            this.syncUniforms(this.defUniforms);
+            for (var i = 0; i < thisAny._packedGeometryPoolSize; i++) {
+                thisAny._packedGeometries[i] = new (this.geometryClass)();
+            }
+            this.initFlushBuffers();
+        };
+        return UniformBatchRenderer;
+    }(AbstractBatchRenderer));
+    pixi_projection.UniformBatchRenderer = UniformBatchRenderer;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var p = [new PIXI.Point(), new PIXI.Point(), new PIXI.Point(), new PIXI.Point()];
+    var a = [0, 0, 0, 0];
+    var Surface = (function () {
+        function Surface() {
+            this.surfaceID = "default";
+            this._updateID = 0;
+            this.vertexSrc = "";
+            this.fragmentSrc = "";
+        }
+        Surface.prototype.fillUniforms = function (uniforms) {
+        };
+        Surface.prototype.clear = function () {
+        };
+        Surface.prototype.boundsQuad = function (v, out, after) {
+            var minX = out[0], minY = out[1];
+            var maxX = out[0], maxY = out[1];
+            for (var i = 2; i < 8; i += 2) {
+                if (minX > out[i])
+                    minX = out[i];
+                if (maxX < out[i])
+                    maxX = out[i];
+                if (minY > out[i + 1])
+                    minY = out[i + 1];
+                if (maxY < out[i + 1])
+                    maxY = out[i + 1];
+            }
+            p[0].set(minX, minY);
+            this.apply(p[0], p[0]);
+            p[1].set(maxX, minY);
+            this.apply(p[1], p[1]);
+            p[2].set(maxX, maxY);
+            this.apply(p[2], p[2]);
+            p[3].set(minX, maxY);
+            this.apply(p[3], p[3]);
+            if (after) {
+                after.apply(p[0], p[0]);
+                after.apply(p[1], p[1]);
+                after.apply(p[2], p[2]);
+                after.apply(p[3], p[3]);
+                out[0] = p[0].x;
+                out[1] = p[0].y;
+                out[2] = p[1].x;
+                out[3] = p[1].y;
+                out[4] = p[2].x;
+                out[5] = p[2].y;
+                out[6] = p[3].x;
+                out[7] = p[3].y;
+            }
+            else {
+                for (var i = 1; i <= 3; i++) {
+                    if (p[i].y < p[0].y || p[i].y == p[0].y && p[i].x < p[0].x) {
+                        var t = p[0];
+                        p[0] = p[i];
+                        p[i] = t;
+                    }
+                }
+                for (var i = 1; i <= 3; i++) {
+                    a[i] = Math.atan2(p[i].y - p[0].y, p[i].x - p[0].x);
+                }
+                for (var i = 1; i <= 3; i++) {
+                    for (var j = i + 1; j <= 3; j++) {
+                        if (a[i] > a[j]) {
+                            var t = p[i];
+                            p[i] = p[j];
+                            p[j] = t;
+                            var t2 = a[i];
+                            a[i] = a[j];
+                            a[j] = t2;
+                        }
+                    }
+                }
+                out[0] = p[0].x;
+                out[1] = p[0].y;
+                out[2] = p[1].x;
+                out[3] = p[1].y;
+                out[4] = p[2].x;
+                out[5] = p[2].y;
+                out[6] = p[3].x;
+                out[7] = p[3].y;
+                if ((p[3].x - p[2].x) * (p[1].y - p[2].y) - (p[1].x - p[2].x) * (p[3].y - p[2].y) < 0) {
+                    out[4] = p[3].x;
+                    out[5] = p[3].y;
+                    return;
+                }
+            }
+        };
+        return Surface;
+    }());
+    pixi_projection.Surface = Surface;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var tempMat = new PIXI.Matrix();
+    var tempRect = new PIXI.Rectangle();
+    var tempPoint = new PIXI.Point();
+    var BilinearSurface = (function (_super) {
+        __extends(BilinearSurface, _super);
+        function BilinearSurface() {
+            var _this = _super.call(this) || this;
+            _this.distortion = new PIXI.Point();
+            return _this;
+        }
+        BilinearSurface.prototype.clear = function () {
+            this.distortion.set(0, 0);
+        };
+        BilinearSurface.prototype.apply = function (pos, newPos) {
+            newPos = newPos || new PIXI.Point();
+            var d = this.distortion;
+            var m = pos.x * pos.y;
+            newPos.x = pos.x + d.x * m;
+            newPos.y = pos.y + d.y * m;
+            return newPos;
+        };
+        BilinearSurface.prototype.applyInverse = function (pos, newPos) {
+            newPos = newPos || new PIXI.Point();
+            var vx = pos.x, vy = pos.y;
+            var dx = this.distortion.x, dy = this.distortion.y;
+            if (dx == 0.0) {
+                newPos.x = vx;
+                newPos.y = vy / (1.0 + dy * vx);
+            }
+            else if (dy == 0.0) {
+                newPos.y = vy;
+                newPos.x = vx / (1.0 + dx * vy);
+            }
+            else {
+                var b = (vy * dx - vx * dy + 1.0) * 0.5 / dy;
+                var d = b * b + vx / dy;
+                if (d <= 0.00001) {
+                    newPos.set(NaN, NaN);
+                    return newPos;
+                }
+                if (dy > 0.0) {
+                    newPos.x = -b + Math.sqrt(d);
+                }
+                else {
+                    newPos.x = -b - Math.sqrt(d);
+                }
+                newPos.y = (vx / newPos.x - 1.0) / dx;
+            }
+            return newPos;
+        };
+        BilinearSurface.prototype.mapSprite = function (sprite, quad, outTransform) {
+            var tex = sprite.texture;
+            tempRect.x = -sprite.anchor.x * tex.orig.width;
+            tempRect.y = -sprite.anchor.y * tex.orig.height;
+            tempRect.width = tex.orig.width;
+            tempRect.height = tex.orig.height;
+            return this.mapQuad(tempRect, quad, outTransform || sprite.transform);
+        };
+        BilinearSurface.prototype.mapQuad = function (rect, quad, outTransform) {
+            var ax = -rect.x / rect.width;
+            var ay = -rect.y / rect.height;
+            var ax2 = (1.0 - rect.x) / rect.width;
+            var ay2 = (1.0 - rect.y) / rect.height;
+            var up1x = (quad[0].x * (1.0 - ax) + quad[1].x * ax);
+            var up1y = (quad[0].y * (1.0 - ax) + quad[1].y * ax);
+            var up2x = (quad[0].x * (1.0 - ax2) + quad[1].x * ax2);
+            var up2y = (quad[0].y * (1.0 - ax2) + quad[1].y * ax2);
+            var down1x = (quad[3].x * (1.0 - ax) + quad[2].x * ax);
+            var down1y = (quad[3].y * (1.0 - ax) + quad[2].y * ax);
+            var down2x = (quad[3].x * (1.0 - ax2) + quad[2].x * ax2);
+            var down2y = (quad[3].y * (1.0 - ax2) + quad[2].y * ax2);
+            var x00 = up1x * (1.0 - ay) + down1x * ay;
+            var y00 = up1y * (1.0 - ay) + down1y * ay;
+            var x10 = up2x * (1.0 - ay) + down2x * ay;
+            var y10 = up2y * (1.0 - ay) + down2y * ay;
+            var x01 = up1x * (1.0 - ay2) + down1x * ay2;
+            var y01 = up1y * (1.0 - ay2) + down1y * ay2;
+            var x11 = up2x * (1.0 - ay2) + down2x * ay2;
+            var y11 = up2y * (1.0 - ay2) + down2y * ay2;
+            var mat = tempMat;
+            mat.tx = x00;
+            mat.ty = y00;
+            mat.a = x10 - x00;
+            mat.b = y10 - y00;
+            mat.c = x01 - x00;
+            mat.d = y01 - y00;
+            tempPoint.set(x11, y11);
+            mat.applyInverse(tempPoint, tempPoint);
+            this.distortion.set(tempPoint.x - 1, tempPoint.y - 1);
+            outTransform.setFromMatrix(mat);
+            return this;
+        };
+        BilinearSurface.prototype.fillUniforms = function (uniforms) {
+            uniforms.distortion = uniforms.distortion || new Float32Array([0, 0, 0, 0]);
+            var ax = Math.abs(this.distortion.x);
+            var ay = Math.abs(this.distortion.y);
+            uniforms.distortion[0] = ax * 10000 <= ay ? 0 : this.distortion.x;
+            uniforms.distortion[1] = ay * 10000 <= ax ? 0 : this.distortion.y;
+            uniforms.distortion[2] = 1.0 / uniforms.distortion[0];
+            uniforms.distortion[3] = 1.0 / uniforms.distortion[1];
+        };
+        return BilinearSurface;
+    }(pixi_projection.Surface));
+    pixi_projection.BilinearSurface = BilinearSurface;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var Container2s = (function (_super) {
+        __extends(Container2s, _super);
+        function Container2s() {
+            var _this = _super.call(this) || this;
+            _this.proj = new pixi_projection.ProjectionSurface(_this.transform);
+            return _this;
+        }
+        Object.defineProperty(Container2s.prototype, "worldTransform", {
+            get: function () {
+                return this.proj;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        return Container2s;
+    }(PIXI.Container));
+    pixi_projection.Container2s = Container2s;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var fun = PIXI.Transform.prototype.updateTransform;
+    function transformHack(parentTransform) {
+        var proj = this.proj;
+        var pp = parentTransform.proj;
+        var ta = this;
+        if (!pp) {
+            fun.call(this, parentTransform);
+            proj._activeProjection = null;
+            return;
+        }
+        if (pp._surface) {
+            proj._activeProjection = pp;
+            this.updateLocalTransform();
+            this.localTransform.copyTo(this.worldTransform);
+            if (ta._parentID < 0) {
+                ++ta._worldID;
+            }
+            return;
+        }
+        fun.call(this, parentTransform);
+        proj._activeProjection = pp._activeProjection;
+    }
+    var ProjectionSurface = (function (_super) {
+        __extends(ProjectionSurface, _super);
+        function ProjectionSurface(legacy, enable) {
+            var _this = _super.call(this, legacy, enable) || this;
+            _this._surface = null;
+            _this._activeProjection = null;
+            _this._currentSurfaceID = -1;
+            _this._currentLegacyID = -1;
+            _this._lastUniforms = null;
+            return _this;
+        }
+        Object.defineProperty(ProjectionSurface.prototype, "enabled", {
+            set: function (value) {
+                if (value === this._enabled) {
+                    return;
+                }
+                this._enabled = value;
+                if (value) {
+                    this.legacy.updateTransform = transformHack;
+                    this.legacy._parentID = -1;
+                }
+                else {
+                    this.legacy.updateTransform = PIXI.Transform.prototype.updateTransform;
+                    this.legacy._parentID = -1;
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(ProjectionSurface.prototype, "surface", {
+            get: function () {
+                return this._surface;
+            },
+            set: function (value) {
+                if (this._surface == value) {
+                    return;
+                }
+                this._surface = value || null;
+                this.legacy._parentID = -1;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        ProjectionSurface.prototype.applyPartial = function (pos, newPos) {
+            if (this._activeProjection !== null) {
+                newPos = this.legacy.worldTransform.apply(pos, newPos);
+                return this._activeProjection.surface.apply(newPos, newPos);
+            }
+            if (this._surface !== null) {
+                return this.surface.apply(pos, newPos);
+            }
+            return this.legacy.worldTransform.apply(pos, newPos);
+        };
+        ProjectionSurface.prototype.apply = function (pos, newPos) {
+            if (this._activeProjection !== null) {
+                newPos = this.legacy.worldTransform.apply(pos, newPos);
+                this._activeProjection.surface.apply(newPos, newPos);
+                return this._activeProjection.legacy.worldTransform.apply(newPos, newPos);
+            }
+            if (this._surface !== null) {
+                newPos = this.surface.apply(pos, newPos);
+                return this.legacy.worldTransform.apply(newPos, newPos);
+            }
+            return this.legacy.worldTransform.apply(pos, newPos);
+        };
+        ProjectionSurface.prototype.applyInverse = function (pos, newPos) {
+            if (this._activeProjection !== null) {
+                newPos = this._activeProjection.legacy.worldTransform.applyInverse(pos, newPos);
+                this._activeProjection._surface.applyInverse(newPos, newPos);
+                return this.legacy.worldTransform.applyInverse(newPos, newPos);
+            }
+            if (this._surface !== null) {
+                newPos = this.legacy.worldTransform.applyInverse(pos, newPos);
+                return this._surface.applyInverse(newPos, newPos);
+            }
+            return this.legacy.worldTransform.applyInverse(pos, newPos);
+        };
+        ProjectionSurface.prototype.mapBilinearSprite = function (sprite, quad) {
+            if (!(this._surface instanceof pixi_projection.BilinearSurface)) {
+                this.surface = new pixi_projection.BilinearSurface();
+            }
+            this.surface.mapSprite(sprite, quad, this.legacy);
+        };
+        ProjectionSurface.prototype.clear = function () {
+            if (this.surface) {
+                this.surface.clear();
+            }
+        };
+        Object.defineProperty(ProjectionSurface.prototype, "uniforms", {
+            get: function () {
+                if (this._currentLegacyID === this.legacy._worldID &&
+                    this._currentSurfaceID === this.surface._updateID) {
+                    return this._lastUniforms;
+                }
+                this._lastUniforms = this._lastUniforms || {};
+                this._lastUniforms.translationMatrix = this.legacy.worldTransform;
+                this._surface.fillUniforms(this._lastUniforms);
+                return this._lastUniforms;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        return ProjectionSurface;
+    }(pixi_projection.AbstractProjection));
+    pixi_projection.ProjectionSurface = ProjectionSurface;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var TYPES = PIXI.TYPES;
+    var premultiplyTint = PIXI.utils.premultiplyTint;
+    var shaderVert = "precision highp float;\nattribute vec2 aVertexPosition;\nattribute vec3 aTrans1;\nattribute vec3 aTrans2;\nattribute vec2 aSamplerSize;\nattribute vec4 aFrame;\nattribute vec4 aColor;\nattribute float aTextureId;\n\nuniform mat3 projectionMatrix;\nuniform mat3 translationMatrix;\n\nvarying vec2 vertexPosition;\nvarying vec3 vTrans1;\nvarying vec3 vTrans2;\nvarying vec2 vSamplerSize;\nvarying vec4 vFrame;\nvarying vec4 vColor;\nvarying float vTextureId;\n\nvoid main(void){\n\tgl_Position.xyw = projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0);\n\tgl_Position.z = 0.0;\n\n\tvertexPosition = aVertexPosition;\n\tvTrans1 = aTrans1;\n\tvTrans2 = aTrans2;\n\tvTextureId = aTextureId;\n\tvColor = aColor;\n\tvSamplerSize = aSamplerSize;\n\tvFrame = aFrame;\n}\n";
+    var shaderFrag = "precision highp float;\nvarying vec2 vertexPosition;\nvarying vec3 vTrans1;\nvarying vec3 vTrans2;\nvarying vec2 vSamplerSize;\nvarying vec4 vFrame;\nvarying vec4 vColor;\nvarying float vTextureId;\n\nuniform sampler2D uSamplers[%count%];\nuniform vec4 distortion;\n\nvoid main(void){\nvec2 surface;\nvec2 surface2;\n\nfloat vx = vertexPosition.x;\nfloat vy = vertexPosition.y;\nfloat dx = distortion.x;\nfloat dy = distortion.y;\nfloat revx = distortion.z;\nfloat revy = distortion.w;\n\nif (distortion.x == 0.0) {\n\tsurface.x = vx;\n\tsurface.y = vy / (1.0 + dy * vx);\n\tsurface2 = surface;\n} else\nif (distortion.y == 0.0) {\n\tsurface.y = vy;\n\tsurface.x = vx / (1.0 + dx * vy);\n\tsurface2 = surface;\n} else {\n\tfloat c = vy * dx - vx * dy;\n\tfloat b = (c + 1.0) * 0.5;\n\tfloat b2 = (-c + 1.0) * 0.5;\n\tfloat d = b * b + vx * dy;\n\tif (d < -0.00001) {\n\t    discard;\n\t}\n\td = sqrt(max(d, 0.0));\n\tsurface.x = (- b + d) * revy;\n\tsurface2.x = (- b - d) * revy;\n\tsurface.y = (- b2 + d) * revx;\n\tsurface2.y = (- b2 - d) * revx;\n}\n\nvec2 uv;\nuv.x = vTrans1.x * surface.x + vTrans1.y * surface.y + vTrans1.z;\nuv.y = vTrans2.x * surface.x + vTrans2.y * surface.y + vTrans2.z;\n\nvec2 pixels = uv * vSamplerSize;\n\nif (pixels.x < vFrame.x || pixels.x > vFrame.z ||\n\tpixels.y < vFrame.y || pixels.y > vFrame.w) {\n\tuv.x = vTrans1.x * surface2.x + vTrans1.y * surface2.y + vTrans1.z;\n\tuv.y = vTrans2.x * surface2.x + vTrans2.y * surface2.y + vTrans2.z;\n\tpixels = uv * vSamplerSize;\n\n   if (pixels.x < vFrame.x || pixels.x > vFrame.z ||\n       pixels.y < vFrame.y || pixels.y > vFrame.w) {\n       discard;\n   }\n}\n\nvec4 edge;\nedge.xy = clamp(pixels - vFrame.xy + 0.5, vec2(0.0, 0.0), vec2(1.0, 1.0));\nedge.zw = clamp(vFrame.zw - pixels + 0.5, vec2(0.0, 0.0), vec2(1.0, 1.0));\n\nfloat alpha = 1.0; //edge.x * edge.y * edge.z * edge.w;\nvec4 rColor = vColor * alpha;\n\nfloat textureId = floor(vTextureId+0.5);\nvec2 vTextureCoord = uv;\nvec4 color;\n%forloop%\ngl_FragColor = color * rColor;\n}";
+    var BatchBilineardGeometry = (function (_super) {
+        __extends(BatchBilineardGeometry, _super);
+        function BatchBilineardGeometry(_static) {
+            if (_static === void 0) { _static = false; }
+            var _this = _super.call(this) || this;
+            _this._buffer = new PIXI.Buffer(null, _static, false);
+            _this._indexBuffer = new PIXI.Buffer(null, _static, true);
+            _this.addAttribute('aVertexPosition', _this._buffer, 2, false, TYPES.FLOAT)
+                .addAttribute('aTrans1', _this._buffer, 3, false, TYPES.FLOAT)
+                .addAttribute('aTrans2', _this._buffer, 3, false, TYPES.FLOAT)
+                .addAttribute('aSamplerSize', _this._buffer, 2, false, TYPES.FLOAT)
+                .addAttribute('aFrame', _this._buffer, 4, false, TYPES.FLOAT)
+                .addAttribute('aColor', _this._buffer, 4, true, TYPES.UNSIGNED_BYTE)
+                .addAttribute('aTextureId', _this._buffer, 1, true, TYPES.FLOAT)
+                .addIndex(_this._indexBuffer);
+            return _this;
+        }
+        return BatchBilineardGeometry;
+    }(PIXI.Geometry));
+    pixi_projection.BatchBilineardGeometry = BatchBilineardGeometry;
+    var BatchBilinearPluginFactory = (function () {
+        function BatchBilinearPluginFactory() {
+        }
+        BatchBilinearPluginFactory.create = function (options) {
+            var _a = Object.assign({
+                vertex: shaderVert,
+                fragment: shaderFrag,
+                geometryClass: BatchBilineardGeometry,
+                vertexSize: 16,
+            }, options), vertex = _a.vertex, fragment = _a.fragment, vertexSize = _a.vertexSize, geometryClass = _a.geometryClass;
+            return (function (_super) {
+                __extends(BatchPlugin, _super);
+                function BatchPlugin(renderer) {
+                    var _this = _super.call(this, renderer) || this;
+                    _this.defUniforms = {
+                        translationMatrix: new PIXI.Matrix(),
+                        distortion: new Float32Array([0, 0, Infinity, Infinity])
+                    };
+                    _this.size = 1000;
+                    _this.forceMaxTextures = 1;
+                    _this.shaderGenerator = new PIXI.BatchShaderGenerator(vertex, fragment);
+                    _this.geometryClass = geometryClass;
+                    _this.vertexSize = vertexSize;
+                    return _this;
+                }
+                BatchPlugin.prototype.getUniforms = function (sprite) {
+                    var proj = sprite.proj;
+                    if (proj.surface !== null) {
+                        return proj.uniforms;
+                    }
+                    if (proj._activeProjection !== null) {
+                        return proj._activeProjection.uniforms;
+                    }
+                    return this.defUniforms;
+                };
+                BatchPlugin.prototype.packInterleavedGeometry = function (element, attributeBuffer, indexBuffer, aIndex, iIndex) {
+                    var uint32View = attributeBuffer.uint32View, float32View = attributeBuffer.float32View;
+                    var p = aIndex / this.vertexSize;
+                    var indices = element.indices;
+                    var vertexData = element.vertexData;
+                    var tex = element._texture;
+                    var frame = tex._frame;
+                    var aTrans = element.aTrans;
+                    var _a = element._texture.baseTexture, _batchLocation = _a._batchLocation, realWidth = _a.realWidth, realHeight = _a.realHeight, resolution = _a.resolution;
+                    var alpha = Math.min(element.worldAlpha, 1.0);
+                    var argb = alpha < 1.0 && element._texture.baseTexture.alphaMode ? premultiplyTint(element._tintRGB, alpha)
+                        : element._tintRGB + (alpha * 255 << 24);
+                    for (var i = 0; i < vertexData.length; i += 2) {
+                        float32View[aIndex] = vertexData[i];
+                        float32View[aIndex + 1] = vertexData[i + 1];
+                        float32View[aIndex + 2] = aTrans.a;
+                        float32View[aIndex + 3] = aTrans.c;
+                        float32View[aIndex + 4] = aTrans.tx;
+                        float32View[aIndex + 5] = aTrans.b;
+                        float32View[aIndex + 6] = aTrans.d;
+                        float32View[aIndex + 7] = aTrans.ty;
+                        float32View[aIndex + 8] = realWidth;
+                        float32View[aIndex + 9] = realHeight;
+                        float32View[aIndex + 10] = frame.x * resolution;
+                        float32View[aIndex + 11] = frame.y * resolution;
+                        float32View[aIndex + 12] = (frame.x + frame.width) * resolution;
+                        float32View[aIndex + 13] = (frame.y + frame.height) * resolution;
+                        uint32View[aIndex + 14] = argb;
+                        float32View[aIndex + 15] = _batchLocation;
+                        aIndex += 16;
+                    }
+                    for (var i = 0; i < indices.length; i++) {
+                        indexBuffer[iIndex++] = p + indices[i];
+                    }
+                };
+                return BatchPlugin;
+            }(pixi_projection.UniformBatchRenderer));
+        };
+        return BatchBilinearPluginFactory;
+    }());
+    pixi_projection.BatchBilinearPluginFactory = BatchBilinearPluginFactory;
+    PIXI.Renderer.registerPlugin('batch_bilinear', BatchBilinearPluginFactory.create({}));
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var Sprite2s = (function (_super) {
+        __extends(Sprite2s, _super);
+        function Sprite2s(texture) {
+            var _this = _super.call(this, texture) || this;
+            _this.aTrans = new PIXI.Matrix();
+            _this.proj = new pixi_projection.ProjectionSurface(_this.transform);
+            _this.pluginName = 'batch_bilinear';
+            return _this;
+        }
+        Sprite2s.prototype._calculateBounds = function () {
+            this.calculateTrimmedVertices();
+            this._bounds.addQuad(this.vertexTrimmedData);
+        };
+        Sprite2s.prototype.calculateVertices = function () {
+            var wid = this.transform._worldID;
+            var tuid = this._texture._updateID;
+            if (this._transformID === wid && this._textureID === tuid) {
+                return;
+            }
+            this._transformID = wid;
+            this._textureID = tuid;
+            var texture = this._texture;
+            var vertexData = this.vertexData;
+            var trim = texture.trim;
+            var orig = texture.orig;
+            var anchor = this._anchor;
+            var w0 = 0;
+            var w1 = 0;
+            var h0 = 0;
+            var h1 = 0;
+            if (trim) {
+                w1 = trim.x - (anchor._x * orig.width);
+                w0 = w1 + trim.width;
+                h1 = trim.y - (anchor._y * orig.height);
+                h0 = h1 + trim.height;
+            }
+            else {
+                w1 = -anchor._x * orig.width;
+                w0 = w1 + orig.width;
+                h1 = -anchor._y * orig.height;
+                h0 = h1 + orig.height;
+            }
+            if (this.proj._surface) {
+                vertexData[0] = w1;
+                vertexData[1] = h1;
+                vertexData[2] = w0;
+                vertexData[3] = h1;
+                vertexData[4] = w0;
+                vertexData[5] = h0;
+                vertexData[6] = w1;
+                vertexData[7] = h0;
+                this.proj._surface.boundsQuad(vertexData, vertexData);
+            }
+            else {
+                var wt = this.transform.worldTransform;
+                var a = wt.a;
+                var b = wt.b;
+                var c = wt.c;
+                var d = wt.d;
+                var tx = wt.tx;
+                var ty = wt.ty;
+                vertexData[0] = (a * w1) + (c * h1) + tx;
+                vertexData[1] = (d * h1) + (b * w1) + ty;
+                vertexData[2] = (a * w0) + (c * h1) + tx;
+                vertexData[3] = (d * h1) + (b * w0) + ty;
+                vertexData[4] = (a * w0) + (c * h0) + tx;
+                vertexData[5] = (d * h0) + (b * w0) + ty;
+                vertexData[6] = (a * w1) + (c * h0) + tx;
+                vertexData[7] = (d * h0) + (b * w1) + ty;
+                if (this.proj._activeProjection) {
+                    this.proj._activeProjection.surface.boundsQuad(vertexData, vertexData);
+                }
+            }
+            if (!texture.uvMatrix) {
+                texture.uvMatrix = new PIXI.TextureMatrix(texture);
+            }
+            texture.uvMatrix.update();
+            var aTrans = this.aTrans;
+            aTrans.set(orig.width, 0, 0, orig.height, w1, h1);
+            if (this.proj._surface === null) {
+                aTrans.prepend(this.transform.worldTransform);
+            }
+            aTrans.invert();
+            aTrans.prepend(texture.uvMatrix.mapCoord);
+        };
+        Sprite2s.prototype.calculateTrimmedVertices = function () {
+            var wid = this.transform._worldID;
+            var tuid = this._texture._updateID;
+            if (!this.vertexTrimmedData) {
+                this.vertexTrimmedData = new Float32Array(8);
+            }
+            else if (this._transformTrimmedID === wid && this._textureTrimmedID === tuid) {
+                return;
+            }
+            this._transformTrimmedID = wid;
+            this._textureTrimmedID = tuid;
+            var texture = this._texture;
+            var vertexData = this.vertexTrimmedData;
+            var orig = texture.orig;
+            var anchor = this._anchor;
+            var w1 = -anchor._x * orig.width;
+            var w0 = w1 + orig.width;
+            var h1 = -anchor._y * orig.height;
+            var h0 = h1 + orig.height;
+            if (this.proj._surface) {
+                vertexData[0] = w1;
+                vertexData[1] = h1;
+                vertexData[2] = w0;
+                vertexData[3] = h1;
+                vertexData[4] = w0;
+                vertexData[5] = h0;
+                vertexData[6] = w1;
+                vertexData[7] = h0;
+                this.proj._surface.boundsQuad(vertexData, vertexData, this.transform.worldTransform);
+            }
+            else {
+                var wt = this.transform.worldTransform;
+                var a = wt.a;
+                var b = wt.b;
+                var c = wt.c;
+                var d = wt.d;
+                var tx = wt.tx;
+                var ty = wt.ty;
+                vertexData[0] = (a * w1) + (c * h1) + tx;
+                vertexData[1] = (d * h1) + (b * w1) + ty;
+                vertexData[2] = (a * w0) + (c * h1) + tx;
+                vertexData[3] = (d * h1) + (b * w0) + ty;
+                vertexData[4] = (a * w0) + (c * h0) + tx;
+                vertexData[5] = (d * h0) + (b * w0) + ty;
+                vertexData[6] = (a * w1) + (c * h0) + tx;
+                vertexData[7] = (d * h0) + (b * w1) + ty;
+                if (this.proj._activeProjection) {
+                    this.proj._activeProjection.surface.boundsQuad(vertexData, vertexData, this.proj._activeProjection.legacy.worldTransform);
+                }
+            }
+        };
+        Object.defineProperty(Sprite2s.prototype, "worldTransform", {
+            get: function () {
+                return this.proj;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        return Sprite2s;
+    }(PIXI.Sprite));
+    pixi_projection.Sprite2s = Sprite2s;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var Text2s = (function (_super) {
+        __extends(Text2s, _super);
+        function Text2s(text, style, canvas) {
+            var _this = _super.call(this, text, style, canvas) || this;
+            _this.aTrans = new PIXI.Matrix();
+            _this.proj = new pixi_projection.ProjectionSurface(_this.transform);
+            _this.pluginName = 'batch_bilinear';
+            return _this;
+        }
+        Object.defineProperty(Text2s.prototype, "worldTransform", {
+            get: function () {
+                return this.proj;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        return Text2s;
+    }(PIXI.Text));
+    pixi_projection.Text2s = Text2s;
+    Text2s.prototype.calculateVertices = pixi_projection.Sprite2s.prototype.calculateVertices;
+    Text2s.prototype.calculateTrimmedVertices = pixi_projection.Sprite2s.prototype.calculateTrimmedVertices;
+    Text2s.prototype._calculateBounds = pixi_projection.Sprite2s.prototype._calculateBounds;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    PIXI.Sprite.prototype.convertTo2s = function () {
+        if (this.proj)
+            return;
+        this.pluginName = 'sprite_bilinear';
+        this.aTrans = new PIXI.Matrix();
+        this.calculateVertices = pixi_projection.Sprite2s.prototype.calculateVertices;
+        this.calculateTrimmedVertices = pixi_projection.Sprite2s.prototype.calculateTrimmedVertices;
+        this._calculateBounds = pixi_projection.Sprite2s.prototype._calculateBounds;
+        PIXI.Container.prototype.convertTo2s.call(this);
+    };
+    PIXI.Container.prototype.convertTo2s = function () {
+        if (this.proj)
+            return;
+        this.proj = new pixi_projection.Projection2d(this.transform);
+        Object.defineProperty(this, "worldTransform", {
+            get: function () {
+                return this.proj;
+            },
+            enumerable: true,
+            configurable: true
+        });
+    };
+    PIXI.Container.prototype.convertSubtreeTo2s = function () {
+        this.convertTo2s();
+        for (var i = 0; i < this.children.length; i++) {
+            this.children[i].convertSubtreeTo2s();
+        }
+    };
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    function container2dWorldTransform() {
+        return this.proj.affine ? this.transform.worldTransform : this.proj.world;
+    }
+    pixi_projection.container2dWorldTransform = container2dWorldTransform;
+    var Container2d = (function (_super) {
+        __extends(Container2d, _super);
+        function Container2d() {
+            var _this = _super.call(this) || this;
+            _this.proj = new pixi_projection.Projection2d(_this.transform);
+            return _this;
+        }
+        Container2d.prototype.toLocal = function (position, from, point, skipUpdate, step) {
+            if (step === void 0) { step = pixi_projection.TRANSFORM_STEP.ALL; }
+            if (from) {
+                position = from.toGlobal(position, point, skipUpdate);
+            }
+            if (!skipUpdate) {
+                this._recursivePostUpdateTransform();
+            }
+            if (step >= pixi_projection.TRANSFORM_STEP.PROJ) {
+                if (!skipUpdate) {
+                    this.displayObjectUpdateTransform();
+                }
+                if (this.proj.affine) {
+                    return this.transform.worldTransform.applyInverse(position, point);
+                }
+                return this.proj.world.applyInverse(position, point);
+            }
+            if (this.parent) {
+                point = this.parent.worldTransform.applyInverse(position, point);
+            }
+            else {
+                point.copyFrom(position);
+            }
+            if (step === pixi_projection.TRANSFORM_STEP.NONE) {
+                return point;
+            }
+            return this.transform.localTransform.applyInverse(point, point);
+        };
+        Object.defineProperty(Container2d.prototype, "worldTransform", {
+            get: function () {
+                return this.proj.affine ? this.transform.worldTransform : this.proj.world;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        return Container2d;
+    }(PIXI.Container));
+    pixi_projection.Container2d = Container2d;
+    pixi_projection.container2dToLocal = Container2d.prototype.toLocal;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var Point = PIXI.Point;
+    var mat3id = [1, 0, 0, 0, 1, 0, 0, 0, 1];
+    var AFFINE;
+    (function (AFFINE) {
+        AFFINE[AFFINE["NONE"] = 0] = "NONE";
+        AFFINE[AFFINE["FREE"] = 1] = "FREE";
+        AFFINE[AFFINE["AXIS_X"] = 2] = "AXIS_X";
+        AFFINE[AFFINE["AXIS_Y"] = 3] = "AXIS_Y";
+        AFFINE[AFFINE["POINT"] = 4] = "POINT";
+        AFFINE[AFFINE["AXIS_XR"] = 5] = "AXIS_XR";
+    })(AFFINE = pixi_projection.AFFINE || (pixi_projection.AFFINE = {}));
+    var Matrix2d = (function () {
+        function Matrix2d(backingArray) {
+            this.floatArray = null;
+            this.mat3 = new Float64Array(backingArray || mat3id);
+        }
+        Object.defineProperty(Matrix2d.prototype, "a", {
+            get: function () {
+                return this.mat3[0] / this.mat3[8];
+            },
+            set: function (value) {
+                this.mat3[0] = value * this.mat3[8];
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Matrix2d.prototype, "b", {
+            get: function () {
+                return this.mat3[1] / this.mat3[8];
+            },
+            set: function (value) {
+                this.mat3[1] = value * this.mat3[8];
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Matrix2d.prototype, "c", {
+            get: function () {
+                return this.mat3[3] / this.mat3[8];
+            },
+            set: function (value) {
+                this.mat3[3] = value * this.mat3[8];
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Matrix2d.prototype, "d", {
+            get: function () {
+                return this.mat3[4] / this.mat3[8];
+            },
+            set: function (value) {
+                this.mat3[4] = value * this.mat3[8];
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Matrix2d.prototype, "tx", {
+            get: function () {
+                return this.mat3[6] / this.mat3[8];
+            },
+            set: function (value) {
+                this.mat3[6] = value * this.mat3[8];
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Matrix2d.prototype, "ty", {
+            get: function () {
+                return this.mat3[7] / this.mat3[8];
+            },
+            set: function (value) {
+                this.mat3[7] = value * this.mat3[8];
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Matrix2d.prototype.set = function (a, b, c, d, tx, ty) {
+            var mat3 = this.mat3;
+            mat3[0] = a;
+            mat3[1] = b;
+            mat3[2] = 0;
+            mat3[3] = c;
+            mat3[4] = d;
+            mat3[5] = 0;
+            mat3[6] = tx;
+            mat3[7] = ty;
+            mat3[8] = 1;
+            return this;
+        };
+        Matrix2d.prototype.toArray = function (transpose, out) {
+            if (!this.floatArray) {
+                this.floatArray = new Float32Array(9);
+            }
+            var array = out || this.floatArray;
+            var mat3 = this.mat3;
+            if (transpose) {
+                array[0] = mat3[0];
+                array[1] = mat3[1];
+                array[2] = mat3[2];
+                array[3] = mat3[3];
+                array[4] = mat3[4];
+                array[5] = mat3[5];
+                array[6] = mat3[6];
+                array[7] = mat3[7];
+                array[8] = mat3[8];
+            }
+            else {
+                array[0] = mat3[0];
+                array[1] = mat3[3];
+                array[2] = mat3[6];
+                array[3] = mat3[1];
+                array[4] = mat3[4];
+                array[5] = mat3[7];
+                array[6] = mat3[2];
+                array[7] = mat3[5];
+                array[8] = mat3[8];
+            }
+            return array;
+        };
+        Matrix2d.prototype.apply = function (pos, newPos) {
+            newPos = newPos || new PIXI.Point();
+            var mat3 = this.mat3;
+            var x = pos.x;
+            var y = pos.y;
+            var z = 1.0 / (mat3[2] * x + mat3[5] * y + mat3[8]);
+            newPos.x = z * (mat3[0] * x + mat3[3] * y + mat3[6]);
+            newPos.y = z * (mat3[1] * x + mat3[4] * y + mat3[7]);
+            return newPos;
+        };
+        Matrix2d.prototype.translate = function (tx, ty) {
+            var mat3 = this.mat3;
+            mat3[0] += tx * mat3[2];
+            mat3[1] += ty * mat3[2];
+            mat3[3] += tx * mat3[5];
+            mat3[4] += ty * mat3[5];
+            mat3[6] += tx * mat3[8];
+            mat3[7] += ty * mat3[8];
+            return this;
+        };
+        Matrix2d.prototype.scale = function (x, y) {
+            var mat3 = this.mat3;
+            mat3[0] *= x;
+            mat3[1] *= y;
+            mat3[3] *= x;
+            mat3[4] *= y;
+            mat3[6] *= x;
+            mat3[7] *= y;
+            return this;
+        };
+        Matrix2d.prototype.scaleAndTranslate = function (scaleX, scaleY, tx, ty) {
+            var mat3 = this.mat3;
+            mat3[0] = scaleX * mat3[0] + tx * mat3[2];
+            mat3[1] = scaleY * mat3[1] + ty * mat3[2];
+            mat3[3] = scaleX * mat3[3] + tx * mat3[5];
+            mat3[4] = scaleY * mat3[4] + ty * mat3[5];
+            mat3[6] = scaleX * mat3[6] + tx * mat3[8];
+            mat3[7] = scaleY * mat3[7] + ty * mat3[8];
+        };
+        Matrix2d.prototype.applyInverse = function (pos, newPos) {
+            newPos = newPos || new Point();
+            var a = this.mat3;
+            var x = pos.x;
+            var y = pos.y;
+            var a00 = a[0], a01 = a[3], a02 = a[6], a10 = a[1], a11 = a[4], a12 = a[7], a20 = a[2], a21 = a[5], a22 = a[8];
+            var newX = (a22 * a11 - a12 * a21) * x + (-a22 * a01 + a02 * a21) * y + (a12 * a01 - a02 * a11);
+            var newY = (-a22 * a10 + a12 * a20) * x + (a22 * a00 - a02 * a20) * y + (-a12 * a00 + a02 * a10);
+            var newZ = (a21 * a10 - a11 * a20) * x + (-a21 * a00 + a01 * a20) * y + (a11 * a00 - a01 * a10);
+            newPos.x = newX / newZ;
+            newPos.y = newY / newZ;
+            return newPos;
+        };
+        Matrix2d.prototype.invert = function () {
+            var a = this.mat3;
+            var a00 = a[0], a01 = a[1], a02 = a[2], a10 = a[3], a11 = a[4], a12 = a[5], a20 = a[6], a21 = a[7], a22 = a[8], b01 = a22 * a11 - a12 * a21, b11 = -a22 * a10 + a12 * a20, b21 = a21 * a10 - a11 * a20;
+            var det = a00 * b01 + a01 * b11 + a02 * b21;
+            if (!det) {
+                return this;
+            }
+            det = 1.0 / det;
+            a[0] = b01 * det;
+            a[1] = (-a22 * a01 + a02 * a21) * det;
+            a[2] = (a12 * a01 - a02 * a11) * det;
+            a[3] = b11 * det;
+            a[4] = (a22 * a00 - a02 * a20) * det;
+            a[5] = (-a12 * a00 + a02 * a10) * det;
+            a[6] = b21 * det;
+            a[7] = (-a21 * a00 + a01 * a20) * det;
+            a[8] = (a11 * a00 - a01 * a10) * det;
+            return this;
+        };
+        Matrix2d.prototype.identity = function () {
+            var mat3 = this.mat3;
+            mat3[0] = 1;
+            mat3[1] = 0;
+            mat3[2] = 0;
+            mat3[3] = 0;
+            mat3[4] = 1;
+            mat3[5] = 0;
+            mat3[6] = 0;
+            mat3[7] = 0;
+            mat3[8] = 1;
+            return this;
+        };
+        Matrix2d.prototype.clone = function () {
+            return new Matrix2d(this.mat3);
+        };
+        Matrix2d.prototype.copyTo2dOr3d = function (matrix) {
+            var mat3 = this.mat3;
+            var ar2 = matrix.mat3;
+            ar2[0] = mat3[0];
+            ar2[1] = mat3[1];
+            ar2[2] = mat3[2];
+            ar2[3] = mat3[3];
+            ar2[4] = mat3[4];
+            ar2[5] = mat3[5];
+            ar2[6] = mat3[6];
+            ar2[7] = mat3[7];
+            ar2[8] = mat3[8];
+            return matrix;
+        };
+        Matrix2d.prototype.copyTo = function (matrix, affine, preserveOrientation) {
+            var mat3 = this.mat3;
+            var d = 1.0 / mat3[8];
+            var tx = mat3[6] * d, ty = mat3[7] * d;
+            matrix.a = (mat3[0] - mat3[2] * tx) * d;
+            matrix.b = (mat3[1] - mat3[2] * ty) * d;
+            matrix.c = (mat3[3] - mat3[5] * tx) * d;
+            matrix.d = (mat3[4] - mat3[5] * ty) * d;
+            matrix.tx = tx;
+            matrix.ty = ty;
+            if (affine >= 2) {
+                var D = matrix.a * matrix.d - matrix.b * matrix.c;
+                if (!preserveOrientation) {
+                    D = Math.abs(D);
+                }
+                if (affine === AFFINE.POINT) {
+                    if (D > 0) {
+                        D = 1;
+                    }
+                    else
+                        D = -1;
+                    matrix.a = D;
+                    matrix.b = 0;
+                    matrix.c = 0;
+                    matrix.d = D;
+                }
+                else if (affine === AFFINE.AXIS_X) {
+                    D /= Math.sqrt(matrix.b * matrix.b + matrix.d * matrix.d);
+                    matrix.c = 0;
+                    matrix.d = D;
+                }
+                else if (affine === AFFINE.AXIS_Y) {
+                    D /= Math.sqrt(matrix.a * matrix.a + matrix.c * matrix.c);
+                    matrix.a = D;
+                    matrix.c = 0;
+                }
+                else if (affine === AFFINE.AXIS_XR) {
+                    matrix.a = matrix.d * D;
+                    matrix.c = -matrix.b * D;
+                }
+            }
+            return matrix;
+        };
+        Matrix2d.prototype.copyFrom = function (matrix) {
+            var mat3 = this.mat3;
+            mat3[0] = matrix.a;
+            mat3[1] = matrix.b;
+            mat3[2] = 0;
+            mat3[3] = matrix.c;
+            mat3[4] = matrix.d;
+            mat3[5] = 0;
+            mat3[6] = matrix.tx;
+            mat3[7] = matrix.ty;
+            mat3[8] = 1.0;
+            return this;
+        };
+        Matrix2d.prototype.setToMultLegacy = function (pt, lt) {
+            var out = this.mat3;
+            var b = lt.mat3;
+            var a00 = pt.a, a01 = pt.b, a10 = pt.c, a11 = pt.d, a20 = pt.tx, a21 = pt.ty, b00 = b[0], b01 = b[1], b02 = b[2], b10 = b[3], b11 = b[4], b12 = b[5], b20 = b[6], b21 = b[7], b22 = b[8];
+            out[0] = b00 * a00 + b01 * a10 + b02 * a20;
+            out[1] = b00 * a01 + b01 * a11 + b02 * a21;
+            out[2] = b02;
+            out[3] = b10 * a00 + b11 * a10 + b12 * a20;
+            out[4] = b10 * a01 + b11 * a11 + b12 * a21;
+            out[5] = b12;
+            out[6] = b20 * a00 + b21 * a10 + b22 * a20;
+            out[7] = b20 * a01 + b21 * a11 + b22 * a21;
+            out[8] = b22;
+            return this;
+        };
+        Matrix2d.prototype.setToMultLegacy2 = function (pt, lt) {
+            var out = this.mat3;
+            var a = pt.mat3;
+            var a00 = a[0], a01 = a[1], a02 = a[2], a10 = a[3], a11 = a[4], a12 = a[5], a20 = a[6], a21 = a[7], a22 = a[8], b00 = lt.a, b01 = lt.b, b10 = lt.c, b11 = lt.d, b20 = lt.tx, b21 = lt.ty;
+            out[0] = b00 * a00 + b01 * a10;
+            out[1] = b00 * a01 + b01 * a11;
+            out[2] = b00 * a02 + b01 * a12;
+            out[3] = b10 * a00 + b11 * a10;
+            out[4] = b10 * a01 + b11 * a11;
+            out[5] = b10 * a02 + b11 * a12;
+            out[6] = b20 * a00 + b21 * a10 + a20;
+            out[7] = b20 * a01 + b21 * a11 + a21;
+            out[8] = b20 * a02 + b21 * a12 + a22;
+            return this;
+        };
+        Matrix2d.prototype.setToMult = function (pt, lt) {
+            var out = this.mat3;
+            var a = pt.mat3, b = lt.mat3;
+            var a00 = a[0], a01 = a[1], a02 = a[2], a10 = a[3], a11 = a[4], a12 = a[5], a20 = a[6], a21 = a[7], a22 = a[8], b00 = b[0], b01 = b[1], b02 = b[2], b10 = b[3], b11 = b[4], b12 = b[5], b20 = b[6], b21 = b[7], b22 = b[8];
+            out[0] = b00 * a00 + b01 * a10 + b02 * a20;
+            out[1] = b00 * a01 + b01 * a11 + b02 * a21;
+            out[2] = b00 * a02 + b01 * a12 + b02 * a22;
+            out[3] = b10 * a00 + b11 * a10 + b12 * a20;
+            out[4] = b10 * a01 + b11 * a11 + b12 * a21;
+            out[5] = b10 * a02 + b11 * a12 + b12 * a22;
+            out[6] = b20 * a00 + b21 * a10 + b22 * a20;
+            out[7] = b20 * a01 + b21 * a11 + b22 * a21;
+            out[8] = b20 * a02 + b21 * a12 + b22 * a22;
+            return this;
+        };
+        Matrix2d.prototype.prepend = function (lt) {
+            if (lt.mat3) {
+                return this.setToMult(lt, this);
+            }
+            else {
+                return this.setToMultLegacy(lt, this);
+            }
+        };
+        Matrix2d.IDENTITY = new Matrix2d();
+        Matrix2d.TEMP_MATRIX = new Matrix2d();
+        return Matrix2d;
+    }());
+    pixi_projection.Matrix2d = Matrix2d;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var t0 = new PIXI.Point();
+    var tt = [new PIXI.Point(), new PIXI.Point(), new PIXI.Point(), new PIXI.Point()];
+    var tempRect = new PIXI.Rectangle();
+    var tempMat = new pixi_projection.Matrix2d();
+    var Projection2d = (function (_super) {
+        __extends(Projection2d, _super);
+        function Projection2d(legacy, enable) {
+            var _this = _super.call(this, legacy, enable) || this;
+            _this.matrix = new pixi_projection.Matrix2d();
+            _this.pivot = new PIXI.ObservablePoint(_this.onChange, _this, 0, 0);
+            _this.reverseLocalOrder = false;
+            _this.local = new pixi_projection.Matrix2d();
+            _this.world = new pixi_projection.Matrix2d();
+            return _this;
+        }
+        Projection2d.prototype.onChange = function () {
+            var pivot = this.pivot;
+            var mat3 = this.matrix.mat3;
+            mat3[6] = -(pivot._x * mat3[0] + pivot._y * mat3[3]);
+            mat3[7] = -(pivot._x * mat3[1] + pivot._y * mat3[4]);
+            this._projID++;
+        };
+        Projection2d.prototype.setAxisX = function (p, factor) {
+            if (factor === void 0) { factor = 1; }
+            var x = p.x, y = p.y;
+            var d = Math.sqrt(x * x + y * y);
+            var mat3 = this.matrix.mat3;
+            mat3[0] = x / d;
+            mat3[1] = y / d;
+            mat3[2] = factor / d;
+            this.onChange();
+        };
+        Projection2d.prototype.setAxisY = function (p, factor) {
+            if (factor === void 0) { factor = 1; }
+            var x = p.x, y = p.y;
+            var d = Math.sqrt(x * x + y * y);
+            var mat3 = this.matrix.mat3;
+            mat3[3] = x / d;
+            mat3[4] = y / d;
+            mat3[5] = factor / d;
+            this.onChange();
+        };
+        Projection2d.prototype.mapSprite = function (sprite, quad) {
+            var tex = sprite.texture;
+            tempRect.x = -sprite.anchor.x * tex.orig.width;
+            tempRect.y = -sprite.anchor.y * tex.orig.height;
+            tempRect.width = tex.orig.width;
+            tempRect.height = tex.orig.height;
+            return this.mapQuad(tempRect, quad);
+        };
+        Projection2d.prototype.mapQuad = function (rect, p) {
+            tt[0].set(rect.x, rect.y);
+            tt[1].set(rect.x + rect.width, rect.y);
+            tt[2].set(rect.x + rect.width, rect.y + rect.height);
+            tt[3].set(rect.x, rect.y + rect.height);
+            var k1 = 1, k2 = 2, k3 = 3;
+            var f = pixi_projection.utils.getIntersectionFactor(p[0], p[2], p[1], p[3], t0);
+            if (f !== 0) {
+                k1 = 1;
+                k2 = 3;
+                k3 = 2;
+            }
+            else {
+                return;
+            }
+            var d0 = Math.sqrt((p[0].x - t0.x) * (p[0].x - t0.x) + (p[0].y - t0.y) * (p[0].y - t0.y));
+            var d1 = Math.sqrt((p[k1].x - t0.x) * (p[k1].x - t0.x) + (p[k1].y - t0.y) * (p[k1].y - t0.y));
+            var d2 = Math.sqrt((p[k2].x - t0.x) * (p[k2].x - t0.x) + (p[k2].y - t0.y) * (p[k2].y - t0.y));
+            var d3 = Math.sqrt((p[k3].x - t0.x) * (p[k3].x - t0.x) + (p[k3].y - t0.y) * (p[k3].y - t0.y));
+            var q0 = (d0 + d3) / d3;
+            var q1 = (d1 + d2) / d2;
+            var q2 = (d1 + d2) / d1;
+            var mat3 = this.matrix.mat3;
+            mat3[0] = tt[0].x * q0;
+            mat3[1] = tt[0].y * q0;
+            mat3[2] = q0;
+            mat3[3] = tt[k1].x * q1;
+            mat3[4] = tt[k1].y * q1;
+            mat3[5] = q1;
+            mat3[6] = tt[k2].x * q2;
+            mat3[7] = tt[k2].y * q2;
+            mat3[8] = q2;
+            this.matrix.invert();
+            mat3 = tempMat.mat3;
+            mat3[0] = p[0].x;
+            mat3[1] = p[0].y;
+            mat3[2] = 1;
+            mat3[3] = p[k1].x;
+            mat3[4] = p[k1].y;
+            mat3[5] = 1;
+            mat3[6] = p[k2].x;
+            mat3[7] = p[k2].y;
+            mat3[8] = 1;
+            this.matrix.setToMult(tempMat, this.matrix);
+            this._projID++;
+        };
+        Projection2d.prototype.updateLocalTransform = function (lt) {
+            if (this._projID !== 0) {
+                if (this.reverseLocalOrder) {
+                    this.local.setToMultLegacy2(this.matrix, lt);
+                }
+                else {
+                    this.local.setToMultLegacy(lt, this.matrix);
+                }
+            }
+            else {
+                this.local.copyFrom(lt);
+            }
+        };
+        Projection2d.prototype.clear = function () {
+            _super.prototype.clear.call(this);
+            this.matrix.identity();
+            this.pivot.set(0, 0);
+        };
+        return Projection2d;
+    }(pixi_projection.LinearProjection));
+    pixi_projection.Projection2d = Projection2d;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var Mesh2d = (function (_super) {
+        __extends(Mesh2d, _super);
+        function Mesh2d(geometry, shader, state, drawMode) {
+            var _this = _super.call(this, geometry, shader, state, drawMode) || this;
+            _this.vertexData2d = null;
+            _this.proj = new pixi_projection.Projection2d(_this.transform);
+            return _this;
+        }
+        Mesh2d.prototype.calculateVertices = function () {
+            if (this.proj._affine) {
+                this.vertexData2d = null;
+                _super.prototype.calculateVertices.call(this);
+                return;
+            }
+            var geometry = this.geometry;
+            var vertices = geometry.buffers[0].data;
+            var thisAny = this;
+            if (geometry.vertexDirtyId === thisAny.vertexDirty && thisAny._transformID === thisAny.transform._worldID) {
+                return;
+            }
+            thisAny._transformID = thisAny.transform._worldID;
+            if (thisAny.vertexData.length !== vertices.length) {
+                thisAny.vertexData = new Float32Array(vertices.length);
+            }
+            if (!this.vertexData2d || this.vertexData2d.length !== vertices.length * 3 / 2) {
+                this.vertexData2d = new Float32Array(vertices.length * 3);
+            }
+            var wt = this.proj.world.mat3;
+            var vertexData2d = this.vertexData2d;
+            var vertexData = thisAny.vertexData;
+            for (var i = 0; i < vertexData.length / 2; i++) {
+                var x = vertices[(i * 2)];
+                var y = vertices[(i * 2) + 1];
+                var xx = (wt[0] * x) + (wt[3] * y) + wt[6];
+                var yy = (wt[1] * x) + (wt[4] * y) + wt[7];
+                var ww = (wt[2] * x) + (wt[5] * y) + wt[8];
+                vertexData2d[i * 3] = xx;
+                vertexData2d[i * 3 + 1] = yy;
+                vertexData2d[i * 3 + 2] = ww;
+                vertexData[(i * 2)] = xx / ww;
+                vertexData[(i * 2) + 1] = yy / ww;
+            }
+            thisAny.vertexDirty = geometry.vertexDirtyId;
+        };
+        Mesh2d.prototype._renderDefault = function (renderer) {
+            var shader = this.shader;
+            shader.alpha = this.worldAlpha;
+            if (shader.update) {
+                shader.update();
+            }
+            renderer.batch.flush();
+            if (shader.program.uniformData.translationMatrix) {
+                shader.uniforms.translationMatrix = this.worldTransform.toArray(true);
+            }
+            renderer.shader.bind(shader, false);
+            renderer.state.set(this.state);
+            renderer.geometry.bind(this.geometry, shader);
+            renderer.geometry.draw(this.drawMode, this.size, this.start, this.geometry.instanceCount);
+        };
+        Mesh2d.prototype.toLocal = function (position, from, point, skipUpdate, step) {
+            if (step === void 0) { step = pixi_projection.TRANSFORM_STEP.ALL; }
+            return pixi_projection.container2dToLocal.call(this, position, from, point, skipUpdate, step);
+        };
+        Object.defineProperty(Mesh2d.prototype, "worldTransform", {
+            get: function () {
+                return this.proj.affine ? this.transform.worldTransform : this.proj.world;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Mesh2d.defaultVertexShader = "precision highp float;\nattribute vec2 aVertexPosition;\nattribute vec2 aTextureCoord;\n\nuniform mat3 projectionMatrix;\nuniform mat3 translationMatrix;\nuniform mat3 uTextureMatrix;\n\nvarying vec2 vTextureCoord;\n\nvoid main(void)\n{\n\tgl_Position.xyw = projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0);\n\tgl_Position.z = 0.0;\n\n\tvTextureCoord = (uTextureMatrix * vec3(aTextureCoord, 1.0)).xy;\n}\n";
+        Mesh2d.defaultFragmentShader = "\nvarying vec2 vTextureCoord;\nuniform vec4 uColor;\n\nuniform sampler2D uSampler;\n\nvoid main(void)\n{\n\tgl_FragColor = texture2D(uSampler, vTextureCoord) * uColor;\n}";
+        return Mesh2d;
+    }(PIXI.Mesh));
+    pixi_projection.Mesh2d = Mesh2d;
+    var SimpleMesh2d = (function (_super) {
+        __extends(SimpleMesh2d, _super);
+        function SimpleMesh2d(texture, vertices, uvs, indices, drawMode) {
+            var _this = _super.call(this, new PIXI.MeshGeometry(vertices, uvs, indices), new PIXI.MeshMaterial(texture, {
+                program: PIXI.Program.from(Mesh2d.defaultVertexShader, Mesh2d.defaultFragmentShader),
+                pluginName: 'batch2d'
+            }), null, drawMode) || this;
+            _this.autoUpdate = true;
+            _this.geometry.getBuffer('aVertexPosition').static = false;
+            return _this;
+        }
+        Object.defineProperty(SimpleMesh2d.prototype, "vertices", {
+            get: function () {
+                return this.geometry.getBuffer('aVertexPosition').data;
+            },
+            set: function (value) {
+                this.geometry.getBuffer('aVertexPosition').data = value;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        SimpleMesh2d.prototype._render = function (renderer) {
+            if (this.autoUpdate) {
+                this.geometry.getBuffer('aVertexPosition').update();
+            }
+            _super.prototype._render.call(this, renderer);
+        };
+        return SimpleMesh2d;
+    }(Mesh2d));
+    pixi_projection.SimpleMesh2d = SimpleMesh2d;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var Sprite2d = (function (_super) {
+        __extends(Sprite2d, _super);
+        function Sprite2d(texture) {
+            var _this = _super.call(this, texture) || this;
+            _this.vertexData2d = null;
+            _this.proj = new pixi_projection.Projection2d(_this.transform);
+            _this.pluginName = 'batch2d';
+            return _this;
+        }
+        Sprite2d.prototype._calculateBounds = function () {
+            this.calculateTrimmedVertices();
+            this._bounds.addQuad(this.vertexTrimmedData);
+        };
+        Sprite2d.prototype.calculateVertices = function () {
+            var texture = this._texture;
+            if (this.proj._affine) {
+                this.vertexData2d = null;
+                _super.prototype.calculateVertices.call(this);
+                return;
+            }
+            if (!this.vertexData2d) {
+                this.vertexData2d = new Float32Array(12);
+            }
+            var wid = this.transform._worldID;
+            var tuid = texture._updateID;
+            if (this._transformID === wid && this._textureID === tuid) {
+                return;
+            }
+            if (this._textureID !== tuid) {
+                this.uvs = texture._uvs.uvsFloat32;
+            }
+            this._transformID = wid;
+            this._textureID = tuid;
+            var wt = this.proj.world.mat3;
+            var vertexData2d = this.vertexData2d;
+            var vertexData = this.vertexData;
+            var trim = texture.trim;
+            var orig = texture.orig;
+            var anchor = this._anchor;
+            var w0 = 0;
+            var w1 = 0;
+            var h0 = 0;
+            var h1 = 0;
+            if (trim) {
+                w1 = trim.x - (anchor._x * orig.width);
+                w0 = w1 + trim.width;
+                h1 = trim.y - (anchor._y * orig.height);
+                h0 = h1 + trim.height;
+            }
+            else {
+                w1 = -anchor._x * orig.width;
+                w0 = w1 + orig.width;
+                h1 = -anchor._y * orig.height;
+                h0 = h1 + orig.height;
+            }
+            vertexData2d[0] = (wt[0] * w1) + (wt[3] * h1) + wt[6];
+            vertexData2d[1] = (wt[1] * w1) + (wt[4] * h1) + wt[7];
+            vertexData2d[2] = (wt[2] * w1) + (wt[5] * h1) + wt[8];
+            vertexData2d[3] = (wt[0] * w0) + (wt[3] * h1) + wt[6];
+            vertexData2d[4] = (wt[1] * w0) + (wt[4] * h1) + wt[7];
+            vertexData2d[5] = (wt[2] * w0) + (wt[5] * h1) + wt[8];
+            vertexData2d[6] = (wt[0] * w0) + (wt[3] * h0) + wt[6];
+            vertexData2d[7] = (wt[1] * w0) + (wt[4] * h0) + wt[7];
+            vertexData2d[8] = (wt[2] * w0) + (wt[5] * h0) + wt[8];
+            vertexData2d[9] = (wt[0] * w1) + (wt[3] * h0) + wt[6];
+            vertexData2d[10] = (wt[1] * w1) + (wt[4] * h0) + wt[7];
+            vertexData2d[11] = (wt[2] * w1) + (wt[5] * h0) + wt[8];
+            vertexData[0] = vertexData2d[0] / vertexData2d[2];
+            vertexData[1] = vertexData2d[1] / vertexData2d[2];
+            vertexData[2] = vertexData2d[3] / vertexData2d[5];
+            vertexData[3] = vertexData2d[4] / vertexData2d[5];
+            vertexData[4] = vertexData2d[6] / vertexData2d[8];
+            vertexData[5] = vertexData2d[7] / vertexData2d[8];
+            vertexData[6] = vertexData2d[9] / vertexData2d[11];
+            vertexData[7] = vertexData2d[10] / vertexData2d[11];
+        };
+        Sprite2d.prototype.calculateTrimmedVertices = function () {
+            if (this.proj._affine) {
+                _super.prototype.calculateTrimmedVertices.call(this);
+                return;
+            }
+            var wid = this.transform._worldID;
+            var tuid = this._texture._updateID;
+            if (!this.vertexTrimmedData) {
+                this.vertexTrimmedData = new Float32Array(8);
+            }
+            else if (this._transformTrimmedID === wid && this._textureTrimmedID === tuid) {
+                return;
+            }
+            this._transformTrimmedID = wid;
+            this._textureTrimmedID = tuid;
+            var texture = this._texture;
+            var vertexData = this.vertexTrimmedData;
+            var orig = texture.orig;
+            var anchor = this._anchor;
+            var wt = this.proj.world.mat3;
+            var w1 = -anchor._x * orig.width;
+            var w0 = w1 + orig.width;
+            var h1 = -anchor._y * orig.height;
+            var h0 = h1 + orig.height;
+            var z = 1.0 / (wt[2] * w1 + wt[5] * h1 + wt[8]);
+            vertexData[0] = z * ((wt[0] * w1) + (wt[3] * h1) + wt[6]);
+            vertexData[1] = z * ((wt[1] * w1) + (wt[4] * h1) + wt[7]);
+            z = 1.0 / (wt[2] * w0 + wt[5] * h1 + wt[8]);
+            vertexData[2] = z * ((wt[0] * w0) + (wt[3] * h1) + wt[6]);
+            vertexData[3] = z * ((wt[1] * w0) + (wt[4] * h1) + wt[7]);
+            z = 1.0 / (wt[2] * w0 + wt[5] * h0 + wt[8]);
+            vertexData[4] = z * ((wt[0] * w0) + (wt[3] * h0) + wt[6]);
+            vertexData[5] = z * ((wt[1] * w0) + (wt[4] * h0) + wt[7]);
+            z = 1.0 / (wt[2] * w1 + wt[5] * h0 + wt[8]);
+            vertexData[6] = z * ((wt[0] * w1) + (wt[3] * h0) + wt[6]);
+            vertexData[7] = z * ((wt[1] * w1) + (wt[4] * h0) + wt[7]);
+        };
+        Sprite2d.prototype.toLocal = function (position, from, point, skipUpdate, step) {
+            if (step === void 0) { step = pixi_projection.TRANSFORM_STEP.ALL; }
+            return pixi_projection.container2dToLocal.call(this, position, from, point, skipUpdate, step);
+        };
+        Object.defineProperty(Sprite2d.prototype, "worldTransform", {
+            get: function () {
+                return this.proj.affine ? this.transform.worldTransform : this.proj.world;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        return Sprite2d;
+    }(PIXI.Sprite));
+    pixi_projection.Sprite2d = Sprite2d;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var Text2d = (function (_super) {
+        __extends(Text2d, _super);
+        function Text2d(text, style, canvas) {
+            var _this = _super.call(this, text, style, canvas) || this;
+            _this.vertexData2d = null;
+            _this.proj = new pixi_projection.Projection2d(_this.transform);
+            _this.pluginName = 'batch2d';
+            return _this;
+        }
+        Object.defineProperty(Text2d.prototype, "worldTransform", {
+            get: function () {
+                return this.proj.affine ? this.transform.worldTransform : this.proj.world;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        return Text2d;
+    }(PIXI.Text));
+    pixi_projection.Text2d = Text2d;
+    Text2d.prototype.calculateVertices = pixi_projection.Sprite2d.prototype.calculateVertices;
+    Text2d.prototype.calculateTrimmedVertices = pixi_projection.Sprite2d.prototype.calculateTrimmedVertices;
+    Text2d.prototype._calculateBounds = pixi_projection.Sprite2d.prototype._calculateBounds;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    function convertTo2d() {
+        if (this.proj)
+            return;
+        this.proj = new pixi_projection.Projection2d(this.transform);
+        this.toLocal = pixi_projection.Container2d.prototype.toLocal;
+        Object.defineProperty(this, "worldTransform", {
+            get: pixi_projection.container2dWorldTransform,
+            enumerable: true,
+            configurable: true
+        });
+    }
+    PIXI.Container.prototype.convertTo2d = convertTo2d;
+    PIXI.Sprite.prototype.convertTo2d = function () {
+        if (this.proj)
+            return;
+        this.calculateVertices = pixi_projection.Sprite2d.prototype.calculateVertices;
+        this.calculateTrimmedVertices = pixi_projection.Sprite2d.prototype.calculateTrimmedVertices;
+        this._calculateBounds = pixi_projection.Sprite2d.prototype._calculateBounds;
+        this.pluginName = 'batch2d';
+        convertTo2d.call(this);
+    };
+    PIXI.Container.prototype.convertSubtreeTo2d = function () {
+        this.convertTo2d();
+        for (var i = 0; i < this.children.length; i++) {
+            this.children[i].convertSubtreeTo2d();
+        }
+    };
+    if (PIXI.SimpleMesh) {
+        PIXI.SimpleMesh.prototype.convertTo2d =
+            PIXI.SimpleRope.prototype.convertTo2d =
+                function () {
+                    if (this.proj)
+                        return;
+                    this.calculateVertices = pixi_projection.Mesh2d.prototype.calculateVertices;
+                    this._renderDefault = pixi_projection.Mesh2d.prototype._renderDefault;
+                    if (this.material.pluginName !== 'batch2d') {
+                        this.material = new PIXI.MeshMaterial(this.material.texture, {
+                            program: PIXI.Program.from(pixi_projection.Mesh2d.defaultVertexShader, pixi_projection.Mesh2d.defaultFragmentShader),
+                            pluginName: 'batch2d'
+                        });
+                    }
+                    convertTo2d.call(this);
+                };
+    }
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var tempTransform = new PIXI.Transform();
+    var TilingSprite2d = (function (_super) {
+        __extends(TilingSprite2d, _super);
+        function TilingSprite2d(texture, width, height) {
+            var _this = _super.call(this, texture, width, height) || this;
+            _this.tileProj = new pixi_projection.Projection2d(_this.tileTransform);
+            _this.tileProj.reverseLocalOrder = true;
+            _this.proj = new pixi_projection.Projection2d(_this.transform);
+            _this.pluginName = 'tilingSprite2d';
+            _this.uvRespectAnchor = true;
+            return _this;
+        }
+        Object.defineProperty(TilingSprite2d.prototype, "worldTransform", {
+            get: function () {
+                return this.proj.affine ? this.transform.worldTransform : this.proj.world;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        TilingSprite2d.prototype.toLocal = function (position, from, point, skipUpdate, step) {
+            if (step === void 0) { step = pixi_projection.TRANSFORM_STEP.ALL; }
+            return pixi_projection.container2dToLocal.call(this, position, from, point, skipUpdate, step);
+        };
+        TilingSprite2d.prototype._render = function (renderer) {
+            var texture = this._texture;
+            if (!texture || !texture.valid) {
+                return;
+            }
+            this.tileTransform.updateTransform(tempTransform);
+            this.uvMatrix.update();
+            renderer.batch.setObjectRenderer(renderer.plugins[this.pluginName]);
+            renderer.plugins[this.pluginName].render(this);
+        };
+        return TilingSprite2d;
+    }(PIXI.TilingSprite));
+    pixi_projection.TilingSprite2d = TilingSprite2d;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var shaderVert = "attribute vec2 aVertexPosition;\nattribute vec2 aTextureCoord;\n\nuniform mat3 projectionMatrix;\nuniform mat3 translationMatrix;\nuniform mat3 uTransform;\n\nvarying vec3 vTextureCoord;\n\nvoid main(void)\n{\n    gl_Position.xyw = projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0);\n\n    vTextureCoord = uTransform * vec3(aTextureCoord, 1.0);\n}\n";
+    var shaderFrag = "\nvarying vec3 vTextureCoord;\n\nuniform sampler2D uSampler;\nuniform vec4 uColor;\nuniform mat3 uMapCoord;\nuniform vec4 uClampFrame;\nuniform vec2 uClampOffset;\n\nvoid main(void)\n{\n    vec2 coord = mod(vTextureCoord.xy / vTextureCoord.z - uClampOffset, vec2(1.0, 1.0)) + uClampOffset;\n    coord = (uMapCoord * vec3(coord, 1.0)).xy;\n    coord = clamp(coord, uClampFrame.xy, uClampFrame.zw);\n\n    vec4 sample = texture2D(uSampler, coord);\n    gl_FragColor = sample * uColor;\n}\n";
+    var shaderSimpleFrag = "\n\tvarying vec3 vTextureCoord;\n\nuniform sampler2D uSampler;\nuniform vec4 uColor;\n\nvoid main(void)\n{\n    vec4 sample = texture2D(uSampler, vTextureCoord.xy / vTextureCoord.z);\n    gl_FragColor = sample * uColor;\n}\n";
+    var tempMat = new pixi_projection.Matrix2d();
+    var WRAP_MODES = PIXI.WRAP_MODES;
+    var utils = PIXI.utils;
+    var TilingSprite2dRenderer = (function (_super) {
+        __extends(TilingSprite2dRenderer, _super);
+        function TilingSprite2dRenderer(renderer) {
+            var _this = _super.call(this, renderer) || this;
+            _this.quad = new PIXI.QuadUv();
+            var uniforms = { globals: _this.renderer.globalUniforms };
+            _this.shader = PIXI.Shader.from(shaderVert, shaderFrag, uniforms);
+            _this.simpleShader = PIXI.Shader.from(shaderVert, shaderSimpleFrag, uniforms);
+            return _this;
+        }
+        TilingSprite2dRenderer.prototype.render = function (ts) {
+            var renderer = this.renderer;
+            var quad = this.quad;
+            var vertices = quad.vertices;
+            vertices[0] = vertices[6] = (ts._width) * -ts.anchor.x;
+            vertices[1] = vertices[3] = ts._height * -ts.anchor.y;
+            vertices[2] = vertices[4] = (ts._width) * (1.0 - ts.anchor.x);
+            vertices[5] = vertices[7] = ts._height * (1.0 - ts.anchor.y);
+            if (ts.uvRespectAnchor) {
+                vertices = quad.uvs;
+                vertices[0] = vertices[6] = -ts.anchor.x;
+                vertices[1] = vertices[3] = -ts.anchor.y;
+                vertices[2] = vertices[4] = 1.0 - ts.anchor.x;
+                vertices[5] = vertices[7] = 1.0 - ts.anchor.y;
+            }
+            quad.invalidate();
+            var tex = ts._texture;
+            var baseTex = tex.baseTexture;
+            var lt = ts.tileProj.world;
+            var uv = ts.uvMatrix;
+            var isSimple = baseTex.isPowerOfTwo
+                && tex.frame.width === baseTex.width && tex.frame.height === baseTex.height;
+            if (isSimple) {
+                if (!baseTex._glTextures[renderer.CONTEXT_UID]) {
+                    if (baseTex.wrapMode === WRAP_MODES.CLAMP) {
+                        baseTex.wrapMode = WRAP_MODES.REPEAT;
+                    }
+                }
+                else {
+                    isSimple = baseTex.wrapMode !== WRAP_MODES.CLAMP;
+                }
+            }
+            var shader = isSimple ? this.simpleShader : this.shader;
+            tempMat.identity();
+            tempMat.scale(tex.width, tex.height);
+            tempMat.prepend(lt);
+            tempMat.scale(1.0 / ts._width, 1.0 / ts._height);
+            tempMat.invert();
+            if (isSimple) {
+                tempMat.prepend(uv.mapCoord);
+            }
+            else {
+                shader.uniforms.uMapCoord = uv.mapCoord.toArray(true);
+                shader.uniforms.uClampFrame = uv.uClampFrame;
+                shader.uniforms.uClampOffset = uv.uClampOffset;
+            }
+            shader.uniforms.uTransform = tempMat.toArray(true);
+            shader.uniforms.uColor = utils.premultiplyTintToRgba(ts.tint, ts.worldAlpha, shader.uniforms.uColor, baseTex.premultiplyAlpha);
+            shader.uniforms.translationMatrix = ts.transform.worldTransform.toArray(true);
+            shader.uniforms.uSampler = tex;
+            renderer.shader.bind(shader, false);
+            renderer.geometry.bind(quad, undefined);
+            renderer.state.setBlendMode(utils.correctBlendMode(ts.blendMode, baseTex.premultiplyAlpha));
+            renderer.geometry.draw(PIXI.DRAW_MODES.TRIANGLES, 6, 0);
+        };
+        return TilingSprite2dRenderer;
+    }(PIXI.ObjectRenderer));
+    pixi_projection.TilingSprite2dRenderer = TilingSprite2dRenderer;
+    PIXI.Renderer.registerPlugin('tilingSprite2d', TilingSprite2dRenderer);
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    PIXI.systems.MaskSystem.prototype.pushSpriteMask = function (maskData) {
+        var maskObject = maskData.maskObject;
+        var target = maskData._target;
+        var alphaMaskFilter = this.alphaMaskPool[this.alphaMaskIndex];
+        if (!alphaMaskFilter) {
+            alphaMaskFilter = this.alphaMaskPool[this.alphaMaskIndex] = [new pixi_projection.SpriteMaskFilter2d(maskObject)];
+        }
+        alphaMaskFilter[0].resolution = this.renderer.resolution;
+        alphaMaskFilter[0].maskSprite = maskObject;
+        var stashFilterArea = target.filterArea;
+        target.filterArea = maskObject.getBounds(true);
+        this.renderer.filter.push(target, alphaMaskFilter);
+        target.filterArea = stashFilterArea;
+        this.alphaMaskIndex++;
+    };
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var spriteMaskVert = "\nattribute vec2 aVertexPosition;\nattribute vec2 aTextureCoord;\n\nuniform mat3 projectionMatrix;\nuniform mat3 otherMatrix;\n\nvarying vec3 vMaskCoord;\nvarying vec2 vTextureCoord;\n\nvoid main(void)\n{\n\tgl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);\n\n\tvTextureCoord = aTextureCoord;\n\tvMaskCoord = otherMatrix * vec3( aTextureCoord, 1.0);\n}\n";
+    var spriteMaskFrag = "\nvarying vec3 vMaskCoord;\nvarying vec2 vTextureCoord;\n\nuniform sampler2D uSampler;\nuniform sampler2D mask;\nuniform float alpha;\nuniform vec4 maskClamp;\n\nvoid main(void)\n{\n    vec2 uv = vMaskCoord.xy / vMaskCoord.z;\n\n    float clip = step(3.5,\n        step(maskClamp.x, uv.x) +\n        step(maskClamp.y, uv.y) +\n        step(uv.x, maskClamp.z) +\n        step(uv.y, maskClamp.w));\n\n    vec4 original = texture2D(uSampler, vTextureCoord);\n    vec4 masky = texture2D(mask, uv);\n\n    original *= (masky.r * masky.a * alpha * clip);\n\n    gl_FragColor = original;\n}\n";
+    var tempMat = new pixi_projection.Matrix2d();
+    var SpriteMaskFilter2d = (function (_super) {
+        __extends(SpriteMaskFilter2d, _super);
+        function SpriteMaskFilter2d(sprite) {
+            var _this = _super.call(this, spriteMaskVert, spriteMaskFrag) || this;
+            _this.maskMatrix = new pixi_projection.Matrix2d();
+            sprite.renderable = false;
+            _this.maskSprite = sprite;
+            return _this;
+        }
+        SpriteMaskFilter2d.prototype.apply = function (filterManager, input, output, clearMode) {
+            var maskSprite = this.maskSprite;
+            var tex = this.maskSprite.texture;
+            if (!tex.valid) {
+                return;
+            }
+            if (!tex.uvMatrix) {
+                tex.uvMatrix = new PIXI.TextureMatrix(tex, 0.0);
+            }
+            tex.uvMatrix.update();
+            this.uniforms.npmAlpha = tex.baseTexture.alphaMode ? 0.0 : 1.0;
+            this.uniforms.mask = maskSprite.texture;
+            this.uniforms.otherMatrix = SpriteMaskFilter2d.calculateSpriteMatrix(input, this.maskMatrix, maskSprite)
+                .prepend(tex.uvMatrix.mapCoord);
+            this.uniforms.alpha = maskSprite.worldAlpha;
+            this.uniforms.maskClamp = tex.uvMatrix.uClampFrame;
+            filterManager.applyFilter(this, input, output, clearMode);
+        };
+        SpriteMaskFilter2d.calculateSpriteMatrix = function (input, mappedMatrix, sprite) {
+            var proj = sprite.proj;
+            var filterArea = input.filterFrame;
+            var worldTransform = proj && !proj._affine ? proj.world.copyTo2dOr3d(tempMat) : tempMat.copyFrom(sprite.transform.worldTransform);
+            var texture = sprite.texture.orig;
+            mappedMatrix.set(input.width, 0, 0, input.height, filterArea.x, filterArea.y);
+            worldTransform.invert();
+            mappedMatrix.setToMult(worldTransform, mappedMatrix);
+            mappedMatrix.scaleAndTranslate(1.0 / texture.width, 1.0 / texture.height, sprite.anchor.x, sprite.anchor.y);
+            return mappedMatrix;
+        };
+        return SpriteMaskFilter2d;
+    }(PIXI.Filter));
+    pixi_projection.SpriteMaskFilter2d = SpriteMaskFilter2d;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    function container3dWorldTransform() {
+        return this.proj.affine ? this.transform.worldTransform : this.proj.world;
+    }
+    pixi_projection.container3dWorldTransform = container3dWorldTransform;
+    var Container3d = (function (_super) {
+        __extends(Container3d, _super);
+        function Container3d() {
+            var _this = _super.call(this) || this;
+            _this.proj = new pixi_projection.Projection3d(_this.transform);
+            return _this;
+        }
+        Container3d.prototype.isFrontFace = function (forceUpdate) {
+            if (forceUpdate === void 0) { forceUpdate = false; }
+            if (forceUpdate) {
+                this._recursivePostUpdateTransform();
+                this.displayObjectUpdateTransform();
+            }
+            var mat = this.proj.world.mat4;
+            var dx1 = mat[0] * mat[15] - mat[3] * mat[12];
+            var dy1 = mat[1] * mat[15] - mat[3] * mat[13];
+            var dx2 = mat[4] * mat[15] - mat[7] * mat[12];
+            var dy2 = mat[5] * mat[15] - mat[7] * mat[13];
+            return dx1 * dy2 - dx2 * dy1 > 0;
+        };
+        Container3d.prototype.getDepth = function (forceUpdate) {
+            if (forceUpdate === void 0) { forceUpdate = false; }
+            if (forceUpdate) {
+                this._recursivePostUpdateTransform();
+                this.displayObjectUpdateTransform();
+            }
+            var mat4 = this.proj.world.mat4;
+            return mat4[14] / mat4[15];
+        };
+        Container3d.prototype.toLocal = function (position, from, point, skipUpdate, step) {
+            if (step === void 0) { step = pixi_projection.TRANSFORM_STEP.ALL; }
+            if (from) {
+                position = from.toGlobal(position, point, skipUpdate);
+            }
+            if (!skipUpdate) {
+                this._recursivePostUpdateTransform();
+            }
+            if (step === pixi_projection.TRANSFORM_STEP.ALL) {
+                if (!skipUpdate) {
+                    this.displayObjectUpdateTransform();
+                }
+                if (this.proj.affine) {
+                    return this.transform.worldTransform.applyInverse(position, point);
+                }
+                return this.proj.world.applyInverse(position, point);
+            }
+            if (this.parent) {
+                point = this.parent.worldTransform.applyInverse(position, point);
+            }
+            else {
+                point.copyFrom(position);
+            }
+            if (step === pixi_projection.TRANSFORM_STEP.NONE) {
+                return point;
+            }
+            point = this.transform.localTransform.applyInverse(point, point);
+            if (step === pixi_projection.TRANSFORM_STEP.PROJ && this.proj.cameraMode) {
+                point = this.proj.cameraMatrix.applyInverse(point, point);
+            }
+            return point;
+        };
+        Object.defineProperty(Container3d.prototype, "worldTransform", {
+            get: function () {
+                return this.proj.affine ? this.transform.worldTransform : this.proj.world;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Container3d.prototype, "position3d", {
+            get: function () {
+                return this.proj.position;
+            },
+            set: function (value) {
+                this.proj.position.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Container3d.prototype, "scale3d", {
+            get: function () {
+                return this.proj.scale;
+            },
+            set: function (value) {
+                this.proj.scale.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Container3d.prototype, "euler", {
+            get: function () {
+                return this.proj.euler;
+            },
+            set: function (value) {
+                this.proj.euler.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Container3d.prototype, "pivot3d", {
+            get: function () {
+                return this.proj.pivot;
+            },
+            set: function (value) {
+                this.proj.pivot.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        return Container3d;
+    }(PIXI.Container));
+    pixi_projection.Container3d = Container3d;
+    pixi_projection.container3dToLocal = Container3d.prototype.toLocal;
+    pixi_projection.container3dGetDepth = Container3d.prototype.getDepth;
+    pixi_projection.container3dIsFrontFace = Container3d.prototype.isFrontFace;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var Camera3d = (function (_super) {
+        __extends(Camera3d, _super);
+        function Camera3d() {
+            var _this = _super.call(this) || this;
+            _this._far = 0;
+            _this._near = 0;
+            _this._focus = 0;
+            _this._orthographic = false;
+            _this.proj.cameraMode = true;
+            _this.setPlanes(400, 10, 10000, false);
+            return _this;
+        }
+        Object.defineProperty(Camera3d.prototype, "far", {
+            get: function () {
+                return this._far;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Camera3d.prototype, "near", {
+            get: function () {
+                return this._near;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Camera3d.prototype, "focus", {
+            get: function () {
+                return this._focus;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Camera3d.prototype, "ortographic", {
+            get: function () {
+                return this._orthographic;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Camera3d.prototype.setPlanes = function (focus, near, far, orthographic) {
+            if (near === void 0) { near = 10; }
+            if (far === void 0) { far = 10000; }
+            if (orthographic === void 0) { orthographic = false; }
+            this._focus = focus;
+            this._near = near;
+            this._far = far;
+            this._orthographic = orthographic;
+            var proj = this.proj;
+            var mat4 = proj.cameraMatrix.mat4;
+            proj._projID++;
+            mat4[10] = 1.0 / (far - near);
+            mat4[14] = (focus - near) / (far - near);
+            if (this._orthographic) {
+                mat4[11] = 0;
+            }
+            else {
+                mat4[11] = 1.0 / focus;
+            }
+        };
+        return Camera3d;
+    }(pixi_projection.Container3d));
+    pixi_projection.Camera3d = Camera3d;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var Euler = (function () {
+        function Euler(x, y, z) {
+            this._quatUpdateId = -1;
+            this._quatDirtyId = 0;
+            this._sign = 1;
+            this._x = x || 0;
+            this._y = y || 0;
+            this._z = z || 0;
+            this.quaternion = new Float64Array(4);
+            this.quaternion[3] = 1;
+            this.update();
+        }
+        Object.defineProperty(Euler.prototype, "x", {
+            get: function () {
+                return this._x;
+            },
+            set: function (value) {
+                if (this._x !== value) {
+                    this._x = value;
+                    this._quatDirtyId++;
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Euler.prototype, "y", {
+            get: function () {
+                return this._y;
+            },
+            set: function (value) {
+                if (this._y !== value) {
+                    this._y = value;
+                    this._quatDirtyId++;
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Euler.prototype, "z", {
+            get: function () {
+                return this._z;
+            },
+            set: function (value) {
+                if (this._z !== value) {
+                    this._z = value;
+                    this._quatDirtyId++;
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Euler.prototype, "pitch", {
+            get: function () {
+                return this._x;
+            },
+            set: function (value) {
+                if (this._x !== value) {
+                    this._x = value;
+                    this._quatDirtyId++;
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Euler.prototype, "yaw", {
+            get: function () {
+                return this._y;
+            },
+            set: function (value) {
+                if (this._y !== value) {
+                    this._y = value;
+                    this._quatDirtyId++;
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Euler.prototype, "roll", {
+            get: function () {
+                return this._z;
+            },
+            set: function (value) {
+                if (this._z !== value) {
+                    this._z = value;
+                    this._quatDirtyId++;
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Euler.prototype.set = function (x, y, z) {
+            var _x = x || 0;
+            var _y = y || 0;
+            var _z = z || 0;
+            if (this._x !== _x || this._y !== _y || this._z !== _z) {
+                this._x = _x;
+                this._y = _y;
+                this._z = _z;
+                this._quatDirtyId++;
+            }
+        };
+        ;
+        Euler.prototype.copyFrom = function (euler) {
+            var _x = euler.x;
+            var _y = euler.y;
+            var _z = euler.z;
+            if (this._x !== _x || this._y !== _y || this._z !== _z) {
+                this._x = _x;
+                this._y = _y;
+                this._z = _z;
+                this._quatDirtyId++;
+            }
+        };
+        Euler.prototype.copyTo = function (p) {
+            p.set(this._x, this._y, this._z);
+            return p;
+        };
+        Euler.prototype.equals = function (euler) {
+            return this._x === euler.x
+                && this._y === euler.y
+                && this._z === euler.z;
+        };
+        Euler.prototype.clone = function () {
+            return new Euler(this._x, this._y, this._z);
+        };
+        Euler.prototype.update = function () {
+            if (this._quatUpdateId === this._quatDirtyId) {
+                return false;
+            }
+            this._quatUpdateId = this._quatDirtyId;
+            var c1 = Math.cos(this._x / 2);
+            var c2 = Math.cos(this._y / 2);
+            var c3 = Math.cos(this._z / 2);
+            var s = this._sign;
+            var s1 = s * Math.sin(this._x / 2);
+            var s2 = s * Math.sin(this._y / 2);
+            var s3 = s * Math.sin(this._z / 2);
+            var q = this.quaternion;
+            q[0] = s1 * c2 * c3 + c1 * s2 * s3;
+            q[1] = c1 * s2 * c3 - s1 * c2 * s3;
+            q[2] = c1 * c2 * s3 + s1 * s2 * c3;
+            q[3] = c1 * c2 * c3 - s1 * s2 * s3;
+            return true;
+        };
+        return Euler;
+    }());
+    pixi_projection.Euler = Euler;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var mat4id = [1, 0, 0, 0,
+        0, 1, 0, 0,
+        0, 0, 1, 0,
+        0, 0, 0, 1];
+    var Matrix3d = (function () {
+        function Matrix3d(backingArray) {
+            this.floatArray = null;
+            this._dirtyId = 0;
+            this._updateId = -1;
+            this._mat4inv = null;
+            this.cacheInverse = false;
+            this.mat4 = new Float64Array(backingArray || mat4id);
+        }
+        Object.defineProperty(Matrix3d.prototype, "a", {
+            get: function () {
+                return this.mat4[0] / this.mat4[15];
+            },
+            set: function (value) {
+                this.mat4[0] = value * this.mat4[15];
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Matrix3d.prototype, "b", {
+            get: function () {
+                return this.mat4[1] / this.mat4[15];
+            },
+            set: function (value) {
+                this.mat4[1] = value * this.mat4[15];
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Matrix3d.prototype, "c", {
+            get: function () {
+                return this.mat4[4] / this.mat4[15];
+            },
+            set: function (value) {
+                this.mat4[4] = value * this.mat4[15];
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Matrix3d.prototype, "d", {
+            get: function () {
+                return this.mat4[5] / this.mat4[15];
+            },
+            set: function (value) {
+                this.mat4[5] = value * this.mat4[15];
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Matrix3d.prototype, "tx", {
+            get: function () {
+                return this.mat4[12] / this.mat4[15];
+            },
+            set: function (value) {
+                this.mat4[12] = value * this.mat4[15];
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Matrix3d.prototype, "ty", {
+            get: function () {
+                return this.mat4[13] / this.mat4[15];
+            },
+            set: function (value) {
+                this.mat4[13] = value * this.mat4[15];
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Matrix3d.prototype.set = function (a, b, c, d, tx, ty) {
+            var mat4 = this.mat4;
+            mat4[0] = a;
+            mat4[1] = b;
+            mat4[2] = 0;
+            mat4[3] = 0;
+            mat4[4] = c;
+            mat4[5] = d;
+            mat4[6] = 0;
+            mat4[7] = 0;
+            mat4[8] = 0;
+            mat4[9] = 0;
+            mat4[10] = 1;
+            mat4[11] = 0;
+            mat4[12] = tx;
+            mat4[13] = ty;
+            mat4[14] = 0;
+            mat4[15] = 1;
+            return this;
+        };
+        Matrix3d.prototype.toArray = function (transpose, out) {
+            if (!this.floatArray) {
+                this.floatArray = new Float32Array(9);
+            }
+            var array = out || this.floatArray;
+            var mat3 = this.mat4;
+            if (transpose) {
+                array[0] = mat3[0];
+                array[1] = mat3[1];
+                array[2] = mat3[3];
+                array[3] = mat3[4];
+                array[4] = mat3[5];
+                array[5] = mat3[7];
+                array[6] = mat3[12];
+                array[7] = mat3[13];
+                array[8] = mat3[15];
+            }
+            else {
+                array[0] = mat3[0];
+                array[1] = mat3[4];
+                array[2] = mat3[12];
+                array[3] = mat3[2];
+                array[4] = mat3[6];
+                array[5] = mat3[13];
+                array[6] = mat3[3];
+                array[7] = mat3[7];
+                array[8] = mat3[15];
+            }
+            return array;
+        };
+        Matrix3d.prototype.setToTranslation = function (tx, ty, tz) {
+            var mat4 = this.mat4;
+            mat4[0] = 1;
+            mat4[1] = 0;
+            mat4[2] = 0;
+            mat4[3] = 0;
+            mat4[4] = 0;
+            mat4[5] = 1;
+            mat4[6] = 0;
+            mat4[7] = 0;
+            mat4[8] = 0;
+            mat4[9] = 0;
+            mat4[10] = 1;
+            mat4[11] = 0;
+            mat4[12] = tx;
+            mat4[13] = ty;
+            mat4[14] = tz;
+            mat4[15] = 1;
+        };
+        Matrix3d.prototype.setToRotationTranslationScale = function (quat, tx, ty, tz, sx, sy, sz) {
+            var out = this.mat4;
+            var x = quat[0], y = quat[1], z = quat[2], w = quat[3];
+            var x2 = x + x;
+            var y2 = y + y;
+            var z2 = z + z;
+            var xx = x * x2;
+            var xy = x * y2;
+            var xz = x * z2;
+            var yy = y * y2;
+            var yz = y * z2;
+            var zz = z * z2;
+            var wx = w * x2;
+            var wy = w * y2;
+            var wz = w * z2;
+            out[0] = (1 - (yy + zz)) * sx;
+            out[1] = (xy + wz) * sx;
+            out[2] = (xz - wy) * sx;
+            out[3] = 0;
+            out[4] = (xy - wz) * sy;
+            out[5] = (1 - (xx + zz)) * sy;
+            out[6] = (yz + wx) * sy;
+            out[7] = 0;
+            out[8] = (xz + wy) * sz;
+            out[9] = (yz - wx) * sz;
+            out[10] = (1 - (xx + yy)) * sz;
+            out[11] = 0;
+            out[12] = tx;
+            out[13] = ty;
+            out[14] = tz;
+            out[15] = 1;
+            return out;
+        };
+        Matrix3d.prototype.apply = function (pos, newPos) {
+            newPos = newPos || new pixi_projection.Point3d();
+            var mat4 = this.mat4;
+            var x = pos.x;
+            var y = pos.y;
+            var z = pos.z || 0;
+            var w = 1.0 / (mat4[3] * x + mat4[7] * y + mat4[11] * z + mat4[15]);
+            newPos.x = w * (mat4[0] * x + mat4[4] * y + mat4[8] * z + mat4[12]);
+            newPos.y = w * (mat4[1] * x + mat4[5] * y + mat4[9] * z + mat4[13]);
+            newPos.z = w * (mat4[2] * x + mat4[6] * y + mat4[10] * z + mat4[14]);
+            return newPos;
+        };
+        Matrix3d.prototype.translate = function (tx, ty, tz) {
+            var a = this.mat4;
+            a[12] = a[0] * tx + a[4] * ty + a[8] * tz + a[12];
+            a[13] = a[1] * tx + a[5] * ty + a[9] * tz + a[13];
+            a[14] = a[2] * tx + a[6] * ty + a[10] * tz + a[14];
+            a[15] = a[3] * tx + a[7] * ty + a[11] * tz + a[15];
+            return this;
+        };
+        Matrix3d.prototype.scale = function (x, y, z) {
+            var mat4 = this.mat4;
+            mat4[0] *= x;
+            mat4[1] *= x;
+            mat4[2] *= x;
+            mat4[3] *= x;
+            mat4[4] *= y;
+            mat4[5] *= y;
+            mat4[6] *= y;
+            mat4[7] *= y;
+            if (z !== undefined) {
+                mat4[8] *= z;
+                mat4[9] *= z;
+                mat4[10] *= z;
+                mat4[11] *= z;
+            }
+            return this;
+        };
+        Matrix3d.prototype.scaleAndTranslate = function (scaleX, scaleY, scaleZ, tx, ty, tz) {
+            var mat4 = this.mat4;
+            mat4[0] = scaleX * mat4[0] + tx * mat4[3];
+            mat4[1] = scaleY * mat4[1] + ty * mat4[3];
+            mat4[2] = scaleZ * mat4[2] + tz * mat4[3];
+            mat4[4] = scaleX * mat4[4] + tx * mat4[7];
+            mat4[5] = scaleY * mat4[5] + ty * mat4[7];
+            mat4[6] = scaleZ * mat4[6] + tz * mat4[7];
+            mat4[8] = scaleX * mat4[8] + tx * mat4[11];
+            mat4[9] = scaleY * mat4[9] + ty * mat4[11];
+            mat4[10] = scaleZ * mat4[10] + tz * mat4[11];
+            mat4[12] = scaleX * mat4[12] + tx * mat4[15];
+            mat4[13] = scaleY * mat4[13] + ty * mat4[15];
+            mat4[14] = scaleZ * mat4[14] + tz * mat4[15];
+        };
+        Matrix3d.prototype.applyInverse = function (pos, newPos) {
+            newPos = newPos || new pixi_projection.Point3d();
+            if (!this._mat4inv) {
+                this._mat4inv = new Float64Array(16);
+            }
+            var mat4 = this._mat4inv;
+            var a = this.mat4;
+            var x = pos.x;
+            var y = pos.y;
+            var z = pos.z || 0;
+            if (!this.cacheInverse || this._updateId !== this._dirtyId) {
+                this._updateId = this._dirtyId;
+                Matrix3d.glMatrixMat4Invert(mat4, a);
+            }
+            var w1 = 1.0 / (mat4[3] * x + mat4[7] * y + mat4[11] * z + mat4[15]);
+            var x1 = w1 * (mat4[0] * x + mat4[4] * y + mat4[8] * z + mat4[12]);
+            var y1 = w1 * (mat4[1] * x + mat4[5] * y + mat4[9] * z + mat4[13]);
+            var z1 = w1 * (mat4[2] * x + mat4[6] * y + mat4[10] * z + mat4[14]);
+            z += 1.0;
+            var w2 = 1.0 / (mat4[3] * x + mat4[7] * y + mat4[11] * z + mat4[15]);
+            var x2 = w2 * (mat4[0] * x + mat4[4] * y + mat4[8] * z + mat4[12]);
+            var y2 = w2 * (mat4[1] * x + mat4[5] * y + mat4[9] * z + mat4[13]);
+            var z2 = w2 * (mat4[2] * x + mat4[6] * y + mat4[10] * z + mat4[14]);
+            if (Math.abs(z1 - z2) < 1e-10) {
+                newPos.set(NaN, NaN, 0);
+            }
+            var alpha = (0 - z1) / (z2 - z1);
+            newPos.set((x2 - x1) * alpha + x1, (y2 - y1) * alpha + y1, 0.0);
+            return newPos;
+        };
+        Matrix3d.prototype.invert = function () {
+            Matrix3d.glMatrixMat4Invert(this.mat4, this.mat4);
+            return this;
+        };
+        Matrix3d.prototype.invertCopyTo = function (matrix) {
+            if (!this._mat4inv) {
+                this._mat4inv = new Float64Array(16);
+            }
+            var mat4 = this._mat4inv;
+            var a = this.mat4;
+            if (!this.cacheInverse || this._updateId !== this._dirtyId) {
+                this._updateId = this._dirtyId;
+                Matrix3d.glMatrixMat4Invert(mat4, a);
+            }
+            matrix.mat4.set(mat4);
+        };
+        Matrix3d.prototype.identity = function () {
+            var mat3 = this.mat4;
+            mat3[0] = 1;
+            mat3[1] = 0;
+            mat3[2] = 0;
+            mat3[3] = 0;
+            mat3[4] = 0;
+            mat3[5] = 1;
+            mat3[6] = 0;
+            mat3[7] = 0;
+            mat3[8] = 0;
+            mat3[9] = 0;
+            mat3[10] = 1;
+            mat3[11] = 0;
+            mat3[12] = 0;
+            mat3[13] = 0;
+            mat3[14] = 0;
+            mat3[15] = 1;
+            return this;
+        };
+        Matrix3d.prototype.clone = function () {
+            return new Matrix3d(this.mat4);
+        };
+        Matrix3d.prototype.copyTo3d = function (matrix) {
+            var mat3 = this.mat4;
+            var ar2 = matrix.mat4;
+            ar2[0] = mat3[0];
+            ar2[1] = mat3[1];
+            ar2[2] = mat3[2];
+            ar2[3] = mat3[3];
+            ar2[4] = mat3[4];
+            ar2[5] = mat3[5];
+            ar2[6] = mat3[6];
+            ar2[7] = mat3[7];
+            ar2[8] = mat3[8];
+            return matrix;
+        };
+        Matrix3d.prototype.copyTo2d = function (matrix) {
+            var mat3 = this.mat4;
+            var ar2 = matrix.mat3;
+            ar2[0] = mat3[0];
+            ar2[1] = mat3[1];
+            ar2[2] = mat3[3];
+            ar2[3] = mat3[4];
+            ar2[4] = mat3[5];
+            ar2[5] = mat3[7];
+            ar2[6] = mat3[12];
+            ar2[7] = mat3[13];
+            ar2[8] = mat3[15];
+            return matrix;
+        };
+        Matrix3d.prototype.copyTo2dOr3d = function (matrix) {
+            if (matrix instanceof pixi_projection.Matrix2d) {
+                return this.copyTo2d(matrix);
+            }
+            else {
+                return this.copyTo3d(matrix);
+            }
+        };
+        Matrix3d.prototype.copyTo = function (matrix, affine, preserveOrientation) {
+            var mat3 = this.mat4;
+            var d = 1.0 / mat3[15];
+            var tx = mat3[12] * d, ty = mat3[13] * d;
+            matrix.a = (mat3[0] - mat3[3] * tx) * d;
+            matrix.b = (mat3[1] - mat3[3] * ty) * d;
+            matrix.c = (mat3[4] - mat3[7] * tx) * d;
+            matrix.d = (mat3[5] - mat3[7] * ty) * d;
+            matrix.tx = tx;
+            matrix.ty = ty;
+            if (affine >= 2) {
+                var D = matrix.a * matrix.d - matrix.b * matrix.c;
+                if (!preserveOrientation) {
+                    D = Math.abs(D);
+                }
+                if (affine === pixi_projection.AFFINE.POINT) {
+                    if (D > 0) {
+                        D = 1;
+                    }
+                    else
+                        D = -1;
+                    matrix.a = D;
+                    matrix.b = 0;
+                    matrix.c = 0;
+                    matrix.d = D;
+                }
+                else if (affine === pixi_projection.AFFINE.AXIS_X) {
+                    D /= Math.sqrt(matrix.b * matrix.b + matrix.d * matrix.d);
+                    matrix.c = 0;
+                    matrix.d = D;
+                }
+                else if (affine === pixi_projection.AFFINE.AXIS_Y) {
+                    D /= Math.sqrt(matrix.a * matrix.a + matrix.c * matrix.c);
+                    matrix.a = D;
+                    matrix.c = 0;
+                }
+            }
+            return matrix;
+        };
+        Matrix3d.prototype.copyFrom = function (matrix) {
+            var mat3 = this.mat4;
+            mat3[0] = matrix.a;
+            mat3[1] = matrix.b;
+            mat3[2] = 0;
+            mat3[3] = 0;
+            mat3[4] = matrix.c;
+            mat3[5] = matrix.d;
+            mat3[6] = 0;
+            mat3[7] = 0;
+            mat3[8] = 0;
+            mat3[9] = 0;
+            mat3[10] = 1;
+            mat3[11] = 0;
+            mat3[12] = matrix.tx;
+            mat3[13] = matrix.ty;
+            mat3[14] = 0;
+            mat3[15] = 1;
+            this._dirtyId++;
+            return this;
+        };
+        Matrix3d.prototype.setToMultLegacy = function (pt, lt) {
+            var out = this.mat4;
+            var b = lt.mat4;
+            var a00 = pt.a, a01 = pt.b, a10 = pt.c, a11 = pt.d, a30 = pt.tx, a31 = pt.ty;
+            var b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3];
+            out[0] = b0 * a00 + b1 * a10 + b3 * a30;
+            out[1] = b0 * a01 + b1 * a11 + b3 * a31;
+            out[2] = b2;
+            out[3] = b3;
+            b0 = b[4];
+            b1 = b[5];
+            b2 = b[6];
+            b3 = b[7];
+            out[4] = b0 * a00 + b1 * a10 + b3 * a30;
+            out[5] = b0 * a01 + b1 * a11 + b3 * a31;
+            out[6] = b2;
+            out[7] = b3;
+            b0 = b[8];
+            b1 = b[9];
+            b2 = b[10];
+            b3 = b[11];
+            out[8] = b0 * a00 + b1 * a10 + b3 * a30;
+            out[9] = b0 * a01 + b1 * a11 + b3 * a31;
+            out[10] = b2;
+            out[11] = b3;
+            b0 = b[12];
+            b1 = b[13];
+            b2 = b[14];
+            b3 = b[15];
+            out[12] = b0 * a00 + b1 * a10 + b3 * a30;
+            out[13] = b0 * a01 + b1 * a11 + b3 * a31;
+            out[14] = b2;
+            out[15] = b3;
+            this._dirtyId++;
+            return this;
+        };
+        Matrix3d.prototype.setToMultLegacy2 = function (pt, lt) {
+            var out = this.mat4;
+            var a = pt.mat4;
+            var a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3];
+            var a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7];
+            var b00 = lt.a, b01 = lt.b, b10 = lt.c, b11 = lt.d, b30 = lt.tx, b31 = lt.ty;
+            out[0] = b00 * a00 + b01 * a10;
+            out[1] = b00 * a01 + b01 * a11;
+            out[2] = b00 * a02 + b01 * a12;
+            out[3] = b00 * a03 + b01 * a13;
+            out[4] = b10 * a00 + b11 * a10;
+            out[5] = b10 * a01 + b11 * a11;
+            out[6] = b10 * a02 + b11 * a12;
+            out[7] = b10 * a03 + b11 * a13;
+            out[8] = a[8];
+            out[9] = a[9];
+            out[10] = a[10];
+            out[11] = a[11];
+            out[12] = b30 * a00 + b31 * a10 + a[12];
+            out[13] = b30 * a01 + b31 * a11 + a[13];
+            out[14] = b30 * a02 + b31 * a12 + a[14];
+            out[15] = b30 * a03 + b31 * a13 + a[15];
+            this._dirtyId++;
+            return this;
+        };
+        Matrix3d.prototype.setToMult = function (pt, lt) {
+            Matrix3d.glMatrixMat4Multiply(this.mat4, pt.mat4, lt.mat4);
+            this._dirtyId++;
+            return this;
+        };
+        Matrix3d.prototype.prepend = function (lt) {
+            if (lt.mat4) {
+                this.setToMult(lt, this);
+            }
+            else {
+                this.setToMultLegacy(lt, this);
+            }
+        };
+        Matrix3d.glMatrixMat4Invert = function (out, a) {
+            var a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3];
+            var a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7];
+            var a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11];
+            var a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15];
+            var b00 = a00 * a11 - a01 * a10;
+            var b01 = a00 * a12 - a02 * a10;
+            var b02 = a00 * a13 - a03 * a10;
+            var b03 = a01 * a12 - a02 * a11;
+            var b04 = a01 * a13 - a03 * a11;
+            var b05 = a02 * a13 - a03 * a12;
+            var b06 = a20 * a31 - a21 * a30;
+            var b07 = a20 * a32 - a22 * a30;
+            var b08 = a20 * a33 - a23 * a30;
+            var b09 = a21 * a32 - a22 * a31;
+            var b10 = a21 * a33 - a23 * a31;
+            var b11 = a22 * a33 - a23 * a32;
+            var det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
+            if (!det) {
+                return null;
+            }
+            det = 1.0 / det;
+            out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det;
+            out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det;
+            out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det;
+            out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det;
+            out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det;
+            out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det;
+            out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det;
+            out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det;
+            out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det;
+            out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det;
+            out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det;
+            out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det;
+            out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det;
+            out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det;
+            out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det;
+            out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det;
+            return out;
+        };
+        Matrix3d.glMatrixMat4Multiply = function (out, a, b) {
+            var a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3];
+            var a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7];
+            var a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11];
+            var a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15];
+            var b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3];
+            out[0] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
+            out[1] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
+            out[2] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
+            out[3] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
+            b0 = b[4];
+            b1 = b[5];
+            b2 = b[6];
+            b3 = b[7];
+            out[4] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
+            out[5] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
+            out[6] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
+            out[7] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
+            b0 = b[8];
+            b1 = b[9];
+            b2 = b[10];
+            b3 = b[11];
+            out[8] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
+            out[9] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
+            out[10] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
+            out[11] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
+            b0 = b[12];
+            b1 = b[13];
+            b2 = b[14];
+            b3 = b[15];
+            out[12] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
+            out[13] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
+            out[14] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
+            out[15] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
+            return out;
+        };
+        Matrix3d.IDENTITY = new Matrix3d();
+        Matrix3d.TEMP_MATRIX = new Matrix3d();
+        return Matrix3d;
+    }());
+    pixi_projection.Matrix3d = Matrix3d;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var ObservableEuler = (function () {
+        function ObservableEuler(cb, scope, x, y, z) {
+            this.cb = cb;
+            this.scope = scope;
+            this._quatUpdateId = -1;
+            this._quatDirtyId = 0;
+            this._sign = 1;
+            this._x = x || 0;
+            this._y = y || 0;
+            this._z = z || 0;
+            this.quaternion = new Float64Array(4);
+            this.quaternion[3] = 1;
+            this.update();
+        }
+        Object.defineProperty(ObservableEuler.prototype, "x", {
+            get: function () {
+                return this._x;
+            },
+            set: function (value) {
+                if (this._x !== value) {
+                    this._x = value;
+                    this._quatDirtyId++;
+                    this.cb.call(this.scope);
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(ObservableEuler.prototype, "y", {
+            get: function () {
+                return this._y;
+            },
+            set: function (value) {
+                if (this._y !== value) {
+                    this._y = value;
+                    this._quatDirtyId++;
+                    this.cb.call(this.scope);
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(ObservableEuler.prototype, "z", {
+            get: function () {
+                return this._z;
+            },
+            set: function (value) {
+                if (this._z !== value) {
+                    this._z = value;
+                    this._quatDirtyId++;
+                    this.cb.call(this.scope);
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(ObservableEuler.prototype, "pitch", {
+            get: function () {
+                return this._x;
+            },
+            set: function (value) {
+                if (this._x !== value) {
+                    this._x = value;
+                    this._quatDirtyId++;
+                    this.cb.call(this.scope);
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(ObservableEuler.prototype, "yaw", {
+            get: function () {
+                return this._y;
+            },
+            set: function (value) {
+                if (this._y !== value) {
+                    this._y = value;
+                    this._quatDirtyId++;
+                    this.cb.call(this.scope);
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(ObservableEuler.prototype, "roll", {
+            get: function () {
+                return this._z;
+            },
+            set: function (value) {
+                if (this._z !== value) {
+                    this._z = value;
+                    this._quatDirtyId++;
+                    this.cb.call(this.scope);
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        ObservableEuler.prototype.set = function (x, y, z) {
+            var _x = x || 0;
+            var _y = y || 0;
+            var _z = z || 0;
+            if (this._x !== _x || this._y !== _y || this._z !== _z) {
+                this._x = _x;
+                this._y = _y;
+                this._z = _z;
+                this._quatDirtyId++;
+                this.cb.call(this.scope);
+            }
+        };
+        ;
+        ObservableEuler.prototype.copyFrom = function (euler) {
+            var _x = euler.x;
+            var _y = euler.y;
+            var _z = euler.z;
+            if (this._x !== _x || this._y !== _y || this._z !== _z) {
+                this._x = _x;
+                this._y = _y;
+                this._z = _z;
+                this._quatDirtyId++;
+                this.cb.call(this.scope);
+            }
+        };
+        ObservableEuler.prototype.copyTo = function (p) {
+            p.set(this._x, this._y, this._z);
+            return p;
+        };
+        ObservableEuler.prototype.equals = function (euler) {
+            return this._x === euler.x
+                && this._y === euler.y
+                && this._z === euler.z;
+        };
+        ObservableEuler.prototype.clone = function () {
+            return new pixi_projection.Euler(this._x, this._y, this._z);
+        };
+        ObservableEuler.prototype.update = function () {
+            if (this._quatUpdateId === this._quatDirtyId) {
+                return false;
+            }
+            this._quatUpdateId = this._quatDirtyId;
+            var c1 = Math.cos(this._x / 2);
+            var c2 = Math.cos(this._y / 2);
+            var c3 = Math.cos(this._z / 2);
+            var s = this._sign;
+            var s1 = s * Math.sin(this._x / 2);
+            var s2 = s * Math.sin(this._y / 2);
+            var s3 = s * Math.sin(this._z / 2);
+            var q = this.quaternion;
+            q[0] = s1 * c2 * c3 + c1 * s2 * s3;
+            q[1] = c1 * s2 * c3 - s1 * c2 * s3;
+            q[2] = c1 * c2 * s3 + s1 * s2 * c3;
+            q[3] = c1 * c2 * c3 - s1 * s2 * s3;
+            return true;
+        };
+        return ObservableEuler;
+    }());
+    pixi_projection.ObservableEuler = ObservableEuler;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var Point3d = (function (_super) {
+        __extends(Point3d, _super);
+        function Point3d(x, y, z) {
+            var _this = _super.call(this, x, y) || this;
+            _this.z = z;
+            return _this;
+        }
+        Point3d.prototype.set = function (x, y, z) {
+            this.x = x || 0;
+            this.y = (y === undefined) ? this.x : (y || 0);
+            this.z = (y === undefined) ? this.x : (z || 0);
+            return this;
+        };
+        Point3d.prototype.copyFrom = function (p) {
+            this.set(p.x, p.y, p.z || 0);
+            return this;
+        };
+        Point3d.prototype.copyTo = function (p) {
+            p.set(this.x, this.y, this.z);
+            return p;
+        };
+        return Point3d;
+    }(PIXI.Point));
+    pixi_projection.Point3d = Point3d;
+    var ObservablePoint3d = (function (_super) {
+        __extends(ObservablePoint3d, _super);
+        function ObservablePoint3d() {
+            var _this = _super !== null && _super.apply(this, arguments) || this;
+            _this._z = 0;
+            return _this;
+        }
+        Object.defineProperty(ObservablePoint3d.prototype, "z", {
+            get: function () {
+                return this._z;
+            },
+            set: function (value) {
+                if (this._z !== value) {
+                    this._z = value;
+                    this.cb.call(this.scope);
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        ObservablePoint3d.prototype.set = function (x, y, z) {
+            var _x = x || 0;
+            var _y = (y === undefined) ? _x : (y || 0);
+            var _z = (y === undefined) ? _x : (z || 0);
+            if (this._x !== _x || this._y !== _y || this._z !== _z) {
+                this._x = _x;
+                this._y = _y;
+                this._z = _z;
+                this.cb.call(this.scope);
+            }
+            return this;
+        };
+        ObservablePoint3d.prototype.copyFrom = function (p) {
+            this.set(p.x, p.y, p.z || 0);
+            return this;
+        };
+        ObservablePoint3d.prototype.copyTo = function (p) {
+            p.set(this._x, this._y, this._z);
+            return p;
+        };
+        return ObservablePoint3d;
+    }(PIXI.ObservablePoint));
+    pixi_projection.ObservablePoint3d = ObservablePoint3d;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var tempMat = new pixi_projection.Matrix3d();
+    var Projection3d = (function (_super) {
+        __extends(Projection3d, _super);
+        function Projection3d(legacy, enable) {
+            var _this = _super.call(this, legacy, enable) || this;
+            _this.cameraMatrix = null;
+            _this._cameraMode = false;
+            _this.position = new pixi_projection.ObservablePoint3d(_this.onChange, _this, 0, 0);
+            _this.scale = new pixi_projection.ObservablePoint3d(_this.onChange, _this, 1, 1);
+            _this.euler = new pixi_projection.ObservableEuler(_this.onChange, _this, 0, 0, 0);
+            _this.pivot = new pixi_projection.ObservablePoint3d(_this.onChange, _this, 0, 0);
+            _this.local = new pixi_projection.Matrix3d();
+            _this.world = new pixi_projection.Matrix3d();
+            _this.local.cacheInverse = true;
+            _this.world.cacheInverse = true;
+            _this.position._z = 0;
+            _this.scale._z = 1;
+            _this.pivot._z = 0;
+            return _this;
+        }
+        Object.defineProperty(Projection3d.prototype, "cameraMode", {
+            get: function () {
+                return this._cameraMode;
+            },
+            set: function (value) {
+                if (this._cameraMode === value) {
+                    return;
+                }
+                this._cameraMode = value;
+                this.euler._sign = this._cameraMode ? -1 : 1;
+                this.euler._quatDirtyId++;
+                if (value) {
+                    this.cameraMatrix = new pixi_projection.Matrix3d();
+                }
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Projection3d.prototype.onChange = function () {
+            this._projID++;
+        };
+        Projection3d.prototype.clear = function () {
+            if (this.cameraMatrix) {
+                this.cameraMatrix.identity();
+            }
+            this.position.set(0, 0, 0);
+            this.scale.set(1, 1, 1);
+            this.euler.set(0, 0, 0);
+            this.pivot.set(0, 0, 0);
+            _super.prototype.clear.call(this);
+        };
+        Projection3d.prototype.updateLocalTransform = function (lt) {
+            if (this._projID === 0) {
+                this.local.copyFrom(lt);
+                return;
+            }
+            var matrix = this.local;
+            var euler = this.euler;
+            var pos = this.position;
+            var scale = this.scale;
+            var pivot = this.pivot;
+            euler.update();
+            if (!this.cameraMode) {
+                matrix.setToRotationTranslationScale(euler.quaternion, pos._x, pos._y, pos._z, scale._x, scale._y, scale._z);
+                matrix.translate(-pivot._x, -pivot._y, -pivot._z);
+                matrix.setToMultLegacy(lt, matrix);
+                return;
+            }
+            matrix.setToMultLegacy(lt, this.cameraMatrix);
+            matrix.translate(pivot._x, pivot._y, pivot._z);
+            matrix.scale(1.0 / scale._x, 1.0 / scale._y, 1.0 / scale._z);
+            tempMat.setToRotationTranslationScale(euler.quaternion, 0, 0, 0, 1, 1, 1);
+            matrix.setToMult(matrix, tempMat);
+            matrix.translate(-pos._x, -pos._y, -pos._z);
+            this.local._dirtyId++;
+        };
+        return Projection3d;
+    }(pixi_projection.LinearProjection));
+    pixi_projection.Projection3d = Projection3d;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var Mesh3d2d = (function (_super) {
+        __extends(Mesh3d2d, _super);
+        function Mesh3d2d(geometry, shader, state, drawMode) {
+            var _this = _super.call(this, geometry, shader, state, drawMode) || this;
+            _this.vertexData2d = null;
+            _this.proj = new pixi_projection.Projection3d(_this.transform);
+            return _this;
+        }
+        Mesh3d2d.prototype.calculateVertices = function () {
+            if (this.proj._affine) {
+                this.vertexData2d = null;
+                _super.prototype.calculateVertices.call(this);
+                return;
+            }
+            var geometry = this.geometry;
+            var vertices = geometry.buffers[0].data;
+            var thisAny = this;
+            if (geometry.vertexDirtyId === thisAny.vertexDirty && thisAny._transformID === thisAny.transform._worldID) {
+                return;
+            }
+            thisAny._transformID = thisAny.transform._worldID;
+            if (thisAny.vertexData.length !== vertices.length) {
+                thisAny.vertexData = new Float32Array(vertices.length);
+            }
+            if (!this.vertexData2d || this.vertexData2d.length !== vertices.length * 3 / 2) {
+                this.vertexData2d = new Float32Array(vertices.length * 3);
+            }
+            var wt = this.proj.world.mat4;
+            var vertexData2d = this.vertexData2d;
+            var vertexData = thisAny.vertexData;
+            for (var i = 0; i < vertexData.length / 2; i++) {
+                var x = vertices[(i * 2)];
+                var y = vertices[(i * 2) + 1];
+                var xx = (wt[0] * x) + (wt[4] * y) + wt[12];
+                var yy = (wt[1] * x) + (wt[5] * y) + wt[13];
+                var ww = (wt[3] * x) + (wt[7] * y) + wt[15];
+                vertexData2d[i * 3] = xx;
+                vertexData2d[i * 3 + 1] = yy;
+                vertexData2d[i * 3 + 2] = ww;
+                vertexData[(i * 2)] = xx / ww;
+                vertexData[(i * 2) + 1] = yy / ww;
+            }
+            thisAny.vertexDirty = geometry.vertexDirtyId;
+        };
+        Object.defineProperty(Mesh3d2d.prototype, "worldTransform", {
+            get: function () {
+                return this.proj.affine ? this.transform.worldTransform : this.proj.world;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Mesh3d2d.prototype.toLocal = function (position, from, point, skipUpdate, step) {
+            if (step === void 0) { step = pixi_projection.TRANSFORM_STEP.ALL; }
+            return pixi_projection.container3dToLocal.call(this, position, from, point, skipUpdate, step);
+        };
+        Mesh3d2d.prototype.isFrontFace = function (forceUpdate) {
+            return pixi_projection.container3dIsFrontFace.call(this, forceUpdate);
+        };
+        Mesh3d2d.prototype.getDepth = function (forceUpdate) {
+            return pixi_projection.container3dGetDepth.call(this, forceUpdate);
+        };
+        Object.defineProperty(Mesh3d2d.prototype, "position3d", {
+            get: function () {
+                return this.proj.position;
+            },
+            set: function (value) {
+                this.proj.position.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Mesh3d2d.prototype, "scale3d", {
+            get: function () {
+                return this.proj.scale;
+            },
+            set: function (value) {
+                this.proj.scale.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Mesh3d2d.prototype, "euler", {
+            get: function () {
+                return this.proj.euler;
+            },
+            set: function (value) {
+                this.proj.euler.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Mesh3d2d.prototype, "pivot3d", {
+            get: function () {
+                return this.proj.pivot;
+            },
+            set: function (value) {
+                this.proj.pivot.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        return Mesh3d2d;
+    }(PIXI.Mesh));
+    pixi_projection.Mesh3d2d = Mesh3d2d;
+    Mesh3d2d.prototype._renderDefault = pixi_projection.Mesh2d.prototype._renderDefault;
+    var SimpleMesh3d2d = (function (_super) {
+        __extends(SimpleMesh3d2d, _super);
+        function SimpleMesh3d2d(texture, vertices, uvs, indices, drawMode) {
+            var _this = _super.call(this, new PIXI.MeshGeometry(vertices, uvs, indices), new PIXI.MeshMaterial(texture, {
+                program: PIXI.Program.from(pixi_projection.Mesh2d.defaultVertexShader, pixi_projection.Mesh2d.defaultFragmentShader),
+                pluginName: 'batch2d'
+            }), null, drawMode) || this;
+            _this.autoUpdate = true;
+            _this.geometry.getBuffer('aVertexPosition').static = false;
+            return _this;
+        }
+        Object.defineProperty(SimpleMesh3d2d.prototype, "vertices", {
+            get: function () {
+                return this.geometry.getBuffer('aVertexPosition').data;
+            },
+            set: function (value) {
+                this.geometry.getBuffer('aVertexPosition').data = value;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        SimpleMesh3d2d.prototype._render = function (renderer) {
+            if (this.autoUpdate) {
+                this.geometry.getBuffer('aVertexPosition').update();
+            }
+            _super.prototype._render.call(this, renderer);
+        };
+        return SimpleMesh3d2d;
+    }(Mesh3d2d));
+    pixi_projection.SimpleMesh3d2d = SimpleMesh3d2d;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var Sprite3d = (function (_super) {
+        __extends(Sprite3d, _super);
+        function Sprite3d(texture) {
+            var _this = _super.call(this, texture) || this;
+            _this.vertexData2d = null;
+            _this.culledByFrustrum = false;
+            _this.trimmedCulledByFrustrum = false;
+            _this.proj = new pixi_projection.Projection3d(_this.transform);
+            _this.pluginName = 'batch2d';
+            return _this;
+        }
+        Sprite3d.prototype.calculateVertices = function () {
+            var texture = this._texture;
+            if (this.proj._affine) {
+                this.vertexData2d = null;
+                _super.prototype.calculateVertices.call(this);
+                return;
+            }
+            if (!this.vertexData2d) {
+                this.vertexData2d = new Float32Array(12);
+            }
+            var wid = this.transform._worldID;
+            var tuid = texture._updateID;
+            if (this._transformID === wid && this._textureID === tuid) {
+                return;
+            }
+            if (this._textureID !== tuid) {
+                this.uvs = texture._uvs.uvsFloat32;
+            }
+            this._transformID = wid;
+            this._textureID = tuid;
+            var wt = this.proj.world.mat4;
+            var vertexData2d = this.vertexData2d;
+            var vertexData = this.vertexData;
+            var trim = texture.trim;
+            var orig = texture.orig;
+            var anchor = this._anchor;
+            var w0 = 0;
+            var w1 = 0;
+            var h0 = 0;
+            var h1 = 0;
+            if (trim) {
+                w1 = trim.x - (anchor._x * orig.width);
+                w0 = w1 + trim.width;
+                h1 = trim.y - (anchor._y * orig.height);
+                h0 = h1 + trim.height;
+            }
+            else {
+                w1 = -anchor._x * orig.width;
+                w0 = w1 + orig.width;
+                h1 = -anchor._y * orig.height;
+                h0 = h1 + orig.height;
+            }
+            var culled = false;
+            var z;
+            vertexData2d[0] = (wt[0] * w1) + (wt[4] * h1) + wt[12];
+            vertexData2d[1] = (wt[1] * w1) + (wt[5] * h1) + wt[13];
+            z = (wt[2] * w1) + (wt[6] * h1) + wt[14];
+            vertexData2d[2] = (wt[3] * w1) + (wt[7] * h1) + wt[15];
+            culled = culled || z < 0;
+            vertexData2d[3] = (wt[0] * w0) + (wt[4] * h1) + wt[12];
+            vertexData2d[4] = (wt[1] * w0) + (wt[5] * h1) + wt[13];
+            z = (wt[2] * w0) + (wt[6] * h1) + wt[14];
+            vertexData2d[5] = (wt[3] * w0) + (wt[7] * h1) + wt[15];
+            culled = culled || z < 0;
+            vertexData2d[6] = (wt[0] * w0) + (wt[4] * h0) + wt[12];
+            vertexData2d[7] = (wt[1] * w0) + (wt[5] * h0) + wt[13];
+            z = (wt[2] * w0) + (wt[6] * h0) + wt[14];
+            vertexData2d[8] = (wt[3] * w0) + (wt[7] * h0) + wt[15];
+            culled = culled || z < 0;
+            vertexData2d[9] = (wt[0] * w1) + (wt[4] * h0) + wt[12];
+            vertexData2d[10] = (wt[1] * w1) + (wt[5] * h0) + wt[13];
+            z = (wt[2] * w1) + (wt[6] * h0) + wt[14];
+            vertexData2d[11] = (wt[3] * w1) + (wt[7] * h0) + wt[15];
+            culled = culled || z < 0;
+            this.culledByFrustrum = culled;
+            vertexData[0] = vertexData2d[0] / vertexData2d[2];
+            vertexData[1] = vertexData2d[1] / vertexData2d[2];
+            vertexData[2] = vertexData2d[3] / vertexData2d[5];
+            vertexData[3] = vertexData2d[4] / vertexData2d[5];
+            vertexData[4] = vertexData2d[6] / vertexData2d[8];
+            vertexData[5] = vertexData2d[7] / vertexData2d[8];
+            vertexData[6] = vertexData2d[9] / vertexData2d[11];
+            vertexData[7] = vertexData2d[10] / vertexData2d[11];
+        };
+        Sprite3d.prototype.calculateTrimmedVertices = function () {
+            if (this.proj._affine) {
+                _super.prototype.calculateTrimmedVertices.call(this);
+                return;
+            }
+            var wid = this.transform._worldID;
+            var tuid = this._texture._updateID;
+            if (!this.vertexTrimmedData) {
+                this.vertexTrimmedData = new Float32Array(8);
+            }
+            else if (this._transformTrimmedID === wid && this._textureTrimmedID === tuid) {
+                return;
+            }
+            this._transformTrimmedID = wid;
+            this._textureTrimmedID = tuid;
+            var texture = this._texture;
+            var vertexData = this.vertexTrimmedData;
+            var orig = texture.orig;
+            var anchor = this._anchor;
+            var wt = this.proj.world.mat4;
+            var w1 = -anchor._x * orig.width;
+            var w0 = w1 + orig.width;
+            var h1 = -anchor._y * orig.height;
+            var h0 = h1 + orig.height;
+            var culled = false;
+            var z;
+            var w = 1.0 / ((wt[3] * w1) + (wt[7] * h1) + wt[15]);
+            vertexData[0] = w * ((wt[0] * w1) + (wt[4] * h1) + wt[12]);
+            vertexData[1] = w * ((wt[1] * w1) + (wt[5] * h1) + wt[13]);
+            z = (wt[2] * w1) + (wt[6] * h1) + wt[14];
+            culled = culled || z < 0;
+            w = 1.0 / ((wt[3] * w0) + (wt[7] * h1) + wt[15]);
+            vertexData[2] = w * ((wt[0] * w0) + (wt[4] * h1) + wt[12]);
+            vertexData[3] = w * ((wt[1] * w0) + (wt[5] * h1) + wt[13]);
+            z = (wt[2] * w0) + (wt[6] * h1) + wt[14];
+            culled = culled || z < 0;
+            w = 1.0 / ((wt[3] * w0) + (wt[7] * h0) + wt[15]);
+            vertexData[4] = w * ((wt[0] * w0) + (wt[4] * h0) + wt[12]);
+            vertexData[5] = w * ((wt[1] * w0) + (wt[5] * h0) + wt[13]);
+            z = (wt[2] * w0) + (wt[6] * h0) + wt[14];
+            culled = culled || z < 0;
+            w = 1.0 / ((wt[3] * w1) + (wt[7] * h0) + wt[15]);
+            vertexData[6] = w * ((wt[0] * w1) + (wt[4] * h0) + wt[12]);
+            vertexData[7] = w * ((wt[1] * w1) + (wt[5] * h0) + wt[13]);
+            z = (wt[2] * w1) + (wt[6] * h0) + wt[14];
+            culled = culled || z < 0;
+            this.culledByFrustrum = culled;
+        };
+        Sprite3d.prototype._calculateBounds = function () {
+            this.calculateVertices();
+            if (this.culledByFrustrum) {
+                return;
+            }
+            var trim = this._texture.trim;
+            var orig = this._texture.orig;
+            if (!trim || (trim.width === orig.width && trim.height === orig.height)) {
+                this._bounds.addQuad(this.vertexData);
+                return;
+            }
+            this.calculateTrimmedVertices();
+            if (!this.trimmedCulledByFrustrum) {
+                this._bounds.addQuad(this.vertexTrimmedData);
+            }
+        };
+        Sprite3d.prototype._render = function (renderer) {
+            this.calculateVertices();
+            if (this.culledByFrustrum) {
+                return;
+            }
+            renderer.batch.setObjectRenderer(renderer.plugins[this.pluginName]);
+            renderer.plugins[this.pluginName].render(this);
+        };
+        Sprite3d.prototype.containsPoint = function (point) {
+            if (this.culledByFrustrum) {
+                return false;
+            }
+            return _super.prototype.containsPoint.call(this, point);
+        };
+        Object.defineProperty(Sprite3d.prototype, "worldTransform", {
+            get: function () {
+                return this.proj.affine ? this.transform.worldTransform : this.proj.world;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Sprite3d.prototype.toLocal = function (position, from, point, skipUpdate, step) {
+            if (step === void 0) { step = pixi_projection.TRANSFORM_STEP.ALL; }
+            return pixi_projection.container3dToLocal.call(this, position, from, point, skipUpdate, step);
+        };
+        Sprite3d.prototype.isFrontFace = function (forceUpdate) {
+            return pixi_projection.container3dIsFrontFace.call(this, forceUpdate);
+        };
+        Sprite3d.prototype.getDepth = function (forceUpdate) {
+            return pixi_projection.container3dGetDepth.call(this, forceUpdate);
+        };
+        Object.defineProperty(Sprite3d.prototype, "position3d", {
+            get: function () {
+                return this.proj.position;
+            },
+            set: function (value) {
+                this.proj.position.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Sprite3d.prototype, "scale3d", {
+            get: function () {
+                return this.proj.scale;
+            },
+            set: function (value) {
+                this.proj.scale.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Sprite3d.prototype, "euler", {
+            get: function () {
+                return this.proj.euler;
+            },
+            set: function (value) {
+                this.proj.euler.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Sprite3d.prototype, "pivot3d", {
+            get: function () {
+                return this.proj.pivot;
+            },
+            set: function (value) {
+                this.proj.pivot.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        return Sprite3d;
+    }(PIXI.Sprite));
+    pixi_projection.Sprite3d = Sprite3d;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var Text3d = (function (_super) {
+        __extends(Text3d, _super);
+        function Text3d(text, style, canvas) {
+            var _this = _super.call(this, text, style, canvas) || this;
+            _this.vertexData2d = null;
+            _this.proj = new pixi_projection.Projection3d(_this.transform);
+            _this.pluginName = 'batch2d';
+            return _this;
+        }
+        Object.defineProperty(Text3d.prototype, "worldTransform", {
+            get: function () {
+                return this.proj.affine ? this.transform.worldTransform : this.proj.world;
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Text3d.prototype.toLocal = function (position, from, point, skipUpdate, step) {
+            if (step === void 0) { step = pixi_projection.TRANSFORM_STEP.ALL; }
+            return pixi_projection.container3dToLocal.call(this, position, from, point, skipUpdate, step);
+        };
+        Text3d.prototype.isFrontFace = function (forceUpdate) {
+            return pixi_projection.container3dIsFrontFace.call(this, forceUpdate);
+        };
+        Text3d.prototype.getDepth = function (forceUpdate) {
+            return pixi_projection.container3dGetDepth.call(this, forceUpdate);
+        };
+        Object.defineProperty(Text3d.prototype, "position3d", {
+            get: function () {
+                return this.proj.position;
+            },
+            set: function (value) {
+                this.proj.position.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Text3d.prototype, "scale3d", {
+            get: function () {
+                return this.proj.scale;
+            },
+            set: function (value) {
+                this.proj.scale.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Text3d.prototype, "euler", {
+            get: function () {
+                return this.proj.euler;
+            },
+            set: function (value) {
+                this.proj.euler.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        Object.defineProperty(Text3d.prototype, "pivot3d", {
+            get: function () {
+                return this.proj.pivot;
+            },
+            set: function (value) {
+                this.proj.pivot.copyFrom(value);
+            },
+            enumerable: false,
+            configurable: true
+        });
+        return Text3d;
+    }(PIXI.Text));
+    pixi_projection.Text3d = Text3d;
+    Text3d.prototype.calculateVertices = pixi_projection.Sprite3d.prototype.calculateVertices;
+    Text3d.prototype.calculateTrimmedVertices = pixi_projection.Sprite3d.prototype.calculateTrimmedVertices;
+    Text3d.prototype._calculateBounds = pixi_projection.Sprite3d.prototype._calculateBounds;
+    Text3d.prototype.containsPoint = pixi_projection.Sprite3d.prototype.containsPoint;
+    Text3d.prototype._render = pixi_projection.Sprite3d.prototype._render;
+})(pixi_projection || (pixi_projection = {}));
+var pixi_projection;
+(function (pixi_projection) {
+    var containerProps = {
+        worldTransform: {
+            get: pixi_projection.container3dWorldTransform,
+            enumerable: true,
+            configurable: true
+        },
+        position3d: {
+            get: function () { return this.proj.position; },
+            set: function (value) { this.proj.position.copy(value); }
+        },
+        scale3d: {
+            get: function () { return this.proj.scale; },
+            set: function (value) { this.proj.scale.copy(value); }
+        },
+        pivot3d: {
+            get: function () { return this.proj.pivot; },
+            set: function (value) { this.proj.pivot.copy(value); }
+        },
+        euler: {
+            get: function () { return this.proj.euler; },
+            set: function (value) { this.proj.euler.copy(value); }
+        }
+    };
+    function convertTo3d() {
+        if (this.proj)
+            return;
+        this.proj = new pixi_projection.Projection3d(this.transform);
+        this.toLocal = pixi_projection.Container3d.prototype.toLocal;
+        this.isFrontFace = pixi_projection.Container3d.prototype.isFrontFace;
+        this.getDepth = pixi_projection.Container3d.prototype.getDepth;
+        Object.defineProperties(this, containerProps);
+    }
+    PIXI.Container.prototype.convertTo3d = convertTo3d;
+    PIXI.Sprite.prototype.convertTo3d = function () {
+        if (this.proj)
+            return;
+        this.calculateVertices = pixi_projection.Sprite3d.prototype.calculateVertices;
+        this.calculateTrimmedVertices = pixi_projection.Sprite3d.prototype.calculateTrimmedVertices;
+        this._calculateBounds = pixi_projection.Sprite3d.prototype._calculateBounds;
+        this.containsPoint = pixi_projection.Sprite3d.prototype.containsPoint;
+        this.pluginName = 'batch2d';
+        convertTo3d.call(this);
+    };
+    PIXI.Container.prototype.convertSubtreeTo3d = function () {
+        this.convertTo3d();
+        for (var i = 0; i < this.children.length; i++) {
+            this.children[i].convertSubtreeTo3d();
+        }
+    };
+    if (PIXI.SimpleMesh) {
+        PIXI.SimpleMesh.prototype.convertTo3d =
+            PIXI.SimpleRope.prototype.convertTo3d =
+                function () {
+                    if (this.proj)
+                        return;
+                    this.calculateVertices = pixi_projection.Mesh3d2d.prototype.calculateVertices;
+                    this._renderDefault = pixi_projection.Mesh3d2d.prototype._renderDefault;
+                    if (this.material.pluginName !== 'batch2d') {
+                        this.material = new PIXI.MeshMaterial(this.material.texture, {
+                            program: PIXI.Program.from(pixi_projection.Mesh2d.defaultVertexShader, pixi_projection.Mesh2d.defaultFragmentShader),
+                            pluginName: 'batch2d'
+                        });
+                    }
+                    convertTo3d.call(this);
+                };
+    }
+})(pixi_projection || (pixi_projection = {}));
\ No newline at end of file
diff --git a/app/data/ct.libs/3d/index.js b/app/data/ct.libs/3d/index.js
new file mode 100644
index 000000000..6c05fb964
--- /dev/null
+++ b/app/data/ct.libs/3d/index.js
@@ -0,0 +1 @@
+ct.camera3d = null;
diff --git a/app/data/ct.libs/3d/injects/beforedraw.js b/app/data/ct.libs/3d/injects/beforedraw.js
new file mode 100644
index 000000000..c6fd3f13d
--- /dev/null
+++ b/app/data/ct.libs/3d/injects/beforedraw.js
@@ -0,0 +1,5 @@
+if (this.threeDEnabled && this.threeDOrientation === 'faceCamera') {
+    this.euler.x = ct.camera3d.euler.x;
+    this.euler.y = ct.camera3d.euler.y;
+    this.euler.z = ct.camera3d.euler.z;
+}
diff --git a/app/data/ct.libs/3d/injects/beforeroomdraw.js b/app/data/ct.libs/3d/injects/beforeroomdraw.js
new file mode 100644
index 000000000..fa592500d
--- /dev/null
+++ b/app/data/ct.libs/3d/injects/beforeroomdraw.js
@@ -0,0 +1,16 @@
+if (this === ct.room) {
+    for (const child of ct.camera3d.room.children) {
+        child.distanceToCamera = child.getDepth();
+    }
+    ct.camera3d.room.children.sort(function sortByDepth(a, b) {
+        return b.distanceToCamera - a.distanceToCamera;
+    });
+    if (ct.camera3d.follow) {
+        const {follow} = ct.camera3d;
+        ct.camera3d.position3d.set(
+            follow.x + ct.camera3d.shiftX,
+            follow.y + ct.camera3d.shiftY,
+            follow.z + ct.camera3d.shiftZ
+        );
+    }
+}
diff --git a/app/data/ct.libs/3d/injects/beforeroomoncreate.js b/app/data/ct.libs/3d/injects/beforeroomoncreate.js
new file mode 100644
index 000000000..51144b1ed
--- /dev/null
+++ b/app/data/ct.libs/3d/injects/beforeroomoncreate.js
@@ -0,0 +1,15 @@
+if (ct.camera3d) {
+    ct.camera3d.destroy();
+}
+ct.camera3d = new PIXI.projection.Camera3d();
+// Disable alignment by 2D camera
+ct.camera3d.isUi = true;
+ct.camera3d.position.set(ct.camera.width / 2, ct.camera.height / 2);
+ct.camera3d.position3d.set(ct.camera.width / 2, ct.camera.height / 2, ct.room.threeDCameraZ);
+ct.camera3d.follow = null;
+ct.camera3d.shiftX = ct.camera3d.shiftY = ct.camera3d.shiftY = 0;
+console.log('heyoo');
+ct.camera3d.setPlanes(1000, 10, 10000, false);
+ct.pixiApp.stage.addChild(ct.camera3d);
+ct.camera3d.room = new PIXI.projection.Container3d();
+ct.camera3d.addChild(ct.camera3d.room);
diff --git a/app/data/ct.libs/3d/injects/htmlbottom.html b/app/data/ct.libs/3d/injects/htmlbottom.html
new file mode 100644
index 000000000..7e62e9f97
--- /dev/null
+++ b/app/data/ct.libs/3d/injects/htmlbottom.html
@@ -0,0 +1 @@
+<script type="text/javascript" src="./pixi.projection.js"></script>
\ No newline at end of file
diff --git a/app/data/ct.libs/3d/injects/onbeforecreate.js b/app/data/ct.libs/3d/injects/onbeforecreate.js
new file mode 100644
index 000000000..d32d0594b
--- /dev/null
+++ b/app/data/ct.libs/3d/injects/onbeforecreate.js
@@ -0,0 +1,24 @@
+if (this.threeDEnabled) {
+    this.convertTo3d();
+    if (this.threeDOrientation === 'xPositive') {
+        this.euler.y = Math.PI / 2;
+    } else if (this.threeDOrientation === 'xNegative') {
+        this.euler.y = -Math.PI / 2;
+    } else if (this.threeDOrientation === 'yPositive') {
+        this.euler.x = Math.PI / 2;
+    } else if (this.threeDOrientation === 'yNegative') {
+        this.euler.x = -Math.PI / 2;
+    } else if (this.threeDOrientation === 'zNegative') {
+        this.euler.x = Math.PI;
+    } else {
+        this.euler.x = ct.camera3d.euler.x;
+        this.euler.y = ct.camera3d.euler.y;
+        this.euler.z = ct.camera3d.euler.z;
+    }
+    if (!ct.room.threeDFlipYZ) {
+        this.position3d.set(this.x, this.y, -this.depth);
+    } else {
+        this.position3d.set(this.x, -this.depth, this.y);
+    }
+    this.position.set(0);
+}
diff --git a/app/data/ct.libs/3d/injects/oncreate.js b/app/data/ct.libs/3d/injects/oncreate.js
new file mode 100644
index 000000000..ed153cbeb
--- /dev/null
+++ b/app/data/ct.libs/3d/injects/oncreate.js
@@ -0,0 +1,3 @@
+if (this.threeDEnabled) {
+    ct.camera3d.room.addChild(this);
+}
diff --git a/app/data/ct.libs/3d/module.json b/app/data/ct.libs/3d/module.json
new file mode 100644
index 000000000..4a3fc2082
--- /dev/null
+++ b/app/data/ct.libs/3d/module.json
@@ -0,0 +1,58 @@
+{
+    "main": {
+        "name": "Pseudo-3D",
+        "tagline": "Create a 3D-ish world from a 2D game!",
+        "version": "0.0.0",
+        "authors": [{
+            "name": "Cosmo Myzrail Gorynych",
+            "mail": "admin@nersta.ru"
+        }],
+        "categories": [
+            "fx"
+        ]
+    },
+    "typeExtends": [{
+        "name": "3D enabled",
+        "type": "checkbox",
+        "default": false,
+        "key": "threeDEnabled"
+    }, {
+        "name": "3D orientation",
+        "type": "select",
+        "default": "zPositive",
+        "key": "threeDOrientation",
+        "options": [{
+            "value": "xPositive",
+            "name": "X positive"
+        }, {
+            "value": "xNegative",
+            "name": "X negative"
+        }, {
+            "value": "yPositive",
+            "name": "Y positive"
+        }, {
+            "value": "yNegative",
+            "name": "Y negative"
+        }, {
+            "value": "zPositive",
+            "name": "Z positive"
+        }, {
+            "value": "zNegative",
+            "name": "Z negative"
+        }, {
+            "value": "faceCamera",
+            "name": "Face camera"
+        }]
+    }],
+    "roomExtends": [{
+        "name": "Camera Z position",
+        "type": "number",
+        "default": 500,
+        "key": "threeDCameraZ"
+    }, {
+        "name": "Flip Y and Z (use it when you design 2D view as a top-down map)",
+        "type": "checkbox",
+        "default": false,
+        "key": "threeDFlipYZ"
+    }]
+}
diff --git a/app/data/ct.libs/3d/projection.ict b/app/data/ct.libs/3d/projection.ict
new file mode 100644
index 000000000..e3cefaabf
--- /dev/null
+++ b/app/data/ct.libs/3d/projection.ict
@@ -0,0 +1,409 @@
+ctjsVersion: 1.4.1
+notes: /* empty */
+libs:
+  place:
+    gridX: 1024
+    gridY: 1024
+  fittoscreen:
+    mode: scaleFit
+  mouse: {}
+  keyboard: {}
+  keyboard.polyfill: {}
+  sound.howler: {}
+  3d: {}
+textures:
+  - name: PlayerShip
+    untill: 0
+    grid:
+      - 1
+      - 1
+    axis:
+      - 150
+      - 150
+    marginx: 0
+    marginy: 0
+    imgWidth: 300
+    imgHeight: 300
+    width: 300
+    height: 300
+    offx: 0
+    offy: 0
+    origname: ia91db6dd-3773-4b88-8d5c-e80080cb0b82.png
+    source: /home/comigo/Desktop/PlayerShip.png
+    shape: rect
+    left: 150
+    right: 150
+    top: 150
+    bottom: 150
+    uid: a91db6dd-3773-4b88-8d5c-e80080cb0b82
+    padding: 1
+    lastmod: 1597371073697
+  - name: Comigo_Standing
+    untill: 0
+    grid:
+      - 1
+      - 1
+    axis:
+      - 83
+      - 373
+    marginx: 0
+    marginy: 0
+    imgWidth: 189
+    imgHeight: 394
+    width: 189
+    height: 394
+    offx: 0
+    offy: 0
+    origname: i059e8892-2ffd-40c8-a97b-7645b40b3db5.png
+    source: /home/comigo/Downloads/Comigo_Standing.png
+    shape: rect
+    left: 83
+    right: 106
+    top: 373
+    bottom: 21
+    uid: 059e8892-2ffd-40c8-a97b-7645b40b3db5
+    padding: 1
+    lastmod: 1597376745239
+  - name: LightPost
+    untill: 0
+    grid:
+      - 1
+      - 1
+    axis:
+      - 90
+      - 492
+    marginx: 0
+    marginy: 0
+    imgWidth: 171
+    imgHeight: 544
+    width: 171
+    height: 544
+    offx: 0
+    offy: 0
+    origname: i2b15568d-6e42-4ecc-a301-e580291e0a9b.png
+    source: /home/comigo/Downloads/LightPost.png
+    shape: rect
+    left: 90
+    right: 81
+    top: 492
+    bottom: 52
+    uid: 2b15568d-6e42-4ecc-a301-e580291e0a9b
+    padding: 1
+    lastmod: 1597376750753
+  - name: SandCell
+    untill: 0
+    grid:
+      - 1
+      - 1
+    axis:
+      - 128
+      - 32
+    marginx: 0
+    marginy: 0
+    imgWidth: 256
+    imgHeight: 64
+    width: 256
+    height: 64
+    offx: 0
+    offy: 0
+    origname: i33bda9f8-75f1-4255-b86d-e5973350fd2b.png
+    source: /home/comigo/Downloads/SandCell(2).png
+    shape: rect
+    left: 128
+    right: 128
+    top: 32
+    bottom: 32
+    uid: 33bda9f8-75f1-4255-b86d-e5973350fd2b
+    padding: 1
+    lastmod: 1597379213488
+  - name: SandCellSquare
+    untill: 0
+    grid:
+      - 1
+      - 1
+    axis:
+      - 128
+      - 128
+    marginx: 0
+    marginy: 0
+    imgWidth: 256
+    imgHeight: 256
+    width: 256
+    height: 256
+    offx: 0
+    offy: 0
+    origname: i8217ffc8-5969-4237-8506-a5458d4d5e6c.png
+    source: /home/comigo/Downloads/SandCell.png
+    shape: rect
+    left: 128
+    right: 128
+    top: 128
+    bottom: 128
+    uid: 8217ffc8-5969-4237-8506-a5458d4d5e6c
+    padding: 1
+    lastmod: 1597380952030
+skeletons: []
+types:
+  - name: Sand
+    depth: 0
+    oncreate: this.depth = (Math.random() - 0.5) * 20;
+    onstep: this.move();
+    ondraw: ''
+    ondestroy: ''
+    texture: 33bda9f8-75f1-4255-b86d-e5973350fd2b
+    extends:
+      threeDOrientation: zPositive
+      threeDEnabled: true
+    uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+    lastmod: 1597543642495
+  - name: Comigo
+    depth: 20
+    oncreate: ''
+    onstep: this.move();
+    ondraw: ''
+    ondestroy: ''
+    texture: 059e8892-2ffd-40c8-a97b-7645b40b3db5
+    extends:
+      threeDEnabled: true
+      threeDOrientation: faceCamera
+    uid: 312c8e4f-52cb-4f54-a3a6-9711048504d2
+    lastmod: 1597543647148
+  - name: Lamp
+    depth: 40
+    oncreate: ''
+    onstep: this.move();
+    ondraw: ''
+    ondestroy: ''
+    texture: 2b15568d-6e42-4ecc-a301-e580291e0a9b
+    extends:
+      threeDOrientation: xNegative
+      threeDEnabled: true
+    uid: 93de9f6e-dc74-4277-a778-b9b9ded16c37
+    lastmod: 1597543640263
+  - name: SandCellSquare
+    depth: 0
+    oncreate: ''
+    onstep: this.move();
+    ondraw: ''
+    ondestroy: ''
+    texture: 8217ffc8-5969-4237-8506-a5458d4d5e6c
+    extends:
+      threeDEnabled: true
+      threeDOrientation: yPositive
+    uid: 69835ba5-5fb8-49f3-bbdf-039611ba03d4
+    lastmod: 1597543644918
+sounds: []
+styles: []
+rooms:
+  - name: Room_SideView
+    oncreate: ''
+    onstep: ''
+    ondraw: >-
+      ct.camera3d.euler.yaw += ct.delta * 0.02;
+
+      //ct.camera3d.euler.roll = ct.u.degToRad(90);
+
+      //ct.camera3d.euler.pitch = ct.u.degToRad(30);
+
+      ct.camera3d.position3d.x = ct.u.ldx(1000,
+      ct.u.radToDeg(-ct.camera3d.euler.yaw) + 90) + ct.camera.width / 2;
+
+      ct.camera3d.position3d.z = ct.u.ldy(1000,
+      ct.u.radToDeg(-ct.camera3d.euler.yaw) + 90) + ct.camera.height / 2;
+    onleave: ''
+    width: 1280
+    height: 720
+    backgrounds: []
+    copies:
+      - x: 192
+        'y': 640
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 448
+        'y': 640
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 704
+        'y': 640
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 960
+        'y': 640
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 1216
+        'y': 640
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 192
+        'y': 576
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 192
+        'y': 512
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 192
+        'y': 448
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 192
+        'y': 384
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 192
+        'y': 320
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 192
+        'y': 256
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 192
+        'y': 192
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 192
+        'y': 128
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 192
+        'y': 64
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 448
+        'y': 64
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 704
+        'y': 64
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 960
+        'y': 64
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 1216
+        'y': 64
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 1216
+        'y': 128
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 1216
+        'y': 192
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 1216
+        'y': 256
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 1216
+        'y': 320
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 1216
+        'y': 384
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 1216
+        'y': 448
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 1216
+        'y': 576
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 1216
+        'y': 512
+        uid: 0ae31f06-ea0f-446a-9caa-22dba4d7cad9
+      - x: 411
+        'y': 609
+        uid: 312c8e4f-52cb-4f54-a3a6-9711048504d2
+      - x: 994
+        'y': 592
+        uid: 93de9f6e-dc74-4277-a778-b9b9ded16c37
+    tiles:
+      - depth: -10
+        tiles: []
+        extends: {}
+    uid: 30515cf2-9b52-401c-8dc0-6ab031e8b734
+    thumbnail: 6ab031e8b734
+    extends:
+      twoPointFiveDEnabled: true
+      threeDCameraZ: 320
+      threeDFlipYZ: false
+    gridX: 64
+    gridY: 64
+    lastmod: 1597543621151
+    backgroundColor: '#85C2FF'
+  - name: Room_TopView
+    oncreate: ''
+    onstep: ''
+    ondraw: >-
+      ct.camera3d.euler.yaw += ct.delta * 0.02;
+
+      //ct.camera3d.euler.roll = ct.u.degToRad(90);
+
+      //ct.camera3d.euler.pitch = ct.u.degToRad(30);
+
+      ct.camera3d.position3d.y = -400;
+
+      ct.camera3d.position3d.x = ct.u.ldx(1000,
+      ct.u.radToDeg(-ct.camera3d.euler.yaw) + 90) + ct.camera.width / 2;
+
+      ct.camera3d.position3d.z = ct.u.ldy(1000,
+      ct.u.radToDeg(-ct.camera3d.euler.yaw) + 90) + ct.camera.height / 2;
+    onleave: ''
+    width: 1280
+    height: 720
+    backgrounds: []
+    copies:
+      - x: 576
+        'y': 128
+        uid: 69835ba5-5fb8-49f3-bbdf-039611ba03d4
+      - x: 576
+        'y': 384
+        uid: 69835ba5-5fb8-49f3-bbdf-039611ba03d4
+      - x: 576
+        'y': 640
+        uid: 69835ba5-5fb8-49f3-bbdf-039611ba03d4
+      - x: 320
+        'y': 256
+        uid: 69835ba5-5fb8-49f3-bbdf-039611ba03d4
+      - x: 320
+        'y': 512
+        uid: 69835ba5-5fb8-49f3-bbdf-039611ba03d4
+      - x: 832
+        'y': 512
+        uid: 69835ba5-5fb8-49f3-bbdf-039611ba03d4
+      - x: 832
+        'y': 256
+        uid: 69835ba5-5fb8-49f3-bbdf-039611ba03d4
+      - x: 704
+        'y': 256
+        uid: 93de9f6e-dc74-4277-a778-b9b9ded16c37
+      - x: 384
+        'y': 512
+        uid: 312c8e4f-52cb-4f54-a3a6-9711048504d2
+    tiles:
+      - depth: -10
+        tiles: []
+        extends: {}
+    uid: ec52dfa5-9395-44df-b2b5-e2209288edd5
+    thumbnail: e2209288edd5
+    extends:
+      twoPointFiveDEnabled: true
+      threeDCameraZ: -400
+      threeDFlipYZ: true
+    gridX: 64
+    gridY: 64
+    lastmod: 1597543695973
+    backgroundColor: '#72A0AB'
+actions: []
+emitterTandems: []
+scripts: []
+starting: 0
+settings:
+  authoring:
+    author: ''
+    site: ''
+    title: ''
+    version:
+      - 0
+      - 0
+      - 0
+    versionPostfix: ''
+  rendering:
+    usePixiLegacy: true
+    maxFPS: 60
+    pixelatedrender: false
+    highDensity: true
+    desktopMode: maximized
+  export:
+    windows: true
+    linux: true
+    mac: true
+  branding:
+    icon: -1
+    accent: '#446adb'
+    invertPreloaderScheme: true
+  fps: 30
+fonts: []
+palette: []
diff --git a/app/data/ct.libs/3d/projection/img/i059e8892-2ffd-40c8-a97b-7645b40b3db5.png b/app/data/ct.libs/3d/projection/img/i059e8892-2ffd-40c8-a97b-7645b40b3db5.png
new file mode 100644
index 000000000..ffc0138b9
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/i059e8892-2ffd-40c8-a97b-7645b40b3db5.png differ
diff --git a/app/data/ct.libs/3d/projection/img/i059e8892-2ffd-40c8-a97b-7645b40b3db5.png_prev.png b/app/data/ct.libs/3d/projection/img/i059e8892-2ffd-40c8-a97b-7645b40b3db5.png_prev.png
new file mode 100644
index 000000000..7cc53b639
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/i059e8892-2ffd-40c8-a97b-7645b40b3db5.png_prev.png differ
diff --git a/app/data/ct.libs/3d/projection/img/i059e8892-2ffd-40c8-a97b-7645b40b3db5.png_prev@2.png b/app/data/ct.libs/3d/projection/img/i059e8892-2ffd-40c8-a97b-7645b40b3db5.png_prev@2.png
new file mode 100644
index 000000000..4e49863c1
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/i059e8892-2ffd-40c8-a97b-7645b40b3db5.png_prev@2.png differ
diff --git a/app/data/ct.libs/3d/projection/img/i2b15568d-6e42-4ecc-a301-e580291e0a9b.png b/app/data/ct.libs/3d/projection/img/i2b15568d-6e42-4ecc-a301-e580291e0a9b.png
new file mode 100644
index 000000000..97fbbc80f
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/i2b15568d-6e42-4ecc-a301-e580291e0a9b.png differ
diff --git a/app/data/ct.libs/3d/projection/img/i2b15568d-6e42-4ecc-a301-e580291e0a9b.png_prev.png b/app/data/ct.libs/3d/projection/img/i2b15568d-6e42-4ecc-a301-e580291e0a9b.png_prev.png
new file mode 100644
index 000000000..a032c776f
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/i2b15568d-6e42-4ecc-a301-e580291e0a9b.png_prev.png differ
diff --git a/app/data/ct.libs/3d/projection/img/i2b15568d-6e42-4ecc-a301-e580291e0a9b.png_prev@2.png b/app/data/ct.libs/3d/projection/img/i2b15568d-6e42-4ecc-a301-e580291e0a9b.png_prev@2.png
new file mode 100644
index 000000000..c7c8153e7
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/i2b15568d-6e42-4ecc-a301-e580291e0a9b.png_prev@2.png differ
diff --git a/app/data/ct.libs/3d/projection/img/i33bda9f8-75f1-4255-b86d-e5973350fd2b.png b/app/data/ct.libs/3d/projection/img/i33bda9f8-75f1-4255-b86d-e5973350fd2b.png
new file mode 100644
index 000000000..9164ca49a
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/i33bda9f8-75f1-4255-b86d-e5973350fd2b.png differ
diff --git a/app/data/ct.libs/3d/projection/img/i33bda9f8-75f1-4255-b86d-e5973350fd2b.png_prev.png b/app/data/ct.libs/3d/projection/img/i33bda9f8-75f1-4255-b86d-e5973350fd2b.png_prev.png
new file mode 100644
index 000000000..6abab913b
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/i33bda9f8-75f1-4255-b86d-e5973350fd2b.png_prev.png differ
diff --git a/app/data/ct.libs/3d/projection/img/i33bda9f8-75f1-4255-b86d-e5973350fd2b.png_prev@2.png b/app/data/ct.libs/3d/projection/img/i33bda9f8-75f1-4255-b86d-e5973350fd2b.png_prev@2.png
new file mode 100644
index 000000000..5887d5e12
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/i33bda9f8-75f1-4255-b86d-e5973350fd2b.png_prev@2.png differ
diff --git a/app/data/ct.libs/3d/projection/img/i8217ffc8-5969-4237-8506-a5458d4d5e6c.png b/app/data/ct.libs/3d/projection/img/i8217ffc8-5969-4237-8506-a5458d4d5e6c.png
new file mode 100644
index 000000000..b02f693ff
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/i8217ffc8-5969-4237-8506-a5458d4d5e6c.png differ
diff --git a/app/data/ct.libs/3d/projection/img/i8217ffc8-5969-4237-8506-a5458d4d5e6c.png_prev.png b/app/data/ct.libs/3d/projection/img/i8217ffc8-5969-4237-8506-a5458d4d5e6c.png_prev.png
new file mode 100644
index 000000000..7ef0b2cd9
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/i8217ffc8-5969-4237-8506-a5458d4d5e6c.png_prev.png differ
diff --git a/app/data/ct.libs/3d/projection/img/i8217ffc8-5969-4237-8506-a5458d4d5e6c.png_prev@2.png b/app/data/ct.libs/3d/projection/img/i8217ffc8-5969-4237-8506-a5458d4d5e6c.png_prev@2.png
new file mode 100644
index 000000000..9ed92f5ac
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/i8217ffc8-5969-4237-8506-a5458d4d5e6c.png_prev@2.png differ
diff --git a/app/data/ct.libs/3d/projection/img/ia91db6dd-3773-4b88-8d5c-e80080cb0b82.png b/app/data/ct.libs/3d/projection/img/ia91db6dd-3773-4b88-8d5c-e80080cb0b82.png
new file mode 100644
index 000000000..5358dbe6b
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/ia91db6dd-3773-4b88-8d5c-e80080cb0b82.png differ
diff --git a/app/data/ct.libs/3d/projection/img/ia91db6dd-3773-4b88-8d5c-e80080cb0b82.png_prev.png b/app/data/ct.libs/3d/projection/img/ia91db6dd-3773-4b88-8d5c-e80080cb0b82.png_prev.png
new file mode 100644
index 000000000..2b174cc12
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/ia91db6dd-3773-4b88-8d5c-e80080cb0b82.png_prev.png differ
diff --git a/app/data/ct.libs/3d/projection/img/ia91db6dd-3773-4b88-8d5c-e80080cb0b82.png_prev@2.png b/app/data/ct.libs/3d/projection/img/ia91db6dd-3773-4b88-8d5c-e80080cb0b82.png_prev@2.png
new file mode 100644
index 000000000..ac3873bc0
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/ia91db6dd-3773-4b88-8d5c-e80080cb0b82.png_prev@2.png differ
diff --git a/app/data/ct.libs/3d/projection/img/r6ab031e8b734.png b/app/data/ct.libs/3d/projection/img/r6ab031e8b734.png
new file mode 100644
index 000000000..6cf162564
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/r6ab031e8b734.png differ
diff --git a/app/data/ct.libs/3d/projection/img/re2209288edd5.png b/app/data/ct.libs/3d/projection/img/re2209288edd5.png
new file mode 100644
index 000000000..79386e323
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/re2209288edd5.png differ
diff --git a/app/data/ct.libs/3d/projection/img/splash.png b/app/data/ct.libs/3d/projection/img/splash.png
new file mode 100644
index 000000000..79386e323
Binary files /dev/null and b/app/data/ct.libs/3d/projection/img/splash.png differ
diff --git a/app/data/ct.libs/3d/types.d.ts b/app/data/ct.libs/3d/types.d.ts
new file mode 100644
index 000000000..be518fc67
--- /dev/null
+++ b/app/data/ct.libs/3d/types.d.ts
@@ -0,0 +1,629 @@
+declare namespace PIXI {
+    interface Transform {
+        proj?: PIXI.projection.AbstractProjection;
+    }
+    interface ObservablePoint {
+        _x?: number;
+        _y?: number;
+    }
+}
+declare module PIXI.projection {
+    class AbstractProjection {
+        constructor(legacy: PIXI.Transform, enable?: boolean);
+        legacy: PIXI.Transform;
+        _enabled: boolean;
+        get enabled(): boolean;
+        set enabled(value: boolean);
+        clear(): void;
+    }
+    enum TRANSFORM_STEP {
+        NONE = 0,
+        BEFORE_PROJ = 4,
+        PROJ = 5,
+        ALL = 9
+    }
+}
+declare module PIXI.projection {
+    class LinearProjection<T> extends AbstractProjection {
+        updateLocalTransform(lt: PIXI.Matrix): void;
+        _projID: number;
+        _currentProjID: number;
+        _affine: AFFINE;
+        affinePreserveOrientation: boolean;
+        scaleAfterAffine: boolean;
+        set affine(value: AFFINE);
+        get affine(): AFFINE;
+        local: T;
+        world: T;
+        set enabled(value: boolean);
+        clear(): void;
+    }
+}
+declare module PIXI.projection {
+    class Batch3dGeometry extends PIXI.Geometry {
+        _buffer: PIXI.Buffer;
+        _indexBuffer: PIXI.Buffer;
+        constructor(_static?: boolean);
+    }
+    class Batch2dPluginFactory {
+        static create(options: any): any;
+    }
+}
+declare module PIXI.projection {
+    import AbstractBatchRenderer = PIXI.AbstractBatchRenderer;
+    class UniformBatchRenderer extends AbstractBatchRenderer {
+        _iIndex: number;
+        _aIndex: number;
+        _dcIndex: number;
+        _bufferedElements: Array<any>;
+        _attributeBuffer: PIXI.ViewableBuffer;
+        _indexBuffer: Uint16Array;
+        vertexSize: number;
+        forceMaxTextures: number;
+        getUniforms(sprite: PIXI.Sprite): any;
+        syncUniforms(obj: any): void;
+        defUniforms: {};
+        buildDrawCalls(texArray: PIXI.BatchTextureArray, start: number, finish: number): void;
+        drawBatches(): void;
+        contextChange(): void;
+    }
+}
+declare module PIXI.projection {
+    import IPoint = PIXI.IPoint;
+    abstract class Surface implements IWorldTransform {
+        surfaceID: string;
+        _updateID: number;
+        vertexSrc: string;
+        fragmentSrc: string;
+        fillUniforms(uniforms: any): void;
+        clear(): void;
+        boundsQuad(v: ArrayLike<number>, out: any, after?: PIXI.Matrix): void;
+        abstract apply(pos: IPoint, newPos: IPoint): IPoint;
+        abstract applyInverse(pos: IPoint, newPos: IPoint): IPoint;
+    }
+}
+declare module PIXI.projection {
+    import IPoint = PIXI.IPoint;
+    class BilinearSurface extends Surface {
+        distortion: PIXI.Point;
+        constructor();
+        clear(): void;
+        apply(pos: IPoint, newPos?: IPoint): IPoint;
+        applyInverse(pos: IPoint, newPos: IPoint): IPoint;
+        mapSprite(sprite: PIXI.Sprite, quad: Array<IPoint>, outTransform?: PIXI.Transform): this;
+        mapQuad(rect: PIXI.Rectangle, quad: Array<IPoint>, outTransform: PIXI.Transform): this;
+        fillUniforms(uniforms: any): void;
+    }
+}
+declare module PIXI.projection {
+    class Container2s extends PIXI.Container {
+        constructor();
+        proj: ProjectionSurface;
+        get worldTransform(): any;
+    }
+}
+declare namespace PIXI {
+    interface Matrix extends PIXI.projection.IWorldTransform {
+        apply(pos: IPoint, newPos?: IPoint): IPoint;
+        applyInverse(pos: IPoint, newPos?: IPoint): IPoint;
+    }
+}
+declare module PIXI.projection {
+    import IPoint = PIXI.IPoint;
+    interface IWorldTransform {
+        apply(pos: IPoint, newPos: IPoint): IPoint;
+        applyInverse(pos: IPoint, newPos: IPoint): IPoint;
+    }
+    class ProjectionSurface extends AbstractProjection {
+        constructor(legacy: PIXI.Transform, enable?: boolean);
+        _surface: Surface;
+        _activeProjection: ProjectionSurface;
+        set enabled(value: boolean);
+        get surface(): Surface;
+        set surface(value: Surface);
+        applyPartial(pos: IPoint, newPos?: IPoint): IPoint;
+        apply(pos: IPoint, newPos?: IPoint): IPoint;
+        applyInverse(pos: IPoint, newPos: IPoint): IPoint;
+        mapBilinearSprite(sprite: PIXI.Sprite, quad: Array<IPoint>): void;
+        _currentSurfaceID: number;
+        _currentLegacyID: number;
+        _lastUniforms: any;
+        clear(): void;
+        get uniforms(): any;
+    }
+}
+declare module PIXI.projection {
+    class BatchBilineardGeometry extends PIXI.Geometry {
+        _buffer: PIXI.Buffer;
+        _indexBuffer: PIXI.Buffer;
+        constructor(_static?: boolean);
+    }
+    class BatchBilinearPluginFactory {
+        static create(options: any): any;
+    }
+}
+declare module PIXI {
+    interface Sprite {
+        convertTo2s(): void;
+    }
+    interface Container {
+        convertTo2s(): void;
+        convertSubtreeTo2s(): void;
+    }
+}
+declare module PIXI.projection {
+}
+declare module PIXI.projection {
+    class Sprite2s extends PIXI.Sprite {
+        constructor(texture: PIXI.Texture);
+        proj: ProjectionSurface;
+        aTrans: PIXI.Matrix;
+        _calculateBounds(): void;
+        calculateVertices(): void;
+        calculateTrimmedVertices(): void;
+        get worldTransform(): any;
+    }
+}
+declare module PIXI.projection {
+    class Text2s extends PIXI.Text {
+        constructor(text?: string, style?: PIXI.TextStyle, canvas?: HTMLCanvasElement);
+        proj: ProjectionSurface;
+        aTrans: PIXI.Matrix;
+        get worldTransform(): any;
+    }
+}
+declare module PIXI.projection {
+    function container2dWorldTransform(): any;
+    class Container2d extends PIXI.Container {
+        constructor();
+        proj: Projection2d;
+        toLocal<T extends PIXI.IPoint>(position: PIXI.IPoint, from?: PIXI.DisplayObject, point?: T, skipUpdate?: boolean, step?: TRANSFORM_STEP): T;
+        get worldTransform(): any;
+    }
+    let container2dToLocal: <T extends PIXI.IPoint>(position: PIXI.IPoint, from?: PIXI.DisplayObject, point?: T, skipUpdate?: boolean, step?: TRANSFORM_STEP) => T;
+}
+declare module PIXI.projection {
+    import IPoint = PIXI.IPoint;
+    enum AFFINE {
+        NONE = 0,
+        FREE = 1,
+        AXIS_X = 2,
+        AXIS_Y = 3,
+        POINT = 4,
+        AXIS_XR = 5
+    }
+    class Matrix2d {
+        static readonly IDENTITY: Matrix2d;
+        static readonly TEMP_MATRIX: Matrix2d;
+        mat3: Float64Array;
+        floatArray: Float32Array;
+        constructor(backingArray?: ArrayLike<number>);
+        get a(): number;
+        get b(): number;
+        get c(): number;
+        get d(): number;
+        get tx(): number;
+        get ty(): number;
+        set a(value: number);
+        set b(value: number);
+        set c(value: number);
+        set d(value: number);
+        set tx(value: number);
+        set ty(value: number);
+        set(a: number, b: number, c: number, d: number, tx: number, ty: number): this;
+        toArray(transpose?: boolean, out?: Float32Array): Float32Array;
+        apply(pos: IPoint, newPos: IPoint): IPoint;
+        translate(tx: number, ty: number): this;
+        scale(x: number, y: number): this;
+        scaleAndTranslate(scaleX: number, scaleY: number, tx: number, ty: number): void;
+        applyInverse(pos: IPoint, newPos: IPoint): IPoint;
+        invert(): Matrix2d;
+        identity(): Matrix2d;
+        clone(): Matrix2d;
+        copyTo2dOr3d(matrix: Matrix2d): Matrix2d;
+        copyTo(matrix: PIXI.Matrix, affine?: AFFINE, preserveOrientation?: boolean): PIXI.Matrix;
+        copyFrom(matrix: PIXI.Matrix): this;
+        setToMultLegacy(pt: PIXI.Matrix, lt: Matrix2d): this;
+        setToMultLegacy2(pt: Matrix2d, lt: PIXI.Matrix): this;
+        setToMult(pt: Matrix2d, lt: Matrix2d): this;
+        prepend(lt: any): this;
+    }
+}
+declare module PIXI.projection {
+    class Mesh2d extends PIXI.Mesh {
+        static defaultVertexShader: string;
+        static defaultFragmentShader: string;
+        constructor(geometry: PIXI.Geometry, shader: PIXI.Shader, state: PIXI.State, drawMode?: number);
+        vertexData2d: Float32Array;
+        proj: Projection2d;
+        calculateVertices(): void;
+        _renderDefault(renderer: PIXI.Renderer): void;
+        toLocal<T extends PIXI.IPoint>(position: PIXI.IPoint, from?: PIXI.DisplayObject, point?: T, skipUpdate?: boolean, step?: TRANSFORM_STEP): T;
+        get worldTransform(): any;
+    }
+    class SimpleMesh2d extends Mesh2d {
+        constructor(texture: PIXI.Texture, vertices?: Float32Array, uvs?: Float32Array, indices?: Uint16Array, drawMode?: number);
+        autoUpdate: boolean;
+        get vertices(): Float32Array;
+        set vertices(value: Float32Array);
+        protected _render(renderer?: PIXI.Renderer): void;
+    }
+}
+declare module PIXI.projection {
+    import IPoint = PIXI.IPoint;
+    class Projection2d extends LinearProjection<Matrix2d> {
+        constructor(legacy: PIXI.Transform, enable?: boolean);
+        matrix: Matrix2d;
+        pivot: PIXI.ObservablePoint;
+        reverseLocalOrder: boolean;
+        onChange(): void;
+        setAxisX(p: IPoint, factor?: number): void;
+        setAxisY(p: IPoint, factor?: number): void;
+        mapSprite(sprite: PIXI.Sprite, quad: Array<IPoint>): void;
+        mapQuad(rect: PIXI.Rectangle, p: Array<IPoint>): void;
+        updateLocalTransform(lt: PIXI.Matrix): void;
+        clear(): void;
+    }
+}
+declare module PIXI {
+    interface Sprite {
+        _texture: PIXI.Texture;
+        vertexData: Float32Array;
+        vertexTrimmedData: Float32Array;
+        _transformID?: number;
+        _textureID?: number;
+        _transformTrimmedID?: number;
+        _textureTrimmedID?: number;
+        _anchor?: ObservablePoint;
+        convertTo2d?(): void;
+    }
+    interface Container {
+        convertTo2d?(): void;
+        convertSubtreeTo2d?(): void;
+    }
+    interface SimpleMesh {
+        convertTo2d?(): void;
+    }
+    interface Graphics {
+        convertTo2d?(): void;
+    }
+}
+declare module PIXI.projection {
+}
+declare module PIXI.projection {
+    class Sprite2d extends PIXI.Sprite {
+        constructor(texture: PIXI.Texture);
+        vertexData2d: Float32Array;
+        proj: Projection2d;
+        _calculateBounds(): void;
+        calculateVertices(): void;
+        calculateTrimmedVertices(): void;
+        toLocal<T extends PIXI.IPoint>(position: PIXI.IPoint, from?: PIXI.DisplayObject, point?: T, skipUpdate?: boolean, step?: TRANSFORM_STEP): T;
+        get worldTransform(): any;
+    }
+}
+declare module PIXI.projection {
+    class Text2d extends PIXI.Text {
+        constructor(text?: string, style?: PIXI.TextStyle, canvas?: HTMLCanvasElement);
+        proj: Projection2d;
+        vertexData2d: Float32Array;
+        get worldTransform(): any;
+    }
+}
+declare module PIXI.projection {
+    class TilingSprite2d extends PIXI.TilingSprite {
+        constructor(texture: PIXI.Texture, width: number, height: number);
+        tileProj: Projection2d;
+        proj: Projection2d;
+        get worldTransform(): any;
+        toLocal<T extends PIXI.IPoint>(position: PIXI.IPoint, from?: PIXI.DisplayObject, point?: T, skipUpdate?: boolean, step?: TRANSFORM_STEP): T;
+        _render(renderer: PIXI.Renderer): void;
+    }
+}
+declare module PIXI.projection {
+    class TilingSprite2dRenderer extends PIXI.ObjectRenderer {
+        constructor(renderer: PIXI.Renderer);
+        shader: PIXI.Shader;
+        simpleShader: PIXI.Shader;
+        quad: PIXI.QuadUv;
+        render(ts: any): void;
+    }
+}
+declare module PIXI.projection {
+}
+declare module PIXI.projection {
+    class SpriteMaskFilter2d extends PIXI.Filter {
+        constructor(sprite: PIXI.Sprite);
+        maskSprite: PIXI.Sprite;
+        maskMatrix: Matrix2d;
+        apply(filterManager: PIXI.systems.FilterSystem, input: PIXI.RenderTexture, output: PIXI.RenderTexture, clearMode?: boolean): void;
+        static calculateSpriteMatrix(input: PIXI.RenderTexture, mappedMatrix: Matrix2d, sprite: PIXI.Sprite): Matrix2d;
+    }
+}
+declare module PIXI.projection {
+    class Camera3d extends Container3d {
+        constructor();
+        _far: number;
+        _near: number;
+        _focus: number;
+        _orthographic: boolean;
+        get far(): number;
+        get near(): number;
+        get focus(): number;
+        get ortographic(): boolean;
+        setPlanes(focus: number, near?: number, far?: number, orthographic?: boolean): void;
+    }
+}
+declare module PIXI.projection {
+    function container3dWorldTransform(): any;
+    class Container3d extends PIXI.Container {
+        constructor();
+        proj: Projection3d;
+        isFrontFace(forceUpdate?: boolean): boolean;
+        getDepth(forceUpdate?: boolean): number;
+        toLocal<T extends PIXI.IPoint>(position: PIXI.IPoint, from?: PIXI.DisplayObject, point?: T, skipUpdate?: boolean, step?: TRANSFORM_STEP): T;
+        get worldTransform(): any;
+        get position3d(): PIXI.IPoint;
+        get scale3d(): PIXI.IPoint;
+        get euler(): IEuler;
+        get pivot3d(): PIXI.IPoint;
+        set position3d(value: PIXI.IPoint);
+        set scale3d(value: PIXI.IPoint);
+        set euler(value: IEuler);
+        set pivot3d(value: PIXI.IPoint);
+    }
+    let container3dToLocal: <T extends PIXI.IPoint>(position: PIXI.IPoint, from?: PIXI.DisplayObject, point?: T, skipUpdate?: boolean, step?: TRANSFORM_STEP) => T;
+    let container3dGetDepth: (forceUpdate?: boolean) => number;
+    let container3dIsFrontFace: (forceUpdate?: boolean) => boolean;
+}
+declare module PIXI.projection {
+    class Euler {
+        constructor(x?: number, y?: number, z?: number);
+        _quatUpdateId: number;
+        _quatDirtyId: number;
+        quaternion: Float64Array;
+        _x: number;
+        _y: number;
+        _z: number;
+        _sign: number;
+        get x(): number;
+        set x(value: number);
+        get y(): number;
+        set y(value: number);
+        get z(): number;
+        set z(value: number);
+        get pitch(): number;
+        set pitch(value: number);
+        get yaw(): number;
+        set yaw(value: number);
+        get roll(): number;
+        set roll(value: number);
+        set(x?: number, y?: number, z?: number): void;
+        copyFrom(euler: IEuler): void;
+        copyTo(p: IEuler): IEuler;
+        equals(euler: IEuler): boolean;
+        clone(): Euler;
+        update(): boolean;
+    }
+}
+declare module PIXI.projection {
+    import IPoint = PIXI.IPoint;
+    class Matrix3d {
+        static readonly IDENTITY: Matrix3d;
+        static readonly TEMP_MATRIX: Matrix3d;
+        mat4: Float64Array;
+        floatArray: Float32Array;
+        _dirtyId: number;
+        _updateId: number;
+        _mat4inv: Float64Array;
+        cacheInverse: boolean;
+        constructor(backingArray?: ArrayLike<number>);
+        get a(): number;
+        get b(): number;
+        get c(): number;
+        get d(): number;
+        get tx(): number;
+        get ty(): number;
+        set a(value: number);
+        set b(value: number);
+        set c(value: number);
+        set d(value: number);
+        set tx(value: number);
+        set ty(value: number);
+        set(a: number, b: number, c: number, d: number, tx: number, ty: number): this;
+        toArray(transpose?: boolean, out?: Float32Array): Float32Array;
+        setToTranslation(tx: number, ty: number, tz: number): void;
+        setToRotationTranslationScale(quat: Float64Array, tx: number, ty: number, tz: number, sx: number, sy: number, sz: number): Float64Array;
+        apply(pos: IPoint, newPos: IPoint): IPoint;
+        translate(tx: number, ty: number, tz: number): this;
+        scale(x: number, y: number, z?: number): this;
+        scaleAndTranslate(scaleX: number, scaleY: number, scaleZ: number, tx: number, ty: number, tz: number): void;
+        applyInverse(pos: IPoint, newPos: IPoint): IPoint;
+        invert(): Matrix3d;
+        invertCopyTo(matrix: Matrix3d): void;
+        identity(): Matrix3d;
+        clone(): Matrix3d;
+        copyTo3d(matrix: Matrix3d): Matrix3d;
+        copyTo2d(matrix: Matrix2d): Matrix2d;
+        copyTo2dOr3d(matrix: Matrix2d | Matrix3d): Matrix2d | Matrix3d;
+        copyTo(matrix: PIXI.Matrix, affine?: AFFINE, preserveOrientation?: boolean): PIXI.Matrix;
+        copyFrom(matrix: PIXI.Matrix): this;
+        setToMultLegacy(pt: PIXI.Matrix, lt: Matrix3d): this;
+        setToMultLegacy2(pt: Matrix3d, lt: PIXI.Matrix): this;
+        setToMult(pt: Matrix3d, lt: Matrix3d): this;
+        prepend(lt: any): void;
+        static glMatrixMat4Invert(out: Float64Array, a: Float64Array): Float64Array;
+        static glMatrixMat4Multiply(out: Float64Array, a: Float64Array, b: Float64Array): Float64Array;
+    }
+}
+declare module PIXI.projection {
+    class Mesh3d2d extends PIXI.Mesh {
+        constructor(geometry: PIXI.Geometry, shader: PIXI.Shader, state: PIXI.State, drawMode?: number);
+        vertexData2d: Float32Array;
+        proj: Projection3d;
+        calculateVertices(): void;
+        get worldTransform(): any;
+        toLocal<T extends PIXI.IPoint>(position: PIXI.IPoint, from?: PIXI.DisplayObject, point?: T, skipUpdate?: boolean, step?: TRANSFORM_STEP): T;
+        isFrontFace(forceUpdate?: boolean): any;
+        getDepth(forceUpdate?: boolean): any;
+        get position3d(): PIXI.IPoint;
+        get scale3d(): PIXI.IPoint;
+        get euler(): Euler;
+        get pivot3d(): PIXI.IPoint;
+        set position3d(value: PIXI.IPoint);
+        set scale3d(value: PIXI.IPoint);
+        set euler(value: Euler);
+        set pivot3d(value: PIXI.IPoint);
+    }
+    class SimpleMesh3d2d extends Mesh3d2d {
+        constructor(texture: PIXI.Texture, vertices?: Float32Array, uvs?: Float32Array, indices?: Uint16Array, drawMode?: number);
+        autoUpdate: boolean;
+        get vertices(): Float32Array;
+        set vertices(value: Float32Array);
+        protected _render(renderer?: PIXI.Renderer): void;
+    }
+}
+declare module PIXI.projection {
+    type IEuler = Euler | ObservableEuler;
+    class ObservableEuler {
+        cb: any;
+        scope: any;
+        constructor(cb: any, scope: any, x?: number, y?: number, z?: number);
+        _quatUpdateId: number;
+        _quatDirtyId: number;
+        quaternion: Float64Array;
+        _x: number;
+        _y: number;
+        _z: number;
+        _sign: number;
+        get x(): number;
+        set x(value: number);
+        get y(): number;
+        set y(value: number);
+        get z(): number;
+        set z(value: number);
+        get pitch(): number;
+        set pitch(value: number);
+        get yaw(): number;
+        set yaw(value: number);
+        get roll(): number;
+        set roll(value: number);
+        set(x?: number, y?: number, z?: number): void;
+        copyFrom(euler: IEuler): void;
+        copyTo(p: IEuler): IEuler;
+        equals(euler: IEuler): boolean;
+        clone(): Euler;
+        update(): boolean;
+    }
+}
+declare namespace PIXI {
+    interface IPoint {
+        z?: number;
+        set(x?: number, y?: number, z?: number): void;
+    }
+    interface Point {
+        z?: number;
+        set(x?: number, y?: number, z?: number): void;
+    }
+    interface ObservablePoint {
+        _z?: number;
+        z: number;
+        cb?: any;
+        scope?: any;
+        set(x?: number, y?: number, z?: number): void;
+    }
+}
+declare module PIXI.projection {
+    class Point3d extends PIXI.Point {
+        constructor(x?: number, y?: number, z?: number);
+        set(x?: number, y?: number, z?: number): this;
+        copyFrom(p: PIXI.IPoint): this;
+        copyTo(p: PIXI.Point): PIXI.Point;
+    }
+    class ObservablePoint3d extends PIXI.ObservablePoint {
+        _z: number;
+        get z(): number;
+        set z(value: number);
+        set(x?: number, y?: number, z?: number): this;
+        copyFrom(p: PIXI.IPoint): this;
+        copyTo(p: PIXI.IPoint): PIXI.IPoint;
+    }
+}
+declare module PIXI.projection {
+    class Projection3d extends LinearProjection<Matrix3d> {
+        constructor(legacy: PIXI.Transform, enable?: boolean);
+        cameraMatrix: Matrix3d;
+        _cameraMode: boolean;
+        get cameraMode(): boolean;
+        set cameraMode(value: boolean);
+        position: ObservablePoint3d;
+        scale: ObservablePoint3d;
+        euler: ObservableEuler;
+        pivot: ObservablePoint3d;
+        onChange(): void;
+        clear(): void;
+        updateLocalTransform(lt: PIXI.Matrix): void;
+    }
+}
+declare module PIXI {
+    interface Container {
+        convertTo3d(): void;
+        convertSubtreeTo3d(): void;
+    }
+}
+declare module PIXI.projection {
+}
+declare module PIXI.projection {
+    class Sprite3d extends PIXI.Sprite {
+        constructor(texture: PIXI.Texture);
+        vertexData2d: Float32Array;
+        proj: Projection3d;
+        culledByFrustrum: boolean;
+        trimmedCulledByFrustrum: boolean;
+        calculateVertices(): void;
+        calculateTrimmedVertices(): void;
+        _calculateBounds(): void;
+        _render(renderer: PIXI.Renderer): void;
+        containsPoint(point: PIXI.IPoint): boolean;
+        get worldTransform(): any;
+        toLocal<T extends PIXI.IPoint>(position: PIXI.IPoint, from?: PIXI.DisplayObject, point?: T, skipUpdate?: boolean, step?: TRANSFORM_STEP): T;
+        isFrontFace(forceUpdate?: boolean): any;
+        getDepth(forceUpdate?: boolean): any;
+        get position3d(): PIXI.IPoint;
+        get scale3d(): PIXI.IPoint;
+        get euler(): Euler;
+        get pivot3d(): PIXI.IPoint;
+        set position3d(value: PIXI.IPoint);
+        set scale3d(value: PIXI.IPoint);
+        set euler(value: Euler);
+        set pivot3d(value: PIXI.IPoint);
+    }
+}
+declare module PIXI.projection {
+    class Text3d extends PIXI.Text {
+        constructor(text?: string, style?: PIXI.TextStyle, canvas?: HTMLCanvasElement);
+        proj: Projection3d;
+        vertexData2d: Float32Array;
+        get worldTransform(): any;
+        toLocal<T extends PIXI.IPoint>(position: PIXI.IPoint, from?: PIXI.DisplayObject, point?: T, skipUpdate?: boolean, step?: TRANSFORM_STEP): T;
+        isFrontFace(forceUpdate?: boolean): any;
+        getDepth(forceUpdate?: boolean): any;
+        get position3d(): PIXI.IPoint;
+        get scale3d(): PIXI.IPoint;
+        get euler(): IEuler;
+        get pivot3d(): PIXI.IPoint;
+        set position3d(value: PIXI.IPoint);
+        set scale3d(value: PIXI.IPoint);
+        set euler(value: IEuler);
+        set pivot3d(value: PIXI.IPoint);
+    }
+}
+declare module PIXI.projection.utils {
+    import IPoint = PIXI.IPoint;
+    function getIntersectionFactor(p1: IPoint, p2: IPoint, p3: IPoint, p4: IPoint, out: IPoint): number;
+    function getPositionFromQuad(p: Array<IPoint>, anchor: IPoint, out: IPoint): IPoint;
+}
+
+namespace ct {
+    camera3d: Camera3d
+}
diff --git a/app/data/ct.libs/PIXI.MultiStyleText/README.md b/app/data/ct.libs/PIXI.MultiStyleText/README.md
new file mode 100644
index 000000000..74b3aa402
--- /dev/null
+++ b/app/data/ct.libs/PIXI.MultiStyleText/README.md
@@ -0,0 +1,70 @@
+# Multistyle text for pixi.js
+
+This module allows **creating** *multistyle* labels in your ***game***.
+
+## Example
+
+In the example below, we are defining 4 text styles.
+`default` is the default style for the text, and the others matches the tags inside the text.
+
+```js
+this.label = new PIXI.MultiStyleText(
+    "Let's make some <ml>multiline</ml>\nand <ms>multistyle</ms> text for\n<ct>ct.js!</ct>", {
+    "default": ct.styles.get('Text_Regular'),
+    "ml": {
+        fontStyle: "italic",
+        fill: "#ff8888"
+    },
+    "ms": {
+        fontStyle: "italic",
+        fill: "#4488ff"
+    },
+    "ct": {
+        fontSize: "64px",
+        fill: "#446adb"
+    }
+});
+this.addChild(this.label);
+```
+
+You can use ct.js styles as well:
+
+```js
+this.label = new PIXI.MultiStyleText(
+    'Let\'s make some <comic>fancy</comic> and <red>terrifying</red> text styles', {
+    default: ct.styles.get('Text_Regular'),
+    comic: ct.styles.get('Text_Comic'),
+    red: ct.styles.get('Text_Red')
+});
+this.addChild(this.label);
+```
+
+## License
+
+Original module is distributed under MIT license, and this catmod is released as a part of ct.js, under MIT license.
+
+Here is the original license:
+
+```
+The MIT License (MIT)
+
+Copyright (c) 2014 Tommy Leunen
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.
+```
diff --git a/app/data/ct.libs/PIXI.MultiStyleText/index.js b/app/data/ct.libs/PIXI.MultiStyleText/index.js
new file mode 100644
index 000000000..f0a5c7eb5
--- /dev/null
+++ b/app/data/ct.libs/PIXI.MultiStyleText/index.js
@@ -0,0 +1 @@
+!function(t,e){t.PIXI.MultiStyleText=e(t.PIXI)}(this,function(t){var e=["pointerover","pointerenter","pointerdown","pointermove","pointerup","pointercancel","pointerout","pointerleave","gotpointercapture","lostpointercapture","mouseover","mouseenter","mousedown","mousemove","mouseup","mousecancel","mouseout","mouseleave","touchover","touchenter","touchdown","touchmove","touchup","touchcancel","touchout","touchleave"],i={bbcode:["[","]"],xml:["<",">"]},s=function(s){function o(t,i){var o=this;s.call(this,t),this.styles=i,e.forEach(function(t){o.on(t,function(t){return o.handleInteraction(t)})})}s&&(o.__proto__=s),(o.prototype=Object.create(s&&s.prototype)).constructor=o;var n={styles:{configurable:!0}};return o.prototype.handleInteraction=function(t){var e=t,i=t.data.getLocalPosition(this),s=this.hitboxes.reduce(function(t,e){return void 0!==t?t:e.hitbox.contains(i.x,i.y)?e:void 0},void 0);e.targetTag=void 0===s?void 0:s.tag},n.styles.set=function(e){for(var i in this.textStyles={},this.textStyles.default=this.assign({},o.DEFAULT_TAG_STYLE),e)"default"===i?this.assign(this.textStyles.default,e[i]):this.textStyles[i]=this.assign({},e[i]);"bbcode"===this.textStyles.default.tagStyle&&(this.textStyles.b=this.assign({},{fontStyle:"bold"}),this.textStyles.i=this.assign({},{fontStyle:"italic"}),this.textStyles.color=this.assign({},{fill:""}),this.textStyles.outline=this.assign({},{stroke:"",strokeThickness:6}),this.textStyles.font=this.assign({},{fontFamily:""}),this.textStyles.shadow=this.assign({},{dropShadowColor:"",dropShadow:!0,dropShadowBlur:3,dropShadowDistance:3,dropShadowAngle:2}),this.textStyles.size=this.assign({},{fontSize:"px"}),this.textStyles.spacing=this.assign({},{letterSpacing:""}),this.textStyles.align=this.assign({},{align:""})),this.withPrivateMembers()._style=new t.TextStyle(this.textStyles.default),this.withPrivateMembers().dirty=!0},o.prototype.setTagStyle=function(e,i){e in this.textStyles?this.assign(this.textStyles[e],i):this.textStyles[e]=this.assign({},i),this.withPrivateMembers()._style=new t.TextStyle(this.textStyles.default),this.withPrivateMembers().dirty=!0},o.prototype.deleteTagStyle=function(e){"default"===e?this.textStyles.default=this.assign({},o.DEFAULT_TAG_STYLE):delete this.textStyles[e],this.withPrivateMembers()._style=new t.TextStyle(this.textStyles.default),this.withPrivateMembers().dirty=!0},o.prototype.getTagRegex=function(t,e){var s=Object.keys(this.textStyles).join("|");s=t?"("+s+")":"(?:"+s+")";var o="bbcode"===this.textStyles.default.tagStyle?"\\"+i.bbcode[0]+s+"(?:\\=(?:[A-Za-z0-9_\\-\\#]+|'(?:[^']+|\\\\')*'))*\\s*\\"+i.bbcode[1]+"|\\"+i.bbcode[0]+"\\/"+s+"\\s*\\"+i.bbcode[1]:"\\"+i.xml[0]+s+"(?:\\s+[A-Za-z0-9_\\-]+=(?:\"(?:[^\"]+|\\\\\")*\"|'(?:[^']+|\\\\')*'))*\\s*\\"+i.xml[1]+"|\\"+i.xml[0]+"\\/"+s+"\\s*\\"+i.xml[1];return e&&(o="("+o+")"),new RegExp(o,"g")},o.prototype.getPropertyRegex=function(){return new RegExp("([A-Za-z0-9_\\-]+)=(?:\"((?:[^\"]+|\\\\\")*)\"|'((?:[^']+|\\\\')*)')","g")},o.prototype.getBBcodePropertyRegex=function(){return new RegExp("[A-Za-z0-9_\\-]+=([A-Za-z0-9_\\-\\#]+)","g")},o.prototype._getTextDataPerLine=function(t){for(var e=[],s=this.getTagRegex(!0,!1),o=[this.assign({},this.textStyles.default)],n=[{name:"default",properties:{}}],r=0;r<t.length;r++){for(var a=[],h=[],l=void 0;l=s.exec(t[r]);)h.push(l);if(0===h.length)a.push(this.createTextData(t[r],o[o.length-1],n[n.length-1]));else{for(var c=0,x=0;x<h.length;x++){if(h[x].index>c&&a.push(this.createTextData(t[r].substring(c,h[x].index),o[o.length-1],n[n.length-1])),"/"===h[x][0][1])o.length>1&&(o.pop(),n.pop());else{for(var d={},g=this.getPropertyRegex(),p=void 0;p=g.exec(h[x][0]);)d[p[1]]=p[2]||p[3];if(n.push({name:h[x][1],properties:d}),"bbcode"===this.textStyles.default.tagStyle&&h[x][0].includes("=")&&this.textStyles[h[x][1]]){var u=this.getBBcodePropertyRegex().exec(h[x][0]),f={};Object.entries(this.textStyles[h[x][1]]).forEach(function(t){f[t[0]]="string"!=typeof t[1]?t[1]:u[1]+t[1]}),o.push(this.assign({},o[o.length-1],f))}else o.push(this.assign({},o[o.length-1],this.textStyles[h[x][1]]))}c=h[x].index+h[x][0].length}if(c<t[r].length){var y=this.createTextData(c?t[r].substring(c):t[r],o[o.length-1],n[n.length-1]);a.push(y)}}e.push(a)}var b=this.textStyles.default.tagStyle;return e[e.length-1].map(function(t){t.text.includes(i[b][0])&&(t.text=t.text.match("bbcode"===b?/^(.*)\[/:/^(.*)\</)[1])}),e},o.prototype.getFontString=function(e){return new t.TextStyle(e).toFontString()},o.prototype.createTextData=function(t,e,i){return{text:t,style:e,width:0,height:0,fontProperties:void 0,tag:i}},o.prototype.getDropShadowPadding=function(){var t=this,e=0,i=0;return Object.keys(this.textStyles).forEach(function(s){var o=t.textStyles[s],n=o.dropShadowBlur;e=Math.max(e,o.dropShadowDistance||0),i=Math.max(i,n||0)}),e+i},o.prototype.withPrivateMembers=function(){return this},o.prototype.updateText=function(){var e=this;if(this.withPrivateMembers().dirty){this.hitboxes=[],this.texture.baseTexture.resolution=this.resolution;var i=this.textStyles,s=this.text;this.withPrivateMembers()._style.wordWrap&&(s=this.wordWrap(this.text));for(var n=s.split(/(?:\r\n|\r|\n)/),r=this._getTextDataPerLine(n),a=[],h=[],l=[],c=0,x=0;x<n.length;x++){for(var d=0,g=0,p=0,u=0;u<r[x].length;u++){var f=r[x][u].style;this.context.font=this.getFontString(f),r[x][u].width=this.context.measureText(r[x][u].text).width,0!==r[x][u].text.length&&(r[x][u].width+=(r[x][u].text.length-1)*f.letterSpacing,u>0&&(d+=f.letterSpacing/2),u<r[x].length-1&&(d+=f.letterSpacing/2)),d+=r[x][u].width,r[x][u].fontProperties=t.TextMetrics.measureFont(this.context.font),r[x][u].height=r[x][u].fontProperties.fontSize,"number"==typeof f.valign?(g=Math.min(g,f.valign-r[x][u].fontProperties.descent),p=Math.max(p,f.valign+r[x][u].fontProperties.ascent)):(g=Math.min(g,-r[x][u].fontProperties.descent),p=Math.max(p,r[x][u].fontProperties.ascent))}a[x]=d,h[x]=g,l[x]=p,c=Math.max(c,d)}var y=Object.keys(i).map(function(t){return i[t]}).reduce(function(t,e){return Math.max(t,e.strokeThickness||0)},0),b=this.getDropShadowPadding(),S=c+2*y+2*b,v=l.reduce(function(t,e){return t+e},0)-h.reduce(function(t,e){return t+e},0)+2*y+2*b;this.canvas.width=S*this.resolution,this.canvas.height=v*this.resolution,this.context.scale(this.resolution,this.resolution),this.context.textBaseline="alphabetic",this.context.lineJoin="round";for(var m=b+y,w=[],T=0;T<r.length;T++){var P=r[T],k=void 0;switch(this.withPrivateMembers()._style.align){case"left":k=b+y;break;case"center":k=b+y+(c-a[T])/2;break;case"right":k=b+y+c-a[T]}for(var _=0;_<P.length;_++){var M=P[_],F=M.style,O=M.text,A=M.fontProperties,E=M.width,D=M.tag,B=m+A.ascent;switch(F.valign){case"top":break;case"baseline":B+=l[T]-A.ascent;break;case"middle":B+=(l[T]-h[T]-A.ascent-A.descent)/2;break;case"bottom":B+=l[T]-h[T]-A.ascent-A.descent;break;default:B+=l[T]-A.ascent-F.valign}if(0===F.letterSpacing)w.push({text:O,style:F,x:k,y:B,width:E,ascent:A.ascent,descent:A.descent,tag:D}),k+=P[_].width;else{this.context.font=this.getFontString(P[_].style);for(var W=0;W<O.length;W++){(W>0||_>0)&&(k+=F.letterSpacing/2);var j=this.context.measureText(O.charAt(W)).width;w.push({text:O.charAt(W),style:F,x:k,y:B,width:j,ascent:A.ascent,descent:A.descent,tag:D}),k+=j,(W<O.length-1||_<P.length-1)&&(k+=F.letterSpacing/2)}}}m+=l[T]-h[T]}this.context.save(),w.forEach(function(i){var s=i.style,o=i.text,n=i.x,r=i.y;if(s.dropShadow){e.context.font=e.getFontString(s);var a=s.dropShadowColor;"number"==typeof a&&(a=t.utils.hex2string(a)),e.context.shadowColor=a,e.context.shadowBlur=s.dropShadowBlur,e.context.shadowOffsetX=Math.cos(s.dropShadowAngle)*s.dropShadowDistance*e.resolution,e.context.shadowOffsetY=Math.sin(s.dropShadowAngle)*s.dropShadowDistance*e.resolution,e.context.fillText(o,n,r)}}),this.context.restore(),w.forEach(function(i){var s=i.style,o=i.text,n=i.x,r=i.y;if(void 0!==s.stroke&&s.strokeThickness){e.context.font=e.getFontString(s);var a=s.stroke;"number"==typeof a&&(a=t.utils.hex2string(a)),e.context.strokeStyle=a,e.context.lineWidth=s.strokeThickness,e.context.strokeText(o,n,r)}}),w.forEach(function(i){var s=i.style,o=i.text,n=i.x,r=i.y;if(void 0!==s.fill){e.context.font=e.getFontString(s);var a=s.fill;if("number"==typeof a)a=t.utils.hex2string(a);else if(Array.isArray(a))for(var h=0;h<a.length;h++){var l=a[h];"number"==typeof l&&(a[h]=t.utils.hex2string(l))}e.context.fillStyle=e._generateFillStyle(new t.TextStyle(s),[o]),e.context.fillText(o,n,r)}}),w.forEach(function(i){var s=i.style,n=i.x,r=i.y,a=i.width,h=i.ascent,l=i.descent,c=i.tag,x=-e.withPrivateMembers()._style.padding-e.getDropShadowPadding();e.hitboxes.push({tag:c,hitbox:new t.Rectangle(n+x,r-h+x,a,h+l)}),(void 0===s.debug?o.debugOptions.spans.enabled:s.debug)&&(e.context.lineWidth=1,o.debugOptions.spans.bounding&&(e.context.fillStyle=o.debugOptions.spans.bounding,e.context.strokeStyle=o.debugOptions.spans.bounding,e.context.beginPath(),e.context.rect(n,r-h,a,h+l),e.context.fill(),e.context.stroke(),e.context.stroke()),o.debugOptions.spans.baseline&&(e.context.strokeStyle=o.debugOptions.spans.baseline,e.context.beginPath(),e.context.moveTo(n,r),e.context.lineTo(n+a,r),e.context.closePath(),e.context.stroke()),o.debugOptions.spans.top&&(e.context.strokeStyle=o.debugOptions.spans.top,e.context.beginPath(),e.context.moveTo(n,r-h),e.context.lineTo(n+a,r-h),e.context.closePath(),e.context.stroke()),o.debugOptions.spans.bottom&&(e.context.strokeStyle=o.debugOptions.spans.bottom,e.context.beginPath(),e.context.moveTo(n,r+l),e.context.lineTo(n+a,r+l),e.context.closePath(),e.context.stroke()),o.debugOptions.spans.text&&(e.context.fillStyle="#ffffff",e.context.strokeStyle="#000000",e.context.lineWidth=2,e.context.font="8px monospace",e.context.strokeText(c.name,n,r-h+8),e.context.fillText(c.name,n,r-h+8),e.context.strokeText(a.toFixed(2)+"x"+(h+l).toFixed(2),n,r-h+16),e.context.fillText(a.toFixed(2)+"x"+(h+l).toFixed(2),n,r-h+16)))}),o.debugOptions.objects.enabled&&(o.debugOptions.objects.bounding&&(this.context.fillStyle=o.debugOptions.objects.bounding,this.context.beginPath(),this.context.rect(0,0,S,v),this.context.fill()),o.debugOptions.objects.text&&(this.context.fillStyle="#ffffff",this.context.strokeStyle="#000000",this.context.lineWidth=2,this.context.font="8px monospace",this.context.strokeText(S.toFixed(2)+"x"+v.toFixed(2),0,8,S),this.context.fillText(S.toFixed(2)+"x"+v.toFixed(2),0,8,S))),this.updateTexture()}},o.prototype.wordWrap=function(t){var e="",i=this.getTagRegex(!0,!0),s=t.split("\n"),o=this.withPrivateMembers()._style.wordWrapWidth,n=[this.assign({},this.textStyles.default)];this.context.font=this.getFontString(this.textStyles.default);for(var r=0;r<s.length;r++){for(var a=o,h=s[r].split(i),l=!0,c=0;c<h.length;c++)if(i.test(h[c]))e+=h[c],"/"===h[c][1]?(c+=2,n.pop()):(n.push(this.assign({},n[n.length-1],this.textStyles[h[++c]])),c++),this.context.font=this.getFontString(n[n.length-1]);else for(var x=h[c].split(" "),d=0;d<x.length;d++){var g=this.context.measureText(x[d]).width;if(this.withPrivateMembers()._style.breakWords&&g>a){var p=x[d].split("");d>0&&(e+=" ",a-=this.context.measureText(" ").width);for(var u=0;u<p.length;u++){var f=this.context.measureText(p[u]).width;f>a?(e+="\n"+p[u],a=o-f):(e+=p[u],a-=f)}}else if(this.withPrivateMembers()._style.breakWords)e+=x[d],a-=g;else{var y=g+(d>0?this.context.measureText(" ").width:0);y>a?(l||(e+="\n"),e+=x[d],a=o-g):(a-=y,d>0&&(e+=" "),e+=x[d])}l=!1}r<s.length-1&&(e+="\n")}return e},o.prototype.updateTexture=function(){var t=this.withPrivateMembers()._texture,e=this.getDropShadowPadding();t.baseTexture.setRealSize(this.canvas.width,this.canvas.height,this.resolution),t.trim.width=t.frame.width=this.canvas.width/this.resolution,t.trim.height=t.frame.height=this.canvas.height/this.resolution,t.trim.x=-this.withPrivateMembers()._style.padding-e,t.trim.y=-this.withPrivateMembers()._style.padding-e,t.orig.width=t.frame.width-2*(this.withPrivateMembers()._style.padding+e),t.orig.height=t.frame.height-2*(this.withPrivateMembers()._style.padding+e),this.withPrivateMembers()._onTextureUpdate(),t.baseTexture.emit("update",t.baseTexture),this.withPrivateMembers().dirty=!1},o.prototype.assign=function(t){for(var e=[],i=arguments.length-1;i-- >0;)e[i]=arguments[i+1];for(var s=0,o=e;s<o.length;s+=1){var n=o[s];for(var r in n)t[r]=n[r]}return t},Object.defineProperties(o.prototype,n),o}(t.Text);return s.DEFAULT_TAG_STYLE={align:"left",breakWords:!1,dropShadow:!1,dropShadowAngle:Math.PI/6,dropShadowBlur:0,dropShadowColor:"#000000",dropShadowDistance:5,fill:"black",fillGradientType:t.TEXT_GRADIENT.LINEAR_VERTICAL,fontFamily:"Arial",fontSize:26,fontStyle:"normal",fontVariant:"normal",fontWeight:"normal",letterSpacing:0,lineHeight:0,lineJoin:"miter",miterLimit:10,padding:0,stroke:"black",strokeThickness:0,textBaseline:"alphabetic",valign:"baseline",wordWrap:!1,wordWrapWidth:100,tagStyle:"xml"},s.debugOptions={spans:{enabled:!1,baseline:"#44BB44",top:"#BB4444",bottom:"#4444BB",bounding:"rgba(255, 255, 255, 0.1)",text:!0},objects:{enabled:!1,bounding:"rgba(255, 255, 255, 0.05)",text:!0}},s});
diff --git a/app/data/ct.libs/PIXI.MultiStyleText/module.json b/app/data/ct.libs/PIXI.MultiStyleText/module.json
new file mode 100644
index 000000000..fd6a9f407
--- /dev/null
+++ b/app/data/ct.libs/PIXI.MultiStyleText/module.json
@@ -0,0 +1,16 @@
+{
+    "main": {
+        "name": "Multistyle text",
+        "tagline": "Make multi-style text with pixi-multistyle-text package, in ct.js.",
+        "version": "1.0.0",
+        "authors": [{
+            "name": "Cosmo Myzrail Gorynych",
+            "mail": "admin@nersta.ru"
+        }, {
+            "name": "Tommy Leunen"
+        }],
+        "categories": [
+            "misc"
+        ]
+    }
+}
diff --git a/app/data/ct.libs/PIXI.MultiStyleText/types.d.ts b/app/data/ct.libs/PIXI.MultiStyleText/types.d.ts
new file mode 100644
index 000000000..17e743878
--- /dev/null
+++ b/app/data/ct.libs/PIXI.MultiStyleText/types.d.ts
@@ -0,0 +1,49 @@
+interface TextStyleExtended extends PIXI.TextStyle {
+    valign?: "top" | "middle" | "bottom" | "baseline" | number;
+    debug?: boolean;
+    tagStyle?: "xml" | "bbcode";
+}
+interface TextStyleSet {
+    [key: string]: TextStyleExtended;
+}
+interface MstDebugOptions {
+    spans: {
+        enabled?: boolean;
+        baseline?: string;
+        top?: string;
+        bottom?: string;
+        bounding?: string;
+        text?: boolean;
+    };
+    objects: {
+        enabled?: boolean;
+        bounding?: string;
+        text?: boolean;
+    };
+}
+
+declare namespace PIXI {
+    class MultiStyleText extends PIXI.Text {
+        private static DEFAULT_TAG_STYLE;
+        static debugOptions: MstDebugOptions;
+        private textStyles;
+        private hitboxes;
+        constructor(text: string, styles: TextStyleSet);
+        private handleInteraction;
+        set styles(styles: TextStyleSet);
+        setTagStyle(tag: string, style: TextStyleExtended): void;
+        deleteTagStyle(tag: string): void;
+        private getTagRegex;
+        private getPropertyRegex;
+        private getBBcodePropertyRegex;
+        private _getTextDataPerLine;
+        private getFontString;
+        private createTextData;
+        private getDropShadowPadding;
+        private withPrivateMembers;
+        updateText(): void;
+        protected wordWrap(text: string): string;
+        protected updateTexture(): void;
+        private assign;
+    }
+}
diff --git a/app/data/ct.libs/fittoscreen/index.js b/app/data/ct.libs/fittoscreen/index.js
index 4728b3649..ff50bb688 100644
--- a/app/data/ct.libs/fittoscreen/index.js
+++ b/app/data/ct.libs/fittoscreen/index.js
@@ -6,7 +6,10 @@
         const pixelScaleModifier = ct.highDensity ? (window.devicePixelRatio || 1) : 1;
         const kw = window.innerWidth / ct.roomWidth,
               kh = window.innerHeight / ct.roomHeight;
-        const k = Math.min(kw, kh);
+        let k = Math.min(kw, kh);
+        if (mode === 'fastScaleInteger') {
+            k = k < 1 ? k : Math.floor(k);
+        }
         var canvasWidth, canvasHeight,
             cameraWidth, cameraHeight;
         if (mode === 'expandViewport' || mode === 'expand') {
@@ -14,7 +17,7 @@
             canvasHeight = Math.ceil(window.innerHeight * pixelScaleModifier);
             cameraWidth = window.innerWidth;
             cameraHeight = window.innerHeight;
-        } else if (mode === 'fastScale') {
+        } else if (mode === 'fastScale' || mode === 'fastScaleInteger') {
             canvasWidth = Math.ceil(ct.roomWidth * pixelScaleModifier);
             canvasHeight = Math.ceil(ct.roomHeight * pixelScaleModifier);
             cameraWidth = ct.roomWidth;
@@ -44,7 +47,7 @@
         ct.camera.width = cameraWidth;
         ct.camera.height = cameraHeight;
 
-        if (mode === 'fastScale') {
+        if (mode === 'fastScale' || mode === 'fastScaleInteger') {
             canv.style.transform = `translate(-50%, -50%) scale(${k})`;
             canv.style.position = 'absolute';
             canv.style.top = '50%';
diff --git a/app/data/ct.libs/fittoscreen/module.json b/app/data/ct.libs/fittoscreen/module.json
index 2475e427e..988bc26dc 100644
--- a/app/data/ct.libs/fittoscreen/module.json
+++ b/app/data/ct.libs/fittoscreen/module.json
@@ -2,7 +2,7 @@
     "main": {
         "name": "Fit to Screen",
         "tagline": "Do nothing and watch your game adapt to any resolution :)",
-        "version": "4.0.0",
+        "version": "4.1.0",
         "authors": [{
             "name": "Cosmo Myzrail Gorynych",
             "mail": "admin@nersta.ru"
@@ -18,6 +18,10 @@
             "value": "fastScale",
             "name": "Fast scaling with letterboxing",
             "help": "This will not change variables `ct.width` and `ct.height` and will try to fit the drawing canvas to the screen. This may result in a blurry image, but you can disable image smoothing in your project's settigs."
+        }, {
+            "value": "fastScaleInteger",
+            "name": "Fast integer scaling with letterboxing",
+            "help": "Similar to fast scaling, but it will try to scale by whole numbers: x1, x2, x3 and so on. It will often leave gaps around the game, but will show the best of your pixelart skills."
         }, {
             "value": "expand",
             "name": "Expand",
diff --git a/app/data/ct.libs/flow/module.json b/app/data/ct.libs/flow/module.json
index 887978bc4..3e368f29b 100644
--- a/app/data/ct.libs/flow/module.json
+++ b/app/data/ct.libs/flow/module.json
@@ -2,7 +2,7 @@
     "main": {
         "name": "Flow control and timing",
         "tagline": "Add high-level methods for asynchronous events, e.g. gate, cumulative delay, retriggerable delay.",
-        "version": "0.0.0",
+        "version": "1.0.0",
         "authors": [{
             "name": "Cosmo Myzrail Gorynych",
             "mail": "admin@nersta.ru"
diff --git a/app/data/ct.libs/fs/DOCS.md b/app/data/ct.libs/fs/DOCS.md
index c30bd3db3..5a6428916 100644
--- a/app/data/ct.libs/fs/DOCS.md
+++ b/app/data/ct.libs/fs/DOCS.md
@@ -3,6 +3,9 @@
 When set to `true`, any operations towards files outside the game's save directory will fail.
 Set to `true` by default.
 
+## `ct.fs.isAvailable: boolean`
+When set to `false`, the game is running in a way that disallows access to the filesystem (such as a web release)
+
 ## `ct.fs.save(filename: string, data: object|Array): Promise<void>`
 
 Saves an object/array to a file.
diff --git a/app/data/ct.libs/fs/README.md b/app/data/ct.libs/fs/README.md
index 3dc9785bf..5216f29a4 100644
--- a/app/data/ct.libs/fs/README.md
+++ b/app/data/ct.libs/fs/README.md
@@ -1,12 +1,21 @@
-A module that provides a uniform API for storing and loading data for your desktop games.
+A module that provides a uniform API for storing and loading data for games exported for desktop.
 
 It allows you to easily save and load JSON objects, as well as plain text data.
 
-JSON objects are regular JavaScript objects, but without functions, Date objects, RegExps, circular references, and some other advanced stuff. If your variable consists of other objects, arrays, strings, numbers and boolean, it can be safely stored, and loaded later in the same form. Thus, they are great for saving your game state.
+[JSON objects](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON) are regular JavaScript objects, but without functions, Date objects, RegExps, circular references, and some other advanced stuff. If your variable consists of other objects, arrays, strings, numbers and boolean, it can be safely stored, and loaded later in the same form. Thus, they are great for saving your game state.
 
-By default, all the file operations will be performed relative to a special directory created for your game, which is templated as a `${User's home directory}/${Author name from the settings tab}/${Project's name from the same tab}`, e.g. `/home/comigo/Cosmo Myzrail Gorynych/Platformer complete tutorial` on a Linux machine. You can inspect this behavior by calling `ct.fs.getPath(...)`, or simply by reading ct.fs.gameFolder parameter.
+By default, all file operations will be done under the application data directory on the player's system. For Windows this will be `%AppData%`, Linux will be `$XDG_DATA_HOME` (or `$HOME/.local/share` if unset), and macOS will be `$HOME/Library/Application Support`. For other operating systems, the player's home directory will be used instead.
 
-This behavior can be changed by setting `ct.fs.gameFolder`, but it's not recommended unless you changed your meta fields and need to preserved user's data.
+Within the application directory, ct.js will create a path using the defined Author Name and Project Name. If both are set, then the path would be `${Application Data Path}/${Author Name}/${Project Name}`, if only the Project Name is set, it would be `${Application Data Path}/${Project Name}`.
+
+> **For Example**: If a player with the name `naturecodevoid` was running the game `jettyCat` by `comigo` on Linux, the default directory would be:
+ `/home/naturecodevoid/.local/share/comigo/jettyCat`
+
+ You can verify this by calling `ct.fs.getPath('')` or by checking the variable `ct.fs.gameFolder`.
+
+It is not recommended, but you can set `ct.fs.gameFolder` to a different directory. This is useful if your meta fields (Author Name, Project Name) have changed, but you wish to preserve user data.
+
+Also to note, operations outside of the game folder are not recommended and by default are not allowed, causing an error to appear in the game's console. To allow operations outside of the game folder set `ct.fs.forceLocal` to `false` first.
 
 Every action in `ct.fs` is asynchronous so that a game stays responsive even on heavy loads, and thus you have to use JS Promises. This is not hard, though:
 
diff --git a/app/data/ct.libs/fs/index.js b/app/data/ct.libs/fs/index.js
index cac14b7e7..9611a5480 100644
--- a/app/data/ct.libs/fs/index.js
+++ b/app/data/ct.libs/fs/index.js
@@ -2,21 +2,74 @@
 /* eslint-disable no-console */
 
 try {
+    // This will fail when in a browser so no browser checking required
     const fs = require('fs').promises;
     const path = require('path');
 
+    // Like an enum, but not.
+    const operatingSystems = {
+        Windows: 'win',
+        macOS: 'mac',
+        ChromeOS: 'cros',
+        Linux: 'linux',
+        iOS: 'ios',
+        Android: 'android'
+    };
+
     // The `HOME` variable is not always available in ct.js on Windows
     const home = process.env.HOME || ((process.env.HOMEDRIVE || '') + process.env.HOMEPATH);
 
+    // Borrowed from keyboard.polyfill
+    const contains = function contains(s, ss) {
+        return String(s).indexOf(ss) !== -1;
+    };
+
+    const operatingSystem = (function getOperatingSystem() {
+        if (contains(navigator.platform, 'Win')) {
+            return operatingSystems.Windows;
+        }
+        if (contains(navigator.platform, 'Mac')) {
+            return operatingSystems.macOS;
+        }
+        if (contains(navigator.platform, 'CrOS')) {
+            return operatingSystems.ChromeOS;
+        }
+        if (contains(navigator.platform, 'Linux')) {
+            return operatingSystems.Linux;
+        }
+        if (contains(navigator.userAgent, 'iPad') || contains(navigator.platform, 'iPod') || contains(navigator.platform, 'iPhone')) {
+            return operatingSystems.iOS;
+        }
+        return '';
+    }());
+
+
+    const getAppData = (home, operatingSystem) => {
+        switch (operatingSystem) {
+        case operatingSystems.Windows:
+            return process.env.AppData;
+        case operatingSystems.macOS:
+            return `${home}/Library/Application Support`;
+        case operatingSystems.Linux:
+            return process.env.XDG_DATA_HOME || `${home}/.local/share`;
+            // Don't know what to do for ChromeOS or iOS, do they use AppData or
+            // Should those default to LocalStorage?
+        default:
+            return home;
+        }
+    };
+
+    const appData = getAppData(home, operatingSystem);
+
     const getPath = dest => {
-        const d = path.isAbsolute(dest)? dest : path.join(ct.fs.gameFolder, dest);
+        const absoluteDest = path.isAbsolute(dest) ? dest : path.join(ct.fs.gameFolder, dest);
         if (ct.fs.forceLocal) {
-            if (d.indexOf(ct.fs.gameFolder) !== 0) {
+            if (absoluteDest.indexOf(ct.fs.gameFolder) !== 0) {
                 throw new Error('[ct.fs] Operations outside the save directory are not permitted by default due to safety concerns. If you do need to work outside the save directory, change `ct.fs.forceLocal` to `false`. ' +
-                    `The save directory: "${ct.fs.gameFolder}", the target directory: "${dest}", which resolves into "${d}".`);
+                    `The save directory: "${ct.fs.gameFolder}", the target directory: "${dest}", which resolves into "${absoluteDest}".`);
             }
         }
-        return d;
+        return absoluteDest;
     };
     const ensureParents = async dest => {
         const parents = path.dirname(getPath(dest));
@@ -25,19 +78,20 @@ try {
         });
     };
 
+
     ct.fs = {
         isAvailable: true,
-        gameFolder: path.join(home, ct.meta.author || '', ct.meta.name || 'Ct.js game'),
+        gameFolder: path.join(appData, ct.meta.author || '', ct.meta.name || 'Ct.js game'),
         forceLocal: true,
 
-        async save(filename, data) {
+        async save(filename, jsonData) {
             await ensureParents(filename);
-            await fs.writeFile(getPath(filename), JSON.stringify(data), 'utf8');
+            await fs.writeFile(getPath(filename), JSON.stringify(jsonData), 'utf8');
             return void 0;
         },
         async load(filename) {
-            const data = await fs.readFile(getPath(filename), 'utf8');
-            return JSON.parse(data);
+            const textData = await fs.readFile(getPath(filename), 'utf8');
+            return JSON.parse(textData);
         },
         async saveText(filename, text) {
             if (!text && text !== '') {
diff --git a/app/data/ct.libs/fs/types.d.ts b/app/data/ct.libs/fs/types.d.ts
index 9e0f20f19..cb396a608 100644
--- a/app/data/ct.libs/fs/types.d.ts
+++ b/app/data/ct.libs/fs/types.d.ts
@@ -36,6 +36,16 @@ declare namespace ct {
          */
         var forceLocal: boolean;
 
+        /**
+         * When set to `false`, the game is running in a way that disallows access to the filesystem (such as a web release)
+         */
+        var isAvailable: boolean;
+
+        /**
+         * The base location for application data. Not for normal usage.
+         */
+        var gameFolder: string;
+
         /** Saves an object/array to a file. */
         function save(filename: string, data: object|any[]): Promise<void>;
 
diff --git a/app/data/ct.libs/keyboard.legacy/module.json b/app/data/ct.libs/keyboard.legacy/module.json
index da667b9d9..46153202d 100644
--- a/app/data/ct.libs/keyboard.legacy/module.json
+++ b/app/data/ct.libs/keyboard.legacy/module.json
@@ -3,6 +3,7 @@
         "name": "Keyboard (legacy)",
         "tagline": "The old implementation of ct.keyboard that did not support Actions system.",
         "version": "2.0.0",
+        "deprecated": true,
         "authors": [{
             "name": "Cosmo Myzrail Gorynych",
             "mail": "admin@nersta.ru"
diff --git a/app/data/ct.libs/mouse.legacy/module.json b/app/data/ct.libs/mouse.legacy/module.json
index 77c461732..cebc3e311 100644
--- a/app/data/ct.libs/mouse.legacy/module.json
+++ b/app/data/ct.libs/mouse.legacy/module.json
@@ -3,6 +3,7 @@
         "name": "Mouse Input (legacy)",
         "tagline": "A deprecated module to listen to mouse events; does not work with Actions system.",
         "version": "1.0.0",
+        "deprecated": true,
         "authors": [{
             "name": "Cosmo Myzrail Gorynych",
             "mail": "admin@nersta.ru"
diff --git a/app/data/ct.libs/mouse/index.js b/app/data/ct.libs/mouse/index.js
index db67bc286..472f08abb 100644
--- a/app/data/ct.libs/mouse/index.js
+++ b/app/data/ct.libs/mouse/index.js
@@ -1,123 +1,132 @@
-(function () {
-    var keyPrefix = 'mouse.';
-    var setKey = function(key, value) {
-        ct.inputs.registry[keyPrefix + key] = value;
-    };
-    var buttonMap = {
-        0: 'Left',
-        1: 'Middle',
-        2: 'Right',
-        3: 'Special1',
-        4: 'Special2',
-        5: 'Special3',
-        6: 'Special4',
-        7: 'Special5',
-        8: 'Special6',
-        unknown: 'Unknown'
-    };
-
-    ct.mouse = {
-        xprev: 0,
-        yprev: 0,
-        xuiprev: 0,
-        yuiprev: 0,
-        inside: false,
-        pressed: false,
-        down: false,
-        released: false,
-        button: 0,
-        hovers(copy) {
-            if (!copy.shape) {
-                return false;
-            }
-            if (copy.shape.type === 'rect') {
-                return ct.u.prect(ct.mouse.x, ct.mouse.y, copy);
-            }
-            if (copy.shape.type === 'circle') {
-                return ct.u.pcircle(ct.mouse.x, ct.mouse.y, copy);
-            }
-            if (copy.shape.type === 'point') {
-                return ct.mouse.x === copy.x && ct.mouse.y === copy.y;
-            }
-            return false;
-        },
-        hoversUi(copy) {
-            if (!copy.shape) {
-                return false;
-            }
-            if (copy.shape.type === 'rect') {
-                return ct.u.prect(ct.mouse.xui, ct.mouse.yui, copy);
-            }
-            if (copy.shape.type === 'circle') {
-                return ct.u.pcircle(ct.mouse.xui, ct.mouse.yui, copy);
-            }
-            if (copy.shape.type === 'point') {
-                return ct.mouse.xui === copy.x && ct.mouse.yui === copy.y;
-            }
-            return false;
-        },
-        hide() {
-            ct.pixiApp.renderer.view.style.cursor = 'none';
-        },
-        show() {
-            ct.pixiApp.renderer.view.style.cursor = '';
-        }
-    };
-
-    ct.mouse.listenerMove = function(e) {
-        var rect = ct.pixiApp.view.getBoundingClientRect();
-        ct.mouse.xui = (e.clientX - rect.left) * ct.camera.width / rect.width;
-        ct.mouse.yui = (e.clientY - rect.top) * ct.camera.height / rect.height;
-        [ct.mouse.x, ct.mouse.y] = ct.u.uiToGameCoord(ct.mouse.xui, ct.mouse.yui);
-        if (ct.mouse.xui > 0 && ct.mouse.yui > 0 && ct.mouse.yui < ct.camera.height && ct.mouse.xui < ct.camera.width) {
-            ct.mouse.inside = true;
-        } else {
-            ct.mouse.inside = false;
-        }
-        window.focus();
-    };
-    ct.mouse.listenerDown = function (e) {
-        setKey(buttonMap[e.button] || buttonMap.unknown, 1);
-        ct.mouse.pressed = true;
-        ct.mouse.down = true;
-        ct.mouse.button = e.button;
-        window.focus();
-        e.preventDefault();
-    };
-    ct.mouse.listenerUp = function (e) {
-        setKey(buttonMap[e.button] || buttonMap.unknown, 0);
-        ct.mouse.released = true;
-        ct.mouse.down = false;
-        ct.mouse.button = e.button;
-        window.focus();
-        e.preventDefault();
-    };
-    ct.mouse.listenerContextMenu = function (e) {
-        e.preventDefault();
-    };
-    ct.mouse.listenerWheel = function (e) {
-        setKey('Wheel', ((e.wheelDelta || -e.detail) < 0)? -1 : 1);
-        //e.preventDefault();
-    };
-
-    ct.mouse.setupListeners = function () {
-        if (document.addEventListener) {
-            document.addEventListener('mousemove', ct.mouse.listenerMove, false);
-            document.addEventListener('mouseup', ct.mouse.listenerUp, false);
-            document.addEventListener('mousedown', ct.mouse.listenerDown, false);
-            document.addEventListener('wheel', ct.mouse.listenerWheel, false, {
-                passive: false
-            });
-            document.addEventListener('contextmenu', ct.mouse.listenerContextMenu, false);
-            document.addEventListener('DOMMouseScroll', ct.mouse.listenerWheel, {
-                passive: false
-            });
-        } else { // IE?
-            document.attachEvent('onmousemove', ct.mouse.listenerMove);
-            document.attachEvent('onmouseup', ct.mouse.listenerUp);
-            document.attachEvent('onmousedown', ct.mouse.listenerDown);
-            document.attachEvent('onmousewheel', ct.mouse.listenerWheel);
-            document.attachEvent('oncontextmenu', ct.mouse.listenerContextMenu);
-        }
-    };
-})();
+(function ctMouse() {
+    var keyPrefix = 'mouse.';
+    var setKey = function (key, value) {
+        ct.inputs.registry[keyPrefix + key] = value;
+    };
+    var buttonMap = {
+        0: 'Left',
+        1: 'Middle',
+        2: 'Right',
+        3: 'Special1',
+        4: 'Special2',
+        5: 'Special3',
+        6: 'Special4',
+        7: 'Special5',
+        8: 'Special6',
+        unknown: 'Unknown'
+    };
+
+    ct.mouse = {
+        xprev: 0,
+        yprev: 0,
+        xuiprev: 0,
+        yuiprev: 0,
+        inside: false,
+        pressed: false,
+        down: false,
+        released: false,
+        button: 0,
+        hovers(copy) {
+            if (!copy.shape) {
+                return false;
+            }
+            if (copy.shape.type === 'rect') {
+                return ct.u.prect(ct.mouse.x, ct.mouse.y, copy);
+            }
+            if (copy.shape.type === 'circle') {
+                return ct.u.pcircle(ct.mouse.x, ct.mouse.y, copy);
+            }
+            if (copy.shape.type === 'point') {
+                return ct.mouse.x === copy.x && ct.mouse.y === copy.y;
+            }
+            return false;
+        },
+        hoversUi(copy) {
+            if (!copy.shape) {
+                return false;
+            }
+            if (copy.shape.type === 'rect') {
+                return ct.u.prect(ct.mouse.xui, ct.mouse.yui, copy);
+            }
+            if (copy.shape.type === 'circle') {
+                return ct.u.pcircle(ct.mouse.xui, ct.mouse.yui, copy);
+            }
+            if (copy.shape.type === 'point') {
+                return ct.mouse.xui === copy.x && ct.mouse.yui === copy.y;
+            }
+            return false;
+        },
+        hide() {
+            ct.pixiApp.renderer.view.style.cursor = 'none';
+        },
+        show() {
+            ct.pixiApp.renderer.view.style.cursor = '';
+        },
+        get x() {
+            return ct.u.uiToGameCoord(ct.mouse.xui, ct.mouse.yui)[0];
+        },
+        get y() {
+            return ct.u.uiToGameCoord(ct.mouse.xui, ct.mouse.yui)[1];
+        }
+    };
+
+    ct.mouse.listenerMove = function listenerMove(e) {
+        var rect = ct.pixiApp.view.getBoundingClientRect();
+        ct.mouse.xui = (e.clientX - rect.left) * ct.camera.width / rect.width;
+        ct.mouse.yui = (e.clientY - rect.top) * ct.camera.height / rect.height;
+        if (ct.mouse.xui > 0 &&
+            ct.mouse.yui > 0 &&
+            ct.mouse.yui < ct.camera.height &&
+            ct.mouse.xui < ct.camera.width
+        ) {
+            ct.mouse.inside = true;
+        } else {
+            ct.mouse.inside = false;
+        }
+        window.focus();
+    };
+    ct.mouse.listenerDown = function listenerDown(e) {
+        setKey(buttonMap[e.button] || buttonMap.unknown, 1);
+        ct.mouse.pressed = true;
+        ct.mouse.down = true;
+        ct.mouse.button = e.button;
+        window.focus();
+        e.preventDefault();
+    };
+    ct.mouse.listenerUp = function listenerUp(e) {
+        setKey(buttonMap[e.button] || buttonMap.unknown, 0);
+        ct.mouse.released = true;
+        ct.mouse.down = false;
+        ct.mouse.button = e.button;
+        window.focus();
+        e.preventDefault();
+    };
+    ct.mouse.listenerContextMenu = function listenerContextMenu(e) {
+        e.preventDefault();
+    };
+    ct.mouse.listenerWheel = function listenerWheel(e) {
+        setKey('Wheel', ((e.wheelDelta || -e.detail) < 0) ? -1 : 1);
+        //e.preventDefault();
+    };
+
+    ct.mouse.setupListeners = function setupListeners() {
+        if (document.addEventListener) {
+            document.addEventListener('mousemove', ct.mouse.listenerMove, false);
+            document.addEventListener('mouseup', ct.mouse.listenerUp, false);
+            document.addEventListener('mousedown', ct.mouse.listenerDown, false);
+            document.addEventListener('wheel', ct.mouse.listenerWheel, false, {
+                passive: false
+            });
+            document.addEventListener('contextmenu', ct.mouse.listenerContextMenu, false);
+            document.addEventListener('DOMMouseScroll', ct.mouse.listenerWheel, {
+                passive: false
+            });
+        } else { // IE?
+            document.attachEvent('onmousemove', ct.mouse.listenerMove);
+            document.attachEvent('onmouseup', ct.mouse.listenerUp);
+            document.attachEvent('onmousedown', ct.mouse.listenerDown);
+            document.attachEvent('onmousewheel', ct.mouse.listenerWheel);
+            document.attachEvent('oncontextmenu', ct.mouse.listenerContextMenu);
+        }
+    };
+})();
diff --git a/app/data/ct.libs/mouse/module.json b/app/data/ct.libs/mouse/module.json
index 8772c3c2b..aa1a5c11f 100644
--- a/app/data/ct.libs/mouse/module.json
+++ b/app/data/ct.libs/mouse/module.json
@@ -2,7 +2,7 @@
     "main": {
         "name": "Mouse Input",
         "tagline": "Get mouse position and read its events in Actions system.",
-        "version": "3.0.0",
+        "version": "3.0.1",
         "authors": [{
             "name": "Cosmo Myzrail Gorynych",
             "mail": "admin@nersta.ru"
diff --git a/app/data/ct.libs/place.legacy/module.json b/app/data/ct.libs/place.legacy/module.json
index 9fe373b9d..e50bace58 100644
--- a/app/data/ct.libs/place.legacy/module.json
+++ b/app/data/ct.libs/place.legacy/module.json
@@ -3,6 +3,7 @@
         "name": "ct.place",
         "tagline": "Old, unoptimized version of collision library.",
         "version": "2.1.0",
+        "deprecated": true,
         "authors": [{
             "name": "Cosmo Myzrail Gorynych",
             "mail": "admin@nersta.ru"
diff --git a/app/data/ct.libs/place/docs/Movement methods.md b/app/data/ct.libs/place/docs/Movement methods.md
index 72921a3bf..0d2ecedac 100644
--- a/app/data/ct.libs/place/docs/Movement methods.md	
+++ b/app/data/ct.libs/place/docs/Movement methods.md	
@@ -8,9 +8,22 @@
 Moves a copy by `stepSize` in a given `direction` untill a `maxLength` is reached or a copy is next to another colliding copy. You can filter collided copies with `ctype`, and set precision with `stepSize` (default is `1`, which means pixel-by-pixel movement). This function is especially useful for side-view games and any fast-moving copies, as it allows precise movement without clipping or passing through surfaces.
 
 
-### this.moveContinuous(ctype);
+## ct.place.moveByAxes(me, dx, dy, [ctype, stepSize])
 
-You can call `this.moveContinuous('CollisionGroup');` at any copy to perform precise movement with collision checks. It takes gravity into account, too, and uses the `ct.place.moveAlong` method.
+Similar to ct.place.moveAlong, this method moves a copy by X and Y axes until dx and dy are reached
+or a copy meets an obstacle on both axes. If an obstacle was met on one axis, a copy may continue
+moving by another axis. You can filter collided copies with `ctype`,
+and set precision with `stepSize` (default is `1`, which means pixel-by-pixel movement).
+This movement suits characters in top-down and side-view worlds.
+
+
+### this.moveContinuous(ctype, [precision]);
+
+You can call `this.moveContinuous('CollisionGroup');` at any copy to perform precise movement with collision checks. It takes gravity and `ct.delta` into account, too, and uses the `ct.place.moveAlong` method.
+
+### this.moveContinuousByAxes(ctype, [precision]);
+
+You can call `this.moveContinuousByAxes('CollisionGroup');` at any copy to perform precise movement with collision checks. It takes gravity and `ct.delta` into account, too, and uses the `ct.place.moveByAxes` method.
 
 
 ## ct.place.go(me, x, y, length, [ctype])
diff --git a/app/data/ct.libs/place/index.js b/app/data/ct.libs/place/index.js
index ceb2486bd..32eadf2b2 100644
--- a/app/data/ct.libs/place/index.js
+++ b/app/data/ct.libs/place/index.js
@@ -376,7 +376,8 @@
             var dx = Math.cos(dir * Math.PI / -180) * precision,
                 dy = Math.sin(dir * Math.PI / -180) * precision;
             for (let i = 0; i < length; i += precision) {
-                const occupied = ct.place.occupied(me, me.x + dx, me.y + dy, ctype);
+                const occupied = ct.place.occupied(me, me.x + dx, me.y + dy, ctype) ||
+                                 ct.place.tile(me, me.x + dx, me.y + dy, ctype);
                 if (!occupied) {
                     me.x += dx;
                     me.y += dy;
@@ -387,6 +388,43 @@
             }
             return false;
         },
+        moveByAxes(me, dx, dy, ctype, precision) {
+            if (typeof ctype === 'number') {
+                precision = ctype;
+                ctype = void 0;
+            }
+            const obstacles = {
+                x: false,
+                y: false
+            };
+            precision = Math.abs(precision || 1);
+            while (Math.abs(dx) > precision) {
+                if (ct.place.free(me, me.x + Math.sign(dx) * precision, me.y, ctype) &&
+                    !ct.place.tile(me, me.x + Math.sign(dx) * precision, me.y, ctype)
+                ) {
+                    me.x += Math.sign(dx) * precision;
+                    dx -= Math.sign(dx) * precision;
+                } else {
+                    obstacles.x = true;
+                    break;
+                }
+            }
+            while (Math.abs(dy) > precision) {
+                if (ct.place.free(me, me.x, me.y + Math.sign(dy) * precision, ctype) &&
+                    !ct.place.tile(me, me.x, me.y + Math.sign(dy) * precision, ctype)
+                ) {
+                    me.y += Math.sign(dy) * precision;
+                    dy -= Math.sign(dy) * precision;
+                } else {
+                    obstacles.y = true;
+                    break;
+                }
+            }
+            if (!obstacles.x && !obstacles.y) {
+                return false;
+            }
+            return obstacles;
+        },
         go(me, x, y, length, ctype) {
             // ct.place.go(<me: Copy, x: number, y: number, length: number>[, ctype: String])
             // tries to reach the target with a simple obstacle avoidance algorithm
diff --git a/app/data/ct.libs/place/injects/start.js b/app/data/ct.libs/place/injects/start.js
index daafa05f0..6a38477f2 100644
--- a/app/data/ct.libs/place/injects/start.js
+++ b/app/data/ct.libs/place/injects/start.js
@@ -1,17 +1,33 @@
 Object.defineProperty(ct.types.Copy.prototype, 'ctype', {
-    set: function(value) {
+    set: function (value) {
         this.$ctype = value;
     },
-    get: function() {
+    get: function () {
         return this.$ctype;
     }
 });
 Object.defineProperty(ct.types.Copy.prototype, 'moveContinuous', {
     value: function (ctype, precision) {
         if (this.gravity) {
-            this.hspeed += this.gravity * ct.delta * Math.cos(this.gravityDir*Math.PI/-180);
-            this.vspeed += this.gravity * ct.delta * Math.sin(this.gravityDir*Math.PI/-180);
+            this.hspeed += this.gravity * ct.delta * Math.cos(this.gravityDir * Math.PI / -180);
+            this.vspeed += this.gravity * ct.delta * Math.sin(this.gravityDir * Math.PI / -180);
         }
-        return ct.place.moveAlong(this, this.direction, this.speed, ctype, precision);
+        return ct.place.moveAlong(this, this.direction, this.speed * ct.delta, ctype, precision);
+    }
+});
+
+Object.defineProperty(ct.types.Copy.prototype, 'moveContinuousByAxes', {
+    value: function (ctype, precision) {
+        if (this.gravity) {
+            this.hspeed += this.gravity * ct.delta * Math.cos(this.gravityDir * Math.PI / -180);
+            this.vspeed += this.gravity * ct.delta * Math.sin(this.gravityDir * Math.PI / -180);
+        }
+        return ct.place.moveByAxes(
+            this,
+            this.hspeed * ct.delta,
+            this.vspeed * ct.delta,
+            ctype,
+            precision
+        );
     }
 });
diff --git a/app/data/ct.libs/place/module.json b/app/data/ct.libs/place/module.json
index 07167e8f3..2ef4bbace 100644
--- a/app/data/ct.libs/place/module.json
+++ b/app/data/ct.libs/place/module.json
@@ -2,7 +2,7 @@
     "main": {
         "name": "ct.place",
         "tagline": "Check for collisions, move copies continuously, and add basic collision avoidance to your copies.",
-        "version": "3.1.0",
+        "version": "3.2.0",
         "authors": [{
             "name": "Cosmo Myzrail Gorynych",
             "mail": "admin@nersta.ru"
diff --git a/app/data/ct.libs/place/types.d.ts b/app/data/ct.libs/place/types.d.ts
index cde8d66d2..995289e8a 100644
--- a/app/data/ct.libs/place/types.d.ts
+++ b/app/data/ct.libs/place/types.d.ts
@@ -144,18 +144,44 @@ declare namespace ct {
 
         /**
          * Moves a copy by `stepSize` in a given `direction` untill a `maxLength` is reached
-         * or a copy is next to another colliding copy. You can filter collided copies with `ctype`,
+         * or a copy is next to an obstacle. You can filter collided copies and tiles with `ctype`,
          * and set precision with `stepSize` (default is `1`, which means pixel-by-pixel movement).
          * This function is especially useful for side-view games and any fast-moving copies,
          * as it allows precise movement without clipping or passing through surfaces.
          *
+         * @remarks
+         * You will usually need to premultiply `maxLength` with `ct.delta` so that the speed is consistent
+         * under different FPS rates.
+         *
          * @param {Copy} me The copy that needs to be moved
-         * @param {number} direction The irection in which to perform a movement
+         * @param {number} direction The direction in which to perform a movement
          * @param {number} maxLength The maximum distance a copy can traverse
          * @param {string} [ctype] A collision group to test against. Tests against every copy if no collision group was specified
          * @param {number} [stepSize=1] Precision of movement
+         * @returns {Copy|boolean} If there was no collision and a copy reached its target, returns `false`.
+         * If a copy met an obstacle as another copy, returns this copy. If there was a tile, returns `true`.
+         */
+        function moveAlong(me: Copy, direction: number, maxLength: number, ctype?: string, stepSize?: number): Copy | boolean;
+
+        /**
+         * Similar to ct.place.moveAlong, this method moves a copy by X and Y axes until dx and dy are reached
+         * or a copy meets an obstacle on both axes. If an obstacle was met on one axis, a copy may continue
+         * moving by another axis. You can filter collided copies with `ctype`,
+         * and set precision with `stepSize` (default is `1`, which means pixel-by-pixel movement).
+         * This movement suits characters in top-down and side-view worlds.
+         *
+         * @remarks
+         * You will usually need to premultiply `dx` and `dy` with `ct.delta` so that the speed is consistent
+         * under different FPS rates.
+         *
+         * @param {Copy} me The copy that needs to be moved
+         * @param {number} dx Amount of pixels to move by X axis
+         * @param {number} dy Amount of pixels to move by Y axis
+         * @param {string} [ctype] A collision group to test against. Tests against every copy if no collision group was specified
+         * @param {number} [stepSize=1] Precision of movement
+         * @returns {false|ISeparateMovementResult} `false` if it reached its target, an object with each axis specified otherwise.
          */
-        function moveAlong(me: Copy, direction: number, maxLength: number, ctype?: string, stepSize?: number): Copy | false;
+        function moveByAxes(me: Copy, dx: number, dy: number, ctype?: string, stepSize?: number): false | ISeparateMovementResult;
 
         /**
          * Tries to reach the target with a simple obstacle avoidance algorithm.
@@ -189,4 +215,24 @@ declare namespace ct {
          */
         function trace(x1: number, y1: number, x2: number, y2: number, ctype?: string): Copy[];
     }
+}
+
+interface ISeparateMovementResult {
+    x: boolean;
+    y: boolean;
+}
+
+interface Copy {
+    /** The current collision group of a copy */
+    ctype: string;
+    /**
+     * Call to perform precise movement with collision checks. It takes gravity
+     * and `ct.delta` into account, too, and uses the `ct.place.moveAlong` method.
+     */
+    moveContinuous(ctype: string, precision?: number): void;
+    /**
+     * Call to perform precise movement with collision checks. It takes gravity
+     * and `ct.delta` into account, too, and uses the `ct.place.moveByAxes` method.
+     */
+    moveContinuousByAxes(ctype: string, precision?: number): void;
 }
\ No newline at end of file
diff --git a/app/data/ct.libs/random/README.md b/app/data/ct.libs/random/README.md
index 16c94c049..359f75ce2 100644
--- a/app/data/ct.libs/random/README.md
+++ b/app/data/ct.libs/random/README.md
@@ -1,21 +1,46 @@
 # ct.random
-
 This module contains handy functions for generating something random.
 
-## `ct.random(x)`
+## Basic methods
+
+### `ct.random(x)`
 Returns a random float value between 0 and x, exclusive.
 
-## `ct.random.dice(dice1,dice2,...diceN)`
+### `ct.random.dice(dice1,dice2,...diceN)`
 Returns a random given argument.
 
-## `ct.random.range(x1, x2)`
+### `ct.random.range(x1, x2)`
 Returns a random float value between `x1` and `x2`, exclusive.
 
-## `ct.random.deg()`
+### `ct.random.deg()`
 Returns a random float value between 0 and 360, exclusive.
 
-## `ct.random.coord()`
+### `ct.random.coord()`
 Returns a pair of random coordinates from 0 to a corresponding room side.
 
-## `ct.random.chance(x[, y])`
+### `ct.random.chance(x[, y])`
 When given both `x` and `y`, randomly returns `true` approximately `x` times out of `y`. When given only a value between 0…100, returns `true` approximately `x` times out of 100. E.g. `ct.random.chance(30)` means a 30% success rate.
+
+## Seeded random
+
+`ct.random` has an initialized seeded random number generator that is persistent across systems and game runs, and also allows creating new random number generators that won't affect the global one. They all use Mulberry32 under the hood.
+
+### `ct.random.seeded()`
+
+Returns next seeded random number.
+
+### `ct.random.setSeed(seed)`
+
+Sets the seed of the `ct.random.seeded()` method.
+
+### `ct.random.createSeededRandomizer(startingSeed)`
+
+Creates a new seeded random number generator. It is a function that you can store and use in the same way as `ct.random.seeded()`:
+
+```js
+this.randomizer = ct.random.createSeededRandomizer(456852);
+// Will output the same numbers on each run
+console.log(this.randomizer());
+console.log(this.randomizer());
+console.log(this.randomizer());
+```
\ No newline at end of file
diff --git a/app/data/ct.libs/random/index.js b/app/data/ct.libs/random/index.js
index fdb7c0a66..96c38ba81 100644
--- a/app/data/ct.libs/random/index.js
+++ b/app/data/ct.libs/random/index.js
@@ -1,28 +1,48 @@
-/* global ct */
-
-ct.random = function (x) {
-    return Math.random()*x;
-};
-ct.u.ext(ct.random,{
-    dice() {
-        return arguments[Math.floor(Math.random() * arguments.length)];
-    },
-    range(x1, x2) {
-        return x1 + Math.random() * (x2-x1);
-    },
-    deg() {
-        return Math.random()*360;
-    },
-    coord() {
-        return [Math.floor(Math.random()*ct.width),Math.floor(Math.random()*ct.height)];
-    },
-    chance(x, y) {
-        if (y) {
-            return (Math.random()*y < x);
-        }
-        return (Math.random()*100 < x);
-    },
-    from(arr) {
-        return arr[Math.floor(Math.random() * arr.length)];
-    }
-});
+/* eslint-disable no-mixed-operators */
+/* eslint-disable no-bitwise */
+ct.random = function random(x) {
+    return Math.random() * x;
+};
+ct.u.ext(ct.random, {
+    dice(...variants) {
+        return variants[Math.floor(Math.random() * variants.length)];
+    },
+    range(x1, x2) {
+        return x1 + Math.random() * (x2 - x1);
+    },
+    deg() {
+        return Math.random() * 360;
+    },
+    coord() {
+        return [Math.floor(Math.random() * ct.width), Math.floor(Math.random() * ct.height)];
+    },
+    chance(x, y) {
+        if (y) {
+            return (Math.random() * y < x);
+        }
+        return (Math.random() * 100 < x);
+    },
+    from(arr) {
+        return arr[Math.floor(Math.random() * arr.length)];
+    },
+    // Mulberry32, by bryc from https://stackoverflow.com/a/47593316
+    createSeededRandomizer(a) {
+        return function seededRandomizer() {
+            var t = a += 0x6D2B79F5;
+            t = Math.imul(t ^ t >>> 15, t | 1);
+            t ^= t + Math.imul(t ^ t >>> 7, t | 61);
+            return ((t ^ t >>> 14) >>> 0) / 4294967296;
+        };
+    }
+});
+{
+    const handle = {};
+    handle.currentRootRandomizer = ct.random.createSeededRandomizer(456852);
+    ct.random.seeded = function seeded() {
+        return handle.currentRootRandomizer();
+    };
+    ct.random.setSeed = function setSeed(seed) {
+        handle.currentRootRandomizer = ct.random.createSeededRandomizer(seed);
+    };
+    ct.random.setSeed(9323846264);
+}
diff --git a/app/data/ct.libs/random/types.d.ts b/app/data/ct.libs/random/types.d.ts
index 10517a17d..cf71a9654 100644
--- a/app/data/ct.libs/random/types.d.ts
+++ b/app/data/ct.libs/random/types.d.ts
@@ -23,5 +23,14 @@ declare namespace ct {
 
         /** When given both `x` and `y`, randomly returns `true` approximately `x` times out of `y`. When given only a value between 0…100, returns `true` approximately `x` times out of 100. E.g. `ct.random.chance(30)` means a 30% success rate. */
         function chance(x: number, y?: number): boolean;
+
+        /** Returns next seeded random number. */
+        function seeded(): number;
+
+        /** Sets the seed of the `ct.random.seeded()` method. */
+        function setSeed(seed: number): void;
+
+        /** Creates a new seeded random number generator. It is a function that you can store and use in the same way as `ct.random.seeded()`. */
+        function createSeededRandomizer(seed: number): Function;
     }
 }
\ No newline at end of file
diff --git a/app/data/ct.libs/tag/module.json b/app/data/ct.libs/tag/module.json
new file mode 100644
index 000000000..59026205e
--- /dev/null
+++ b/app/data/ct.libs/tag/module.json
@@ -0,0 +1,26 @@
+{
+    "main": {
+        "name": "Tags",
+        "tagline": "Add tags (this.tag) to your rooms and individual copies!",
+        "version": "1.0.0",
+        "authors": [{
+            "name": "Cosmo Myzrail Gorynych",
+            "mail": "admin@nersta.ru"
+        }],
+        "categories": [
+            "utilities"
+        ]
+    },
+    "roomExtends": [{
+        "name": "Tag",
+        "type": "text",
+        "default": "",
+        "key": "tag"
+    }],
+    "copyExtends": [{
+        "name": "Tag",
+        "type": "text",
+        "default": "",
+        "key": "tag"
+    }]
+}
diff --git a/app/data/ct.libs/tag/types.d.ts b/app/data/ct.libs/tag/types.d.ts
new file mode 100644
index 000000000..51df8b99c
--- /dev/null
+++ b/app/data/ct.libs/tag/types.d.ts
@@ -0,0 +1,8 @@
+interface Copy {
+    /** The tag of a copy, set inside the room editor. */
+    tag: string;
+}
+interface Room {
+    /** The tag of a room, set inside the room's properties. */
+    tag: string;
+}
diff --git a/app/data/ct.libs/tween/module.json b/app/data/ct.libs/tween/module.json
index 7dd9a3f5b..17695dd82 100644
--- a/app/data/ct.libs/tween/module.json
+++ b/app/data/ct.libs/tween/module.json
@@ -2,7 +2,7 @@
     "main": {
         "name": "ct.tween",
         "tagline": "Animate values through time with different interpolation curves.",
-        "version": "0.0.1",
+        "version": "1.0.0",
         "authors": [{
             "name": "Cosmo Myzrail Gorynych",
             "mail": "admin@nersta.ru"
diff --git a/app/data/ct.libs/yarn/module.json b/app/data/ct.libs/yarn/module.json
index 428675dde..473242e44 100644
--- a/app/data/ct.libs/yarn/module.json
+++ b/app/data/ct.libs/yarn/module.json
@@ -2,7 +2,7 @@
     "main": {
         "name": "ct.yarn",
         "tagline": "Use YarnSpinner projects to create interactive dialogues in your game.",
-        "version": "0.0.0",
+        "version": "1.0.0",
         "authors": [{
             "name": "Cosmo Myzrail Gorynych",
             "mail": "admin@nersta.ru"
diff --git a/app/data/ct.release/ct.css b/app/data/ct.release/ct.css
index 43fb83f35..395ed570a 100644
--- a/app/data/ct.release/ct.css
+++ b/app/data/ct.release/ct.css
@@ -54,4 +54,5 @@ body {
 }
 
 /*@pixelatedrender@*/
+/*@hidecursor@*/
 /*%css%*/
\ No newline at end of file
diff --git a/app/data/ct.release/rooms.js b/app/data/ct.release/rooms.js
index d6e73ce0f..98ae47338 100644
--- a/app/data/ct.release/rooms.js
+++ b/app/data/ct.release/rooms.js
@@ -26,6 +26,9 @@ class Room extends PIXI.Container {
             this.onLeave = template.onLeave;
             this.template = template;
             this.name = template.name;
+            if (this === ct.room) {
+                ct.pixiApp.renderer.backgroundColor = Number('0x' + this.template.backgroundColor.slice(1));
+            }
             /*%beforeroomoncreate%*/
             for (let i = 0, li = template.bgs.length; i < li; i++) {
                 const bg = new ct.types.Background(
@@ -44,6 +47,7 @@ class Room extends PIXI.Container {
                 this.addChild(tl);
             }
             for (let i = 0, li = template.objects.length; i < li; i++) {
+                const exts = template.objects[i].exts || {};
                 ct.types.make(
                     template.objects[i].type,
                     template.objects[i].x,
@@ -51,7 +55,8 @@ class Room extends PIXI.Container {
                     {
                         tx: template.objects[i].tx,
                         ty: template.objects[i].ty,
-                        tr: template.objects[i].tr
+                        tr: template.objects[i].tr,
+                        ...exts
                     },
                     this
                 );
diff --git a/app/data/i18n/English.json b/app/data/i18n/English.json
index 8426944ab..2d003efdd 100644
--- a/app/data/i18n/English.json
+++ b/app/data/i18n/English.json
@@ -11,10 +11,12 @@
         "cancel": "Cancel",
         "cannotBeEmpty": "This cannot be empty",
         "clear": "Clear",
+        "close": "Close",
         "confirmDelete": "Are you sure you want to delete {0}? It cannot be undone.",
         "contribute": "Contribute",
         "copy": "Copy",
         "copyName": "Copy the name",
+        "couldNotLoadFromClipboard": "Could not load data from clipboard",
         "ctsite": "ct.js homepage",
         "cut": "Cut",
         "delete": "Delete",
@@ -39,6 +41,7 @@
         "open": "Open",
         "openproject": "Open project…",
         "paste": "Paste",
+        "pastedFromClipboard": "Pasted from clipboard",
         "reallyexit": "Are you sure you want to exit? All unsaved changes will be lost.",
         "rename": "Rename",
         "save": "Save",
@@ -167,6 +170,7 @@
         "codeFont": "Font for code",
         "codeLigatures": "Ligatures",
         "codeDense": "Dense layout",
+        "disableSounds": "Disable UI sounds",
         "openIncludeFolder": "Open \"include\" folder",
         "troubleshooting": "Troubleshooting",
         "toggleDevTools": "Toggle Dev Tools",
@@ -205,7 +209,7 @@
         },
         "authoring": {
             "heading": "Authoring",
-            "author": "Author name:",
+            "author": "Developer:",
             "site": "Homepage:",
             "title": "Name:",
             "version": "Version:",
@@ -228,6 +232,7 @@
             "highDensity": "Support high pixel density (e.g. on retina screens)",
             "maxFPS": "Max framerate:",
             "pixelatedrender": "Disable image smoothing here and in exported project (preserve crisp pixels)",
+            "hideCursor": "Hide system cursor",
             "usePixiLegacy": "Add a legacy, canvas-based renderer to support older browsers and graphics cards (adds ~20kb up to your game)",
             "desktopBuilds": "Desktop builds",
             "launchMode": "Launch mode:",
@@ -263,6 +268,9 @@
         "enabledModules": "Enabled modules",
         "availableModules": "Available modules",
         "filter": "Filter",
+        "preview": "(preview)",
+        "previewTooltip": "This module has not yet been released and is for preview purposes.",
+        "deprecatedTooltip": "This module is deprecated and will be removed in a future version.",
         "categories": {
             "customization": "Customization",
             "utilities": "Utilities",
@@ -282,7 +290,21 @@
         "create": "Create",
         "import": "Import",
         "skeletons": "Skeletal Animation",
-        "createType": "Create a type from it"
+        "createType": "Create a type from it",
+        "importFromClipboard": "Import from clipboard",
+        "generatePlaceholder": "Generate a placeholder"
+    },
+    "textureGenerator": {
+        "name": "Texture's name:",
+        "width": "Width:",
+        "height": "Height:",
+        "color": "Background color:",
+        "label": "Label:",
+        "optional": "(optional)",
+        "createAndClose": "Create and close",
+        "createAndContinue": "Create and add another",
+        "scalingAtX4": "Scaling by x4 for a small texture",
+        "generationSuccessMessage": "Successfully added $1 texture to your project."
     },
     "textureview": {
         "bgcolor": "Change bg color",
@@ -303,6 +325,7 @@
         "speed": "Framerate:",
         "tiled": "Use as a background?",
         "replacetexture": "Replace…",
+        "updateFromClipboard": "Update from clipboard",
         "corrupted": "File is corrupted or missing! Closing now.",
         "showmask": "Show mask",
         "width": "Width:",
@@ -319,7 +342,8 @@
         "movePoint": "Move this point",
         "symmetryTool": "Symmetry tool",
         "padding": "Padding:",
-        "paddingNotice": "This affects how a texture is exported: it adds duplicate pixels on edges and prevents bleeding artifacts on tiled and scaled textures. The default value is usually enough, but, if you shrink textures strongly, the bleeding may re-appear. Increment this value if this texture has artifacts while in-game."
+        "paddingNotice": "This affects how a texture is exported: it adds duplicate pixels on edges and prevents bleeding artifacts on tiled and scaled textures. The default value is usually enough, but, if you shrink textures strongly, the bleeding may re-appear. Increment this value if this texture has artifacts while in-game.",
+        "previewAnimationNotice": "This is a preview. Use this.animationSpeed property to change it for real copies."
     },
     "sounds": {
         "create": "Create"
@@ -498,7 +522,10 @@
         "parallax": "Parallax (X, Y):",
         "repeat": "Repeat:",
         "scale": "Scaling (X, Y):",
-        "shift": "Shift (X, Y):"
+        "shift": "Shift (X, Y):",
+        "notBackgroundTextureWarning": "This texture is not marked as a background. It will have gaps when exported.",
+        "fixBackground": "Fix it.",
+        "dismissWarning": "Dismiss."
     },
     "roomtiles": {
         "moveTileLayer": "Move to a new depth",
@@ -513,6 +540,7 @@
         "events": "Room events",
         "copies": "Copies",
         "backgrounds": "Backgrounds",
+        "backgroundColor": "Background color:",
         "tiles": "Tiles",
         "properties": "Properties",
         "add": "Add",
@@ -535,6 +563,8 @@
         "deletecopy": "Delete copy {0}",
         "deleteCopies": "Delete copies",
         "shiftCopies": "Shift copies",
+        "sortHorizontally": "Sort horizontally",
+        "sortVertically": "Sort vertically",
         "selectAndMove": "Select and Move",
         "changecopyscale": "Change scale",
         "changecopyrotation": "Rotate",
@@ -543,7 +573,12 @@
         "deletetiles": "Delete tiles",
         "movetilestolayer": "Move to layer",
         "shifttiles": "Shift tiles",
-        "findTileset": "Find a tileset"
+        "findTileset": "Find a tileset",
+        "copyProperties": {
+            "position": "Position",
+            "rotation": "Rotation",
+            "scale": "Scale"
+        }
     },
     "notepad": {
         "local": "Project's notepad",
diff --git a/app/data/i18n/Russian.json b/app/data/i18n/Russian.json
index 6754787d8..105cc778b 100644
--- a/app/data/i18n/Russian.json
+++ b/app/data/i18n/Russian.json
@@ -15,6 +15,7 @@
         "contribute": "Внести вклад в разработку",
         "copy": "Копировать",
         "copyName": "Скопировать название",
+        "couldNotLoadFromClipboard": "Не получилось загрузить данные из буфера обмена",
         "ctsite": "Домашняя страница ct.js",
         "cut": "Вырезать",
         "delete": "Удалить",
@@ -38,6 +39,7 @@
         "open": "Открыть",
         "openproject": "Открыть проект…",
         "paste": "Вставить",
+        "pastedFromClipboard": "Добавлено из буфера обмена",
         "reallyexit": "Вы уверены, что хотите выйти? Все несохранённые данные будут потеряны!",
         "rename": "Переименовать",
         "save": "Сохранить",
@@ -154,7 +156,8 @@
         "restart": "Перезапустить",
         "themeLucasDracula": "Люкас Дракула",
         "openProject": "Открыть проект…",
-        "openExample": "Открыть пример…"
+        "openExample": "Открыть пример…",
+        "disableSounds": "Отключить звуки интерфейса"
     },
     "onboarding": {
         "hoorayHeader": "Ух-ты! Мы сделали новый проект!",
@@ -181,7 +184,7 @@
         },
         "authoring": {
             "heading": "Авторство",
-            "author": "Автор:",
+            "author": "Разработчик:",
             "site": "Сайт автора:",
             "title": "Название:",
             "version": "Версия:",
@@ -201,6 +204,7 @@
             "highDensity": "Поддерживать высокую плотность пикселей (напр. на ретина-экранах)",
             "maxFPS": "Максмальная частота кадров:",
             "pixelatedrender": "Здесь и в проекте отключать сглаживание (сохранять пиксели)",
+            "hideCursor": "Спрятать системный курсор",
             "usePixiLegacy": "Добавить рендерер на HTMLCanvas для поддержки старых браузеров и видеокарт (добавляет ~20kb к весу игры)",
             "desktopBuilds": "Сборки для ПК",
             "launchMode": "Запустить в режиме:",
@@ -258,7 +262,8 @@
         "create": "Создать",
         "import": "Импорт",
         "skeletons": "Скелетная анимация",
-        "createType": "Создать тип с этой текстурой"
+        "createType": "Создать тип с этой текстурой",
+        "importFromClipboard": "Импортировать из буфера обмена"
     },
     "textureview": {
         "bgcolor": "Сменить цвет фона",
@@ -278,6 +283,7 @@
         "speed": "Скорость превью:",
         "tiled": "Использовать как фон?",
         "replacetexture": "Заменить…",
+        "updateFromClipboard": "Обновить из буфера обмена",
         "corrupted": "Файл изображения повреждён или отсутствует! Невозможно открыть спрайт.",
         "showmask": "Показать маску",
         "width": "Ширина:",
@@ -295,7 +301,8 @@
         "reimport": "Обновить",
         "symmetryTool": "Симметрия",
         "padding": "Отбивка:",
-        "paddingNotice": "Влияет на то, как текстура экспортируется: дублирует пиксели по краям, чтобы избежать артефактов грязных краёв на повторяющихся и уменьшенных изображениях. Обычно значения по-умолчанию достаточно, но если сильно уменьшить текстуру в игре, эффект может вернуться. Увеличьте значение этого поля, если замечаете грязные края у этой текстуры в игре."
+        "paddingNotice": "Влияет на то, как текстура экспортируется: дублирует пиксели по краям, чтобы избежать артефактов грязных краёв на повторяющихся и уменьшенных изображениях. Обычно значения по-умолчанию достаточно, но если сильно уменьшить текстуру в игре, эффект может вернуться. Увеличьте значение этого поля, если замечаете грязные края у этой текстуры в игре.",
+        "previewAnimationNotice": "Это предпросмотр. Чтобы изменить скорость анимации у настоящих копий, используйте this.animationSpeed."
     },
     "sounds": {
         "create": "Создать"
@@ -463,7 +470,10 @@
         "parallax": "Параллакс (по X, Y):",
         "repeat": "Повторять:",
         "scale": "Размер (по X, Y):",
-        "shift": "Сдвиг (по X, Y):"
+        "shift": "Сдвиг (по X, Y):",
+        "notBackgroundTextureWarning": "Эта текстура не отмечена как фон. В игре у неё будут повторяющиеся разрывы.",
+        "fixBackground": "Исправить.",
+        "dismissWarning": "Скрыть предупреждение."
     },
     "roomtiles": {
         "moveTileLayer": "Переместить на другую глубину",
@@ -478,6 +488,7 @@
         "events": "События комнаты",
         "copies": "Копии",
         "backgrounds": "Фоны",
+        "backgroundColor": "Цвет фона",
         "tiles": "Плитки",
         "add": "Добавить",
         "none": "Ничего",
diff --git a/app/package-lock.json b/app/package-lock.json
index 94c2209ef..166fbbac8 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -5,9 +5,9 @@
   "requires": true,
   "dependencies": {
     "@electron/get": {
-      "version": "1.7.6",
-      "resolved": "https://registry.npmjs.org/@electron/get/-/get-1.7.6.tgz",
-      "integrity": "sha512-zlNikt6ziVLNcm4lly1L4y62fJd/eYpEBjF5DiV/VAQq2vdPjH4sbUphXt9upmHz86lAhAj8g9lTnWrxJ/KBZw==",
+      "version": "1.12.2",
+      "resolved": "https://registry.npmjs.org/@electron/get/-/get-1.12.2.tgz",
+      "integrity": "sha512-vAuHUbfvBQpYTJ5wB7uVIDq5c/Ry0fiTBMs7lnEYAo/qXXppIVcWdfBr57u6eRnKdVso7KSiH6p/LbQAG6Izrg==",
       "requires": {
         "debug": "^4.1.1",
         "env-paths": "^2.2.0",
@@ -15,18 +15,11 @@
         "global-agent": "^2.0.2",
         "global-tunnel-ng": "^2.7.1",
         "got": "^9.6.0",
+        "progress": "^2.0.3",
         "sanitize-filename": "^1.6.2",
         "sumchecker": "^3.0.1"
       },
       "dependencies": {
-        "debug": {
-          "version": "4.1.1",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
-          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
-          "requires": {
-            "ms": "^2.1.1"
-          }
-        },
         "fs-extra": {
           "version": "8.1.0",
           "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@@ -36,72 +29,6 @@
             "jsonfile": "^4.0.0",
             "universalify": "^0.1.0"
           }
-        },
-        "get-stream": {
-          "version": "4.1.0",
-          "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
-          "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
-          "requires": {
-            "pump": "^3.0.0"
-          }
-        },
-        "got": {
-          "version": "9.6.0",
-          "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz",
-          "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==",
-          "requires": {
-            "@sindresorhus/is": "^0.14.0",
-            "@szmarczak/http-timer": "^1.1.2",
-            "cacheable-request": "^6.0.0",
-            "decompress-response": "^3.3.0",
-            "duplexer3": "^0.1.4",
-            "get-stream": "^4.1.0",
-            "lowercase-keys": "^1.0.1",
-            "mimic-response": "^1.0.1",
-            "p-cancelable": "^1.0.0",
-            "to-readable-stream": "^1.0.0",
-            "url-parse-lax": "^3.0.0"
-          }
-        },
-        "graceful-fs": {
-          "version": "4.2.3",
-          "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz",
-          "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ=="
-        },
-        "jsonfile": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
-          "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
-          "requires": {
-            "graceful-fs": "^4.1.6"
-          }
-        },
-        "ms": {
-          "version": "2.1.2",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
-        },
-        "prepend-http": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz",
-          "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc="
-        },
-        "pump": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
-          "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
-          "requires": {
-            "end-of-stream": "^1.1.0",
-            "once": "^1.3.1"
-          }
-        },
-        "url-parse-lax": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",
-          "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=",
-          "requires": {
-            "prepend-http": "^2.0.0"
-          }
         }
       }
     },
@@ -1405,19 +1332,12 @@
         "defer-to-connect": "^1.0.1"
       }
     },
-    "@types/events": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
-      "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==",
-      "optional": true
-    },
     "@types/glob": {
-      "version": "7.1.1",
-      "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz",
-      "integrity": "sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w==",
+      "version": "7.1.3",
+      "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.3.tgz",
+      "integrity": "sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w==",
       "optional": true,
       "requires": {
-        "@types/events": "*",
         "@types/minimatch": "*",
         "@types/node": "*"
       }
@@ -1556,37 +1476,21 @@
       }
     },
     "asar": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/asar/-/asar-2.1.0.tgz",
-      "integrity": "sha512-d2Ovma+bfqNpvBzY/KU8oPY67ZworixTpkjSx0PCXnQi67c2cXmssaTxpFDUM0ttopXoGx/KRxNg/GDThYbXQA==",
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/asar/-/asar-3.0.3.tgz",
+      "integrity": "sha512-k7zd+KoR+n8pl71PvgElcoKHrVNiSXtw7odKbyNpmgKe7EGRF9Pnu3uLOukD37EvavKwVFxOUpqXTIZC5B5Pmw==",
       "requires": {
         "@types/glob": "^7.1.1",
         "chromium-pickle-js": "^0.2.0",
-        "commander": "^2.20.0",
-        "cuint": "^0.2.2",
-        "glob": "^7.1.3",
-        "minimatch": "^3.0.4",
-        "mkdirp": "^0.5.1",
-        "tmp-promise": "^1.0.5"
+        "commander": "^5.0.0",
+        "glob": "^7.1.6",
+        "minimatch": "^3.0.4"
       },
       "dependencies": {
         "commander": {
-          "version": "2.20.3",
-          "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
-          "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
-        },
-        "glob": {
-          "version": "7.1.6",
-          "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
-          "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
-          "requires": {
-            "fs.realpath": "^1.0.0",
-            "inflight": "^1.0.4",
-            "inherits": "2",
-            "minimatch": "^3.0.4",
-            "once": "^1.3.0",
-            "path-is-absolute": "^1.0.0"
-          }
+          "version": "5.1.0",
+          "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
+          "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="
         }
       }
     },
@@ -1619,9 +1523,9 @@
       "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw=="
     },
     "bluebird": {
-      "version": "3.4.7",
-      "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
-      "integrity": "sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM="
+      "version": "3.7.2",
+      "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+      "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
     },
     "boolean": {
       "version": "3.0.1",
@@ -1690,27 +1594,10 @@
         "responselike": "^1.0.2"
       },
       "dependencies": {
-        "get-stream": {
-          "version": "5.1.0",
-          "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz",
-          "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==",
-          "requires": {
-            "pump": "^3.0.0"
-          }
-        },
         "lowercase-keys": {
           "version": "2.0.0",
           "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
           "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="
-        },
-        "pump": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
-          "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
-          "requires": {
-            "end-of-stream": "^1.1.0",
-            "once": "^1.3.1"
-          }
         }
       }
     },
@@ -1723,6 +1610,11 @@
         "upper-case": "^1.1.1"
       }
     },
+    "camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
+    },
     "chalk": {
       "version": "2.4.1",
       "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz",
@@ -1834,9 +1726,9 @@
       "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ="
     },
     "core-js": {
-      "version": "3.6.4",
-      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.4.tgz",
-      "integrity": "sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw==",
+      "version": "3.6.5",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz",
+      "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==",
       "optional": true
     },
     "core-util-is": {
@@ -1873,37 +1765,6 @@
         }
       }
     },
-    "cross-zip": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/cross-zip/-/cross-zip-3.0.0.tgz",
-      "integrity": "sha512-cm+l8PJ6WiSQmKZ/x8DGvUm2u/3FX2JFs1AFd18gdHaVhP5Lf4oE6Jrj2Jd05JYSioz5x+nIRVp0zBQuzuCRcQ==",
-      "requires": {
-        "rimraf": "^3.0.0"
-      },
-      "dependencies": {
-        "glob": {
-          "version": "7.1.6",
-          "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
-          "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
-          "requires": {
-            "fs.realpath": "^1.0.0",
-            "inflight": "^1.0.4",
-            "inherits": "2",
-            "minimatch": "^3.0.4",
-            "once": "^1.3.0",
-            "path-is-absolute": "^1.0.0"
-          }
-        },
-        "rimraf": {
-          "version": "3.0.2",
-          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
-          "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
-          "requires": {
-            "glob": "^7.1.3"
-          }
-        }
-      }
-    },
     "csswring": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/csswring/-/csswring-7.0.0.tgz",
@@ -1914,17 +1775,12 @@
         "postcss": "^7.0.0"
       }
     },
-    "cuint": {
-      "version": "0.2.2",
-      "resolved": "https://registry.npmjs.org/cuint/-/cuint-0.2.2.tgz",
-      "integrity": "sha1-QICG1AlVDCYxFVYZ6fp7ytw7mRs="
-    },
     "debug": {
-      "version": "2.6.9",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
-      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+      "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
       "requires": {
-        "ms": "2.0.0"
+        "ms": "^2.1.1"
       }
     },
     "decamelize": {
@@ -1971,56 +1827,18 @@
       "integrity": "sha512-5jIMi2RB3HtGPHcYd9Yyl0cczo84y+48lgKPxMijliNQaKAHEZJbdzLmKmdxG/mCdS/YD9DQ1gihL8mxzR0F9w=="
     },
     "electron-notarize": {
-      "version": "0.2.1",
-      "resolved": "https://registry.npmjs.org/electron-notarize/-/electron-notarize-0.2.1.tgz",
-      "integrity": "sha512-oZ6/NhKeXmEKNROiFmRNfytqu3cxqC95sjooG7kBXQVEUSQkZnbiAhxVh5jXngL881G197pbwpeVPJyM7Ikmxw==",
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/electron-notarize/-/electron-notarize-1.0.0.tgz",
+      "integrity": "sha512-dsib1IAquMn0onCrNMJ6gtEIZn/azG8hZMCYOuZIMVMUeRMgBYHK1s5TK9P8xAcrAjh/2aN5WYHzgVSWX314og==",
       "requires": {
         "debug": "^4.1.1",
-        "fs-extra": "^8.1.0"
-      },
-      "dependencies": {
-        "debug": {
-          "version": "4.1.1",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
-          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
-          "requires": {
-            "ms": "^2.1.1"
-          }
-        },
-        "fs-extra": {
-          "version": "8.1.0",
-          "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
-          "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
-          "requires": {
-            "graceful-fs": "^4.2.0",
-            "jsonfile": "^4.0.0",
-            "universalify": "^0.1.0"
-          }
-        },
-        "graceful-fs": {
-          "version": "4.2.3",
-          "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz",
-          "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ=="
-        },
-        "jsonfile": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
-          "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
-          "requires": {
-            "graceful-fs": "^4.1.6"
-          }
-        },
-        "ms": {
-          "version": "2.1.2",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
-        }
+        "fs-extra": "^9.0.1"
       }
     },
     "electron-osx-sign": {
-      "version": "0.4.15",
-      "resolved": "https://registry.npmjs.org/electron-osx-sign/-/electron-osx-sign-0.4.15.tgz",
-      "integrity": "sha512-1QtPNpjIji9bGZ0VRFwtJUyU1uHi7q3XUAOG0qFsvAUfs5H0T8hbgUfyg3xvPzmF1ruV8T8pQmQ86vNfLrcRiA==",
+      "version": "0.4.17",
+      "resolved": "https://registry.npmjs.org/electron-osx-sign/-/electron-osx-sign-0.4.17.tgz",
+      "integrity": "sha512-wUJPmZJQCs1zgdlQgeIpRcvrf7M5/COQaOV68Va1J/SgmWx5KL2otgg+fAae7luw6qz9R8Gvu/Qpe9tAOu/3xQ==",
       "requires": {
         "bluebird": "^3.5.0",
         "compare-version": "^0.1.2",
@@ -2030,25 +1848,34 @@
         "plist": "^3.0.1"
       },
       "dependencies": {
-        "bluebird": {
-          "version": "3.7.2",
-          "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
-          "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
+        "debug": {
+          "version": "2.6.9",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+          "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
         }
       }
     },
     "electron-packager": {
-      "version": "14.2.1",
-      "resolved": "https://registry.npmjs.org/electron-packager/-/electron-packager-14.2.1.tgz",
-      "integrity": "sha512-g6y3BVrAOz/iavKD+VMFbehrQcwCWuA3CZvVbmmbQuCfegGA1ytwWn0BNIDDrEdbuz31Fti7mnNHhb5L+3Wq9A==",
+      "version": "15.0.0",
+      "resolved": "https://registry.npmjs.org/electron-packager/-/electron-packager-15.0.0.tgz",
+      "integrity": "sha512-J0yQP7/fKPkjxo9Yz5+vsQVig0dBbSXW8LQYA1pvNMvi+bL00hfI2SAyORP6EU7XaeiXGUIBSG2Px01EkKfGCw==",
       "requires": {
         "@electron/get": "^1.6.0",
-        "asar": "^2.0.1",
-        "cross-zip": "^3.0.0",
+        "asar": "^3.0.0",
         "debug": "^4.0.1",
-        "electron-notarize": "^0.2.0",
+        "electron-notarize": "^1.0.0",
         "electron-osx-sign": "^0.4.11",
-        "fs-extra": "^8.1.0",
+        "extract-zip": "^2.0.0",
+        "filenamify": "^4.1.0",
+        "fs-extra": "^9.0.0",
         "galactus": "^0.2.1",
         "get-package-info": "^1.0.0",
         "junk": "^3.1.0",
@@ -2056,57 +1883,8 @@
         "plist": "^3.0.0",
         "rcedit": "^2.0.0",
         "resolve": "^1.1.6",
-        "sanitize-filename": "^1.6.0",
-        "semver": "^6.0.0",
-        "yargs-parser": "^16.0.0"
-      },
-      "dependencies": {
-        "debug": {
-          "version": "4.1.1",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
-          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
-          "requires": {
-            "ms": "^2.1.1"
-          }
-        },
-        "fs-extra": {
-          "version": "8.1.0",
-          "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
-          "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
-          "requires": {
-            "graceful-fs": "^4.2.0",
-            "jsonfile": "^4.0.0",
-            "universalify": "^0.1.0"
-          }
-        },
-        "graceful-fs": {
-          "version": "4.2.3",
-          "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz",
-          "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ=="
-        },
-        "jsonfile": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
-          "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
-          "requires": {
-            "graceful-fs": "^4.1.6"
-          }
-        },
-        "ms": {
-          "version": "2.1.2",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
-        },
-        "rcedit": {
-          "version": "2.1.0",
-          "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-2.1.0.tgz",
-          "integrity": "sha512-Nrd/65LzMjFmKpS9d2fqIxVYdW0M8ovsN0PgZhCrPMQss2yznkp6/zjEQ1a9DzzoGv2uuN3yDJAeHybOD5ZNKA=="
-        },
-        "semver": {
-          "version": "6.3.0",
-          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
-          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
-        }
+        "semver": "^7.1.3",
+        "yargs-parser": "^18.0.0"
       }
     },
     "encodeurl": {
@@ -2209,6 +1987,21 @@
         "pend": "~1.2.0"
       }
     },
+    "filename-reserved-regex": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz",
+      "integrity": "sha1-q/c9+rc10EVECr/qLZHzieu/oik="
+    },
+    "filenamify": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.1.0.tgz",
+      "integrity": "sha512-KQV/uJDI9VQgN7sHH1Zbk6+42cD6mnQ2HONzkXUfPJ+K2FC8GZ1dpewbbHw0Sz8Tf5k3EVdHVayM4DoAwWlmtg==",
+      "requires": {
+        "filename-reserved-regex": "^2.0.0",
+        "strip-outer": "^1.0.1",
+        "trim-repeated": "^1.0.0"
+      }
+    },
     "find-up": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
@@ -2226,14 +2019,6 @@
         "fs-extra": "^7.0.0"
       },
       "dependencies": {
-        "debug": {
-          "version": "4.1.1",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
-          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
-          "requires": {
-            "ms": "^2.1.1"
-          }
-        },
         "fs-extra": {
           "version": "7.0.1",
           "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
@@ -2243,11 +2028,6 @@
             "jsonfile": "^4.0.0",
             "universalify": "^0.1.0"
           }
-        },
-        "ms": {
-          "version": "2.1.2",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
         }
       }
     },
@@ -2325,19 +2105,6 @@
             "jsonfile": "^4.0.0",
             "universalify": "^0.1.0"
           }
-        },
-        "jsonfile": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
-          "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
-          "requires": {
-            "graceful-fs": "^4.1.6"
-          }
-        },
-        "ms": {
-          "version": "2.1.2",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
         }
       }
     },
@@ -2350,6 +2117,21 @@
         "debug": "^2.2.0",
         "lodash.get": "^4.0.0",
         "read-pkg-up": "^2.0.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "2.6.9",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+          "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        }
       }
     },
     "get-stream": {
@@ -2360,27 +2142,32 @@
         "pump": "^3.0.0"
       }
     },
+    "glob": {
+      "version": "7.1.6",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
+      "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
+      "requires": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      }
+    },
     "global-agent": {
-      "version": "2.1.8",
-      "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-2.1.8.tgz",
-      "integrity": "sha512-VpBe/rhY6Rw2VDOTszAMNambg+4Qv8j0yiTNDYEXXXxkUNGWLHp8A3ztK4YDBbFNcWF4rgsec6/5gPyryya/+A==",
+      "version": "2.1.12",
+      "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-2.1.12.tgz",
+      "integrity": "sha512-caAljRMS/qcDo69X9BfkgrihGUgGx44Fb4QQToNQjsiWh+YlQ66uqYVAdA8Olqit+5Ng0nkz09je3ZzANMZcjg==",
       "optional": true,
       "requires": {
-        "boolean": "^3.0.0",
-        "core-js": "^3.6.4",
+        "boolean": "^3.0.1",
+        "core-js": "^3.6.5",
         "es6-error": "^4.1.1",
-        "matcher": "^2.1.0",
-        "roarr": "^2.15.2",
-        "semver": "^7.1.2",
-        "serialize-error": "^5.0.0"
-      },
-      "dependencies": {
-        "semver": {
-          "version": "7.1.3",
-          "resolved": "https://registry.npmjs.org/semver/-/semver-7.1.3.tgz",
-          "integrity": "sha512-ekM0zfiA9SCBlsKa2X1hxyxiI4L3B6EbVJkkdgQXnSEEaHlGdvyodMruTiulSRWMMB4NeIuYNMC9rTKTz97GxA==",
-          "optional": true
-        }
+        "matcher": "^3.0.0",
+        "roarr": "^2.15.3",
+        "semver": "^7.3.2",
+        "serialize-error": "^7.0.1"
       }
     },
     "global-tunnel-ng": {
@@ -2450,10 +2237,38 @@
       "integrity": "sha512-iKdz2bWrrM4zLv3USCRtWX4kLWzZhbj/afh/W6giLxg5XQzbNg+UpVF+2G6f/LEYK9UNBgS2TdyNTuw8mod+Mg==",
       "optional": true
     },
+    "got": {
+      "version": "9.6.0",
+      "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz",
+      "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==",
+      "requires": {
+        "@sindresorhus/is": "^0.14.0",
+        "@szmarczak/http-timer": "^1.1.2",
+        "cacheable-request": "^6.0.0",
+        "decompress-response": "^3.3.0",
+        "duplexer3": "^0.1.4",
+        "get-stream": "^4.1.0",
+        "lowercase-keys": "^1.0.1",
+        "mimic-response": "^1.0.1",
+        "p-cancelable": "^1.0.0",
+        "to-readable-stream": "^1.0.0",
+        "url-parse-lax": "^3.0.0"
+      },
+      "dependencies": {
+        "get-stream": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
+          "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
+          "requires": {
+            "pump": "^3.0.0"
+          }
+        }
+      }
+    },
     "graceful-fs": {
-      "version": "4.1.15",
-      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz",
-      "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA=="
+      "version": "4.2.4",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz",
+      "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw=="
     },
     "has-flag": {
       "version": "3.0.0",
@@ -2490,9 +2305,9 @@
       }
     },
     "http-cache-semantics": {
-      "version": "4.0.4",
-      "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
-      "integrity": "sha512-Z2EICWNJou7Tr9Bd2M2UqDJq3A9F2ePG9w3lIpjoyuSyXFP9QbniJVu3XQYytuw5ebmG7dXSXO9PgAjJG8DDKA=="
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
+      "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ=="
     },
     "ieee754": {
       "version": "1.1.13",
@@ -2690,18 +2505,18 @@
       }
     },
     "matcher": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/matcher/-/matcher-2.1.0.tgz",
-      "integrity": "sha512-o+nZr+vtJtgPNklyeUKkkH42OsK8WAfdgaJE2FNxcjLPg+5QbeEoT6vRj8Xq/iv18JlQ9cmKsEu0b94ixWf1YQ==",
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
+      "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
       "optional": true,
       "requires": {
-        "escape-string-regexp": "^2.0.0"
+        "escape-string-regexp": "^4.0.0"
       },
       "dependencies": {
         "escape-string-regexp": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
-          "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+          "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
           "optional": true
         }
       }
@@ -2757,14 +2572,6 @@
       "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
       "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
     },
-    "mkdirp": {
-      "version": "0.5.5",
-      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
-      "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
-      "requires": {
-        "minimist": "^1.2.5"
-      }
-    },
     "monaco-editor": {
       "version": "0.20.0",
       "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.20.0.tgz",
@@ -2779,9 +2586,9 @@
       }
     },
     "ms": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
     },
     "no-case": {
       "version": "2.3.2",
@@ -2802,13 +2609,10 @@
         "validate-npm-package-license": "^3.0.1"
       },
       "dependencies": {
-        "resolve": {
-          "version": "1.15.1",
-          "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz",
-          "integrity": "sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w==",
-          "requires": {
-            "path-parse": "^1.0.6"
-          }
+        "semver": {
+          "version": "5.7.1",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+          "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
         }
       }
     },
@@ -3072,11 +2876,21 @@
         }
       }
     },
+    "prepend-http": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz",
+      "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc="
+    },
     "process-nextick-args": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz",
       "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw=="
     },
+    "progress": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+      "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="
+    },
     "proto-list": {
       "version": "1.2.4",
       "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
@@ -3107,6 +2921,11 @@
       "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz",
       "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4="
     },
+    "rcedit": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-2.2.0.tgz",
+      "integrity": "sha512-dhFtYmQS+V8qQIANyX6zDK+sO50ayDePKApi46ZPK8I6QeyyTDD6LManMa7a3p3c9mLM4zi9QBP41pfhQ9p7Sg=="
+    },
     "read-pkg": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz",
@@ -3156,9 +2975,12 @@
       "integrity": "sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs="
     },
     "resolve": {
-      "version": "1.1.7",
-      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz",
-      "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs="
+      "version": "1.17.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",
+      "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==",
+      "requires": {
+        "path-parse": "^1.0.6"
+      }
     },
     "resource-loader": {
       "version": "3.0.1",
@@ -3178,9 +3000,9 @@
       }
     },
     "roarr": {
-      "version": "2.15.2",
-      "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.2.tgz",
-      "integrity": "sha512-jmaDhK9CO4YbQAV8zzCnq9vjAqeO489MS5ehZ+rXmFiPFFE6B+S9KYO6prjmLJ5A0zY3QxVlQdrIya7E/azz/Q==",
+      "version": "2.15.3",
+      "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.3.tgz",
+      "integrity": "sha512-AEjYvmAhlyxOeB9OqPUzQCo3kuAkNfuDk/HqWbZdFsqDFpapkTjiw+p4svNEoRLvuqNTxqfL+s+gtD4eDgZ+CA==",
       "optional": true,
       "requires": {
         "boolean": "^3.0.0",
@@ -3213,9 +3035,9 @@
       }
     },
     "semver": {
-      "version": "5.6.0",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz",
-      "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg=="
+      "version": "7.3.2",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz",
+      "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ=="
     },
     "semver-compare": {
       "version": "1.0.0",
@@ -3224,12 +3046,12 @@
       "optional": true
     },
     "serialize-error": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-5.0.0.tgz",
-      "integrity": "sha512-/VtpuyzYf82mHYTtI4QKtwHa79vAdU5OQpNPAmE/0UDdlGT0ZxHwC+J6gXkw29wwoVI8fMPsfcVHOwXtUQYYQA==",
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
+      "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
       "optional": true,
       "requires": {
-        "type-fest": "^0.8.0"
+        "type-fest": "^0.13.1"
       }
     },
     "serve-handler": {
@@ -3253,23 +3075,23 @@
       "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
     },
     "spdx-correct": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz",
-      "integrity": "sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==",
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
+      "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==",
       "requires": {
         "spdx-expression-parse": "^3.0.0",
         "spdx-license-ids": "^3.0.0"
       }
     },
     "spdx-exceptions": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz",
-      "integrity": "sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA=="
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+      "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A=="
     },
     "spdx-expression-parse": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz",
-      "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==",
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+      "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
       "requires": {
         "spdx-exceptions": "^2.1.0",
         "spdx-license-ids": "^3.0.0"
@@ -3303,27 +3125,20 @@
       "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
       "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM="
     },
+    "strip-outer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz",
+      "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==",
+      "requires": {
+        "escape-string-regexp": "^1.0.2"
+      }
+    },
     "sumchecker": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
       "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
       "requires": {
         "debug": "^4.1.0"
-      },
-      "dependencies": {
-        "debug": {
-          "version": "4.1.1",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
-          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
-          "requires": {
-            "ms": "^2.1.1"
-          }
-        },
-        "ms": {
-          "version": "2.1.2",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
-        }
       }
     },
     "supports-color": {
@@ -3339,58 +3154,19 @@
       "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
       "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
     },
-    "tmp": {
-      "version": "0.1.0",
-      "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz",
-      "integrity": "sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==",
-      "requires": {
-        "rimraf": "^2.6.3"
-      },
-      "dependencies": {
-        "glob": {
-          "version": "7.1.6",
-          "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
-          "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
-          "requires": {
-            "fs.realpath": "^1.0.0",
-            "inflight": "^1.0.4",
-            "inherits": "2",
-            "minimatch": "^3.0.4",
-            "once": "^1.3.0",
-            "path-is-absolute": "^1.0.0"
-          }
-        },
-        "rimraf": {
-          "version": "2.7.1",
-          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
-          "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
-          "requires": {
-            "glob": "^7.1.3"
-          }
-        }
-      }
-    },
-    "tmp-promise": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-1.1.0.tgz",
-      "integrity": "sha512-8+Ah9aB1IRXCnIOxXZ0uFozV1nMU5xiu7hhFVUSxZ3bYu+psD4TzagCzVbexUCgNNGJnsmNDQlS4nG3mTyoNkw==",
-      "requires": {
-        "bluebird": "^3.5.0",
-        "tmp": "0.1.0"
-      },
-      "dependencies": {
-        "bluebird": {
-          "version": "3.7.2",
-          "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
-          "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
-        }
-      }
-    },
     "to-readable-stream": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz",
       "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q=="
     },
+    "trim-repeated": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz",
+      "integrity": "sha1-42RqLqTokTEr9+rObPsFOAvAHCE=",
+      "requires": {
+        "escape-string-regexp": "^1.0.2"
+      }
+    },
     "truncate-utf8-bytes": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
@@ -3416,9 +3192,9 @@
       "optional": true
     },
     "type-fest": {
-      "version": "0.8.1",
-      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
-      "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+      "version": "0.13.1",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
+      "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
       "optional": true
     },
     "uc.micro": {
@@ -3468,6 +3244,14 @@
         }
       }
     },
+    "url-parse-lax": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",
+      "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=",
+      "requires": {
+        "prepend-http": "^2.0.0"
+      }
+    },
     "utf8-byte-length": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz",
@@ -3526,24 +3310,17 @@
       "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0="
     },
     "xmldom": {
-      "version": "0.1.27",
-      "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz",
-      "integrity": "sha1-1QH5ezvbQDr4757MIFcxh6rawOk="
+      "version": "0.1.31",
+      "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz",
+      "integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ=="
     },
     "yargs-parser": {
-      "version": "16.1.0",
-      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-16.1.0.tgz",
-      "integrity": "sha512-H/V41UNZQPkUMIT5h5hiwg4QKIY1RPvoBV4XcjUbRM8Bk2oKqqyZ0DIEbTFZB0XjbtSPG8SAa/0DxCQmiRgzKg==",
+      "version": "18.1.3",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+      "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
       "requires": {
         "camelcase": "^5.0.0",
         "decamelize": "^1.2.0"
-      },
-      "dependencies": {
-        "camelcase": {
-          "version": "5.3.1",
-          "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
-          "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
-        }
       }
     },
     "yauzl": {
diff --git a/app/package.json b/app/package.json
index dcff66c1a..0686615bb 100644
--- a/app/package.json
+++ b/app/package.json
@@ -2,7 +2,7 @@
   "main": "index.html",
   "name": "ctjs",
   "description": "ct.js — a free 2D game engine",
-  "version": "1.4.1",
+  "version": "1.4.2",
   "homepage": "https://ctjs.rocks/",
   "author": {
     "name": "Cosmo Myzrail Gorynych",
@@ -55,7 +55,7 @@
   "dependencies": {
     "archiver": "^3.1.1",
     "csswring": "7.0.0",
-    "electron-packager": "^14.2.1",
+    "electron-packager": "^15.0.0",
     "extract-zip": "^2.0.1",
     "fs-extra": "^9.0.1",
     "fuse.js": "^3.6.1",
diff --git a/buildAssets/icon.png b/buildAssets/icon.png
new file mode 100644
index 000000000..38bbc1450
Binary files /dev/null and b/buildAssets/icon.png differ
diff --git a/buildAssets/linux.itch.toml b/buildAssets/linux.itch.toml
new file mode 100644
index 000000000..8701c7114
--- /dev/null
+++ b/buildAssets/linux.itch.toml
@@ -0,0 +1,3 @@
+[[actions]]
+name = "play"
+path = "ctjs"
\ No newline at end of file
diff --git a/buildAssets/mac.itch.toml b/buildAssets/mac.itch.toml
new file mode 100644
index 000000000..1d714bf2d
--- /dev/null
+++ b/buildAssets/mac.itch.toml
@@ -0,0 +1,3 @@
+[[actions]]
+name = "play"
+path = "ctjs.app"
\ No newline at end of file
diff --git a/buildAssets/nightly.icns b/buildAssets/nightly.icns
new file mode 100644
index 000000000..182643617
Binary files /dev/null and b/buildAssets/nightly.icns differ
diff --git a/buildAssets/nightly.ico b/buildAssets/nightly.ico
new file mode 100644
index 000000000..435204b3d
Binary files /dev/null and b/buildAssets/nightly.ico differ
diff --git a/buildAssets/nightly.png b/buildAssets/nightly.png
new file mode 100644
index 000000000..b02a3795e
Binary files /dev/null and b/buildAssets/nightly.png differ
diff --git a/buildAssets/windows.itch.toml b/buildAssets/windows.itch.toml
new file mode 100644
index 000000000..a5dafe36e
--- /dev/null
+++ b/buildAssets/windows.itch.toml
@@ -0,0 +1,3 @@
+[[actions]]
+name = "play"
+path = "ctjs.exe"
\ No newline at end of file
diff --git a/docs b/docs
index 18ead7cdf..9c7bc905b 160000
--- a/docs
+++ b/docs
@@ -1 +1 @@
-Subproject commit 18ead7cdfb509a9700e1adafeed72d45156f7080
+Subproject commit 9c7bc905baf0b347831a06af33cf6421796d48bd
diff --git a/gulpfile.js b/gulpfile.js
index b8eef81a6..835759603 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -33,7 +33,13 @@ const npm = (/^win/).test(process.platform) ? 'npm.cmd' : 'npm';
 const pack = require('./app/package.json');
 
 var channelPostfix = argv.channel || false,
-    fixEnabled = argv.fix || false;
+    fixEnabled = argv.fix || false,
+    nightly = argv.nightly || false,
+    buildNumber = argv.buildNum || false;
+
+if (nightly) {
+    channelPostfix = 'nightly';
+}
 
 let errorBoxShown = false;
 const showErrorBox = function () {
@@ -390,6 +396,12 @@ const build = gulp.parallel([
 
 const bakePackages = async () => {
     const NwBuilder = require('nw-builder');
+    // Use the appropriate icon for each release channel
+    if (nightly) {
+        await fs.copy('./buildAssets/icon.png', './app/ct_ide.png');
+    } else {
+        await fs.copy('./buildAssets/nightly.png', './app/ct_ide.png');
+    }
     await fs.remove(path.join('./build', `ctjs - v${pack.version}`));
     var nw = new NwBuilder({
         files: nwFiles,
@@ -399,9 +411,29 @@ const bakePackages = async () => {
         buildType: 'versioned',
         // forceDownload: true,
         zip: false,
-        macIcns: './buildAssets/icon.icns'
+        macIcns: nightly ? './buildAssets/nightly.icns' : './buildAssets/icon.icns'
     });
     await nw.build();
+
+    // Copy .itch.toml files for each target platform
+    await Promise.all(platforms.map(platform => {
+        if (platform.indexOf('win') === 0) {
+            return fs.copy(
+                './buildAssets/windows.itch.toml',
+                path.join(`./build/ctjs - v${pack.version}`, platform, '.itch.toml')
+            );
+        }
+        if (platform === 'osx64') {
+            return fs.copy(
+                './buildAssets/mac.itch.toml',
+                path.join(`./build/ctjs - v${pack.version}`, platform, '.itch.toml')
+            );
+        }
+        return fs.copy(
+            './buildAssets/linux.itch.toml',
+            path.join(`./build/ctjs - v${pack.version}`, platform, '.itch.toml')
+        );
+    }));
     console.log('Built to this location:', path.join('./build', `ctjs - v${pack.version}`));
 };
 
@@ -527,6 +559,13 @@ const packages = gulp.series([
 
 const deployOnly = () => {
     console.log(`For channel ${channelPostfix}`);
+    if (nightly) {
+        return spawnise.spawn('./butler', ['push', `./build/ctjs - v${pack.version}/linux32`, `comigo/ct-nightly:linux32${channelPostfix ? '-' + channelPostfix : ''}`, '--userversion', buildNumber])
+        .then(() => spawnise.spawn('./butler', ['push', `./build/ctjs - v${pack.version}/linux64`, `comigo/ct-nightly:linux64${channelPostfix ? '-' + channelPostfix : ''}`, '--userversion', buildNumber]))
+        .then(() => spawnise.spawn('./butler', ['push', `./build/ctjs - v${pack.version}/osx64`, `comigo/ct-nightly:osx64${channelPostfix ? '-' + channelPostfix : ''}`, '--userversion', buildNumber]))
+        .then(() => spawnise.spawn('./butler', ['push', `./build/ctjs - v${pack.version}/win32`, `comigo/ct-nightly:win32${channelPostfix ? '-' + channelPostfix : ''}`, '--userversion', buildNumber]))
+        .then(() => spawnise.spawn('./butler', ['push', `./build/ctjs - v${pack.version}/win64`, `comigo/ct-nightly:win64${channelPostfix ? '-' + channelPostfix : ''}`, '--userversion', buildNumber]));
+    }
     return spawnise.spawn('./butler', ['push', `./build/ctjs - v${pack.version}/linux32`, `comigo/ct:linux32${channelPostfix ? '-' + channelPostfix : ''}`, '--userversion', pack.version])
     .then(() => spawnise.spawn('./butler', ['push', `./build/ctjs - v${pack.version}/linux64`, `comigo/ct:linux64${channelPostfix ? '-' + channelPostfix : ''}`, '--userversion', pack.version]))
     .then(() => spawnise.spawn('./butler', ['push', `./build/ctjs - v${pack.version}/osx64`, `comigo/ct:osx64${channelPostfix ? '-' + channelPostfix : ''}`, '--userversion', pack.version]))
diff --git a/package.json b/package.json
index b713f7472..ba3748e14 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "ctjsbuildenvironment",
-  "version": "1.4.1",
+  "version": "1.4.2",
   "description": "",
   "directories": {
     "doc": "docs"
diff --git a/src/icons/sort-horizontal.svg b/src/icons/sort-horizontal.svg
new file mode 100644
index 000000000..6d66400e5
--- /dev/null
+++ b/src/icons/sort-horizontal.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+    stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather">
+    <path d="M4 18L19 18" />
+    <path d="M16 22L20 18L16 14" />
+    <path d="M4 6L19 6" />
+    <path d="M16 10L20 6L16 2" />
+</svg>
diff --git a/src/icons/sort-vertical.svg b/src/icons/sort-vertical.svg
new file mode 100644
index 000000000..3c708f4d0
--- /dev/null
+++ b/src/icons/sort-vertical.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+    stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather">
+    <path d="M6 4V19"/>
+    <path d="M2 16L6 20L10 16"/>
+    <path d="M18 4V19"/>
+    <path d="M14 16L18 20L22 16"/>
+</svg>
diff --git a/src/js/3rdparty/alertify.js b/src/js/3rdparty/alertify.js
index 00cd8e9bf..c5329270e 100644
--- a/src/js/3rdparty/alertify.js
+++ b/src/js/3rdparty/alertify.js
@@ -1,20 +1,22 @@
+/* eslint-disable func-names */
+/* eslint-disable max-lines-per-function */
+/* eslint-disable max-len */
+/* eslint-disable id-length */
 /* eslint {
     'no-underscore-dangle': 'off',
     max-params: 'off'
 } */
 
-(function() {
-
+(function alertify() {
     'use strict';
 
     var TRANSITION_FALLBACK_DURATION = 500;
-    var hideElement = function(el) {
-
+    var hideElement = function (el) {
         if (!el) {
             return;
         }
 
-        var removeThis = function() {
+        var removeThis = function () {
             if (el && el.parentNode) {
                 el.parentNode.removeChild(el);
             }
@@ -26,11 +28,9 @@
 
         // Fallback for no transitions.
         setTimeout(removeThis, TRANSITION_FALLBACK_DURATION);
-
     };
 
-    var Alertify = function () {
-
+    var Alertify = function Alertify() {
         /**
          * Alertify private object
          * @type {Object}
@@ -82,7 +82,6 @@
              * @return {String}         An HTML string of the message box
              */
             build(item) {
-
                 var btnTxt = this.dialogs.buttons.ok;
                 var html = '<div class=\'dialog\'><div>' + this.dialogs.message.replace('{{message}}', item.message);
 
@@ -100,7 +99,6 @@
                     .replace('{{cancel}}', this.cancelLabel);
 
                 return html;
-
             },
 
             setCloseLogOnClick(bool) {
@@ -116,9 +114,8 @@
              * @return {undefined}
              */
             close(elem, wait) {
-
                 if (this.closeLogOnClick) {
-                    elem.addEventListener('click', function() {
+                    elem.addEventListener('click', function () {
                         hideElement(elem);
                     });
                 }
@@ -128,11 +125,10 @@
                 if (wait < 0) {
                     hideElement(elem);
                 } else if (wait > 0) {
-                    setTimeout(function() {
+                    setTimeout(function () {
                         hideElement(elem);
                     }, wait);
                 }
-
             },
 
             /**
@@ -164,7 +160,6 @@
              * @return {void}
              */
             log(message, type, click) {
-
                 var existing = document.querySelectorAll('.alertify-logs > div');
                 if (existing) {
                     var diff = existing.length - this.maxLogItems;
@@ -183,7 +178,6 @@
             },
 
             setupLogContainer() {
-
                 var elLog = document.querySelector('.alertify-logs');
                 var className = this.logContainerClass;
                 if (!elLog) {
@@ -198,7 +192,6 @@
                 }
 
                 return elLog;
-
             },
 
             /**
@@ -213,7 +206,6 @@
              * @return {undefined}
              */
             notify(message, type, click) {
-
                 var elLog = this.setupLogContainer();
                 var log = document.createElement('div');
 
@@ -230,12 +222,11 @@
                 }
 
                 elLog.appendChild(log);
-                setTimeout(function() {
+                setTimeout(function () {
                     log.className += ' show';
                 }, 10);
 
                 this.close(log, this.delay);
-
             },
 
             /**
@@ -246,14 +237,13 @@
              * @return {undefined}
              */
             setup(item) {
-
                 var el = document.createElement('div');
                 el.className = 'alertify hide';
                 el.innerHTML = this.build(item);
 
                 var btnOK = el.querySelector('.ok');
                 var btnCancel = el.querySelector('.cancel');
-                var input = item.type === 'prompt'? el.querySelector('input') : null;
+                var input = item.type === 'prompt' ? el.querySelector('input') : null;
 
                 // Set default value/placeholder of input
                 if (input) {
@@ -262,14 +252,16 @@
                     }
                 }
 
-                var setupHandlers = function(resolve) {
+                var setupHandlers = function (resolve) {
                     if (typeof resolve !== 'function') {
                         // promises are not available so resolve is a no-op
-                        resolve = function () {void 0;};
+                        resolve = function () {
+                            void 0;
+                        };
                     }
 
                     if (btnOK) {
-                        btnOK.addEventListener('click', function(ev) {
+                        btnOK.addEventListener('click', function (ev) {
                             if (item.onOkay && typeof item.onOkay === 'function') {
                                 if (input) {
                                     item.onOkay(input.value, ev);
@@ -296,7 +288,7 @@
                     }
 
                     if (btnCancel) {
-                        btnCancel.addEventListener('click', function(ev) {
+                        btnCancel.addEventListener('click', function (ev) {
                             if (item.onCancel && typeof item.onCancel === 'function') {
                                 item.onCancel(ev);
                             }
@@ -311,7 +303,7 @@
                     }
 
                     if (input) {
-                        input.addEventListener('keyup', function(ev) {
+                        input.addEventListener('keyup', function (ev) {
                             if (ev.which === 13) {
                                 btnOK.click();
                             }
@@ -328,14 +320,14 @@
                 }
 
                 this.parent.appendChild(el);
-                setTimeout(function() {
+                setTimeout(function () {
                     el.classList.remove('hide');
                     if (input && item.type && item.type === 'prompt') {
                         input.select();
                         input.focus();
                     } else if (btnOK) {
-                            btnOK.focus();
-                        }
+                        btnOK.focus();
+                    }
                 }, 100);
 
                 return promise;
@@ -431,12 +423,16 @@
             },
             success(message, click) {
                 _alertify.log(message, 'success', click);
-                soundbox.play('Success');
+                if (localStorage.disableSounds !== 'on') {
+                    soundbox.play('Success');
+                }
                 return this;
             },
             error(message, click) {
                 _alertify.log(message, 'error', click);
-                soundbox.play('Failure');
+                if (localStorage.disableSounds !== 'on') {
+                    soundbox.play('Failure');
+                }
                 return this;
             },
             cancelBtn(label) {
@@ -484,7 +480,6 @@
     };
 
     window.alertify = new Alertify();
-
 }());
 
 window.alertify.reset();
diff --git a/src/js/3rdparty/keymage.js b/src/js/3rdparty/keymage.js
deleted file mode 100644
index 77f4f8870..000000000
--- a/src/js/3rdparty/keymage.js
+++ /dev/null
@@ -1,355 +0,0 @@
-/** @license
- * keymage.js - Javascript keyboard bindings handling
- * http://github.com/piranha/keymage
- *
- * (c) 2012-2016 Alexander Solovyov under terms of ISC License
- */
-
-(function(define, undefined) {
-define(function() {
-    'use strict';
-
-    var VERSION = '1.1.3';
-    var isOsx = ~navigator.userAgent.indexOf('Mac OS X');
-
-    // Defining all keys
-    var MODPROPS = ['shiftKey', 'ctrlKey', 'altKey', 'metaKey'];
-    var MODS = {
-        'shift': 'shift',
-        'ctrl': 'ctrl', 'control': 'ctrl',
-        'alt': 'alt', 'option': 'alt',
-        'win': 'meta', 'cmd': 'meta', 'super': 'meta',
-                          'meta': 'meta',
-        // default modifier for os x is cmd and for others is ctrl
-        'defmod':  isOsx ? 'meta' : 'ctrl'
-        };
-    var MODORDER = ['shift', 'ctrl', 'alt', 'meta'];
-    var MODNUMS = [16, 17, 18, 91];
-
-    var KEYS = {
-        'backspace': 8,
-        'tab': 9,
-        'enter': 13, 'return': 13,
-        'pause': 19,
-        'caps': 20, 'capslock': 20,
-        'escape': 27, 'esc': 27,
-        'space': 32,
-        'pgup': 33, 'pageup': 33,
-        'pgdown': 34, 'pagedown': 34,
-        'end': 35,
-        'home': 36,
-        'ins': 45, 'insert': 45,
-        'del': 46, 'delete': 46,
-
-        'left': 37,
-        'up': 38,
-        'right': 39,
-        'down': 40,
-
-        '*': 106,
-        '+': 107, 'plus': 107,
-        'minus': 109,
-        ';': 186,
-        '=': 187,
-        ',': 188,
-        '-': 189,
-        '.': 190,
-        '/': 191,
-        'num/': 111,
-        '`': 192,
-        '[': 219,
-        '\\': 220,
-        ']': 221,
-        "'": 222
-    };
-
-    var i;
-    // numpad
-    for (i = 0; i < 10; i++) {
-        KEYS['num' + i] = i + 96;
-    }
-    // top row 0-9
-    for (i = 0; i < 10; i++) {
-        KEYS['' + i] = i + 48;
-    }
-    // f1-f24
-    for (i = 1; i < 25; i++) {
-        KEYS['f' + i] = i + 111;
-    }
-    // alphabet
-    for (i = 65; i < 91; i++) {
-        KEYS[String.fromCharCode(i).toLowerCase()] = i;
-    }
-
-    // Reverse key codes
-    var KEYREV = {};
-    Object.keys(KEYS).forEach(function(k) {
-        var val = KEYS[k];
-        if (!KEYREV[val] || KEYREV[val].length < k.length) {
-            KEYREV[val] = k;
-        }
-    });
-
-    // -----------------------
-    // Actual work is done here
-
-    var currentScope = '';
-    var allChains = {};
-
-    function parseKeyString(keystring) {
-        var bits = keystring.split(/-(?!$)/);
-        var button = bits[bits.length - 1];
-        var key = {code: KEYS[button]};
-
-        if (!key.code) {
-            throw 'Unknown key "' + button + '" in keystring "' +
-                keystring + '"';
-        }
-
-        var mod;
-        for (var i = 0; i < bits.length - 1; i++) {
-            button = bits[i];
-            mod = MODS[button];
-            if (!mod) {
-                    throw 'Unknown modifier "' + button + '" in keystring "' +
-                        keystring + '"';
-            }
-            key[mod] = true;
-        }
-
-        return key;
-    }
-
-    function stringifyKey(key) {
-        var s = '';
-        for (var i = 0; i < MODORDER.length; i++) {
-            if (key[MODORDER[i]]) {
-                s += MODORDER[i] + '-';
-            }
-        }
-        s += KEYREV[key.code];
-        return s;
-    }
-
-    function normalizeKeyChain(keychainString) {
-        var keychain = [];
-        var keys = keychainString.split(' ');
-
-        for (var i = 0; i < keys.length; i++) {
-            var key = parseKeyString(keys[i]);
-            key = stringifyKey(key);
-            keychain.push(key);
-        }
-
-        keychain.original = keychainString;
-        return keychain;
-    }
-
-    function eventKeyString(e) {
-        var key = {code: e.keyCode};
-        for (var i = 0; i < MODPROPS.length; i++) {
-            var mod = MODPROPS[i];
-            if (e[mod]) {
-                key[mod.slice(0, mod.length - 3)] = true;
-            }
-        }
-        return stringifyKey(key);
-    }
-
-    function getNestedChains(chains, scope) {
-        for (var i = 0; i < scope.length; i++) {
-            var bit = scope[i];
-
-            if (bit) {
-                chains = chains[bit];
-            }
-
-            if (!chains) {
-                break;
-            }
-        }
-        return chains;
-    }
-
-    var sequence = [];
-    function dispatch(e) {
-        // Skip all modifiers
-        if (~MODNUMS.indexOf(e.keyCode)) {
-            return;
-        }
-
-        var seq = sequence.slice();
-        seq.push(eventKeyString(e));
-        var scope = currentScope.split('.');
-        var matched, chains, key;
-
-        for (var i = scope.length; i >= 0; i--) {
-            chains = getNestedChains(allChains, scope.slice(0, i));
-            if (!chains) {
-                continue;
-            }
-            matched = true;
-            for (var j = 0; j < seq.length; j++) {
-                key = seq[j];
-                if (!chains[key]) {
-                    matched = false;
-                    break;
-                }
-                chains = chains[key];
-            }
-
-            if (matched) {
-                break;
-            }
-        }
-
-        var definitionScope = scope.slice(0, i).join('.');
-        var preventDefault = chains.preventDefault;
-
-        // partial match, save the sequence
-        if (matched && !chains.handlers) {
-            sequence = seq;
-            if (preventDefault) {
-                e.preventDefault();
-            }
-            return;
-        }
-
-        if (matched) {
-            for (i = 0; i < chains.handlers.length; i++) {
-                var handler = chains.handlers[i];
-                var options = handler._keymage;
-
-                var res = handler.call(options.context, e, {
-                    shortcut: options.original,
-                    scope: currentScope,
-                    definitionScope: definitionScope
-                });
-
-                if (res === false || preventDefault) {
-                    e.preventDefault();
-                }
-            }
-        }
-
-        // either matched or not, drop the sequence
-        sequence = [];
-    }
-
-    function getHandlers(scope, keychain, fn) {
-        var bits = scope.split('.');
-        var chains = allChains;
-        bits = bits.concat(keychain);
-
-        for (var i = 0, l = bits.length; i < l; i++) {
-            var bit = bits[i];
-            if (!bit) {continue;}
-
-            chains = chains[bit] || (chains[bit] = {});
-            if (fn && fn._keymage.preventDefault) {
-                chains.preventDefault = true;
-            }
-
-            if (i === l - 1) {
-                var handlers = chains.handlers || (chains.handlers = []);
-                return handlers;
-            }
-        }
-    }
-
-    function assignKey(scope, keychain, fn) {
-        var handlers = getHandlers(scope, keychain, fn);
-        handlers.push(fn);
-    }
-
-    function unassignKey(scope, keychain, fn) {
-        var handlers = getHandlers(scope, keychain);
-        var idx = handlers.indexOf(fn);
-        if (~idx) {
-            handlers.splice(idx, 1);
-        }
-    }
-
-    function parsed(scope, keychain, fn, options) {
-        if (keychain === undefined && fn === undefined) {
-            return function(keychain, fn) {
-                return keymage(scope, keychain, fn);
-            };
-        }
-
-        if (typeof keychain === 'function') {
-            options = fn;
-            fn = keychain;
-            keychain = scope;
-            scope = '';
-        }
-
-        var normalized = normalizeKeyChain(keychain);
-
-        return [scope, normalized, fn, options];
-    }
-
-    // optional arguments: scope, options.
-    function keymage(scope, keychain, fn, options) {
-        var args = parsed(scope, keychain, fn, options);
-        fn = args[2];
-        options = args[3];
-        fn._keymage = options || {};
-        fn._keymage.original = keychain;
-        assignKey.apply(null, args);
-
-        return function () {
-            unassignKey.apply(null, args);
-        };
-    }
-
-    keymage.unbind = function(scope, keychain, fn) {
-        var args = parsed(scope, keychain, fn);
-        unassignKey.apply(null, args);
-    };
-
-    keymage.parse = parseKeyString;
-    keymage.stringify = stringifyKey;
-    keymage.stringifyEvent = eventKeyString;
-
-    keymage.bindings = allChains;
-
-    keymage.setScope = function(scope) {
-        currentScope = scope ? scope : '';
-    };
-
-    keymage.getScope = function() { return currentScope; };
-
-    keymage.pushScope = function(scope) {
-        currentScope = (currentScope ? currentScope + '.' : '') + scope;
-        return currentScope;
-    };
-
-    keymage.popScope = function(scope) {
-        var i;
-
-        if (!scope) {
-            i = currentScope.lastIndexOf('.');
-            scope = currentScope.slice(i + 1);
-            currentScope = i == -1 ? '' : currentScope.slice(0, i);
-            return scope;
-        }
-
-        currentScope = currentScope.replace(
-            new RegExp('(^|\\.)' + scope + '(\\.|$).*'), '');
-        return scope;
-    };
-
-    keymage.version = VERSION;
-
-    window.addEventListener('keydown', dispatch);
-
-    return keymage;
-});
-})(typeof define !== 'undefined' ? define : function(factory) {
-    if (typeof module !== 'undefined') {
-        module.exports = factory();
-    } else {
-        window.keymage = factory();
-    }
-});
diff --git a/src/js/3rdparty/keymaster.js b/src/js/3rdparty/keymaster.js
deleted file mode 100644
index b247d7683..000000000
--- a/src/js/3rdparty/keymaster.js
+++ /dev/null
@@ -1,299 +0,0 @@
-//     keymaster.js
-//     (c) 2011-2013 Thomas Fuchs
-//     keymaster.js may be freely distributed under the MIT license.
-
-;(function(global){
-  var k,
-    _handlers = {},
-    _mods = { 16: false, 18: false, 17: false, 91: false },
-    _scope = 'all',
-    // modifier keys
-    _MODIFIERS = {
-      '⇧': 16, shift: 16,
-      '⌥': 18, alt: 18, option: 18,
-      '⌃': 17, ctrl: 17, control: 17,
-      '⌘': 91, command: 91
-    },
-    // special keys
-    _MAP = {
-      backspace: 8, tab: 9, clear: 12,
-      enter: 13, 'return': 13,
-      esc: 27, escape: 27, space: 32,
-      left: 37, up: 38,
-      right: 39, down: 40,
-      del: 46, 'delete': 46,
-      home: 36, end: 35,
-      divide: 111, multiply: 106, subtract: 109, add: 107,
-      numpad0: '96', numpad1: '97', numpad2: '98', numpad3: '99', numpad4: '100',
-      numpad5: '101', numpad6: '102', numpad7: '103', numpad8: '104', numpad9: '105',
-      pageup: 33, pagedown: 34,
-      ',': 188, '.': 190, '/': 191,
-      '`': 192, '-': 189, '=': 187,
-      ';': 186, '\'': 222,
-      '[': 219, ']': 221, '\\': 220
-    },
-    code = function(x){
-      return _MAP[x] || x.toUpperCase().charCodeAt(0);
-    },
-    _downKeys = [];
-
-  for(k=1;k<20;k++) _MAP['f'+k] = 111+k;
-
-  // IE doesn't support Array#indexOf, so have a simple replacement
-  function index(array, item){
-    var i = array.length;
-    while(i--) if(array[i]===item) return i;
-    return -1;
-  }
-
-  // for comparing mods before unassignment
-  function compareArray(a1, a2) {
-    if (a1.length != a2.length) return false;
-    for (var i = 0; i < a1.length; i++) {
-        if (a1[i] !== a2[i]) return false;
-    }
-    return true;
-  }
-
-  var modifierMap = {
-      16:'shiftKey',
-      18:'altKey',
-      17:'ctrlKey',
-      91:'metaKey'
-  };
-  function updateModifierKey(event) {
-      for(k in _mods) _mods[k] = event[modifierMap[k]];
-  };
-
-  // handle keydown event
-  function dispatch(event) {
-    var key, handler, k, i, modifiersMatch, scope;
-    key = event.keyCode;
-
-    if (index(_downKeys, key) == -1) {
-        _downKeys.push(key);
-    }
-
-    // if a modifier key, set the key.<modifierkeyname> property to true and return
-    if(key == 93 || key == 224) key = 91; // right command on webkit, command on Gecko
-    if(key in _mods) {
-      _mods[key] = true;
-      // 'assignKey' from inside this closure is exported to window.key
-      for(k in _MODIFIERS) if(_MODIFIERS[k] == key) assignKey[k] = true;
-      return;
-    }
-    updateModifierKey(event);
-
-    // see if we need to ignore the keypress (filter() can can be overridden)
-    // by default ignore key presses if a select, textarea, or input is focused
-    if(!assignKey.filter.call(this, event)) return;
-
-    // abort if no potentially matching shortcuts found
-    if (!(key in _handlers)) return;
-
-    scope = getScope();
-
-    // for each potential shortcut
-    for (i = 0; i < _handlers[key].length; i++) {
-      handler = _handlers[key][i];
-
-      // see if it's in the current scope
-      if(handler.scope == scope || handler.scope == 'all'){
-        // check if modifiers match if any
-        modifiersMatch = handler.mods.length > 0;
-        for(k in _mods)
-          if((!_mods[k] && index(handler.mods, +k) > -1) ||
-            (_mods[k] && index(handler.mods, +k) == -1)) modifiersMatch = false;
-        // call the handler and stop the event if neccessary
-        if((handler.mods.length == 0 && !_mods[16] && !_mods[18] && !_mods[17] && !_mods[91]) || modifiersMatch){
-          if(handler.method(event, handler)===false){
-            if(event.preventDefault) event.preventDefault();
-              else event.returnValue = false;
-            if(event.stopPropagation) event.stopPropagation();
-            if(event.cancelBubble) event.cancelBubble = true;
-          }
-        }
-      }
-    }
-  };
-
-  // unset modifier keys on keyup
-  function clearModifier(event){
-    var key = event.keyCode, k,
-        i = index(_downKeys, key);
-
-    // remove key from _downKeys
-    if (i >= 0) {
-        _downKeys.splice(i, 1);
-    }
-
-    if(key == 93 || key == 224) key = 91;
-    if(key in _mods) {
-      _mods[key] = false;
-      for(k in _MODIFIERS) if(_MODIFIERS[k] == key) assignKey[k] = false;
-    }
-  };
-
-  function resetModifiers() {
-    for(k in _mods) _mods[k] = false;
-    for(k in _MODIFIERS) assignKey[k] = false;
-  };
-
-  // parse and assign shortcut
-  function assignKey(key, scope, method){
-    var keys, mods;
-    keys = getKeys(key);
-    if (method === undefined) {
-      method = scope;
-      scope = 'all';
-    }
-
-    // for each shortcut
-    for (var i = 0; i < keys.length; i++) {
-      // set modifier keys if any
-      mods = [];
-      key = keys[i].split('+');
-      if (key.length > 1){
-        mods = getMods(key);
-        key = [key[key.length-1]];
-      }
-      // convert to keycode and...
-      key = key[0]
-      key = code(key);
-      // ...store handler
-      if (!(key in _handlers)) _handlers[key] = [];
-      _handlers[key].push({ shortcut: keys[i], scope: scope, method: method, key: keys[i], mods: mods });
-    }
-  };
-
-  // unbind all handlers for given key in current scope
-  function unbindKey(key, scope) {
-    var multipleKeys, keys,
-      mods = [],
-      i, j, obj;
-
-    multipleKeys = getKeys(key);
-
-    for (j = 0; j < multipleKeys.length; j++) {
-      keys = multipleKeys[j].split('+');
-
-      if (keys.length > 1) {
-        mods = getMods(keys);
-      }
-
-      key = keys[keys.length - 1];
-      key = code(key);
-
-      if (scope === undefined) {
-        scope = getScope();
-      }
-      if (!_handlers[key]) {
-        return;
-      }
-      for (i = 0; i < _handlers[key].length; i++) {
-        obj = _handlers[key][i];
-        // only clear handlers if correct scope and mods match
-        if (obj.scope === scope && compareArray(obj.mods, mods)) {
-          _handlers[key][i] = {};
-        }
-      }
-    }
-  };
-
-  // Returns true if the key with code 'keyCode' is currently down
-  // Converts strings into key codes.
-  function isPressed(keyCode) {
-      if (typeof(keyCode)=='string') {
-        keyCode = code(keyCode);
-      }
-      return index(_downKeys, keyCode) != -1;
-  }
-
-  function getPressedKeyCodes() {
-      return _downKeys.slice(0);
-  }
-
-  function filter(event){
-    var tagName = (event.target || event.srcElement).tagName;
-    // ignore keypressed in any elements that support keyboard data input
-    return !(tagName == 'INPUT' || tagName == 'SELECT' || tagName == 'TEXTAREA');
-  }
-
-  // initialize key.<modifier> to false
-  for(k in _MODIFIERS) assignKey[k] = false;
-
-  // set current scope (default 'all')
-  function setScope(scope){ _scope = scope || 'all' };
-  function getScope(){ return _scope || 'all' };
-
-  // delete all handlers for a given scope
-  function deleteScope(scope){
-    var key, handlers, i;
-
-    for (key in _handlers) {
-      handlers = _handlers[key];
-      for (i = 0; i < handlers.length; ) {
-        if (handlers[i].scope === scope) handlers.splice(i, 1);
-        else i++;
-      }
-    }
-  };
-
-  // abstract key logic for assign and unassign
-  function getKeys(key) {
-    var keys;
-    key = key.replace(/\s/g, '');
-    keys = key.split(',');
-    if ((keys[keys.length - 1]) == '') {
-      keys[keys.length - 2] += ',';
-    }
-    return keys;
-  }
-
-  // abstract mods logic for assign and unassign
-  function getMods(key) {
-    var mods = key.slice(0, key.length - 1);
-    for (var mi = 0; mi < mods.length; mi++)
-    mods[mi] = _MODIFIERS[mods[mi]];
-    return mods;
-  }
-
-  // cross-browser events
-  function addEvent(object, event, method) {
-    if (object.addEventListener)
-      object.addEventListener(event, method, false);
-    else if(object.attachEvent)
-      object.attachEvent('on'+event, function(){ method(window.event) });
-  };
-
-  // set the handlers globally on document
-  addEvent(document, 'keydown', function(event) { dispatch(event) }); // Passing _scope to a callback to ensure it remains the same by execution. Fixes #48
-  addEvent(document, 'keyup', clearModifier);
-
-  // reset modifiers to false whenever the window is (re)focused.
-  addEvent(window, 'focus', resetModifiers);
-
-  // store previously defined key
-  var previousKey = global.key;
-
-  // restore previously defined key and return reference to our key object
-  function noConflict() {
-    var k = global.key;
-    global.key = previousKey;
-    return k;
-  }
-
-  // set window.key and window.key.set/get/deleteScope, and the default filter
-  global.key = assignKey;
-  global.key.setScope = setScope;
-  global.key.getScope = getScope;
-  global.key.deleteScope = deleteScope;
-  global.key.filter = filter;
-  global.key.isPressed = isPressed;
-  global.key.getPressedKeyCodes = getPressedKeyCodes;
-  global.key.noConflict = noConflict;
-  global.key.unbind = unbindKey;
-
-  if(typeof module !== 'undefined') module.exports = assignKey;
-
-})(this);
diff --git a/src/js/additionalShellHandiness.js b/src/js/additionalShellHandiness.js
index a524bb9ad..022cf4af4 100644
--- a/src/js/additionalShellHandiness.js
+++ b/src/js/additionalShellHandiness.js
@@ -17,6 +17,9 @@ window.showOpenDialog = function showOpenDialog(options = {}) {
     const input = document.createElement('input');
     input.setAttribute('type', 'file');
     input.style.opacity = 0;
+    input.style.position = 'fixed';
+    input.style.right = '100%';
+    input.style.bottom = '100%';
     document.body.appendChild(input);
     if (options.openDirectory) {
         input.setAttribute('nwdirectory', 'nwdirectory');
diff --git a/src/js/codeEditorProjectAwareCompletions.js b/src/js/codeEditorProjectAwareCompletions.js
new file mode 100644
index 000000000..a20c4d2c1
--- /dev/null
+++ b/src/js/codeEditorProjectAwareCompletions.js
@@ -0,0 +1,150 @@
+(function codeEditorCompletions() {
+    const getInsertRange = function getInsertRange(model, position) {
+        var word = model.getWordUntilPosition(position);
+        return {
+            startLineNumber: position.lineNumber,
+            endLineNumber: position.lineNumber,
+            startColumn: word.startColumn,
+            endColumn: word.endColumn
+        };
+    };
+
+    const createTypeProposals = function createTypeProposals(range) {
+        // filtering is done by the Monaco editor
+        return global.currentProject.types.map(type => ({
+            label: type.name,
+            kind: monaco.languages.CompletionItemKind.Value,
+            insertText: `'${type.name}'`,
+            range
+        })).sort((a, b) => a.label.localeCompare(b.label));
+    };
+
+    const createRoomProposals = function createRoomProposals(range) {
+        return global.currentProject.rooms.map(room => ({
+            label: room.name,
+            kind: monaco.languages.CompletionItemKind.Value,
+            insertText: `'${room.name}'`,
+            range
+        })).sort((a, b) => a.label.localeCompare(b.label));
+    };
+
+    const createSoundProposals = function createSoundProposals(range) {
+        return global.currentProject.sounds.map(sound => ({
+            label: sound.name,
+            kind: monaco.languages.CompletionItemKind.Value,
+            insertText: `'${sound.name}'`,
+            range
+        })).sort((a, b) => a.label.localeCompare(b.label));
+    };
+
+    const createActionProposals = function createActionProposals(range) {
+        return global.currentProject.actions.map(action => ({
+            label: action.name,
+            kind: monaco.languages.CompletionItemKind.Property,
+            insertText: action.name,
+            range
+        })).sort((a, b) => a.label.localeCompare(b.label));
+    };
+
+    const createPSProposals = function createPSProposals(range) {
+        return global.currentProject.emitterTandems.map(et => ({
+            label: et.name,
+            kind: monaco.languages.CompletionItemKind.Value,
+            insertText: `'${et.name}'`,
+            range
+        })).sort((a, b) => a.label.localeCompare(b.label));
+    };
+
+    const checkMatch = function checkMatch(model, position, regex) {
+        var textUntilPosition = model.getValueInRange({
+            startLineNumber: position.lineNumber,
+            startColumn: 1,
+            endLineNumber: position.lineNumber,
+            endColumn: position.column
+        });
+        return textUntilPosition.match(regex);
+    };
+
+    const provideTypeNames = function provideTypeNames(model, position) {
+        if (!checkMatch(model, position, /ct\.types\.((make|copy)\(|list\[|templates\[)$/)) {
+            return {
+                suggestions: []
+            };
+        }
+        const range = getInsertRange(model, position);
+        return {
+            suggestions: createTypeProposals(range)
+        };
+    };
+
+    const provideRoomNames = function provideRoomNames(model, position) {
+        if (!checkMatch(model, position, /ct\.rooms\.((switch|append|prepend|merge)\(|templates\[)$/)) {
+            return {
+                suggestions: []
+            };
+        }
+        const range = getInsertRange(model, position);
+        return {
+            suggestions: createRoomProposals(range)
+        };
+    };
+
+    const provideSoundNames = function provideSoundNames(model, position) {
+        if (!checkMatch(model, position, /ct\.sound\.(spawn|volume|fade|stop|pause|resume|position|load|playing)\($/)) {
+            return {
+                suggestions: []
+            };
+        }
+        const range = getInsertRange(model, position);
+        return {
+            suggestions: createSoundProposals(range)
+        };
+    };
+
+    const provideActionNames = function provideActionNames(model, position) {
+        if (!checkMatch(model, position, /ct\.actions\.$/)) {
+            return {
+                suggestions: []
+            };
+        }
+        const range = getInsertRange(model, position);
+        return {
+            suggestions: createActionProposals(range)
+        };
+    };
+
+    const providePSNames = function providePSNames(model, position) {
+        if (!checkMatch(model, position, /ct\.emitters\.(fire|append|follow)\($/)) {
+            return {
+                suggestions: []
+            };
+        }
+        const range = getInsertRange(model, position);
+        return {
+            suggestions: createPSProposals(range)
+        };
+    };
+
+    window.signals.on('monacoBooted', () => {
+        monaco.languages.registerCompletionItemProvider('typescript', {
+            provideCompletionItems: provideTypeNames,
+            triggerCharacters: ['(', '[']
+        });
+        monaco.languages.registerCompletionItemProvider('typescript', {
+            provideCompletionItems: provideSoundNames,
+            triggerCharacters: ['(']
+        });
+        monaco.languages.registerCompletionItemProvider('typescript', {
+            provideCompletionItems: provideActionNames,
+            triggerCharacters: ['.']
+        });
+        monaco.languages.registerCompletionItemProvider('typescript', {
+            provideCompletionItems: provideRoomNames,
+            triggerCharacters: ['(', '[']
+        });
+        monaco.languages.registerCompletionItemProvider('typescript', {
+            provideCompletionItems: providePSNames,
+            triggerCharacters: ['(']
+        });
+    });
+})();
diff --git a/src/js/loadProject.js b/src/js/loadProject.js
index ee813f4ba..681dc2a62 100644
--- a/src/js/loadProject.js
+++ b/src/js/loadProject.js
@@ -119,8 +119,6 @@
                 lastProjects.pop();
             }
             localStorage.lastProjects = lastProjects.join(';');
-            window.signals.trigger('hideProjectSelector');
-            window.signals.trigger('projectLoaded');
 
             if (global.currentProject.settings.title) {
                 document.title = global.currentProject.settings.title + ' — ct.js';
@@ -138,6 +136,7 @@
             resetTypedefs();
             loadAllTypedefs();
 
+            window.signals.trigger('projectLoaded');
             setTimeout(() => {
                 window.riot.update();
             }, 0);
@@ -187,7 +186,7 @@
         }
     };
 
-    window.loadProject = proj => {
+    window.loadProject = async proj => {
         if (!proj) {
             const baseMessage = 'An attempt to open a project with an empty path.';
             alertify.error(baseMessage + ' See the console for the call stack.');
@@ -197,36 +196,37 @@
         sessionStorage.projname = path.basename(proj);
         global.projdir = path.dirname(proj) + path.sep + path.basename(proj, '.ict');
 
-        fs.stat(proj + '.recovery', (err, stat) => {
-            if (!err && stat.isFile()) {
-                var targetStat = fs.statSync(proj),
-                    voc = window.languageJSON.intro.recovery;
-                window.alertify
-                .okBtn(voc.loadRecovery)
-                .cancelBtn(voc.loadTarget)
-                /* {0} — target file date
-                   {1} — target file state (newer/older)
-                   {2} — recovery file date
-                   {3} — recovery file state (newer/older)
-                */
-                .confirm(voc.message
-                    .replace('{0}', targetStat.mtime.toLocaleString())
-                    .replace('{1}', targetStat.mtime < stat.mtime ? voc.older : voc.newer)
-                    .replace('{2}', stat.mtime.toLocaleString())
-                    .replace('{3}', stat.mtime < targetStat.mtime ? voc.older : voc.newer))
-                .then(e => {
-                    if (e.buttonClicked === 'ok') {
-                        loadProjectFile(proj + '.recovery');
-                    } else {
-                        loadProjectFile(proj);
-                    }
-                    window.alertify
-                    .okBtn(window.languageJSON.common.ok)
-                    .cancelBtn(window.languageJSON.common.cancel);
-                });
-            } else {
-                loadProjectFile(proj);
+        let recoveryStat;
+        try {
+            recoveryStat = await fs.stat(proj + '.recovery');
+        } catch (err) {
+            // no recovery file found
+            void 0;
+        }
+        if (recoveryStat && recoveryStat.isFile()) {
+            const targetStat = await fs.stat(proj);
+            const voc = window.languageJSON.intro.recovery;
+            const userResponse = await window.alertify
+            .okBtn(voc.loadRecovery)
+            .cancelBtn(voc.loadTarget)
+            /* {0} — target file date
+                {1} — target file state (newer/older)
+                {2} — recovery file date
+                {3} — recovery file state (newer/older)
+            */
+            .confirm(voc.message
+                .replace('{0}', targetStat.mtime.toLocaleString())
+                .replace('{1}', targetStat.mtime < recoveryStat.mtime ? voc.older : voc.newer)
+                .replace('{2}', recoveryStat.mtime.toLocaleString())
+                .replace('{3}', recoveryStat.mtime < targetStat.mtime ? voc.older : voc.newer));
+            window.alertify
+            .okBtn(window.languageJSON.common.ok)
+            .cancelBtn(window.languageJSON.common.cancel);
+            if (userResponse.buttonClicked === 'ok') {
+                return loadProjectFile(proj + '.recovery');
             }
-        });
+            return loadProjectFile(proj);
+        }
+        return loadProjectFile(proj);
     };
 })(this);
diff --git a/src/js/migration/1.4.2.js b/src/js/migration/1.4.2.js
new file mode 100644
index 000000000..e8f45854c
--- /dev/null
+++ b/src/js/migration/1.4.2.js
@@ -0,0 +1,17 @@
+window.migrationProcess = window.migrationProcess || [];
+
+window.migrationProcess.push({
+    version: '1.4.2',
+    process: project => new Promise(resolve => {
+        /**
+         * Copies now have their own extensions
+         */
+        for (const room of project.rooms) {
+            for (const copy of room.copies) {
+                copy.exts = copy.exts || {};
+            }
+        }
+
+        resolve();
+    })
+});
diff --git a/src/js/preventDnDNavigation.js b/src/js/preventDnDNavigation.js
new file mode 100644
index 000000000..1220e8a8c
--- /dev/null
+++ b/src/js/preventDnDNavigation.js
@@ -0,0 +1,16 @@
+/*
+ * This file prevents opening an image or such when a file was dragged into
+ * ct.js window and it was not catched by other listeners
+ */
+
+{
+    const draghHandler = function draghHandler(e) {
+        if (e.target.nodeName === 'INPUT' && e.target.type === 'file') {
+            return;
+        }
+        e.preventDefault();
+    };
+    document.addEventListener('dragenter', draghHandler);
+    document.addEventListener('dragover', draghHandler);
+    document.addEventListener('drop', draghHandler);
+}
diff --git a/src/js/roomCopyTools.js b/src/js/roomCopyTools.js
index b4fc19cd5..d0b5886a3 100644
--- a/src/js/roomCopyTools.js
+++ b/src/js/roomCopyTools.js
@@ -1,5 +1,5 @@
 (function roomCopyTools() {
-    const clickThreshold = 16;
+    const clickThreshold = 10;
     const glob = require('./data/node_requires/glob');
 
     const drawInsertPreview = function (e) {
@@ -158,11 +158,31 @@
             };
             // Place a copy on click
             this.onCanvasClickCopies = e => {
-                if (Math.hypot(e.offsetX - this.startx, e.offsetY - this.starty) > clickThreshold &&
+                if (
+                    Math.hypot(
+                        e.offsetX - this.startx,
+                        e.offsetY - this.starty
+                    ) > clickThreshold &&
                     !e.shiftKey
                 ) {
                     return; // this looks neither like a regular click nor like a Shift+drag
                 }
+                if (
+                    Math.hypot(
+                        e.offsetX - this.startx,
+                        e.offsetY - this.starty
+                    ) <= clickThreshold &&
+                    e.shiftKey
+                ) {
+                    // It is a shift + click. Select the nearest copy
+                    if (!this.room.copies.length) {
+                        return;
+                    }
+                    var copy = selectACopyAt.apply(this, [e]);
+                    this.selectedCopies = [copy];
+                    this.refreshRoomCanvas();
+                    return;
+                }
                 // Cancel copy selection on click
                 if (this.selectedCopies &&
                     !this.movingStuff &&
diff --git a/src/js/roomTileTools.js b/src/js/roomTileTools.js
index 8ebd72aef..ae9cb39ec 100644
--- a/src/js/roomTileTools.js
+++ b/src/js/roomTileTools.js
@@ -1,13 +1,31 @@
 (function roomTileTools() {
-    const clickThreshold = 16;
+    const clickThreshold = 10;
     const glob = require('./data/node_requires/glob');
 
+    const selectATileAt = function (e) {
+        var pos = 0,
+            length = Infinity,
+            l,
+            fromx = this.xToRoom(e.offsetX),
+            fromy = this.yToRoom(e.offsetY);
+        for (let i = 0, li = this.currentTileLayer.tiles.length; i < li; i++) {
+            const xp = this.currentTileLayer.tiles[i].x - fromx,
+                  yp = this.currentTileLayer.tiles[i].y - fromy;
+            l = Math.sqrt(xp * xp + yp * yp);
+            if (l < length) {
+                length = l;
+                pos = i;
+            }
+        }
+        return this.currentTileLayer.tiles[pos];
+    };
+
     const onCanvasMouseUpTiles = function (e) {
         if (e.button === 0 &&
             this.currentTileLayer &&
             Math.hypot(e.offsetX - this.startx, e.offsetY - this.starty) > clickThreshold
         ) {
-            // Было прямоугольное выделение
+            // There was a rectangular selection
             this.selectedTiles = [];
             var x1 = this.xToRoom(this.startx),
                 y1 = this.yToRoom(this.starty),
@@ -80,14 +98,14 @@
                 this.refs.canvas.x.drawImage(
                     img,
                     sx, sy, w, h,
-                    e.offsetX / this.zoomFactor,
-                    e.offsetY / this.zoomFactor,
+                    (e.offsetX) / this.zoomFactor - w / 2,
+                    (e.offsetY) / this.zoomFactor - h / 2,
                     w, h
                 );
             } else {
                 // snap coordinates to a grid if it is enabled
-                const dx = this.xToRoom(e.offsetX),
-                      dy = this.yToRoom(e.offsetY);
+                const dx = this.xToRoom(e.offsetX - w / 2 * this.zoomFactor),
+                      dy = this.yToRoom(e.offsetY - h / 2 * this.zoomFactor);
                 this.refs.canvas.x.drawImage(
                     img,
                     sx, sy, w, h,
@@ -101,12 +119,32 @@
 
     const onCanvasClickTiles = function (e) {
         if (
-            Math.hypot(e.offsetX - this.startx, e.offsetY - this.starty) > clickThreshold &&
+            Math.hypot(
+                e.offsetX - this.startx,
+                e.offsetY - this.starty
+            ) > clickThreshold &&
             !e.shiftKey
         ) {
             return; // this looks neither like a regular click nor like a Shift+drag
         }
 
+        if (
+            Math.hypot(
+                e.offsetX - this.startx,
+                e.offsetY - this.starty
+            ) <= clickThreshold &&
+            e.shiftKey
+        ) {
+            // It is a shift + click. Select the nearest copy
+            if (!this.currentTileLayer.tiles.length) {
+                return;
+            }
+            var tile = selectATileAt.apply(this, [e]);
+            this.selectedTiles = [tile];
+            this.refreshRoomCanvas();
+            return;
+        }
+
         // cancel potential tile selection on click
         if (
             this.selectedTiles && !this.movingStuff &&
@@ -121,12 +159,16 @@
             return;
         }
         // insert tiles
+
+        const tex = this.currentTileset;
+        const w = (tex.width + tex.marginx) * this.tileSpanX - tex.marginx,
+              h = (tex.height + tex.marginy) * this.tileSpanY - tex.marginy;
         if (Number(this.room.gridX) === 0 || e.altKey) {
-            if (this.lastTileX !== Math.floor(this.xToRoom(e.offsetX)) ||
-                this.lastTileY !== Math.floor(this.yToRoom(e.offsetY))
+            if (this.lastTileX !== Math.floor(this.xToRoom(e.offsetX) - w / 2) ||
+                this.lastTileY !== Math.floor(this.yToRoom(e.offsetY) - h / 2)
             ) {
-                this.lastTileX = Math.floor(this.xToRoom(e.offsetX));
-                this.lastTileY = Math.floor(this.yToRoom(e.offsetY));
+                this.lastTileX = Math.floor(this.xToRoom(e.offsetX) - w / 2);
+                this.lastTileY = Math.floor(this.yToRoom(e.offsetY) - h / 2);
                 this.currentTileLayer.tiles.push({
                     x: this.lastTileX,
                     y: this.lastTileY,
@@ -135,8 +177,8 @@
                 });
             }
         } else {
-            var x = Math.floor(this.xToRoom(e.offsetX)),
-                y = Math.floor(this.yToRoom(e.offsetY));
+            var x = Math.floor(this.xToRoom(e.offsetX - w / 2 * this.zoomFactor)),
+                y = Math.floor(this.yToRoom(e.offsetY - h / 2 * this.zoomFactor));
             if (this.lastTileX !== Math.round(x / this.room.gridX) * this.room.gridX ||
                 this.lastTileY !== Math.round(y / this.room.gridY) * this.room.gridY
             ) {
@@ -177,23 +219,9 @@
                 if (!this.room.tiles.length || !this.currentTileLayer.tiles.length) {
                     return false;
                 }
-                var pos = 0,
-                    length = Infinity,
-                    l,
-                    fromx = this.xToRoom(e.offsetX),
-                    fromy = this.yToRoom(e.offsetY);
-                for (let i = 0, li = this.currentTileLayer.tiles.length; i < li; i++) {
-                    const xp = this.currentTileLayer.tiles[i].x - fromx,
-                          yp = this.currentTileLayer.tiles[i].y - fromy;
-                    l = Math.sqrt(xp * xp + yp * yp);
-                    if (l < length) {
-                        length = l;
-                        pos = i;
-                    }
-                }
-                var tile = this.currentTileLayer.tiles[pos],
+                var tile = selectATileAt.apply(this, [e]),
                     tex = glob.texturemap[tile.texture].g;
-                this.closestPos = pos;
+                this.closestPos = this.currentTileLayer.tiles.indexOf(tile);
                 // draw the tile preview
                 this.refreshRoomCanvas();
                 var left = tile.x - 1.5,
diff --git a/src/node_requires/exporter/css.js b/src/node_requires/exporter/css.js
index 096c7fb0c..c1edd294d 100644
--- a/src/node_requires/exporter/css.js
+++ b/src/node_requires/exporter/css.js
@@ -7,7 +7,8 @@ const substituteCssVars = (str, project, injects) => {
         [color1, color2] = [color2, color1];
     }
     return str
-        .replace('/*@pixelatedrender@*/', project.settings.pixelatedrender ? 'canvas,img{image-rendering:optimizeSpeed;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:optimize-contrast;image-rendering:pixelated;ms-interpolation-mode:nearest-neighbor}' : '')
+        .replace('/*@pixelatedrender@*/', project.settings.rendering.pixelatedrender ? 'canvas,img{image-rendering:optimizeSpeed;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:optimize-contrast;image-rendering:pixelated;ms-interpolation-mode:nearest-neighbor}' : '')
+        .replace('/*@hidecursor@*/', project.settings.rendering.hideCursor ? '#ct { cursor: none; }' : '')
         .replace(/\/\*@preloaderforeground@\*\//g, color1)
         .replace(/\/\*@preloaderbackground@\*\//g, color2)
         .replace('/*%css%*/', injects.css);
diff --git a/src/node_requires/exporter/rooms.js b/src/node_requires/exporter/rooms.js
index c0a10da2f..504f9bb89 100644
--- a/src/node_requires/exporter/rooms.js
+++ b/src/node_requires/exporter/rooms.js
@@ -66,6 +66,7 @@ ct.rooms.templates['${r.name}'] = {
     objects: JSON.parse('${JSON.stringify(objs)}'),
     bgs: JSON.parse('${JSON.stringify(bgsCopy)}'),
     tiles: JSON.parse('${JSON.stringify(tileLayers)}'),
+    backgroundColor: '${r.backgroundColor || '#000000'}',
     onStep() {
         ${proj.rooms[k].onstep}
     },
diff --git a/src/node_requires/exporter/utils.js b/src/node_requires/exporter/utils.js
index 5beecce5f..d5544f77c 100644
--- a/src/node_requires/exporter/utils.js
+++ b/src/node_requires/exporter/utils.js
@@ -18,6 +18,11 @@ const getUnwrappedExtends = function getUnwrappedExtends(exts) {
         }
         const postfix = split.pop();
         const key = split.join('@@');
+        if ((postfix === 'type' || postfix === 'texture') &&
+            (exts[i] === void 0 || exts[i] === -1)) {
+            // Skip unset values
+            continue;
+        }
         if (postfix === 'type') {
             try {
                 out[key] = getTypeFromId(exts[i]).name;
diff --git a/src/node_requires/hotkeys.js b/src/node_requires/hotkeys.js
index ad6c7c88d..116be69a0 100644
--- a/src/node_requires/hotkeys.js
+++ b/src/node_requires/hotkeys.js
@@ -1,3 +1,33 @@
+/**
+ * @author CoMiGo
+ *
+ */
+/*
+So originally there were GitHub's Hotkeys,
+but I wanted a more automagical workflow of defining hotkeys.
+
+This lib triggers form elements (clicks or focus events, depending on input's type)
+on key presses in a declarative way, based on HTML markup plus a couple of scoping functions.
+
+On each key press, the lib queries the document for the resulting key combination.
+The selector is `[data-hotkey="Your-Code"]`
+
+You can narrow the scope of the query by calling `hotkey.push(scope)`. (See more methods below.)
+The scope is nested, forming a stack. If a scope is specified, it is read from the most recently
+added part to the outer scope, trying to call the elements that are inside a scoped parent.
+Such parent is defined by adding `data-hotkey-scope="scope"` attribute to an HTML element.
+
+By default, if there are no suitable elements that suit the current scope,
+the hotkey event is checked against the whole page. To disable it, add
+`data-hotkey-require-scope="scope"` attribute to a form element.
+
+If there are a number of form elements that suit the scope and a hotkey,
+only one is triggered. You can set priority with `data-hotkey-priority="10"`.
+The default priority is 0.
+
+Use of priorities is not encouraged. Use scope whenever possible.
+*/
+
 /* From @github/hotkey
     see https://github.com/github/hotkey/ */
 const isFormField = function (inputElement) {
@@ -8,13 +38,21 @@ const isFormField = function (inputElement) {
     var type = (inputElement.getAttribute('type') || '').toLowerCase();
     /* eslint no-mixed-operators: off*/
     return name === 'select' ||
-            name === 'textarea' ||
-            name === 'input' &&
-            type !== 'submit' &&
-            type !== 'reset' &&
-            type !== 'checkbox' &&
-            type !== 'radio' ||
-            inputElement.isContentEditable;
+        name === 'textarea' ||
+        name === 'input' &&
+        type !== 'submit' &&
+        type !== 'reset' &&
+        type !== 'checkbox' &&
+        type !== 'radio' ||
+        inputElement.isContentEditable;
+};
+/* GitHub code ends */
+
+const getPriority = function getPriority(elt) {
+    if (elt.hasAttribute('data-hotkey-priority')) {
+        return Number(elt.getAttribute('data-hotkey-priority'));
+    }
+    return 0;
 };
 
 const getCode = e => ''
@@ -67,12 +105,21 @@ class Hotkeys {
                 event();
             }
         }
-        const elts = this.document.querySelectorAll(`[data-hotkey="${code.replace(/"/g, '\\"')}"]`);
+
+        // querySelectorAll returns a NodeList, which is not a sortable array. Convert by spreading.
+        const elts = [...this.document.querySelectorAll(`[data-hotkey="${code.replace(/"/g, '\\"')}"]`)];
+
+        elts.sort((a, b) => getPriority(b) - getPriority(a));
         if (this.scopeStack.length) {
             // walk from the most recent scope to the last one
             for (let i = this.scopeStack.length - 1; i >= 0; i--) {
                 const scope = this.scopeStack[i];
                 for (const elt of elts) {
+                    if (elt.hasAttribute('data-hotkey-require-scope') &&
+                        elt.getAttribute('data-hotkey-require-scope') !== scope
+                    ) {
+                        continue;
+                    }
                     if (!elt.closest(`[data-hotkey-scope="${scope}"]`)) {
                         continue;
                     }
@@ -88,6 +135,9 @@ class Hotkeys {
         // Look for all the elements if no scope
         // is specified or no scoped elements were found
         for (const elt of elts) {
+            if (elt.hasAttribute('data-hotkey-require-scope')) {
+                continue;
+            }
             if (isFormField(elt)) {
                 elt.focus();
             } else {
diff --git a/src/node_requires/platformUtils.js b/src/node_requires/platformUtils.js
index c6ccb5444..9b63ad1bd 100644
--- a/src/node_requires/platformUtils.js
+++ b/src/node_requires/platformUtils.js
@@ -7,6 +7,7 @@ const isMac = !(isWin || isLinux);
 
 const mod = {
     isWin,
+    isWindows: isWin,
     isLinux,
     isMac,
 
@@ -48,17 +49,22 @@ const mod = {
         return new Promise((resolve, reject) => {
             // writing to an exec path on Mac is not a good idea,
             // as it will be hidden inside an app's directory, which is shown as one file.
-            if (isMac || !(execWritable)) {
-                if (!homeWritable) {
-                    reject(new Error(`Could not write to folders ${home} and ${exec}.`));
-                } else {
-                    fs.ensureDir(path.join(home, 'ct.js'))
-                    .then(() => {
-                        resolve(path.join(home, 'ct.js'));
-                    })
-                    .catch(reject);
-                }
+            if (isMac && !homeWritable) {
+                reject(new Error(`Could not write to folder ${home}. It is needed to create builds and run debugger. Check rights to these folders, and tweak sandbox settings if it is used.`));
+                return;
+            }
+            // Home directory takes priority
+            if (homeWritable) {
+                fs.ensureDir(path.join(home, 'ct.js'))
+                .then(() => {
+                    resolve(path.join(home, 'ct.js'));
+                })
+                .catch(reject);
             } else {
+                if (!execWritable) {
+                    reject(new Error(`Could not write to folders ${home} and ${exec}. A folder is needed to create builds and run debugger. Check access rights to these folders, and tweak sandbox settings if it is used.`));
+                    return;
+                }
                 resolve(exec);
             }
         });
@@ -66,7 +72,12 @@ const mod = {
 };
 
 {
-    let exportDir, exportDirPromise;
+    let exportDir,
+        exportDirPromise,
+        buildDir,
+        buildDirPromise,
+        projectsDir,
+        projectsDirPromise;
     // We compute a directory once and store it forever
     mod.getExportDir = () => {
         if (exportDir) {
@@ -75,12 +86,44 @@ const mod = {
         if (exportDirPromise) {
             return exportDirPromise;
         }
-        exportDirPromise = mod.getWritableDir().then(dir => {
-            exportDir = require('path').join(dir, 'export');
+        exportDirPromise = mod.getWritableDir().then(async ctjsDir => {
+            const dir = require('path').join(ctjsDir, 'Exported');
+            await fs.ensureDir(dir);
+            exportDir = dir;
             return exportDir;
         });
         return exportDirPromise;
     };
+    mod.getBuildDir = () => {
+        if (buildDir) {
+            return Promise.resolve(buildDir);
+        }
+        if (buildDirPromise) {
+            return buildDirPromise;
+        }
+        buildDirPromise = mod.getWritableDir().then(async ctjsDir => {
+            const dir = require('path').join(ctjsDir, 'Builds');
+            await fs.ensureDir(dir);
+            buildDir = dir;
+            return buildDir;
+        });
+        return buildDirPromise;
+    };
+    mod.getProjectsDir = () => {
+        if (projectsDir) {
+            return Promise.resolve(projectsDir);
+        }
+        if (projectsDirPromise) {
+            return projectsDirPromise;
+        }
+        projectsDirPromise = mod.getWritableDir().then(async ctjsDir => {
+            const dir = require('path').join(ctjsDir, 'Projects');
+            await fs.ensureDir(dir);
+            projectsDir = dir;
+            return projectsDir;
+        });
+        return projectsDirPromise;
+    };
 }
 
 module.exports = mod;
diff --git a/src/node_requires/resources/fonts/bitmapFontGenerator/index.js b/src/node_requires/resources/fonts/bitmapFontGenerator/index.js
index 8a4ff4a67..f48e339ba 100644
--- a/src/node_requires/resources/fonts/bitmapFontGenerator/index.js
+++ b/src/node_requires/resources/fonts/bitmapFontGenerator/index.js
@@ -5,8 +5,8 @@ const opentype = require('opentype.js');
 const draw = function draw(ctx, glyphList, descend, options) {
     var dict = {};
 
-    var drawX = 0;
-    var drawY = 0;
+    var drawX = 1;
+    var drawY = 1;
     var drawHeight = options.baseline + descend;
     var mg;
 
@@ -25,8 +25,8 @@ const draw = function draw(ctx, glyphList, descend, options) {
                 drawWidth = g.width;
             }
             if (drawX + drawWidth > ctx.canvas.width) {
-                drawX = 0;
-                drawY += drawHeight + options.margin;
+                drawX = 1;
+                drawY += drawHeight + options.margin * 2;
             }
             var path = g.glyph.getPath(drawX + (drawWidth / 2) - (g.width / 2), drawY + options.baseline, options.height);
             path.fill = options.fill;
@@ -43,7 +43,7 @@ const draw = function draw(ctx, glyphList, descend, options) {
                     };
                 });
             }
-            drawX += drawWidth + options.margin;
+            drawX += drawWidth + options.margin * 2;
         }
     });
 
@@ -52,6 +52,7 @@ const draw = function draw(ctx, glyphList, descend, options) {
     };
 };
 
+// eslint-disable-next-line max-lines-per-function
 const generateBitmapFont = async function generateBitmapFont(fontSrc, outputPath, options, callback) {
     const fs = require('fs-extra');
     const buffer = await fs.readFile(fontSrc);
@@ -66,6 +67,7 @@ const generateBitmapFont = async function generateBitmapFont(fontSrc, outputPath
 
     var lostChars = [];
     var glyphList = [];
+
     Array.from(options.list).forEach((char) => {
         const [glyph] = font.stringToGlyphs(char);
         glyph.font = font;
@@ -107,9 +109,20 @@ const generateBitmapFont = async function generateBitmapFont(fontSrc, outputPath
     // Calculate the required canvas size
     var canvasSize;
     if (options.width === void 0) {
-        canvasSize = util.calculateCanvasSizeProp(options.list, glyphList, adjustedHeight, options.baseline + descend);
+        canvasSize = util.calculateCanvasSizeProp(
+            options.list,
+            glyphList,
+            adjustedHeight,
+            options.baseline + descend,
+            options.margin || 1
+        );
     } else {
-        canvasSize = util.calculateCanvasSize(options.list, options.width, adjustedHeight);
+        canvasSize = util.calculateCanvasSize(
+            options.list,
+            options.width,
+            adjustedHeight,
+            options.margin || 1
+        );
     }
 
     // Check if the created canvas size is valid
@@ -141,6 +154,7 @@ const generateBitmapFont = async function generateBitmapFont(fontSrc, outputPath
             'Try Using other font or characters.');
     }
     await util.outputBitmapFont(outputPath, canvas, callback);
+
     return {
         map: drawResult.map,
         missingGlyph: drawResult.missingGlyph,
diff --git a/src/node_requires/resources/fonts/bitmapFontGenerator/util.js b/src/node_requires/resources/fonts/bitmapFontGenerator/util.js
index ebbd03c92..7ce3dc8b8 100644
--- a/src/node_requires/resources/fonts/bitmapFontGenerator/util.js
+++ b/src/node_requires/resources/fonts/bitmapFontGenerator/util.js
@@ -1,7 +1,7 @@
 /* eslint-disable max-len */
 const fs = require('fs').promises;
 
-const calculateCanvasSize = function calculateCanvasSize(text, charWidth, charHeight) {
+const calculateCanvasSize = function calculateCanvasSize(text, charWidth, charHeight, margin) {
     if (charWidth <= 0 || charHeight <= 0) {
         return {
             width: -1, height: -1
@@ -12,13 +12,13 @@ const calculateCanvasSize = function calculateCanvasSize(text, charWidth, charHe
     var canvasSquareSideSize = 1;
 
     // Find the length of the side of a square that can contain characters
-    while ((canvasSquareSideSize / charWidth) * (canvasSquareSideSize / charHeight) < textSize) {
+    while ((canvasSquareSideSize / (charWidth + margin * 2)) * (canvasSquareSideSize / (charHeight + margin * 2)) < textSize) {
         canvasSquareSideSize *= 2;
     }
     var canvasWidth = canvasSquareSideSize;
 
     // CanvasSquareSideSize cannot be used because it may not be square
-    var tmpCanvasHeight = Math.ceil(textSize / Math.floor(canvasWidth / charWidth)) * charHeight;
+    var tmpCanvasHeight = Math.ceil(textSize / Math.floor(canvasWidth / (charWidth + margin * 2))) * (charHeight + margin * 2);
     var canvasHeight = 1;
     while (canvasHeight < tmpCanvasHeight) {
         canvasHeight *= 2;
@@ -29,34 +29,35 @@ const calculateCanvasSize = function calculateCanvasSize(text, charWidth, charHe
     };
 };
 
-const canGoIn = function canGoIn(canvasSize, glyphList, charHeight) {
-    var drawX = 0;
-    var drawY = 0;
+const canGoIn = function canGoIn(canvasSize, glyphList, charHeight, margin) {
+    var drawX = 1;
+    var drawY = 1;
 
     glyphList.forEach(glyph => {
-        if (drawX + glyph.width > canvasSize.width) {
-            drawX = 0;
-            drawY += charHeight;
+        if (drawX + glyph.width + margin * 2 > canvasSize.width) {
+            drawX = 1;
+            drawY += charHeight + margin * 2;
         }
-        drawX += glyph.width;
+        drawX += glyph.width + margin * 2;
     });
 
-    return drawY + charHeight < canvasSize.height;
+    return drawY + charHeight + margin * 2 < canvasSize.height;
 };
 
 const calculateCanvasSizeProp = function calculateCanvasSizeProp(
     text,
     glyphList,
     height,
-    charHeight
+    charHeight,
+    margin
 ) {
     var widthAverage = 0;
     var widthMax = 0;
     glyphList.forEach(glyph => {
-        if (glyph.width > widthMax) {
-            widthMax = glyph.width;
+        if (glyph.width + margin * 2 > widthMax) {
+            widthMax = glyph.width + margin * 2;
         }
-        widthAverage += glyph.width;
+        widthAverage += glyph.width + margin * 2;
     });
     widthAverage /= glyphList.length;
 
@@ -66,9 +67,9 @@ const calculateCanvasSizeProp = function calculateCanvasSizeProp(
         };
     }
     // Use the average value to calculate the approximate size
-    var canvasSize = calculateCanvasSize(text, widthAverage, height);
+    var canvasSize = calculateCanvasSize(text, widthAverage, height, margin);
     // Increase the vertical width until the text can be entered
-    while (!canGoIn(canvasSize, glyphList, charHeight)) {
+    while (!canGoIn(canvasSize, glyphList, charHeight, margin)) {
         canvasSize.height *= 2;
     }
     return canvasSize;
diff --git a/src/node_requires/resources/fonts/index.js b/src/node_requires/resources/fonts/index.js
new file mode 100644
index 000000000..832332499
--- /dev/null
+++ b/src/node_requires/resources/fonts/index.js
@@ -0,0 +1,95 @@
+/**
+ * @param {object|string} font The font object in ct.js project, or its UID.
+ * @param {boolean} fs If set to `true`, returns a clean path in a file system.
+ * Otherwise, returns an URL.
+ */
+const getPathToTtf = function getPathToTtf(font, fs) {
+    const path = require('path');
+    if (fs) {
+        return path.join(global.projdir, 'fonts', font.origname);
+    }
+    return `file://${global.projdir.replace(/\\/g, '/')}/fonts/${font.origname}`;
+};
+
+/**
+ * @param {object|string} font The font object in ct.js project, or its UID.
+ * @param {boolean} fs If set to `true`, returns a clean path in a file system.
+ * Otherwise, returns an URL.
+ */
+const getFontPreview = function getFontPreview(font, fs) {
+    const path = require('path');
+    if (fs) {
+        return path.join(global.projdir, 'fonts', `${font.origname}_prev.png`);
+    }
+    return `file://${global.projdir.replace(/\\/g, '/')}/fonts/${font.origname}_prev.png?cache=${font.lastmod}`;
+};
+
+const fontGenPreview = async function fontGenPreview(font) {
+    const template = {
+        weight: font.weight,
+        style: font.italic ? 'italic' : 'normal'
+    };
+    const fs = require('fs-extra');
+    const face = new FontFace('CTPROJFONT' + font.typefaceName, `url(${getPathToTtf(font)})`, template);
+
+    // Trigger font loading by creating an invisible label with this font
+    // const elt = document.createElement('span');
+    // elt.innerHTML = 'testString';
+    // elt.style.position = 'fixed';
+    // elt.style.right = '200%';
+    // elt.style.fontFamily = 'CTPROJFONT' + font.typefaceName;
+    // document.body.appendChild(elt);
+
+    const loaded = await face.load();
+    loaded.external = true;
+    loaded.ctId = face.ctId = font.uid;
+    document.fonts.add(loaded);
+    // document.body.removeChild(elt);
+
+    const c = document.createElement('canvas');
+    c.x = c.getContext('2d');
+    c.width = c.height = 64;
+    c.x.clearRect(0, 0, 64, 64);
+    c.x.font = `${font.italic ? 'italic ' : ''}${font.weight} ${Math.floor(64 * 0.75)}px "${loaded.family}"`;
+    c.x.fillStyle = '#000';
+    c.x.fillText('Ab', 64 * 0.05, 64 * 0.75);
+
+    // strip off the data:image url prefix to get just the base64-encoded bytes
+    const dataURL = c.toDataURL();
+    const previewBuffer = dataURL.replace(/^data:image\/\w+;base64,/, '');
+    const buf = new Buffer(previewBuffer, 'base64');
+    await fs.writeFile(getFontPreview(font, true), buf);
+};
+
+const importTtfToFont = async function importTtfToFont(src) {
+    const fs = require('fs-extra'),
+          path = require('path');
+    if (path.extname(src).toLowerCase() !== '.ttf') {
+        throw new Error(`[resources/fonts] Rejecting a file as it does not have a .ttf extension: ${src}`);
+    }
+    const generateGUID = require('./../../generateGUID');
+    const uid = generateGUID();
+    await fs.copy(src, path.join(global.projdir, '/fonts/f' + uid + '.ttf'));
+    const obj = {
+        typefaceName: path.basename(src).replace(/\.ttf$/i, ''),
+        weight: 400,
+        italic: false,
+        origname: `f${uid}.ttf`,
+        lastmod: Number(new Date()),
+        bitmapFont: false,
+        bitmapFontSize: 16,
+        bitmapFontLineHeight: 18,
+        charsets: ['allInFont'],
+        customCharset: '',
+        uid
+    };
+    global.currentProject.fonts.push(obj);
+    await fontGenPreview(obj);
+    window.signals.trigger('fontCreated');
+};
+module.exports = {
+    importTtfToFont,
+    fontGenPreview,
+    getFontPreview,
+    getPathToTtf
+};
diff --git a/src/node_requires/resources/projects/index.js b/src/node_requires/resources/projects/index.js
index aa0aaee20..7f82278fb 100644
--- a/src/node_requires/resources/projects/index.js
+++ b/src/node_requires/resources/projects/index.js
@@ -1,8 +1,12 @@
 const defaultProject = require('./defaultProject');
 
+/**
+ * @returns {Promise<string>} A promise that resolves into the absolute path
+ * to the projects' directory
+ */
 const getDefaultProjectDir = function () {
-    const path = require('path');
-    return path.join(nw.App.startPath, 'projects');
+    const {getProjectsDir} = require('./../../platformUtils');
+    return getProjectsDir();
 };
 
 const getExamplesDir = function () {
diff --git a/src/node_requires/resources/skeletons.js b/src/node_requires/resources/skeletons.js
new file mode 100644
index 000000000..9fd27b91e
--- /dev/null
+++ b/src/node_requires/resources/skeletons.js
@@ -0,0 +1,106 @@
+const path = require('path');
+
+const getSkeletonData = function getSkeletonData(skeleton, fs) {
+    if (fs) {
+        return path.join(global.projdir, 'img', skeleton.origname);
+    }
+    return `file://${global.projdir}/img/${skeleton.origname}`;
+};
+const getSkeletonTextureData = function getSkeletonTextureData(skeleton, fs) {
+    const slice = skeleton.origname.replace('_ske.json', '');
+    if (fs) {
+        return path.join(global.projdir, 'img', `${slice}_tex.json`);
+    }
+    return `file://${global.projdir}/img/${slice}_tex.json`;
+};
+const getSkeletonTexture = function getSkeletonTexture(skeleton, fs) {
+    const slice = skeleton.origname.replace('_ske.json', '');
+    if (fs) {
+        return path.join(global.projdir, 'img', `${slice}_tex.png`);
+    }
+    return `file://${global.projdir}/img/${slice}_tex.png`;
+};
+
+const getSkeletonPreview = function getSkeletonPreview(skeleton, fs) {
+    if (fs) {
+        return path.join(global.projdir, 'img', `${skeleton.origname}_prev.png`);
+    }
+    return `file://${global.projdir.replace(/\\/g, '/')}/img/${skeleton.origname}_prev.png`;
+};
+
+/**
+ * Generates a square thumbnail of a given skeleton
+ * @param {String} skeleton The skeleton object to generate a preview for.
+ * @returns {Promise<void>} Resolves after creating a thumbnail.
+ */
+const skeletonGenPreview = function (skeleton) {
+    const loader = new PIXI.loaders.Loader(),
+          dbf = dragonBones.PixiFactory.factory;
+    const fs = require('fs-extra');
+    return new Promise((resolve, reject) => {
+        // Draw the armature on a canvas/in a Pixi.js app
+        const skelData = getSkeletonData(skeleton),
+              texData = getSkeletonTextureData(skeleton),
+              tex = getSkeletonTexture(skeleton);
+        loader.add(skelData, skelData)
+              .add(texData, texData)
+              .add(tex, tex);
+        loader.load(() => {
+            dbf.parseDragonBonesData(loader.resources[skelData].data);
+            dbf.parseTextureAtlasData(
+                loader.resources[texData].data,
+                loader.resources[tex].texture
+            );
+            const skel = dbf.buildArmatureDisplay('Armature', loader.resources[skelData].data.name);
+
+            const app = new PIXI.Application();
+
+            const rawSkelBase64 = app.renderer.plugins.extract.base64(skel);
+            const skelBase64 = rawSkelBase64.replace(/^data:image\/\w+;base64,/, '');
+            const buf = new Buffer(skelBase64, 'base64');
+
+            fs.writeFile(getSkeletonPreview(skeleton, true), buf)
+            .then(() => {
+                // Clean memory from DragonBones' armatures
+                // eslint-disable-next-line no-underscore-dangle
+                delete dbf._dragonBonesDataMap[loader.resources[skelData].data.name];
+                // eslint-disable-next-line no-underscore-dangle
+                delete dbf._textureAtlasDataMap[loader.resources[skelData].data.name];
+            })
+            .then(resolve)
+            .catch(reject);
+        });
+    });
+};
+
+const importSkeleton = async function importSkeleton(source) {
+    const generateGUID = require('./../generateGUID');
+    const fs = require('fs-extra');
+
+    const uid = generateGUID();
+    const partialDest = path.join(global.projdir + '/img/skdb' + uid);
+
+    await Promise.all([
+        fs.copy(source, partialDest + '_ske.json'),
+        fs.copy(source.replace('_ske.json', '_tex.json'), partialDest + '_tex.json'),
+        fs.copy(source.replace('_ske.json', '_tex.png'), partialDest + '_tex.png')
+    ]);
+    const skel = {
+        name: path.basename(source).replace('_ske.json', ''),
+        origname: path.basename(partialDest + '_ske.json'),
+        from: 'dragonbones',
+        uid
+    };
+    await skeletonGenPreview(skel);
+    global.currentProject.skeletons.push(skel);
+    window.signals.trigger('skeletonImported', skel);
+};
+
+module.exports = {
+    getSkeletonData,
+    getSkeletonTextureData,
+    getSkeletonTexture,
+    getSkeletonPreview,
+    skeletonGenPreview,
+    importSkeleton
+};
diff --git a/src/node_requires/resources/textures.js b/src/node_requires/resources/textures/index.js
similarity index 74%
rename from src/node_requires/resources/textures.js
rename to src/node_requires/resources/textures/index.js
index 4e15df039..75dda8421 100644
--- a/src/node_requires/resources/textures.js
+++ b/src/node_requires/resources/textures/index.js
@@ -28,7 +28,7 @@ const getTexturePreview = function (texture, x2, fs) {
     if (fs) {
         return `${global.projdir}/img/${texture.origname}_prev${x2 ? '@2' : ''}.png`;
     }
-    return `file://${global.projdir}/img/${texture.origname}_prev${x2 ? '@2' : ''}.png?cache=${texture.lastmod}`;
+    return `file://${global.projdir.replace(/\\/g, '/')}/img/${texture.origname}_prev${x2 ? '@2' : ''}.png?cache=${texture.lastmod}`;
 };
 
 /**
@@ -47,14 +47,14 @@ const getTextureOrig = function (texture, fs) {
     if (fs) {
         return `${global.projdir}/img/${texture.origname}`;
     }
-    return `file://${global.projdir}/img/${texture.origname}?cache=${texture.lastmod}`;
+    return `file://${global.projdir.replace(/\\/g, '/')}/img/${texture.origname}?cache=${texture.lastmod}`;
 };
 
 const baseTextureFromTexture = texture => new Promise((resolve, reject) => {
     const textureLoader = new PIXI.Loader();
     const {resources} = textureLoader;
 
-    const path = 'file://' + global.projdir + '/img/' + texture.origname + '?' + texture.lastmod;
+    const path = 'file://' + global.projdir.replace(/\\/g, '/') + '/img/' + texture.origname + '?' + texture.lastmod;
 
     textureLoader.add(texture.uid, path);
     textureLoader.onError.add(reject);
@@ -111,7 +111,7 @@ const getDOMImage = function (texture, deflt) {
         if (typeof texture === 'string') {
             texture = getTextureFromId(texture);
         }
-        path = 'file://' + global.projdir + '/img/' + texture.origname + '?' + texture.lastmod;
+        path = 'file://' + global.projdir.replace(/\\/g, '/') + '/img/' + texture.origname + '?' + texture.lastmod;
     }
     img.src = path;
     return new Promise((resolve, reject) => {
@@ -170,75 +170,73 @@ const getTextureFromName = function (name) {
     }
     return texture;
 };
+const textureGenPreview = async function textureGenPreview(texture, destination, size) {
+    if (typeof texture === 'string') {
+        texture = getTextureFromId(texture);
+    }
 
-/**
- * Generates a square preview for a given skeleton
- * @param {string} source Path to the image
- * @param {string} destFile Path to the destinating image
- * @param {number} size Size of the square thumbnail, in pixels
- * @returns {Promise<string>} Resolves after creating a thumbnail.
- * On success, passes `destFile`, the path to the created thumbnail.
- */
-const imgGenPreview = (source, destFile, size) => {
-    const thumbnail = document.createElement('img');
+    const source = await getDOMImage(texture);
+
+    const c = document.createElement('canvas');
+    c.x = c.getContext('2d');
+    c.width = c.height = size;
+    c.x.clearRect(0, 0, size, size);
+
+    const x = texture.offx,
+          y = texture.offy,
+          w = texture.width,
+          h = texture.height;
+
+    let k;
+    if (w > h) {
+        k = size / w;
+    } else {
+        k = size / h;
+    }
+    if (k > 1) {
+        if (global.currentProject.settings.rendering.pixelatedrender) {
+            k = Math.floor(k);
+            c.x.imageSmoothingEnabled = false;
+        } else {
+            k = 1;
+        }
+    }
+    c.x.drawImage(
+        source,
+        x, y, w, h,
+        (size - w * k) / 2, (size - h * k) / 2,
+        w * k, h * k
+    );
+    const thumbnailBase64 = c.toDataURL().replace(/^data:image\/\w+;base64,/, '');
+    const buf = Buffer.from(thumbnailBase64, 'base64');
     const fs = require('fs-extra');
-    return new Promise((accept, reject) => {
-        thumbnail.onload = () => {
-            var c = document.createElement('canvas'),
-                w, h, k;
-            c.x = c.getContext('2d');
-            c.width = c.height = size;
-            c.x.clearRect(0, 0, size, size);
-            w = thumbnail.width;
-            h = thumbnail.height;
-            if (w > h) {
-                k = size / w;
-            } else {
-                k = size / h;
-            }
-            if (k > 1) {
-                k = 1;
-            }
-            c.x.drawImage(
-                thumbnail,
-                (size - thumbnail.width * k) / 2,
-                (size - thumbnail.height * k) / 2,
-                thumbnail.width * k,
-                thumbnail.height * k
-            );
-            // strip off the data:image url prefix to get just the base64-encoded bytes
-            var dataURL = c.toDataURL();
-            var base64data = dataURL.replace(/^data:image\/\w+;base64,/, '');
-            var buf = new Buffer(base64data, 'base64');
-            var stream = fs.createWriteStream(destFile);
-            stream.on('finish', () => {
-                setTimeout(() => { // WHY THE HECK I EVER NEED THIS?!
-                    accept(destFile);
-                }, 100);
-            });
-            stream.on('error', err => {
-                reject(err);
-            });
-            stream.end(buf);
-        };
-        thumbnail.src = 'file://' + source;
-    });
+    await fs.writeFile(destination, buf);
+    return destination;
 };
 
 const texturePostfixParser = /_(?<cols>\d+)x(?<rows>\d+)(?:@(?<until>\d+))?$/;
 const isBgPostfixTester = /@bg$/;
 /**
  * Tries to load an image, then adds it to the projects and creates a thumbnail
- * @param {string} src A path to the source image
+ * @param {string|Buffer} src A path to the source image, or a Buffer of an already read image.
+ * @param {string} [name] The name of the texture. Optional, defaults to 'NewTexture'
+ * or file's basename.
  * @returns {Promise<object>} A promise that resolves into the resulting texture object.
  */
-const importImageToTexture = async src => {
+// eslint-disable-next-line max-lines-per-function
+const importImageToTexture = async (src, name) => {
     const fs = require('fs-extra'),
           path = require('path'),
-          generateGUID = require('./../generateGUID');
+          generateGUID = require('./../../generateGUID');
     const id = generateGUID();
-    const dest = path.join(global.projdir, 'img', `i${id}${path.extname(src)}`);
-    await fs.copy(src, dest);
+    let dest;
+    if (src instanceof Buffer) {
+        dest = path.join(global.projdir, 'img', `i${id}.png}`);
+        await fs.writeFile(dest, src);
+    } else {
+        dest = path.join(global.projdir, 'img', `i${id}${path.extname(src)}`);
+        await fs.copy(src, dest);
+    }
     const image = document.createElement('img');
     // Wait while the image is loading
     await new Promise((resolve, reject) => {
@@ -251,13 +249,16 @@ const importImageToTexture = async src => {
         };
         image.src = 'file://' + dest + '?' + Math.random();
     });
-    await Promise.all([
-        imgGenPreview(dest, dest + '_prev.png', 64),
-        imgGenPreview(dest, dest + '_prev@2.png', 128)
-    ]);
-    const texName = path.basename(src)
-                        .replace(/\.(jpg|gif|png|jpeg)/gi, '')
-                        .replace(/\s/g, '_');
+    let texName;
+    if (name) {
+        texName = name;
+    } else if (src instanceof Buffer) {
+        texName = 'NewTexture';
+    } else {
+        texName = path.basename(src)
+            .replace(/\.(jpg|gif|png|jpeg)/gi, '')
+            .replace(/\s/g, '_');
+    }
     const obj = {
         name: texName,
         untill: 0,
@@ -272,7 +273,6 @@ const importImageToTexture = async src => {
         offx: 0,
         offy: 0,
         origname: path.basename(dest),
-        source: src,
         shape: 'rect',
         left: 0,
         right: image.width,
@@ -281,6 +281,9 @@ const importImageToTexture = async src => {
         uid: id,
         padding: 1
     };
+    if (!(src instanceof Buffer)) {
+        obj.source = src;
+    }
 
     // Test if this has a postfix _NxM@K or _NxM
     const exec = texturePostfixParser.exec(obj.name);
@@ -300,7 +303,13 @@ const importImageToTexture = async src => {
         obj.tiled = true;
     }
 
+    await Promise.all([
+        textureGenPreview(obj, dest + '_prev.png', 64),
+        textureGenPreview(obj, dest + '_prev@2.png', 128)
+    ]);
+
     global.currentProject.textures.push(obj);
+
     window.signals.trigger('textureImported');
     return obj;
 };
@@ -325,5 +334,5 @@ module.exports = {
     getPixiTexture,
     getDOMImage,
     importImageToTexture,
-    imgGenPreview
+    textureGenPreview
 };
diff --git a/src/pug/index.pug b/src/pug/index.pug
index 15db0cf0e..ca62741b4 100644
--- a/src/pug/index.pug
+++ b/src/pug/index.pug
@@ -87,6 +87,7 @@ html
         script.
             'use strict';
             window.signals = window.signals || riot.observable({});
+            window.hotkeys = require('./data/node_requires/hotkeys')(document);
             riot.mount('*');
             setTimeout(() => {
                 document.getElementById('loading').classList.add('fadeout');
diff --git a/src/riotTags/debugger/debugger-toolbar.tag b/src/riotTags/debugger/debugger-toolbar.tag
index 7d0c28b3b..cd564f45c 100644
--- a/src/riotTags/debugger/debugger-toolbar.tag
+++ b/src/riotTags/debugger/debugger-toolbar.tag
@@ -159,7 +159,9 @@ debugger-toolbar
                 const buff = new Buffer(shotBase64, 'base64');
                 const stream = fs.createWriteStream(fullPath);
                 stream.on('finish', () => {
-                    window.soundbox.play('Success');
+                    if (localStorage.disableSounds !== 'on') {
+                        window.soundbox.play('Success');
+                    }
                     // eslint-disable-next-line no-new
                     new Notification('Done!', {
                         body: `Saved to ${fullPath} 👌`,
diff --git a/src/riotTags/dnd-processor.tag b/src/riotTags/dnd-processor.tag
new file mode 100644
index 000000000..e1f6074a1
--- /dev/null
+++ b/src/riotTags/dnd-processor.tag
@@ -0,0 +1,68 @@
+dnd-processor
+    .aDropzone(if="{dropping}")
+        .middleinner
+            svg.feather
+                use(xlink:href="data/icons.svg#download")
+            h2 {languageJSON.common.fastimport}
+            input(
+                type="file" multiple
+                accept=".png,.jpg,.jpeg,.bmp,.gif,.json,.ttf"
+                onchange="{dndImport}"
+            )
+    script.
+        this.dndImport = e => {
+            const files = [...e.target.files].map(file => file.path);
+            for (let i = 0; i < files.length; i++) {
+                if (/\.(jpg|gif|png|jpeg)/gi.test(files[i])) {
+                    const {importImageToTexture} = require('./data/node_requires/resources/textures');
+                    importImageToTexture(files[i]);
+                } else if (/_ske\.json/i.test(files[i])) {
+                    const {importSkeleton} = require('./data/node_requires/resources/skeletons');
+                    importSkeleton(files[i]);
+                } else if (/\.ttf/gi.test(files[i])) {
+                    const {importTtfToFont} = require('./data/node_requires/resources/fonts');
+                    importTtfToFont(files[i]);
+                } else {
+                    alertify.log(`Skipped ${files[i]} as it is not supported by drag-and-drop importer.`);
+                }
+            }
+            e.srcElement.value = '';
+            this.dropping = false;
+            e.preventDefault();
+        };
+
+        /*
+         * drag-n-drop handling
+         */
+        let dragTimer;
+        this.onDragOver = e => {
+            var dt = e.dataTransfer;
+            if (dt.types && (dt.types.indexOf ? dt.types.indexOf('Files') !== -1 : dt.types.contains('Files'))) {
+                this.dropping = true;
+                this.update();
+                window.clearTimeout(dragTimer);
+            }
+            e.preventDefault();
+            e.stopPropagation();
+        };
+        this.onDrop = e => {
+            e.stopPropagation();
+        };
+        this.onDragLeave = e => {
+            dragTimer = window.setTimeout(() => {
+                this.dropping = false;
+                this.update();
+            }, 25);
+            e.preventDefault();
+            e.stopPropagation();
+        };
+        this.on('mount', () => {
+            document.addEventListener('dragover', this.onDragOver);
+            document.addEventListener('dragleave', this.onDragLeave);
+            document.addEventListener('drop', this.onDrop);
+        });
+        this.on('unmount', () => {
+            document.removeEventListener('dragover', this.onDragOver);
+            document.removeEventListener('dragleave', this.onDragLeave);
+            document.removeEventListener('drop', this.onDrop);
+        });
\ No newline at end of file
diff --git a/src/riotTags/export-panel.tag b/src/riotTags/export-panel.tag
index 3a3eea072..9f94f2d6f 100644
--- a/src/riotTags/export-panel.tag
+++ b/src/riotTags/export-panel.tag
@@ -92,12 +92,11 @@ export-panel
                 this.working = true;
                 this.update();
 
-                const {getWritableDir} = require('./data/node_requires/platformUtils');
+                const {getBuildDir, getExportDir} = require('./data/node_requires/platformUtils');
                 const runCtExport = require('./data/node_requires/exporter');
-                const writable = await getWritableDir(),
-                      projectDir = global.projdir,
-                      exportDir = path.join(writable, 'export'),
-                      buildDir = path.join(writable, 'builds');
+                const projectDir = global.projdir,
+                      exportDir = await getExportDir(),
+                      buildDir = await getBuildDir();
 
                 this.log.push('Exporting the project…');
                 this.update();
diff --git a/src/riotTags/fonts-panel.tag b/src/riotTags/fonts-panel.tag
index 740ecde5a..50b377c61 100644
--- a/src/riotTags/fonts-panel.tag
+++ b/src/riotTags/fonts-panel.tag
@@ -20,14 +20,6 @@ fonts-panel.flexfix.tall.fifty
                     svg.feather
                         use(xlink:href="data/icons.svg#download")
                     span {voc.import}
-    .aDropzone(if="{dropping}")
-        .middleinner
-            svg.feather
-                use(xlink:href="data/icons.svg#download")
-            h2 {languageJSON.common.fastimport}
-            input(type="file" multiple
-                accept=".ttf"
-                onchange="{fontImport}")
     context-menu(menu="{fontMenu}" ref="fontMenu")
     font-editor(if="{editingFont}" fontobj="{editedFont}")
     script.
@@ -36,10 +28,8 @@ fonts-panel.flexfix.tall.fifty
         this.fonts = global.currentProject.fonts;
         this.namespace = 'fonts';
         this.mixin(window.riotVoc);
-        const fs = require('fs-extra'),
-              path = require('path');
 
-        this.thumbnails = font => `file://${window.global.projdir}/fonts/${font.origname}_prev.png?cache=${font.lastmod}`;
+        this.thumbnails = require('./data/node_requires/resources/fonts').getFontPreview;
         this.names = font => `${font.typefaceName} ${font.weight} ${font.italic ? this.voc.italic : ''}`;
 
         this.setUpPanel = () => {
@@ -121,130 +111,31 @@ fonts-panel.flexfix.tall.fifty
          * The event of importing a font through a file manager
          */
         this.fontImport = e => { // e.target:input[type="file"]
-            const generateGUID = require('./data/node_requires/generateGUID');
             const files = [...e.target.files].map(file => file.path);
             e.target.value = '';
+            const {importTtfToFont} = require('./data/node_requires/resources/fonts');
             for (let i = 0; i < files.length; i++) {
                 if (/\.ttf/gi.test(files[i])) {
-                    const id = generateGUID();
-                    this.loadFont(
-                        id,
-                        files[i],
-                        path.join(global.projdir, '/fonts/f' + id + '.ttf'),
-                        true
-                    );
-                } else {
-                    alertify.log(`Skipped ${files[i]} as it is not a .ttf file.`);
-                    void 0;
-                }
-            }
-            this.dropping = false;
-            e.srcElement.value = ''; // clear input value that prevent to upload the same filename again
-            e.preventDefault();
-        };
-        this.loadFont = (uid, filename, dest) => {
-            fs.copy(filename, dest, e => {
-                if (e) {
-                    throw e;
-                }
-                var obj = {
-                    typefaceName: path.basename(filename).replace('.ttf', ''),
-                    weight: 400,
-                    italic: false,
-                    origname: path.basename(dest),
-                    lastmod: Number(new Date()),
-                    pixelFont: false,
-                    pixelFontSize: 16,
-                    pixelFontLineHeight: 18,
-                    charsets: ['allInFont'],
-                    customCharset: '',
-                    uid
-                };
-                global.currentProject.fonts.push(obj);
-                setTimeout(() => {
-                    this.fontGenPreview(dest, dest + '_prev.png', 64, obj)
+                    importTtfToFont(files[i])
                     .then(() => {
                         this.refs.fonts.updateList();
                         this.update();
                     });
-                }, 250);
-            });
-        };
-        this.fontGenPreview = (source, destFile, size, obj) => new Promise((resolve, reject) => {
-            const template = {
-                weight: obj.weight,
-                style: obj.italic ? 'italic' : 'normal'
-            };
-            // we clean the source url from the possible space and the \ to / (windows specific)
-            const cleanedSource = source.replace(/ /g, '%20').replace(/\\/g, '/');
-            const face = new FontFace('CTPROJFONT' + obj.typefaceName, `url(file://${cleanedSource})`, template);
-            const elt = document.createElement('span');
-            elt.innerHTML = 'testString';
-            elt.style.fontFamily = obj.typefaceName;
-            document.body.appendChild(elt);
-            face.load()
-            .then(loaded => {
-                loaded.external = true;
-                loaded.ctId = face.ctId = obj.uid;
-                document.fonts.add(loaded);
-                const c = document.createElement('canvas');
-                c.x = c.getContext('2d');
-                c.width = c.height = size;
-                c.x.clearRect(0, 0, size, size);
-                c.x.font = `${obj.italic ? 'italic ' : ''}${obj.weight} ${Math.floor(size * 0.75)}px "${loaded.family}"`;
-                c.x.fillStyle = '#000';
-                c.x.fillText('Aa', size * 0.05, size * 0.75);
-                // strip off the data:image url prefix to get just the base64-encoded bytes
-                const dataURL = c.toDataURL();
-                const previewBuffer = dataURL.replace(/^data:image\/\w+;base64,/, '');
-                const buf = new Buffer(previewBuffer, 'base64');
-                const stream = fs.createWriteStream(destFile);
-                stream.on('finish', () => {
-                    setTimeout(() => { // WHY THE HECK I EVER NEED THIS?!
-                        resolve(destFile);
-                    }, 100);
-                });
-                stream.on('error', err => {
-                    reject(err);
-                });
-                stream.end(buf);
-            })
-            .catch(reject);
-        });
-        /*
-         * Additions for drag-n-drop
-         */
-        var dragTimer;
-        this.onDragOver = e => {
-            var dt = e.dataTransfer;
-            if (dt.types && (dt.types.indexOf ? dt.types.indexOf('Files') !== -1 : dt.types.contains('Files'))) {
-                this.dropping = true;
-                this.update();
-                window.clearTimeout(dragTimer);
+                } else {
+                    alertify.log(`Skipped ${files[i]} as it is not a .ttf file.`);
+                }
             }
+            e.srcElement.value = ''; // clear input value that prevent to upload the same filename again
             e.preventDefault();
-            e.stopPropagation();
         };
-        this.onDrop = e => {
-            e.stopPropagation();
-        };
-        this.onDragLeave = e => {
-            dragTimer = window.setTimeout(() => {
-                this.dropping = false;
-                this.update();
-            }, 25);
-            e.preventDefault();
-            e.stopPropagation();
+
+        const updatePanels = () => {
+            this.refs.fonts.updateList();
+            this.update();
         };
-        this.on('mount', () => {
-            document.addEventListener('dragover', this.onDragOver);
-            document.addEventListener('dragleave', this.onDragLeave);
-            document.addEventListener('drop', this.onDrop);
-        });
+        window.signals.on('fontCreated', updatePanels);
         this.on('unmount', () => {
-            document.removeEventListener('dragover', this.onDragOver);
-            document.removeEventListener('dragleave', this.onDragLeave);
-            document.removeEventListener('drop', this.onDrop);
+            window.signals.off('fontCreated', updatePanels);
         });
 
         this.loadFonts = () => {
diff --git a/src/riotTags/license-panel.tag b/src/riotTags/license-panel.tag
index e5d4d791e..446cd5fcf 100644
--- a/src/riotTags/license-panel.tag
+++ b/src/riotTags/license-panel.tag
@@ -56,7 +56,7 @@ license-panel.modal.pad
     p
         | The ct.js' theme called Lucas Dracula is a rough port of a beautiful theme by Lucas Moreira.
         | You can get this theme for VSCode, too! See
-        | 
+        |
         a(href="https://github.com/lucasmsa/arkham-theme") github.com/lucasmsa/arkham-theme
         | .
     pre
@@ -131,29 +131,6 @@ license-panel.modal.pad
             COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
             IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
             CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-    h2 keymaster.js
-    pre
-        code.
-            Copyright (c) 2011-2013 Thomas Fuchs
-
-            Permission is hereby granted, free of charge, to any person obtaining
-            a copy of this software and associated documentation files (the
-            "Software"), to deal in the Software without restriction, including
-            without limitation the rights to use, copy, modify, merge, publish,
-            distribute, sublicense, and/or sell copies of the Software, and to
-            permit persons to whom the Software is furnished to do so, subject to
-            the following conditions:
-
-            The above copyright notice and this permission notice shall be
-            included in all copies or substantial portions of the Software.
-
-            THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-            EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-            MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
-            NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
-            LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
-            OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
-            WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
     h2 Open Sans font by Steve Matteson
     p Released under #[a(href="http://www.apache.org/licenses/LICENSE-2.0") Apache License 2.0].
 
diff --git a/src/riotTags/main-menu.tag b/src/riotTags/main-menu.tag
index 5dbf60bbd..f3b304ef6 100644
--- a/src/riotTags/main-menu.tag
+++ b/src/riotTags/main-menu.tag
@@ -24,27 +24,27 @@ main-menu.flexcol
                 svg.feather
                     use(xlink:href="data/icons.svg#sliders")
                 span {voc.project}
-            li(onclick="{changeTab('texture')}" class="{active: tab === 'texture'}" data-hotkey="Control+3" title="Control+3")
+            li(onclick="{changeTab('texture')}" class="{active: tab === 'texture'}" data-hotkey="Control+2" title="Control+2")
                 svg.feather
                     use(xlink:href="data/icons.svg#texture")
                 span {voc.texture}
-            li(onclick="{changeTab('ui')}" class="{active: tab === 'ui'}" data-hotkey="Control+4" title="Control+4")
+            li(onclick="{changeTab('ui')}" class="{active: tab === 'ui'}" data-hotkey="Control+3" title="Control+3")
                 svg.feather
                     use(xlink:href="data/icons.svg#ui")
                 span {voc.ui}
-            li(onclick="{changeTab('fx')}" class="{active: tab === 'fx'}" data-hotkey="Control+5" title="Control+5")
+            li(onclick="{changeTab('fx')}" class="{active: tab === 'fx'}" data-hotkey="Control+4" title="Control+4")
                 svg.feather
                     use(xlink:href="data/icons.svg#sparkles")
                 span {voc.fx}
-            li(onclick="{changeTab('sounds')}" class="{active: tab === 'sounds'}" data-hotkey="Control+6" title="Control+6")
+            li(onclick="{changeTab('sounds')}" class="{active: tab === 'sounds'}" data-hotkey="Control+5" title="Control+5")
                 svg.feather
                     use(xlink:href="data/icons.svg#headphones")
                 span {voc.sounds}
-            li(onclick="{changeTab('types')}" class="{active: tab === 'types'}" data-hotkey="Control+7" title="Control+7")
+            li(onclick="{changeTab('types')}" class="{active: tab === 'types'}" data-hotkey="Control+6" title="Control+6")
                 svg.feather
                     use(xlink:href="data/icons.svg#type")
                 span {voc.types}
-            li(onclick="{changeTab('rooms')}" class="{active: tab === 'rooms'}" data-hotkey="Control+8" title="Control+8")
+            li(onclick="{changeTab('rooms')}" class="{active: tab === 'rooms'}" data-hotkey="Control+7" title="Control+7")
                 svg.feather
                     use(xlink:href="data/icons.svg#room")
                 span {voc.rooms}
@@ -67,20 +67,14 @@ main-menu.flexcol
         const archiver = require('archiver');
         const glob = require('./data/node_requires/glob');
 
-        // Mounts the hotkey plugins, enabling hotkeys on elements with data-hotkey attributes
-        const hotkey = require('./data/node_requires/hotkeys')(document);
-        this.on('unmount', () => {
-            hotkey.unmount();
-        });
-
         this.namespace = 'menu';
         this.mixin(window.riotVoc);
 
         this.tab = 'project';
         this.changeTab = tab => () => {
             this.tab = tab;
-            hotkey.cleanScope();
-            hotkey.push(tab);
+            window.hotkeys.cleanScope();
+            window.hotkeys.push(tab);
             window.signals.trigger('globalTabChanged');
             window.signals.trigger(`${tab}Focus`);
         };
@@ -206,7 +200,7 @@ main-menu.flexcol
                 nw.Shell.openExternal(`http://localhost:${fileServer.address().port}/`);
             });
         };
-        hotkey.on('Alt+F5', this.runProjectAlt);
+        window.hotkeys.on('Alt+F5', this.runProjectAlt);
 
         this.zipProject = async () => {
             try {
@@ -241,11 +235,14 @@ main-menu.flexcol
             }
         };
         this.zipExport = async () => {
-            const {getWritableDir} = require('./data/node_requires/platformUtils');
-            const writable = await getWritableDir();
+            const {getBuildDir, getExportDir} = require('./data/node_requires/platformUtils');
+            const buildFolder = await getBuildDir();
             const runCtExport = require('./data/node_requires/exporter');
-            const exportFile = path.join(writable, '/export.zip'),
-                  inDir = path.join(writable, '/export/');
+            const exportFile = path.join(
+                buildFolder,
+                `${global.currentProject.settings.authoring.title || 'ct.js game'}.zip`
+            );
+            const inDir = await getExportDir();
             await fs.remove(exportFile);
             runCtExport(global.currentProject, global.projdir)
             .then(() => {
@@ -424,6 +421,15 @@ main-menu.flexcol
                 }
             }, {
                 type: 'separator'
+            }, {
+                label: window.languageJSON.menu.disableSounds,
+                type: 'checkbox',
+                checked: () => localStorage.disableSounds === 'on',
+                click: () => {
+                    localStorage.disableSounds = (localStorage.disableSounds || 'off') === 'off' ? 'on' : 'off';
+                }
+            }, {
+                type: 'separator'
             }, {
                 label: window.languageJSON.common.zoomIn,
                 icon: 'zoom-in',
diff --git a/src/riotTags/notepad-panel.tag b/src/riotTags/notepad-panel.tag
index 3218c8b55..84010f7a6 100644
--- a/src/riotTags/notepad-panel.tag
+++ b/src/riotTags/notepad-panel.tag
@@ -34,7 +34,6 @@ notepad-panel#notepad.panel.dockright(class="{opened: opened}")
             use(xlink:href="data/icons.svg#{opened? 'chevron-right' : 'chevron-left'}")
     script.
         const glob = require('./data/node_requires/glob');
-        const hotkey = require('./data/node_requires/hotkeys')(document);
         this.opened = false;
         this.namespace = 'notepad';
         this.mixin(window.riotVoc);
@@ -42,10 +41,14 @@ notepad-panel#notepad.panel.dockright(class="{opened: opened}")
             this.opened = !this.opened;
         };
 
-        hotkey.on('F1', () => {
+        const openHelp = () => {
             this.opened = true;
             this.tab = 'helppages';
             this.update();
+        };
+        window.hotkeys.on('F1', openHelp);
+        this.on('unmount', () => {
+            window.hotkeys.off('F1', openHelp);
         });
 
         this.tab = 'notepadlocal';
diff --git a/src/riotTags/particles/emitter-tandem-editor.tag b/src/riotTags/particles/emitter-tandem-editor.tag
index a816ee970..cf06dac26 100644
--- a/src/riotTags/particles/emitter-tandem-editor.tag
+++ b/src/riotTags/particles/emitter-tandem-editor.tag
@@ -45,15 +45,12 @@ emitter-tandem-editor.panel.view.flexrow
                 svg.feather
                     use(xlink:href="data/icons.svg#droplet")
                 span {voc.changeBg}
-        .zoom
-            b(if="{window.innerWidth - panelWidth > 500}") {vocGlob.zoom}
-            div.button-stack
-                button.inline(if="{window.innerWidth - panelWidth > 320}" onclick="{setZoom(0.125)}" class="{active: zoom === 0.125}") 12%
-                button.inline(onclick="{setZoom(0.25)}" class="{active: zoom === 0.25}") 25%
-                button.inline(if="{window.innerWidth - panelWidth > 320}" onclick="{setZoom(0.5)}" class="{active: zoom === 0.5}") 50%
-                button.inline(onclick="{setZoom(1)}" class="{active: zoom === 1}") 100%
-                button.inline(onclick="{setZoom(2)}" class="{active: zoom === 2}") 200%
-                button.inline(if="{window.innerWidth - panelWidth > 320}" onclick="{setZoom(4)}" class="{active: zoom === 4}") 400%
+        .zoom.flexrow
+            b(if="{window.innerWidth - panelWidth > 550}") {vocGlob.zoom}
+            .spacer
+            b {Math.round(zoom * 100)}%
+            .spacer
+            zoom-slider(onchanged="{setZoom}" ref="zoomslider" value="{zoom}")
     color-picker(
         ref="previewBackgroundColor" if="{changingPreviewColor}"
         hidealpha="true"
@@ -275,9 +272,9 @@ emitter-tandem-editor.panel.view.flexrow
                 width: Math.round(box.width),
                 height: Math.round(box.height),
                 view: this.refs.canvas,
-                antialias: !global.currentProject.settings.pixelatedrender
+                antialias: !global.currentProject.settings.rendering.pixelatedrender
             });
-            if (global.currentProject.settings.pixelatedrender) {
+            if (global.currentProject.settings.rendering.pixelatedrender) {
                 PIXI.settings.ROUND_PIXELS = true;
                 PIXI.settings.SCALE_MODE = PIXI.SCALE_MODES.NEAREST;
             }
@@ -386,38 +383,11 @@ emitter-tandem-editor.panel.view.flexrow
         /* Zoom in/out by clicking buttons and scrolling mouse wheel */
 
         this.zoom = 1;
-        this.setZoom = zoom => () => {
+        this.setZoom = zoom => {
             this.zoom = zoom;
             if (this.emitterContainer) {
                 this.emitterContainer.scale.x = this.emitterContainer.scale.y = this.zoom;
             }
-        };
-        this.onCanvasWheel = e => {
-            e.preventUpdate = true;
-            if (e.wheelDelta > 0) {
-                // in
-                if (this.zoom === 2) {
-                    this.zoom = 4;
-                } else if (this.zoom === 1) {
-                    this.zoom = 2;
-                } else if (this.zoom === 0.5) {
-                    this.zoom = 1;
-                } else if (this.zoom === 0.25) {
-                    this.zoom = 0.5;
-                } else if (this.zoom === 0.125) {
-                    this.zoom = 0.25;
-                }
-            } else if (this.zoom === 4) { // out
-                this.zoom = 2;
-            } else if (this.zoom === 2) {
-                this.zoom = 1;
-            } else if (this.zoom === 1) {
-                this.zoom = 0.5;
-            } else if (this.zoom === 0.5) {
-                this.zoom = 0.25;
-            } else if (this.zoom === 0.25) {
-                this.zoom = 0.125;
-            }
             this.emitterContainer.scale.x =
                 this.emitterContainer.scale.y =
                 this.visualizersContainer.scale.x =
@@ -426,6 +396,14 @@ emitter-tandem-editor.panel.view.flexrow
                 this.previewTexture.scale.y =
                     this.zoom;
             this.updateGrid();
+            this.update();
+        };
+        this.onCanvasWheel = e => {
+            if (e.wheelDelta > 0) {
+                this.refs.zoomslider.zoomIn();
+            } else {
+                this.refs.zoomslider.zoomOut();
+            }
         };
 
         this.onCanvasMove = e => {
diff --git a/src/riotTags/project-selector.tag b/src/riotTags/project-selector.tag
index b5bc1eda2..c3bf9b25e 100644
--- a/src/riotTags/project-selector.tag
+++ b/src/riotTags/project-selector.tag
@@ -136,7 +136,8 @@ project-selector
          * Handler for a manual search for a project folder, triggered by an input[type="file"]
          */
         this.chooseProjectFolder = async () => {
-            const defaultProjectDir = require('./data/node_requires/resources/projects').getDefaultProjectDir();
+            const {getProjectsDir} = require('./data/node_requires/platformUtils');
+            const defaultProjectDir = await getProjectsDir() + '/';
             const projPath = await window.showOpenDialog({
                 title: this.voc.newProject.selectProjectFolder,
                 defaultPath: defaultProjectDir,
diff --git a/src/riotTags/project-settings/modules/module-meta.tag b/src/riotTags/project-settings/modules/module-meta.tag
index b96a68c59..4a6e79e4a 100644
--- a/src/riotTags/project-settings/modules/module-meta.tag
+++ b/src/riotTags/project-settings/modules/module-meta.tag
@@ -1,8 +1,12 @@
 module-meta(onclick="{toggleModule(opts.module.name)}")
     .flexrow
         div
-            h1 {opts.module.manifest.main.name}
-            code {opts.module.name} v{opts.module.manifest.main.version}
+            h1.nmt {opts.module.manifest.main.name}
+            code
+                | {opts.module.name} v{opts.module.manifest.main.version}
+                |
+                span(if="{opts.module.manifest.main.version.indexOf(0) === 0}") {voc.preview}
+
         label.nogrow.bigpower(class="{off: !(opts.module.name in global.currentProject.libs)}")
             svg.feather
                 use(xlink:href="data/icons.svg#{opts.module.name in global.currentProject.libs? 'check' : 'x'}")
@@ -29,6 +33,12 @@ module-meta(onclick="{toggleModule(opts.module.name)}")
     .filler
 
     .flexrow
+        span.nogrow.module-meta-aWarningIcon(title="{voc.deprecatedTooltip}" if="{opts.module.manifest.main.deprecated}")
+            svg.feather.error
+                use(xlink:href="data/icons.svg#alert-circle")
+        span.nogrow.module-meta-aWarningIcon(title="{voc.previewTooltip}" if="{opts.module.manifest.main.version.indexOf(0) === 0}")
+            svg.feather.warning
+                use(xlink:href="data/icons.svg#alert-triangle")
         .aModuleAuthorList
             a.external(
                 each="{author in opts.module.manifest.main.authors}"
diff --git a/src/riotTags/project-settings/rendering-settings.tag b/src/riotTags/project-settings/rendering-settings.tag
index 8e44a33b8..946b8d84b 100644
--- a/src/riotTags/project-settings/rendering-settings.tag
+++ b/src/riotTags/project-settings/rendering-settings.tag
@@ -15,6 +15,10 @@ rendering-settings
         label.block.checkbox
             input(type="checkbox" value="{renderSettings.usePixiLegacy}" checked="{renderSettings.usePixiLegacy}" onchange="{wire('this.renderSettings.usePixiLegacy')}")
             span {voc.usePixiLegacy}
+    fieldset
+        label.block.checkbox
+            input(type="checkbox" checked="{renderSettings.hideCursor}" onchange="{wire('this.renderSettings.hideCursor')}")
+            span {voc.hideCursor}
     h2 {voc.desktopBuilds}
     fieldset
         b {voc.launchMode}
diff --git a/src/riotTags/rooms/room-backgrounds-editor.tag b/src/riotTags/rooms/room-backgrounds-editor.tag
index 4d78d9bd6..452632ab1 100644
--- a/src/riotTags/rooms/room-backgrounds-editor.tag
+++ b/src/riotTags/rooms/room-backgrounds-editor.tag
@@ -1,13 +1,20 @@
 room-backgrounds-editor.room-editor-Backgrounds.tabbed.tall
     ul
         li.bg(each="{background, ind in opts.room.backgrounds}" oncontextmenu="{onContextMenu}")
-            img(src="{background.texture === -1? 'data/img/notexture.png' : (glob.texturemap[background.texture].src.split('?')[0] + '_prev.png?' + glob.texturemap[background.texture].g.lastmod)}" onclick="{onChangeBgTexture}")
+            img(src="{getTexturePreview(background.texture)}" onclick="{onChangeBgTexture}")
             span
                 span(class="{active: detailedBackground === background}" onclick="{editBackground}")
                     svg.feather
                         use(xlink:href="data/icons.svg#settings")
-                | {glob.texturemap[background.texture].g.name} ({background.depth})
+                | {getTextureFromId(background.texture).name} ({background.depth})
             .clear
+            .anErrorNotice(if="{background.texture && background.texture !== -1 && !getTextureFromId(background.texture).tiled && !getTextureFromId(background.texture).ignoreTiledUse}")
+                | {voc.notBackgroundTextureWarning}
+                |
+                span.a(onclick="{fixTexture(background)}") {voc.fixBackground}
+                |
+                |
+                span.a(onclick="{dismissWarning(background)}") {voc.dismissWarning}
             div(if="{detailedBackground === background}")
                 .clear
                 label
@@ -59,6 +66,11 @@ room-backgrounds-editor.room-editor-Backgrounds.tabbed.tall
     script.
         const glob = require('./data/node_requires/glob');
         this.glob = glob;
+
+        const {getTextureFromId, getTexturePreview} = require('./data/node_requires/resources/textures');
+        this.getTextureFromId = getTextureFromId;
+        this.getTexturePreview = getTexturePreview;
+
         this.pickingBackground = false;
         this.namespace = 'roombackgrounds';
         this.mixin(window.riotVoc);
@@ -134,4 +146,13 @@ room-backgrounds-editor.room-editor-Backgrounds.tabbed.tall
                     this.detailedBackground.extends = {};
                 }
             }
+        };
+
+        this.fixTexture = background => () => {
+            const tex = getTextureFromId(background.texture);
+            tex.tiled = true;
+        };
+        this.dismissWarning = background => () => {
+            const tex = getTextureFromId(background.texture);
+            tex.ignoreTiledUse = true;
         };
\ No newline at end of file
diff --git a/src/riotTags/rooms/room-copy-properties.tag b/src/riotTags/rooms/room-copy-properties.tag
new file mode 100644
index 000000000..1a87a0129
--- /dev/null
+++ b/src/riotTags/rooms/room-copy-properties.tag
@@ -0,0 +1,59 @@
+room-copy-properties.panel
+    b {voc.position}:
+    .aPoint2DInput.compact.wide
+        label
+            span X:
+            |
+            input.compact(
+                step="8" type="number"
+                oninput="{wire('this.opts.copy.x')}"
+                value="{opts.copy.x}"
+            )
+        .spacer
+        label
+          span.nogrow Y:
+          |
+          input.compact(
+                step="8" type="number"
+                oninput="{wire('this.opts.copy.y')}"
+                value="{opts.copy.y}"
+            )
+    b {voc.scale}:
+    .aPoint2DInput.compact.wide
+        label
+            span X:
+            |
+            input.compact(
+                step="0.1" type="number"
+                oninput="{wire('this.opts.copy.tx')}"
+                value="{opts.copy.tx === void 0 ? 1 : opts.copy.tx}"
+            )
+        .spacer
+        label
+            span.nogrow Y:
+            |
+            input.compact(
+                step="0.1" type="number"
+                oninput="{wire('this.opts.copy.ty')}"
+                value="{opts.copy.ty === void 0 ? 1 : opts.copy.ty}"
+            )
+    b {voc.rotation}:
+    dd
+        .flexrow
+            .aSliderWrap
+                input.compact(
+                    type="range" min="0" max="360" step="1"
+                    value="{opts.copy.tr || 0}"
+                    oninput="{wire('this.opts.copy.tr')}"
+                )
+            .spacer
+            input.compact(
+                min="0" max="360" step="1" type="number"
+                value="{opts.copy.tr || 0}"
+                oninput="{wire('this.opts.copy.tr')}"
+            )
+    extensions-editor(entity="{opts.copy.exts}" type="copy" compact="yes" wide="yup")
+    script.
+        this.namespace = 'roomview.copyProperties';
+        this.mixin(window.riotVoc);
+        this.mixin(window.riotWired);
\ No newline at end of file
diff --git a/src/riotTags/rooms/room-editor.tag b/src/riotTags/rooms/room-editor.tag
index bf51862f8..aafab76ff 100644
--- a/src/riotTags/rooms/room-editor.tag
+++ b/src/riotTags/rooms/room-editor.tag
@@ -42,6 +42,9 @@ room-editor.panel.view
                             br
                             input.wide(type="number" value="{room.height}" onchange="{wire('this.room.height')}")
                         .clear
+                        b {voc.backgroundColor}
+                        br
+                        color-input.wide(onchange="{updateRoomBackground}" color="{room.backgroundColor || '#000000'}")
                         extensions-editor(entity="{this.room.extends}" type="room" wide="aye" compact="sure")
 
         .done.nogrow
@@ -65,22 +68,38 @@ room-editor.panel.view
             button.inline.square(title="{voc.shift}" onclick="{roomShift}")
                 svg.feather
                     use(xlink:href="data/icons.svg#move")
-            span(if="{window.innerWidth - sidebarWidth > 840}") {voc.hotkeysNotice}
-        .zoom
-            b(if="{window.innerWidth - sidebarWidth > 840}") {vocGlob.zoom}
-            div.button-stack
-                button#roomzoom12.inline(if="{window.innerWidth - sidebarWidth > 470}" onclick="{roomToggleZoom(0.125)}" class="{active: zoomFactor === 0.125}") 12%
-                button#roomzoom25.inline(onclick="{roomToggleZoom(0.25)}" class="{active: zoomFactor === 0.25}") 25%
-                button#roomzoom50.inline(if="{window.innerWidth - sidebarWidth > 470}" onclick="{roomToggleZoom(0.5)}" class="{active: zoomFactor === 0.5}") 50%
-                button#roomzoom100.inline(onclick="{roomToggleZoom(1)}" class="{active: zoomFactor === 1}") 100%
-                button#roomzoom200.inline(onclick="{roomToggleZoom(2)}" class="{active: zoomFactor === 2}") 200%
-                button#roomzoom400.inline(if="{window.innerWidth - sidebarWidth > 470}" onclick="{roomToggleZoom(4)}" class="{active: zoomFactor === 4}") 400%
+            button.inline.square(
+                title="{voc.sortHorizontally}"
+                onclick="{sortHorizontally}"
+                if="{tab === 'roomcopies' || tab === 'roomtiles'}"
+            )
+                svg.feather
+                    use(xlink:href="data/icons.svg#sort-horizontal")
+            button.inline.square(
+                title="{voc.sortVertically}"
+                onclick="{sortVertically}"
+                if="{tab === 'roomcopies' || tab === 'roomtiles'}"
+            )
+                svg.feather
+                    use(xlink:href="data/icons.svg#sort-vertical")
+            span(if="{window.innerWidth - sidebarWidth > 940}") {voc.hotkeysNotice}
+        .zoom.flexrow
+            b(if="{window.innerWidth - sidebarWidth > 980}") {vocGlob.zoom}:
+            .spacer
+            b {Math.round(zoomFactor * 100)}%
+            .spacer
+            zoom-slider(onchanged="{setZoom}" ref="zoomslider" value="{zoomFactor}")
         .grid
             button#roomgrid(onclick="{roomToggleGrid}" class="{active: room.gridX > 0}")
                 span {voc[room.gridX > 0? 'gridoff' : 'grid']}
         .center
             button#roomcenter(onclick="{roomToCenter}") {voc.tocenter}
-            span.aMouseCoord(if="{window.innerWidth - sidebarWidth > 470}" ref="mousecoords") ({mouseX}:{mouseY})
+            span.aMouseCoord(show="{window.innerWidth - sidebarWidth > 470}" ref="mousecoords") ({mouseX}:{mouseY})
+        room-copy-properties(
+            if="{this.selectedCopies && this.selectedCopies.length === 1}"
+            copy="{this.selectedCopies[0]}"
+            onchange="{refreshRoomCanvas}" oninput="{refreshRoomCanvas}"
+        )
     room-events-editor(if="{editingCode}" room="{room}")
     context-menu(menu="{roomCanvasCopiesMenu}" ref="roomCanvasCopiesMenu")
     context-menu(menu="{roomCanvasMenu}" ref="roomCanvasMenu")
@@ -150,6 +169,11 @@ room-editor.panel.view
         this.dragging = false;
         this.tab = 'roomcopies';
 
+        this.updateRoomBackground = (e, color) => {
+            this.room.backgroundColor = color;
+            this.refreshRoomCanvas();
+        };
+
         var updateCanvasSize = () => {
             // Firstly, check that we don't need to reflow the layout due to window shrinking
             const oldSidebarWidth = this.sidebarWidth;
@@ -237,6 +261,7 @@ room-editor.panel.view
         this.tab = 'roomcopies';
         this.changeTab = tab => () => {
             this.tab = tab;
+            this.selectedCopies = this.selectedTiles = false;
             if (tab === 'roombackgrounds' || tab === 'properties') {
                 this.roomUnpickType();
             }
@@ -284,6 +309,9 @@ room-editor.panel.view
             this.lastTileY = null;
             if (this.dragging) {
                 this.dragging = false;
+                this.roomx = Math.round(this.roomx);
+                this.roomy = Math.round(this.roomy);
+                this.refreshRoomCanvas();
             } else if (this.tab === 'roomtiles') {
                 this.onCanvasMouseUpTiles(e);
             } else if (this.tab === 'roomcopies') {
@@ -323,15 +351,15 @@ room-editor.panel.view
             this.refs.mousecoords.innerHTML = `(${this.mouseX}:${this.mouseY})`;
         };
 
-        /** Начинаем перемещение, или же показываем предварительное расположение новой копии */
+        /** Start moving or show a placement preview **/
         this.onCanvasMove = e => {
             e.preventUpdate = true;
             if (this.dragging && !this.movingStuff) {
-                // перетаскивание
-                this.roomx -= Math.floor(e.movementX / this.zoomFactor);
-                this.roomy -= Math.floor(e.movementY / this.zoomFactor);
+                // Drag the viewport
+                this.roomx -= e.movementX / this.zoomFactor;
+                this.roomy -= e.movementY / this.zoomFactor;
                 this.refreshRoomCanvas(e);
-            } else if ( // если зажата мышь и клавиша Shift, то создавать больше копий/тайлов
+            } else if ( // Make more tiles or copies if Shift key is down
                 e.shiftKey && this.mouseDown &&
                 (
                     (this.tab === 'roomcopies' && this.currentType !== -1) ||
@@ -347,36 +375,21 @@ room-editor.panel.view
             this.updateMouseCoords(e);
         };
 
-        /** При прокрутке колёсиком меняем фактор зума */
+        /** Change zoom on mouse wheel */
         this.onCanvasWheel = e => {
             if (e.wheelDelta > 0) {
-                // in
-                if (this.zoomFactor === 2) {
-                    this.zoomFactor = 4;
-                } else if (this.zoomFactor === 1) {
-                    this.zoomFactor = 2;
-                } else if (this.zoomFactor === 0.5) {
-                    this.zoomFactor = 1;
-                } else if (this.zoomFactor === 0.25) {
-                    this.zoomFactor = 0.5;
-                } else if (this.zoomFactor === 0.125) {
-                    this.zoomFactor = 0.25;
-                }
-            } else if (this.zoomFactor === 4) {
-                this.zoomFactor = 2;
-            } else if (this.zoomFactor === 2) {
-                this.zoomFactor = 1;
-            } else if (this.zoomFactor === 1) {
-                this.zoomFactor = 0.5;
-            } else if (this.zoomFactor === 0.5) {
-                this.zoomFactor = 0.25;
-            } else if (this.zoomFactor === 0.25) {
-                this.zoomFactor = 0.125;
+                this.refs.zoomslider.zoomIn();
+            } else {
+                this.refs.zoomslider.zoomOut();
             }
+        };
+        this.setZoom = zoom => {
+            this.zoomFactor = zoom;
+            this.update();
             this.redrawGrid();
-            this.refreshRoomCanvas(e);
-            this.updateMouseCoords(e);
+            this.refreshRoomCanvas();
         };
+
         this.onCanvasContextMenu = e => {
             this.dragging = false;
             this.mouseDown = false;
@@ -432,7 +445,9 @@ room-editor.panel.view
             if (this.nameTaken) {
                 // animate the error notice
                 require('./data/node_requires/jellify')(this.refs.errorNotice);
-                window.soundbox.play('Failure');
+                if (localStorage.disableSounds !== 'on') {
+                    window.soundbox.play('Failure');
+                }
                 return false;
             }
             this.room.lastmod = Number(new Date());
@@ -451,6 +466,27 @@ room-editor.panel.view
             return true;
         };
 
+        this.sortHorizontally = () => {
+            if (this.tab === 'roomcopies') {
+                this.room.copies.sort((a, b) => a.x - b.x);
+            } else {
+                // tiles
+                this.currentTileLayer.tiles.sort((a, b) => a.x - b.x);
+            }
+            this.resortRoom();
+            this.refreshRoomCanvas();
+        };
+        this.sortVertically = () => {
+            if (this.tab === 'roomcopies') {
+                this.room.copies.sort((a, b) => a.y - b.y);
+            } else {
+                // tiles
+                this.currentTileLayer.tiles.sort((a, b) => a.y - b.y);
+            }
+            this.resortRoom();
+            this.refreshRoomCanvas();
+        };
+
         this.resortRoom = () => {
             // Make an array of all the backgrounds, tile layers and copies, and then sort it.
             this.stack = this.room.copies.concat(this.room.backgrounds).concat(this.room.tiles);
@@ -489,6 +525,9 @@ room-editor.panel.view
             canvas.x.globalAlpha = 1;
             // Clear the canvas
             canvas.x.clearRect(0, 0, canvas.width, canvas.height);
+            // Fill it with a background color
+            canvas.x.fillStyle = this.room.backgroundColor || '#000000';
+            canvas.x.fillRect(0, 0, canvas.width, canvas.height);
 
             // Apply camera movement + zoom
             canvas.x.translate(Math.floor(canvas.width / 2), Math.floor(canvas.height / 2));
@@ -496,7 +535,7 @@ room-editor.panel.view
             canvas.x.translate(-this.roomx, -this.roomy);
 
             // Disable pixel interpolation, if needed
-            canvas.x.imageSmoothingEnabled = !global.currentProject.settings.pixelatedrender;
+            canvas.x.imageSmoothingEnabled = !global.currentProject.settings.rendering.pixelatedrender;
 
             for (let i = 0, li = this.stack.length; i < li; i++) {
                 if (this.stack[i].tiles) { // a tile layer
@@ -563,7 +602,7 @@ room-editor.panel.view
                         canvas.x.drawImage(
                             texture,
                             ox, oy, w, h,
-                            -grax * (copy.tx || 1), -gray * (copy.ty || 1), w, h
+                            -grax, -gray, w, h
                         );
                         canvas.x.restore();
                     } else {
diff --git a/src/riotTags/rooms/room-tile-editor.tag b/src/riotTags/rooms/room-tile-editor.tag
index c146107cb..125607144 100644
--- a/src/riotTags/rooms/room-tile-editor.tag
+++ b/src/riotTags/rooms/room-tile-editor.tag
@@ -140,6 +140,11 @@ room-tile-editor.room-editor-Tiles.tabbed.tall.flexfix
                 i = glob.texturemap[g.uid];
             c.width = i.width;
             c.height = i.height;
+            if (global.currentProject.settings.rendering.pixelatedrender) {
+                c.style.imageRendering = 'pixelated';
+            } else {
+                c.style.imageRendering = 'unset';
+            }
             cx.globalAlpha = 1;
             cx.drawImage(i, 0, 0);
             cx.strokeStyle = '#0ff';
@@ -173,13 +178,17 @@ room-tile-editor.room-editor-Tiles.tabbed.tall.flexfix
             if (!this.parent.currentTileset) {
                 return;
             }
+            // Adjust the pointer coordinates to account for potential scaling
+            const bbox = e.target.getBoundingClientRect();
+            const px = e.layerX / bbox.width * e.target.width,
+                  py = e.layerY / bbox.height * e.target.height;
             var g = this.parent.currentTileset;
             this.parent.tileSpanX = 1;
             this.parent.tileSpanY = 1;
             this.selectingTile = true;
-            this.tileStartX = Math.round((e.layerX - g.offx - g.width * 0.5) / (g.width + g.marginx));
+            this.tileStartX = Math.round((px - g.offx - g.width * 0.5) / (g.width + g.marginx));
             this.tileStartX = Math.max(0, Math.min(g.grid[0], this.tileStartX));
-            this.tileStartY = Math.round((e.layerY - g.offy - g.height * 0.5) / (g.height + g.marginy));
+            this.tileStartY = Math.round((py - g.offy - g.height * 0.5) / (g.height + g.marginy));
             this.tileStartY = Math.max(0, Math.min(g.grid[1], this.tileStartY));
             this.parent.tileX = this.tileStartX;
             this.parent.tileY = this.tileStartY;
@@ -189,10 +198,14 @@ room-tile-editor.room-editor-Tiles.tabbed.tall.flexfix
             if (!this.selectingTile) {
                 return;
             }
+            // Adjust the pointer coordinates to account for potential scaling
+            const bbox = e.target.getBoundingClientRect();
+            const px = e.layerX / bbox.width * e.target.width,
+                  py = e.layerY / bbox.height * e.target.height;
             var g = this.parent.currentTileset;
-            this.tileEndX = Math.round((e.layerX - g.offx - g.width * 0.5) / (g.width + g.marginx));
+            this.tileEndX = Math.round((px - g.offx - g.width * 0.5) / (g.width + g.marginx));
             this.tileEndX = Math.max(0, Math.min(g.grid[0], this.tileEndX));
-            this.tileEndY = Math.round((e.layerY - g.offy - g.height * 0.5) / (g.height + g.marginy));
+            this.tileEndY = Math.round((py - g.offy - g.height * 0.5) / (g.height + g.marginy));
             this.tileEndY = Math.max(0, Math.min(g.grid[1], this.tileEndY));
             this.parent.tileSpanX = 1 + Math.abs(this.tileStartX - this.tileEndX);
             this.parent.tileSpanY = 1 + Math.abs(this.tileStartY - this.tileEndY);
diff --git a/src/riotTags/root-tag.tag b/src/riotTags/root-tag.tag
index cb1cf2611..f94b461fa 100644
--- a/src/riotTags/root-tag.tag
+++ b/src/riotTags/root-tag.tag
@@ -1,14 +1,19 @@
 root-tag
-    main-menu(if="{!selectorVisible}")
-    notepad-panel(if="{!selectorVisible}")
-    project-selector(if="{selectorVisible}")
+    main-menu(if="{projectOpened}")
+    notepad-panel(if="{projectOpened}")
+    dnd-processor(if="{projectOpened}")
+    project-selector(if="{!projectOpened}")
     script.
-        this.selectorVisible = true;
+        this.projectOpened = false;
         window.signals.on('resetAll', () => {
             global.currentProject = false;
-            this.selectorVisible = true;
+            this.projectOpened = false;
             riot.update();
         });
+        window.signals.on('projectLoaded', () => {
+            this.projectOpened = true;
+            this.update();
+        });
 
         const stylesheet = document.createElement('style');
         document.head.appendChild(stylesheet);
diff --git a/src/riotTags/shared/color-input.tag b/src/riotTags/shared/color-input.tag
index c8ff1c1f4..258ea96ff 100644
--- a/src/riotTags/shared/color-input.tag
+++ b/src/riotTags/shared/color-input.tag
@@ -8,6 +8,9 @@
         Calls the funtion when a user changes the color while working with the color picker.
         Passes an object `{target: RiotTag}` as one argument and a value (an rgba/HEX string).
 
+    @attribute color (string)
+        The preset color.
+
     @attribute hidealpha (atomic)
         Passed as is to color-picker. Disables alpha input.
 
@@ -62,4 +65,4 @@ color-input
             if (this.lastValue !== this.opts.color) {
                 this.value = this.lastValue = this.opts.color || '#FFFFFF';
             }
-        });
\ No newline at end of file
+        });
diff --git a/src/riotTags/shared/color-picker.tag b/src/riotTags/shared/color-picker.tag
index 287e158e2..501fa7bba 100644
--- a/src/riotTags/shared/color-picker.tag
+++ b/src/riotTags/shared/color-picker.tag
@@ -35,7 +35,7 @@ color-picker
                 .aSwatch(each="{colr in global.currentProject.palette}" style="background-color: {colr};" onclick="{onSwatchClick}")
                 button.anAddSwatchButton(onclick="{addAsLocal}")
                     | +
-        .c6.npt.npr.npb
+        .c6.np
             .flexrow
                 .aRangePipeStack
                     .pipe.huebar
diff --git a/src/riotTags/shared/context-menu.tag b/src/riotTags/shared/context-menu.tag
index 02dd4de81..6ceed903f 100644
--- a/src/riotTags/shared/context-menu.tag
+++ b/src/riotTags/shared/context-menu.tag
@@ -69,10 +69,19 @@ context-menu(class="{opened: opts.menu.opened}" ref="root" style="{opts.menu.col
             setTimeout(() => {
                 noFakeClicks = false;
             }, 100);
-            y -= this.root.parentNode.getBoundingClientRect().y;
+            this.root.style.left = this.root.style.top = this.root.style.right = this.root.style.bottom = 'unset';
+            this.root.style.position = 'fixed';
             if (x !== void 0 && y !== void 0) {
-                this.root.style.left = x + 'px';
-                this.root.style.top = y + 'px';
+                if (x < window.innerWidth / 2) {
+                    this.root.style.left = x + 'px';
+                } else {
+                    this.root.style.right = (window.innerWidth - x) + 'px';
+                }
+                if (y < window.innerHeight / 2) {
+                    this.root.style.top = y + 'px';
+                } else {
+                    this.root.style.bottom = (window.innerHeight - y) + 'px';
+                }
             }
             this.opts.menu.opened = true;
             this.update();
diff --git a/src/riotTags/shared/extensions-editor.tag b/src/riotTags/shared/extensions-editor.tag
index cecf2b194..2c4ad460f 100644
--- a/src/riotTags/shared/extensions-editor.tag
+++ b/src/riotTags/shared/extensions-editor.tag
@@ -4,7 +4,7 @@
 
     @attribute entity (riot object)
         An object to which apply editing to.
-    @attribute type (string, 'type'|'tileLayer'|'room')
+    @attribute type (string, 'type'|'tileLayer'|'room'|'copy')
         The type of the edited asset. Not needed if customextends is set.
 
     @attribute [compact] (atomic)
@@ -22,7 +22,8 @@
     declare interface IExtensionField {
         name: string, // the displayed name.
         // Below 'h1', 'h2', 'h3', 'h4' are purely decorational, for grouping fields. Others denote the type of an input field.
-        type: 'h1' | 'h2' | 'h3' | 'h4' | 'text' | 'textfield' | 'code' | 'number' | 'point2D' | 'checkbox' | 'radio' | 'texture' | 'type',
+        type: 'h1' | 'h2' | 'h3' | 'h4' | 'text' | 'textfield' | 'code' | 'number' |
+              'slider' | 'sliderAndNumber' | 'point2D' | 'checkbox' | 'radio' | 'texture' | 'type',
         key?: string, // the name of a JSON key to write into the `opts.entity`. Not needed for hN types, but required otherwise
                       // The key may have special suffixes that tell the exporter to unwrap foreign keys (resources' UIDs) into asset names.
                       // These are supposed to always be used with `'type'` and `'texture'` input types.
@@ -50,7 +51,7 @@ extensions-editor
                     input.nogrow(
                         if="{ext.type === 'checkbox'}"
                         type="checkbox"
-                        value="{parent.opts.entity[ext.key] || ext.default}"
+                        checked="{parent.opts.entity[ext.key] || ext.default}"
                         onchange="{wire('this.opts.entity.'+ ext.key)}"
                     )
                     span   {ext.name}
@@ -117,7 +118,41 @@ extensions-editor
                     type="number"
                     value="{parent.opts.entity[ext.key] || ext.default}"
                     onchange="{wire('this.opts.entity.'+ ext.key)}"
+                    min="{ext.min}"
+                    max="{ext.max}"
+                    step="{ext.step}"
                 )
+                .aSliderWrap(if="{ext.type === 'slider'}")
+                    input(
+                        class="{compact: parent.opts.compact, wide: parent.opts.wide}"
+                        type="range"
+                        value="{parent.opts.entity[ext.key] || ext.default}"
+                        onchange="{wire('this.opts.entity.'+ ext.key)}"
+                        min="{ext.min}"
+                        max="{ext.max}"
+                        step="{ext.step}"
+                    )
+                .flexrow(if="{ext.type === 'sliderAndNumber'}")
+                    .aSliderWrap
+                        input(
+                            class="{compact: parent.opts.compact}"
+                            type="range"
+                            value="{parent.opts.entity[ext.key] || ext.default}"
+                            onchange="{wire('this.opts.entity.'+ ext.key)}"
+                            min="{ext.min}"
+                            max="{ext.max}"
+                            step="{ext.step}"
+                        )
+                    .spacer
+                    input(
+                        class="{compact: parent.opts.compact}"
+                        type="number"
+                        value="{parent.opts.entity[ext.key] || ext.default}"
+                        onchange="{wire('this.opts.entity.'+ ext.key)}"
+                        min="{ext.min}"
+                        max="{ext.max}"
+                        step="{ext.step}"
+                    )
                 label.block.checkbox(if="{ext.type === 'radio'}" each="{option in ext.options}")
                     input(
                         type="radio"
@@ -127,6 +162,16 @@ extensions-editor
                     )
                     |   {option.name}
                     div.desc(if="{option.help}") {option.help}
+                select(
+                    if="{ext.type === 'select'}"
+                    onchange="{wire('this.opts.entity.'+ ext.key)}"
+                    class="{wide: parent.opts.wide}"
+                )
+                    option(
+                        each="{option in ext.options}"
+                        value="{option.value}"
+                        selected="{parent.parent.opts.entity[ext.key] === option.value}"
+                    ) {option.name}
                 .dim(if="{ext.help && !parent.opts.compact}") {ext.help}
     script.
         const libsDir = './data/ct.libs';
diff --git a/src/riotTags/shared/zoom-slider.tag b/src/riotTags/shared/zoom-slider.tag
new file mode 100644
index 000000000..f5d25e28b
--- /dev/null
+++ b/src/riotTags/shared/zoom-slider.tag
@@ -0,0 +1,80 @@
+//
+    A slider that smoothly transitions between zoom points
+
+    @attribute onchanged (riot function)
+        Calls the funtion when a user changes the zoom value.
+        Passes the new zoom value as the one argument.
+
+    @method zoomIn
+        Call this property to advance the zoom value. This will call opts.onchage callback.
+    @method zoomOut
+        Call this property to decrease the zoom value. This will call opts.onchage callback.
+zoom-slider
+    .aSliderWrap
+        input(
+            type="range" list="theZoomSnapPoints"
+            min="-100" step="1" max="100"
+            ref="zoomslider"
+            value="{zoomToRaw(opts.value || 1)}" oninput="{setValue}"
+        )
+        datalist#theZoomSnapPoints
+            option(value="-100")
+            option(value="-67")
+            option(value="-33")
+            option(value="0")
+            option(value="20")
+            option(value="40")
+            option(value="60")
+            option(value="80")
+            option(value="100")
+        .DataTicks
+            .aDataTick(each="{value in [-67, -33, 0, 20, 40, 60, 80]}" style="left: {(value + 100) / 2}%")
+    script.
+        this.sliderPoints = [-100, -67, -33, 0, 20, 40, 60, 80, 100];
+        this.zoomPoints = [0.125, 0.25, 0.5, 1, 2, 4, 8, 16, 32];
+
+        this.zoomToRaw = value => {
+            value = Number(value);
+            const anchorPointId = this.zoomPoints.findIndex(p => p >= value);
+            if (this.zoomPoints[anchorPointId] === 0.125) {
+                return -100;
+            }
+            const ab = value - this.zoomPoints[anchorPointId - 1],
+                  l = this.zoomPoints[anchorPointId] - this.zoomPoints[anchorPointId - 1];
+            const alpha = ab / l;
+            return this.sliderPoints[anchorPointId - 1] +
+                   (this.sliderPoints[anchorPointId] - this.sliderPoints[anchorPointId - 1]) *
+                   alpha;
+        };
+        this.rawToZoom = value => {
+            value = Number(value);
+            const anchorPointId = this.sliderPoints.findIndex(p => p >= value);
+            if (this.sliderPoints[anchorPointId] === -100) {
+                return 0.125;
+            }
+            const ab = value - this.sliderPoints[anchorPointId - 1],
+                  l = this.sliderPoints[anchorPointId] - this.sliderPoints[anchorPointId - 1];
+            const alpha = ab / l;
+            return this.zoomPoints[anchorPointId - 1] +
+                   (this.zoomPoints[anchorPointId] - this.zoomPoints[anchorPointId - 1]) *
+                   alpha;
+        };
+
+        this.setValue = e => {
+            if (this.opts.onchanged) {
+                this.opts.onchanged(this.rawToZoom(e.target.value));
+            }
+        };
+
+        this.zoomIn = () => {
+            const rawValue = Math.min(Number(this.refs.zoomslider.value) + 10, 100);
+            this.refs.zoomslider.value = rawValue;
+            this.opts.onchanged(this.rawToZoom(rawValue));
+            this.update();
+        };
+        this.zoomOut = () => {
+            const rawValue = Math.max(Number(this.refs.zoomslider.value) - 10, -100);
+            this.refs.zoomslider.value = rawValue;
+            this.opts.onchanged(this.rawToZoom(rawValue));
+            this.update();
+        };
diff --git a/src/riotTags/style-editor.tag b/src/riotTags/style-editor.tag
index 919b7f830..3146ea18b 100644
--- a/src/riotTags/style-editor.tag
+++ b/src/riotTags/style-editor.tag
@@ -260,7 +260,9 @@ style-editor.panel.view
             if (this.nameTaken) {
                 // animate the error notice
                 require('./data/node_requires/jellify')(this.refs.errorNotice);
-                soundbox.play('Failure');
+                if (localStorage.disableSounds !== 'on') {
+                    soundbox.play('Failure');
+                }
                 return false;
             }
             this.styleobj.lastmod = Number(new Date());
diff --git a/src/riotTags/texture-editor.tag b/src/riotTags/textures/texture-editor.tag
similarity index 88%
rename from src/riotTags/texture-editor.tag
rename to src/riotTags/textures/texture-editor.tag
index 43990c8d6..7d1999dbb 100644
--- a/src/riotTags/texture-editor.tag
+++ b/src/riotTags/textures/texture-editor.tag
@@ -115,16 +115,32 @@ texture-editor.panel.view
                             svg.feather
                                 use(xlink:href="data/icons.svg#folder")
                             span {voc.replacetexture}
-                    .button.inline(title="{voc.reimport}" if="{opts.texture.source}" onclick="{reimport}")
+                    .button.inline(
+                        title="{voc.updateFromClipboard} (Control+V)"
+                        onclick="{paste}"
+                        data-hotkey="Control+v"
+                        data-hotkey-require-scope="texture"
+                        data-hotkey-priority="10"
+                    )
+                        svg.feather
+                            use(xlink:href="data/icons.svg#clipboard")
+                    .button.inline(
+                        if="{opts.texture.source}"
+                        title="{voc.reimport} (Control+R)"
+                        onclick="{reimport}"
+                        data-hotkey="Control+r"
+                    )
                         svg.feather
                             use(xlink:href="data/icons.svg#refresh-ccw")
-            .textureview-zoom
-                div.button-stack.inlineblock
-                    button#texturezoom25.inline(onclick="{textureToggleZoom(0.25)}" class="{active: zoomFactor === 0.25}") 25%
-                    button#texturezoom50.inline(onclick="{textureToggleZoom(0.5)}" class="{active: zoomFactor === 0.5}") 50%
-                    button#texturezoom100.inline(onclick="{textureToggleZoom(1)}" class="{active: zoomFactor === 1}") 100%
-                    button#texturezoom200.inline(onclick="{textureToggleZoom(2)}" class="{active: zoomFactor === 2}") 200%
-                    button#texturezoom400.inline(onclick="{textureToggleZoom(4)}" class="{active: zoomFactor === 4}") 400%
+            .textureview-zoom.flexrow
+                b {Math.round(zoomFactor * 100)}%
+                .spacer
+                zoom-slider(onchanged="{setZoom}" ref="zoomslider" value="{zoomFactor}")
+            .textureview-bg
+                button.inline(onclick="{changePreviewBg}")
+                    svg.feather
+                        use(xlink:href="data/icons.svg#droplet")
+                    span {voc.bgcolor}
         .column.column2.borderleft.tall.flexfix.nogrow.noshrink(show="{!opts.texture.tiled}")
             .flexfix-body
                 fieldset
@@ -148,6 +164,7 @@ texture-editor.panel.view
                             b {voc.height}
                             br
                             input.wide(type="number" value="{opts.texture.height}" onchange="{wire('this.texture.height')}" oninput="{wire('this.texture.height')}")
+                fieldset
                     .flexrow
                         div
                             b {voc.marginx}
@@ -182,27 +199,22 @@ texture-editor.panel.view
                 #preview(ref="preview" style="background-color: {previewColor};")
                     canvas(ref="grprCanvas")
                 .flexrow
-                    button#textureplay.square.inline(onclick="{previewPlayPause}")
+                    button.nogrow.square.inline(onclick="{previewPlayPause}")
                         svg.feather
                             use(xlink:href="data/icons.svg#{prevPlaying? 'pause' : 'play'}")
                     span(ref="textureviewframe") 0 / 1
-                    .filler
-                    button#textureviewback.square.inline(onclick="{previewBack}")
+                    button.nogrow.square.inline(onclick="{previewBack}")
                         svg.feather
                             use(xlink:href="data/icons.svg#skip-back")
-                    button#textureviewnext.square.inline.nmr(onclick="{previewNext}")
+                    button.nogrow.square.inline.nmr(onclick="{previewNext}")
                         svg.feather
                             use(xlink:href="data/icons.svg#skip-forward")
                 .flexrow
-                    b {voc.speed}
+                    b.alignmiddle {voc.speed}
                     .filler
                     input#grahpspeed.short(type="number" min="1" value="{prevSpeed}" onchange="{wire('this.prevSpeed')}" oninput="{wire('this.prevSpeed')}")
-                .relative
-                    button#texturecolor.inline.wide(onclick="{changePreviewBg}")
-                        svg.feather
-                            use(xlink:href="data/icons.svg#droplet")
-                        span {voc.bgcolor}
-                input.color.rgb#previewbgcolor
+                p
+                .aNotice {voc.previewAnimationNotice}
 
     color-picker(
         ref="previewBackgroundColor" if="{changingPreviewBg}"
@@ -269,7 +281,6 @@ texture-editor.panel.view
             const val = this.refs.textureReplacer.files[0].path;
             if (/\.(jpg|gif|png|jpeg)/gi.test(val)) {
                 this.loadImg(
-                    this.texture.uid,
                     val,
                     global.projdir + '/img/i' + this.texture.uid + path.extname(val)
                 );
@@ -281,22 +292,37 @@ texture-editor.panel.view
         };
         this.reimport = () => {
             this.loadImg(
-                this.texture.uid,
                 this.texture.source,
                 global.projdir + '/img/i' + this.texture.uid + path.extname(this.texture.source)
             );
         };
+        this.paste = async () => {
+            const png = nw.Clipboard.get().get('png');
+            if (!png) {
+                alertify.error(this.vocGlob.couldNotLoadFromClipboard);
+                return;
+            }
+            const imageBase64 = png.replace(/^data:image\/\w+;base64,/, '');
+            const imageBuffer = new Buffer(imageBase64, 'base64');
+            await this.loadImg(
+                imageBuffer,
+                global.projdir + '/img/i' + this.texture.uid + '.png'
+            );
+            alertify.success(this.vocGlob.pastedFromClipboard);
+        };
 
         /**
-         * Загружает изображение в редактор и генерирует квадратную превьюху из исходного изображения
-         * @param {Number} uid Идентификатор изображения
-         * @param {String} filename Путь к исходному изображению
-         * @param {Sting} dest Путь к изображению в папке проекта
+         * Loads an image into the project, generating thumbnails and updating the preview.
+         * @param {String|Buffer} filename A source image. It can be either a full path in a file system,
+         * or a buffer.
+         * @param {Sting} dest The path to write to.
          */
-        this.loadImg = (uid, filename, dest) => {
-            fs.copy(filename, dest, e => {
-                if (e) {
-                    throw e;
+        this.loadImg = async (filename, dest) => {
+            try {
+                if (filename instanceof Buffer) {
+                    await fs.writeFile(dest, filename);
+                } else {
+                    await fs.copy(filename, dest);
                 }
                 const image = document.createElement('img');
                 image.onload = () => {
@@ -315,11 +341,11 @@ texture-editor.panel.view
                     textureCanvas.img = image;
                     this.texture.lastmod = Number(new Date());
 
-                    const {imgGenPreview} = require('./data/node_requires/resources/textures');
-                    imgGenPreview(dest, dest + '_prev.png', 64, () => {
+                    const {textureGenPreview} = require('./data/node_requires/resources/textures');
+                    textureGenPreview(this.texture, dest + '_prev.png', 64, () => {
                         this.update();
                     });
-                    imgGenPreview(dest, dest + '_prev@2.png', 128);
+                    textureGenPreview(this.texture, dest + '_prev@2.png', 128);
                     setTimeout(() => {
                         this.refreshTextureCanvas();
                         this.parent.fillTextureMap();
@@ -330,33 +356,22 @@ texture-editor.panel.view
                     alertify.error(e);
                 };
                 image.src = 'file://' + dest + '?' + Math.random();
-            });
+            } catch (e) {
+                alertify.error(e);
+                throw e;
+            }
         };
 
-        this.textureToggleZoom = zoom => () => {
+        this.setZoom = zoom => {
             this.zoomFactor = zoom;
+            this.update();
         };
         /** Change zoomFactor on mouse wheel roll */
         this.onMouseWheel = e => {
             if (e.wheelDelta > 0) {
-                // in
-                if (this.zoomFactor === 2) {
-                    this.zoomFactor = 4;
-                } else if (this.zoomFactor === 1) {
-                    this.zoomFactor = 2;
-                } else if (this.zoomFactor === 0.5) {
-                    this.zoomFactor = 1;
-                } else if (this.zoomFactor === 0.25) {
-                    this.zoomFactor = 0.5;
-                }
-            } else if (this.zoomFactor === 4) { // out
-                this.zoomFactor = 2;
-            } else if (this.zoomFactor === 2) {
-                this.zoomFactor = 1;
-            } else if (this.zoomFactor === 1) {
-                this.zoomFactor = 0.5;
-            } else if (this.zoomFactor === 0.5) {
-                this.zoomFactor = 0.25;
+                this.refs.zoomslider.zoomIn();
+            } else {
+                this.refs.zoomslider.zoomOut();
             }
             e.preventDefault();
             this.update();
@@ -757,14 +772,17 @@ texture-editor.panel.view
             if (this.nameTaken) {
                 // animate the error notice
                 require('./data/node_requires/jellify')(this.refs.errorNotice);
-                soundbox.play('Failure');
+                if (localStorage.disableSounds !== 'on') {
+                    soundbox.play('Failure');
+                }
                 return false;
             }
             this.parent.fillTextureMap();
             glob.modified = true;
             this.texture.lastmod = Number(new Date());
-            this.textureGenPreview(global.projdir + '/img/' + this.texture.origname + '_prev@2.png', 128);
-            this.textureGenPreview(global.projdir + '/img/' + this.texture.origname + '_prev.png', 64)
+            const {textureGenPreview} = require('./data/node_requires/resources/textures');
+            textureGenPreview(this.texture, global.projdir + '/img/' + this.texture.origname + '_prev@2.png', 128);
+            textureGenPreview(this.texture, global.projdir + '/img/' + this.texture.origname + '_prev.png', 64)
             .then(() => {
                 this.parent.editing = false;
                 this.parent.update();
@@ -772,49 +790,6 @@ texture-editor.panel.view
             return true;
         };
 
-        /**
-         * Генерирует превьюху первого кадра графики
-         * @returns {Promise} Промис
-         */
-        this.textureGenPreview = function textureGenPreview(destination, size) {
-            return new Promise((accept, decline) => {
-                var c = document.createElement('canvas');
-                const x = this.texture.offx,
-                      y = this.texture.offy,
-                      w = this.texture.width,
-                      h = this.texture.height;
-                let k;
-                c.x = c.getContext('2d');
-                c.width = c.height = size;
-                c.x.clearRect(0, 0, size, size);
-                if (w > h) {
-                    k = size / w;
-                } else {
-                    k = size / h;
-                }
-                if (k > 1) {
-                    k = 1;
-                }
-                c.x.drawImage(
-                    textureCanvas.img,
-                    x, y, w, h,
-                    (size - w * k) / 2, (size - h * k) / 2,
-                    w * k, h * k
-                );
-                var thumbnailBase64 = c.toDataURL().replace(/^data:image\/\w+;base64,/, '');
-                var buf = Buffer.from(thumbnailBase64, 'base64');
-                fs.writeFile(destination, buf, err => {
-                    if (err) {
-                        console.error(err);
-                        decline(err);
-                    } else {
-                        accept(destination);
-                    }
-                });
-            });
-        };
-
-
         this.changePreviewBg = () => {
             this.changingPreviewBg = !this.changingPreviewBg;
             if (this.changingPreviewBg) {
diff --git a/src/riotTags/textures/texture-generator.tag b/src/riotTags/textures/texture-generator.tag
new file mode 100644
index 000000000..f95ad2b79
--- /dev/null
+++ b/src/riotTags/textures/texture-generator.tag
@@ -0,0 +1,137 @@
+//
+    @attribute onclose (riot function)
+        Called when a user presses the "Close" button. Passes no arguments.
+texture-generator.view
+    .flexcol.tall
+        .flexrow.tall
+            .panel.texture-generator-Settings.nogrow
+                fieldset
+                    label.block
+                        b {voc.name}
+                        input.wide(type="text" oninput="{wire('this.textureName')}" value="{textureName}")
+                    .anErrorNotice(if="{nameTaken}" ref="errorNotice") {vocGlob.nametaken}
+                fieldset
+                    label.block
+                        b {voc.width}
+                        input.wide(type="number" oninput="{wire('this.textureWidth')}" value="{textureWidth}" min="8" step="8")
+                    label.block
+                        b {voc.height}
+                        input.wide(type="number" oninput="{wire('this.textureHeight')}" value="{textureHeight}" min="8" step="8")
+                    label.block
+                        b {voc.color}
+                    color-input(onchange="{changeColor}" color="{textureColor}")
+                fieldset
+                    label.block
+                        b {voc.label}
+                        |
+                        | {voc.optional}
+                        input.wide(type="text" oninput="{wire('this.textureLabel')}" value="{textureLabel}")
+            .texture-generator-aPreview
+                canvas(ref="canvas" style="image-rendering: {small ? 'pixelated' : 'unset'}; transform: scale({small ? 4 : 1});")
+                .texture-generator-aScalingNotice(if="{textureWidth < 64 && textureHeight < 64}")
+                    | {voc.scalingAtX4}
+        .flexrow.nogrow
+            button(onclick="{close}")
+                svg.feather
+                    use(xlink:href="data/icons.svg#x")
+                span {vocGlob.close}
+            button(onclick="{createAndClose}")
+                svg.feather
+                    use(xlink:href="data/icons.svg#check")
+                span {voc.createAndClose}
+            button(onclick="{create}")
+                svg.feather
+                    use(xlink:href="data/icons.svg#loader")
+                span {voc.createAndContinue}
+    script.
+        this.namespace = 'textureGenerator';
+        this.mixin(window.riotVoc);
+        this.mixin(window.riotWired);
+
+        this.textureName = 'Placeholder';
+        this.textureWidth = this.textureHeight = 64;
+        this.textureColor = '#446adb';
+        this.textureLabel = '';
+
+        this.changeColor = e => {
+            this.wire('this.textureColor')(e);
+            this.update();
+        };
+
+        this.refreshCanvas = () => {
+            const {canvas} = this.refs;
+            if (!canvas.x) {
+                canvas.x = canvas.getContext('2d');
+            }
+            const {x} = canvas;
+            this.small = this.textureWidth < 64 && this.textureHeight < 64;
+            canvas.width = this.textureWidth;
+            canvas.height = this.textureHeight;
+            x.clearRect(0, 0, canvas.width, canvas.height);
+
+            x.fillStyle = this.textureColor;
+            x.fillRect(0, 0, canvas.width, canvas.height);
+
+            let label;
+            if (this.textureLabel.trim()) {
+                label = this.textureLabel.trim();
+            } else {
+                label = `${canvas.width}×${canvas.height}`;
+            }
+
+            // Fit the text into 80% of canvas' width
+            x.font = '100px Iosevka';
+            const measure = x.measureText(label).width;
+            let k = canvas.width * 0.8 / measure;
+            // Cannot exceed canvas' height
+            if (k * 100 > canvas.height * 0.8) {
+                k = canvas.height * 0.8 / 100;
+            }
+
+            x.font = `${Math.round(k * 100)}px Iosevka`;
+            x.textBaseline = 'middle';
+            x.textAlign = 'center';
+
+            const dark = window.brehautColor(this.textureColor).getLuminance() < 0.5;
+            x.fillStyle = dark ? '#fff' : '#000';
+            x.fillText(label, canvas.width / 2, canvas.height / 2);
+        };
+
+        this.on('mount', this.refreshCanvas);
+        this.on('update', this.refreshCanvas);
+
+        this.close = () => {
+            if (this.opts.onclose) {
+                this.opts.onclose();
+            }
+        };
+        this.create = async () => {
+            const {textures} = global.currentProject;
+            this.nameTaken = textures.find(texture => this.textureName === texture.name);
+
+            if (this.nameTaken) {
+                this.update();
+                require('./data/node_requires/jellify')(this.refs.errorNotice);
+                if (localStorage.disableSounds !== 'on') {
+                    soundbox.play('Failure');
+                }
+                return false;
+            }
+
+            this.refreshCanvas();
+            const {canvas} = this.refs;
+            const {importImageToTexture} = require('./data/node_requires/resources/textures');
+            const png = canvas.toDataURL();
+            const imageBase64 = png.replace(/^data:image\/\w+;base64,/, '');
+            const imageBuffer = new Buffer(imageBase64, 'base64');
+            await importImageToTexture(imageBuffer, this.textureName);
+            window.alertify.success(this.voc.generationSuccessMessage.replace('$1', this.textureName));
+
+            return true;
+        };
+        this.createAndClose = async () => {
+            if (!(await this.create())) {
+                return;
+            }
+            this.close();
+        };
\ No newline at end of file
diff --git a/src/riotTags/textures-panel.tag b/src/riotTags/textures/textures-panel.tag
similarity index 62%
rename from src/riotTags/textures-panel.tag
rename to src/riotTags/textures/textures-panel.tag
index 9cad9837a..456bd12ba 100644
--- a/src/riotTags/textures-panel.tag
+++ b/src/riotTags/textures/textures-panel.tag
@@ -7,10 +7,10 @@ textures-panel.panel.view
                 vocspace="texture"
                 namespace="textures"
                 click="{openTexture}"
-                thumbnails="{thumbnails}"
+                thumbnails="{textureThumbnails}"
                 ref="textures"
             )
-                label.file.flexfix-header
+                label.file.inlineblock
                     input(type="file" multiple
                         accept=".png,.jpg,.jpeg,.bmp,.gif,.json"
                         onchange="{parent.textureImport}")
@@ -18,12 +18,26 @@ textures-panel.panel.view
                         svg.feather
                             use(xlink:href="data/icons.svg#download")
                         span {voc.import}
+                button(
+                    onclick="{parent.pasteTexture}"
+                    title="{voc.importFromClipboard}"
+                    data-hotkey="Control+v"
+                    data-hotkey-require-scope="texture"
+                )
+                    svg.feather
+                        use(xlink:href="data/icons.svg#clipboard")
+                button(
+                    onclick="{parent.openGenerator}"
+                    title="{voc.generatePlaceholder}"
+                )
+                    svg.feather
+                        use(xlink:href="data/icons.svg#loader")
             asset-viewer(
                 collection="{global.currentProject.skeletons}"
                 contextmenu="{showSkeletonPopup}"
                 vocspace="texture"
                 namespace="skeletons"
-                thumbnails="{thumbnails}"
+                thumbnails="{skelThumbnails}"
                 ref="skeletons"
             )
                 h2
@@ -37,31 +51,18 @@ textures-panel.panel.view
                         svg.feather
                             use(xlink:href="data/icons.svg#download")
                         span {voc.import}
-
-    .aDropzone(if="{dropping}")
-        .middleinner
-            svg.feather
-                use(xlink:href="data/icons.svg#download")
-            h2 {languageJSON.common.fastimport}
-            input(type="file" multiple
-                accept=".png,.jpg,.jpeg,.bmp,.gif,.json"
-                onchange="{textureImport}")
-
     texture-editor(if="{editing}" texture="{currentTexture}")
+    texture-generator(if="{generating}" onclose="{closeGenerator}")
     context-menu(menu="{textureMenu}" ref="textureMenu")
     script.
-        const fs = require('fs-extra'),
-              path = require('path');
         const glob = require('./data/node_requires/glob');
-        const generateGUID = require('./data/node_requires/generateGUID');
         this.namespace = 'texture';
         this.mixin(window.riotVoc);
         this.editing = false;
         this.dropping = false;
 
-        const {getTexturePreview} = require('./data/node_requires/resources/textures');
-        // this.thumbnails = texture => `file://${global.projdir}/img/${texture.origname}_prev.png?cache=${texture.lastmod}`;
-        this.thumbnails = getTexturePreview;
+        this.textureThumbnails = require('./data/node_requires/resources/textures').getTexturePreview;
+        this.skelThumbnails = require('./data/node_requires/resources/skeletons').getSkeletonPreview;
 
         this.fillTextureMap = () => {
             glob.texturemap = {};
@@ -108,10 +109,12 @@ textures-panel.panel.view
 
         window.signals.on('projectLoaded', this.setUpPanel);
         window.signals.on('textureImported', this.updateTextureData);
+        window.signals.on('skeletonImported', this.updateTextureData);
         this.on('mount', this.setUpPanel);
         this.on('unmount', () => {
             window.signals.off('projectLoaded', this.setUpPanel);
             window.signals.off('textureImported', this.updateTextureData);
+            window.signals.off('skeletonImported', this.updateTextureData);
         });
 
         /**
@@ -125,86 +128,33 @@ textures-panel.panel.view
                 if (/\.(jpg|gif|png|jpeg)/gi.test(files[i])) {
                     importImageToTexture(files[i]);
                 } else if (/_ske\.json/i.test(files[i])) {
-                    const id = generateGUID();
-                    this.loadSkeleton(
-                        id,
-                        files[i],
-                        global.projdir + '/img/skdb' + id + '_ske.json'
-                    );
+                    const {importSkeleton} = require('./data/node_requires/resources/skeletons');
+                    importSkeleton(files[i]);
                 }
             }
             e.srcElement.value = '';
-            this.dropping = false;
             e.preventDefault();
         };
 
-        this.loadSkeleton = (uid, filename, dest) => {
-            fs.copy(filename, dest)
-            .then(() => fs.copy(filename.replace('_ske.json', '_tex.json'), dest.replace('_ske.json', '_tex.json')))
-            .then(() => fs.copy(filename.replace('_ske.json', '_tex.png'), dest.replace('_ske.json', '_tex.png')))
-            .then(() => {
-                global.currentProject.skeletons.push({
-                    name: path.basename(filename).replace('_ske.json', ''),
-                    origname: path.basename(dest),
-                    from: 'dragonbones',
-                    uid
-                });
-                this.skelGenPreview(dest, dest + '_prev.png', [64, 128])
-                .then(() => {
-                    this.refs.skeletons.updateList();
-                    this.update();
-                });
-            });
+        this.pasteTexture = () => {
+            const png = nw.Clipboard.get().get('png');
+            if (!png) {
+                alertify.error(this.vocGlob.couldNotLoadFromClipboard);
+                return;
+            }
+            const imageBase64 = png.replace(/^data:image\/\w+;base64,/, '');
+            const imageBuffer = new Buffer(imageBase64, 'base64');
+            const {importImageToTexture} = require('./data/node_requires/resources/textures');
+            importImageToTexture(imageBuffer);
+            alertify.success(this.vocGlob.pastedFromClipboard);
         };
 
-        /**
-         *  Generates a square preview for a given skeleton
-         * @param {String} source Path to the source _ske.json file
-         * @param {String} destFile Path to the destinating image
-         * @param {Array<Number>} sizes Size of the square thumbnail, in pixels
-         * @returns {Promise} Resolves after creating a thumbnail. On success,
-         * passes data-url of the created thumbnail.
-         */
-        this.skelGenPreview = (source, destFile, sizes) => {
-            // TODO: Actually generate previews of different sizes
-            const loader = new PIXI.loaders.Loader(),
-                  dbf = dragonBones.PixiFactory.factory;
-            const slice = 'file://' + source.replace('_ske.json', '');
-            return new Promise((resolve, reject) => {
-                loader.add(`${slice}_ske.json`, `${slice}_ske.json`)
-                    .add(`${slice}_tex.json`, `${slice}_tex.json`)
-                    .add(`${slice}_tex.png`, `${slice}_tex.png`);
-                loader.load(() => {
-                    dbf.parseDragonBonesData(loader.resources[`${slice}_ske.json`].data);
-                    dbf.parseTextureAtlasData(loader.resources[`${slice}_tex.json`].data, loader.resources[`${slice}_tex.png`].texture);
-                    const skel = dbf.buildArmatureDisplay('Armature', loader.resources[`${slice}_ske.json`].data.name);
-                    const promises = sizes.map(() => new Promise((resolve, reject) => {
-                        const app = new PIXI.Application();
-                        const rawSkelBase64 = app.renderer.plugins.extract.base64(skel);
-                        const skelBase64 = rawSkelBase64.replace(/^data:image\/\w+;base64,/, '');
-                        const buf = new Buffer(skelBase64, 'base64');
-                        const stream = fs.createWriteStream(destFile);
-                        stream.on('finish', () => {
-                            setTimeout(() => { // WHY THE HECK I EVER NEED THIS?!
-                                resolve(destFile);
-                            }, 100);
-                        });
-                        stream.on('error', err => {
-                            reject(err);
-                        });
-                        stream.end(buf);
-                    }));
-                    Promise.all(promises)
-                    .then(() => {
-                        // eslint-disable-next-line no-underscore-dangle
-                        delete dbf._dragonBonesDataMap[loader.resources[`${slice}_ske.json`].data.name];
-                        // eslint-disable-next-line no-underscore-dangle
-                        delete dbf._textureAtlasDataMap[loader.resources[`${slice}_ske.json`].data.name];
-                    })
-                    .then(resolve)
-                    .catch(reject);
-                });
-            });
+        this.openGenerator = () => {
+            this.generating = true;
+        };
+        this.closeGenerator = () => {
+            this.generating = false;
+            this.update();
         };
 
         const deleteCurrentTexture = () => {
@@ -342,39 +292,3 @@ textures-panel.panel.view
             this.currentTextureId = global.currentProject.textures.indexOf(texture);
             this.editing = true;
         };
-
-        /*
-         * drag-n-drop handling
-         */
-        var dragTimer;
-        this.onDragOver = e => {
-            var dt = e.dataTransfer;
-            if (dt.types && (dt.types.indexOf ? dt.types.indexOf('Files') !== -1 : dt.types.contains('Files'))) {
-                this.dropping = true;
-                this.update();
-                window.clearTimeout(dragTimer);
-            }
-            e.preventDefault();
-            e.stopPropagation();
-        };
-        this.onDrop = e => {
-            e.stopPropagation();
-        };
-        this.onDragLeave = e => {
-            dragTimer = window.setTimeout(() => {
-                this.dropping = false;
-                this.update();
-            }, 25);
-            e.preventDefault();
-            e.stopPropagation();
-        };
-        this.on('mount', () => {
-            document.addEventListener('dragover', this.onDragOver);
-            document.addEventListener('dragleave', this.onDragLeave);
-            document.addEventListener('drop', this.onDrop);
-        });
-        this.on('unmount', () => {
-            document.removeEventListener('dragover', this.onDragOver);
-            document.removeEventListener('dragleave', this.onDragLeave);
-            document.removeEventListener('drop', this.onDrop);
-        });
diff --git a/src/riotTags/type-editor.tag b/src/riotTags/type-editor.tag
index bd648d3db..5c1583d39 100644
--- a/src/riotTags/type-editor.tag
+++ b/src/riotTags/type-editor.tag
@@ -192,7 +192,9 @@ type-editor.panel.view.flexrow
             if (this.nameTaken) {
                 // animate the error notice
                 require('./data/node_requires/jellify')(this.refs.errorNotice);
-                soundbox.play('Failure');
+                if (localStorage.disableSounds !== 'on') {
+                    soundbox.play('Failure');
+                }
                 return false;
             }
             glob.modified = true;
diff --git a/src/styl/hvost.styl b/src/styl/hvost.styl
index 9d2c10529..f16476735 100644
--- a/src/styl/hvost.styl
+++ b/src/styl/hvost.styl
@@ -112,13 +112,14 @@ textarea
     border-radius 100%
 
 /* display modifiers */
-.block
+/* use double-classing to ensure higher priority over other user-defined CSS rules */
+.block.block
     display block
-.inline
+.inline.inline
     display inline
-.inlineblock
+.inlineblock.inlineblock
     display inline-block
-.relative
+.relative.relative
     position relative
 /* color modifiers */
 .red
@@ -228,6 +229,7 @@ for i in 1 2 3 4 5 6 7 8
 /* misc */
 .alignmiddle
     vertical-align middle !important
+    align-self center
 
 /* utilities */
 .scrollbar-measure
diff --git a/src/styl/inputs.styl b/src/styl/inputs.styl
index da3753ada..280a5ce7b 100644
--- a/src/styl/inputs.styl
+++ b/src/styl/inputs.styl
@@ -248,6 +248,63 @@ select
         right 0.5em
         top 50%
 
+[type=range]
+    min-height 1.5em
+    background transparent
+    font inherit
+    width 100%
+[type=range], [type=range]::-webkit-slider-thumb
+    -webkit-appearance none
+
+[type=range]::-webkit-slider-runnable-track
+    box-sizing border-box
+    border 1px solid borderBright
+    border-radius br
+    width 100%
+    height 0.75em
+    background backgroundDeeper
+
+thumbWidth = 1.5em
+[type=range]::-webkit-slider-thumb
+    margin-top -0.425em
+    box-sizing border-box
+    border 0
+    width thumbWidth
+    height thumbWidth
+    border-radius 50%
+    background act
+    z-index 2
+    position relative
+
+// Regular range inputs should be wrapped into .aSliderWrap
+
+.aSliderWrap
+    display flex
+    align-items center
+    position relative
+    width 100%
+    height 2em
+    font 1em/1 sans-seri
+
+.aSliderWrap [type=range]
+    flex 1
+    margin 0
+    padding 0
+
+.DataTicks
+    position absolute
+    left (thumbWidth / 2) // pad by half oh thumb's width
+    right (thumbWidth / 2)
+    top 50%
+    margin-top -0.375rem
+    user-select none
+    pointer-events none
+.aDataTick
+    width 1px
+    height 0.75rem
+    background borderBright
+    position absolute
+
 label, .label
     & + &
         margin-top 0.5rem
@@ -313,9 +370,10 @@ fieldset
         &:first-child
             margin-top 0
 
-input[type="range"]
+// Range inputs with stacked backgrounds, for usage with color pickers
+
+.aRangePipeStack input[type="range"]
     -webkit-appearance none
-    width 100%
     background transparent
     margin 8px 0
     &:focus
@@ -325,12 +383,8 @@ input[type="range"]
         height 24px
         cursor pointer
         background backgroundDeeper
-        if (themeDark) // dark themes use an inverted color scheme in sense of variables
-            background background
         border-radius br
-        border 1px solid borderPale
-        if (themeDark)
-            border 1px solid borderBright
+        border 1px solid borderBright
     &::-webkit-slider-thumb
         border 1px solid borderBright
         height 40px
@@ -391,20 +445,14 @@ input[type="range"]
     top 0
     bottom 0
     background rgba(background, 0.65)
-    // @stylint off
-    background-image:
-        linear-gradient(to right, accent1 0, accent1 35%, transparent 35%, transparent 100%),
-        linear-gradient(to left, accent1 0, accent1 35%, transparent 35%, transparent 100%),
-        linear-gradient(to bottom, accent1 0, accent1 35%, transparent 35%, transparent 100%),
-        linear-gradient(to top, accent1 0, accent1 35%, transparent 35%, transparent 100%)
-    // @stylint on
-    background-size 5rem 0.5rem, 5rem 0.5rem, 0.5rem 5rem, 0.5rem 5rem
-    background-repeat repeat-x, repeat-x, repeat-y, repeat-y
-    background-position 0 0, 0 bottom, right 0, 0 0
+    border 0.5rem solid act
+    animation aDropzonePulsate 1.25s ease-in-out infinite
     text-align center
     svg
-        font-size 5rem
         color accent1
+        width 5rem
+        height 5rem
+        stroke-width 1px
     h2
         font-size 2rem
     input
@@ -416,6 +464,12 @@ input[type="range"]
         opacity 0
         cursor -webkit-grabbing
 
+@keyframes aDropzonePulsate
+    0%, 100%
+        border 0.5rem solid act
+    50%
+        border 0.5rem solid rgba(act, 0.35)
+
 .aResizer
     background backgroundDeeper
     if (darkTheme)
diff --git a/src/styl/tags/docs-panel.styl b/src/styl/tags/docs-panel.styl
index f38039dab..32f20e1f0 100644
--- a/src/styl/tags/docs-panel.styl
+++ b/src/styl/tags/docs-panel.styl
@@ -20,7 +20,7 @@ docs-panel
         height 100%
         padding 1rem 2rem 2rem
         box-sizing border-box
-        user-select text
+        user-select unquote('text')
 
     aside
         ul
diff --git a/src/styl/tags/rooms/room-copy-properties.styl b/src/styl/tags/rooms/room-copy-properties.styl
new file mode 100644
index 000000000..48a758e1c
--- /dev/null
+++ b/src/styl/tags/rooms/room-copy-properties.styl
@@ -0,0 +1,9 @@
+room-copy-properties
+    display block
+    padding 1rem
+    room-editor &.panel
+        {shadamb}
+        position absolute
+        right 1rem
+        top 4rem
+        width 20rem
\ No newline at end of file
diff --git a/src/styl/tags/rooms/room-editor.styl b/src/styl/tags/rooms/room-editor.styl
index 3b098035a..a09a8e842 100644
--- a/src/styl/tags/rooms/room-editor.styl
+++ b/src/styl/tags/rooms/room-editor.styl
@@ -52,6 +52,8 @@ room-editor
             right 0.5rem
             b
                 text-shadow 0 1px 0 background
+            zoom-slider
+                width 20rem
         .grid
             position absolute
             bottom 0.5rem
@@ -158,6 +160,7 @@ room-editor
 .room-editor-Tiles
     canvas
         cursor pointer
+        min-width 100%
     .act
         color text
         cursor pointer
@@ -172,4 +175,4 @@ room-editor
     .flexfix-footer button
         margin-bottom 0.25rem
     select
-        padding 0.25rem
\ No newline at end of file
+        padding 0.25rem
diff --git a/src/styl/tags/settings/modules/module-meta.styl b/src/styl/tags/settings/modules/module-meta.styl
index a8820c0e7..0b11e91ad 100644
--- a/src/styl/tags/settings/modules/module-meta.styl
+++ b/src/styl/tags/settings/modules/module-meta.styl
@@ -1,4 +1,8 @@
 module-meta
+    display flex
+    flex-flow column nowrap
+    & > *
+        flex 0 0 auto
     h1
         margin 0 0 0.35rem
     .bigpower
@@ -10,7 +14,6 @@ module-meta
         float right
         opacity 0.65
     .aModuleAuthorList
-        margin-top 0.75rem
         font-size 90%
         a
             margin-right 1rem
@@ -28,6 +31,12 @@ module-meta
         margin-left 1rem
         flex 0 0 auto
         align-self flex-end
+    .&-aWarningIcon
+        align-self center
+        margin-right 0.5rem
     .hsub
         line-height 1.5
-        margin 0.5rem 0
\ No newline at end of file
+        margin 0.5rem 0
+    .filler
+        flex 1 1 auto
+        padding-top 0.75rem
\ No newline at end of file
diff --git a/src/styl/tags/settings/modules/modules-settings.styl b/src/styl/tags/settings/modules/modules-settings.styl
index 74aa9c9a1..683c1905f 100644
--- a/src/styl/tags/settings/modules/modules-settings.styl
+++ b/src/styl/tags/settings/modules/modules-settings.styl
@@ -10,12 +10,6 @@
         grid-gap 1rem
         module-meta
             height 100%
-            display flex
-            flex-flow column nowrap
-            & > *
-                flex 0 0 auto
-            .filler
-                flex 1 1 auto
     .aModuleCard
         @extends .panel
         padding 1rem 1rem 1rem 1.5rem
diff --git a/src/styl/tags/shared/color-input.styl b/src/styl/tags/shared/color-input.styl
index 059dd2944..f0cebb7f0 100644
--- a/src/styl/tags/shared/color-input.styl
+++ b/src/styl/tags/shared/color-input.styl
@@ -10,4 +10,7 @@
     padding 0 0.5rem
     font-family mono
     font-size 0.75rem
-    overflow hidden
\ No newline at end of file
+    overflow hidden
+    .wide > &
+        width 100%
+        max-width unset
diff --git a/src/styl/tags/shared/zoom-slider.styl b/src/styl/tags/shared/zoom-slider.styl
new file mode 100644
index 000000000..215dbe01c
--- /dev/null
+++ b/src/styl/tags/shared/zoom-slider.styl
@@ -0,0 +1,3 @@
+zoom-slider
+    width 20rem
+    max-width 100%
\ No newline at end of file
diff --git a/src/styl/tags/textures/texture-editor.styl b/src/styl/tags/textures/texture-editor.styl
index 29a63e5d3..2862447cf 100644
--- a/src/styl/tags/textures/texture-editor.styl
+++ b/src/styl/tags/textures/texture-editor.styl
@@ -59,8 +59,14 @@ texture-editor
 .textureview-zoom
     position absolute
     bottom 0.5em
-    right 0.5em
+    right 1em
     text-align right
+    zoom-slider
+        width 20rem
+.textureview-bg
+    position absolute
+    left 0.5em
+    bottom 0.5em
 #textureviewframes
     position absolute
     left 16em
diff --git a/src/styl/tags/textures/texture-generator.styl b/src/styl/tags/textures/texture-generator.styl
new file mode 100644
index 000000000..555397864
--- /dev/null
+++ b/src/styl/tags/textures/texture-generator.styl
@@ -0,0 +1,14 @@
+texture-generator
+    .&-aPreview
+        padding 1rem
+        position relative
+        canvas
+            transform-origin 0 0
+    .&-aScalingNotice
+        position absolute
+        bottom 1rem
+        left 1rem
+    .&-Settings
+        border-width 0 1px 0 0
+        width 15rem
+        padding 1rem
\ No newline at end of file
diff --git a/src/styl/themeHorizon.styl b/src/styl/themeHorizon.styl
index 7bb0b26dd..75da61fc7 100644
--- a/src/styl/themeHorizon.styl
+++ b/src/styl/themeHorizon.styl
@@ -79,6 +79,10 @@ input[type="reset"],
 
 @require 'tags/**/*.styl'
 
+.anErrorNotice
+    a, .a
+        text-decoration underline
+
 gradCore = #733041
 gradTransition = #2e2233
 gradOuter = #1c1e26