├── config.nims ├── .prettierrc ├── dist ├── icons │ └── icon.png └── manifest.json ├── .gitignore ├── src ├── loadInjector.js ├── background.js ├── mapSizeInfo.nim ├── kkleeSettings.nim ├── runInjectors.js ├── mapBackups.nim ├── transferOwnership.nim ├── kkleeStyles.css ├── kkleeMain.nim ├── editorImageOverlay.nim ├── colours.nim ├── bonkElements.nim ├── kkleeApi.nim ├── vertexEditor.nim ├── platformMultiSelect.nim ├── shapeGenerator.nim ├── shapeMultiSelect.nim ├── injector.js └── main.nim ├── kklee.nimble ├── package.json ├── webpack.config.js ├── generateUserscript.nims ├── LICENSE.txt ├── README.md ├── guide.md └── CHANGELOG.md /config.nims: -------------------------------------------------------------------------------- 1 | --backend: js 2 | --define: release 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "printWidth": 80 5 | } 6 | -------------------------------------------------------------------------------- /dist/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BonkModdingCommunity/kklee/HEAD/dist/icons/icon.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | src/___nimBuild___.js 3 | dist/* 4 | !dist/manifest.json 5 | !dist/icons 6 | build/ 7 | -------------------------------------------------------------------------------- /src/loadInjector.js: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | 3 | const script = document.createElement("script"); 4 | script.src = browser.runtime.getURL("injector.js"); 5 | document.head.appendChild(script); 6 | -------------------------------------------------------------------------------- /kklee.nimble: -------------------------------------------------------------------------------- 1 | version = "0.0.0" 2 | author = "." 3 | description = "." 4 | license = "MIT" 5 | skipDirs = @["src"] 6 | 7 | requires "nim == 1.6.0" 8 | requires "karax#aab6357cd5b6c7c9588e0097616a5a2a339e7f37" # 1.2.2 9 | requires "mathexpr#90e6e026bdbe84f2ab622d86eb024e0e749640a1" # 1.3.2 10 | requires "chroma#b2e71179174e040884ebf6a16cbac711c84620b9" # 0.2.7 -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | 3 | browser.webRequest.onBeforeRequest.addListener( 4 | (req) => { 5 | if (req.url.includes("/js/alpha2s.js") && !req.url.includes("?")) 6 | return { 7 | redirectUrl: browser.runtime.getURL("runInjectors.js") 8 | }; 9 | }, 10 | { urls: ["*://bonk.io/*"] }, 11 | ["blocking"] 12 | ); 13 | -------------------------------------------------------------------------------- /src/mapSizeInfo.nim: -------------------------------------------------------------------------------- 1 | import 2 | std/[strformat], 3 | pkg/karax/[karaxdsl, vdom, vstyles], 4 | kkleeApi 5 | 6 | proc mapSizeInfo*: VNode = buildHtml tdiv( 7 | style = "display: flex; flex-flow: column; font-size: 16px".toCss): 8 | span text &"Shapes: {moph.fixtures.len}/1000" 9 | span text &"Platforms: {moph.bodies.len}/300" 10 | span text &"Joints: {moph.joints.len}/100" 11 | span text &"Spawns: {mapObject.spawns.len}/100" 12 | span text &"Capzones: {mapObject.capZones.len}/50" 13 | span text &"Data limit: {dataLimitInfo()}" 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kklee", 3 | "dependencies": { 4 | "poly-decomp": "^0.3.0", 5 | "webextension-polyfill": "^0.8.0" 6 | }, 7 | "devDependencies": { 8 | "prettier": "^2.7.1", 9 | "web-ext": "^7.2.0", 10 | "webpack": "^5.74.0", 11 | "webpack-cli": "^4.10.0" 12 | }, 13 | "scripts": { 14 | "test": "web-ext run -s ./dist", 15 | "buildDev": "webpack && web-ext build -s ./dist -a ./build --overwrite-dest -n kklee.zip && nim e generateUserscript.nims", 16 | "buildRelease": "WEBPACK_MINIMIZE=1 npm run buildDev" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/kkleeSettings.nim: -------------------------------------------------------------------------------- 1 | import 2 | pkg/karax/[karax, karaxdsl, vdom, vstyles], 3 | kkleeApi, bonkElements 4 | 5 | proc kkleeSettings*: VNode = buildHtml tdiv( 6 | style = "display: flex; flex-flow: column".toCss): 7 | tdiv: 8 | proc onMouseEnter = 9 | setEditorExplanation( 10 | "[kklee]\nAutomatically check for kklee updates when bonk.io loads " & 11 | "(at a maximum of once per hour). " & 12 | "This sends a HTTP request to GitHub.com." 13 | ) 14 | var updateChecksEnabled {.global.} = areUpdateChecksEnabled() 15 | prop("Automatic update checks", checkbox(updateChecksEnabled, proc = 16 | setEnableUpdateChecks(updateChecksEnabled) 17 | )) 18 | -------------------------------------------------------------------------------- /src/runInjectors.js: -------------------------------------------------------------------------------- 1 | (async () => { 2 | const bonkScriptResponse = await fetch("https://bonk.io/js/alpha2s.js?real"); 3 | let src = await bonkScriptResponse.text(); 4 | 5 | await null; 6 | 7 | if (!window.bonkCodeInjectors) { 8 | window.bonkCodeInjectors = []; 9 | alert("Something went wrong with loading Bonk.io extensions."); 10 | } 11 | for (const inj of window.bonkCodeInjectors) { 12 | try { 13 | src = inj(src); 14 | } catch (error) { 15 | alert("One of your Bonk.io extensions was unable to be loaded"); 16 | console.error(error); 17 | } 18 | } 19 | const script = document.createElement("script"); 20 | script.text = src; 21 | document.head.appendChild(script); 22 | console.log("injectors loaded"); 23 | })(); 24 | -------------------------------------------------------------------------------- /src/mapBackups.nim: -------------------------------------------------------------------------------- 1 | import 2 | pkg/karax/[karax, karaxdsl, vdom, vstyles], 3 | kkleeApi 4 | 5 | proc mapBackupLoader*: VNode = buildHtml tdiv( 6 | style = "display: flex; flex-flow: column; font-size: 13px".toCss): 7 | proc backupOption(b: MapBackupObject): VNode = buildHtml tdiv( 8 | style = "padding: 5px 0px; border-top: 2px grey solid".toCss 9 | ): 10 | text b.getBackupLabel() 11 | proc onClick = 12 | b.loadBackup() 13 | saveToUndoHistory() 14 | updateLeftBox() 15 | updateModeDropdown() 16 | updateRenderer(true) 17 | updateRightBoxBody(-1) 18 | updateWarnings() 19 | 20 | for i in countdown(kkleeApi.mapBackups.high, 0): 21 | backupOption(kkleeApi.mapBackups[i]) 22 | 23 | proc onMouseEnter = 24 | setEditorExplanation( 25 | "[kklee]\nMaps are automatically backed up to your browser's storage." 26 | ) 27 | -------------------------------------------------------------------------------- /dist/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "kklee", 4 | "description": "A Bonk.io mod that extends the functionality of the map editor.", 5 | "author": "kklkkj & Salama", 6 | "homepage_url": "https://github.com/BonkModdingCommunity/kklee", 7 | "version": "1.4.13", 8 | "permissions": ["webRequest", "webRequestBlocking", "*://bonk.io/*"], 9 | "background": { 10 | "scripts": ["./background.js"] 11 | }, 12 | "content_scripts": [ 13 | { 14 | "matches": [ 15 | "*://*.bonk.io/gameframe-release.html", 16 | "*://*.bonkisback.io/gameframe-release.html", 17 | "*://*.multiplayer.gg/physics/gameframe-release.html" 18 | ], 19 | "js": ["loadInjector.js"], 20 | "all_frames": true 21 | } 22 | ], 23 | "web_accessible_resources": ["injector.js", "runInjectors.js"], 24 | "icons": { 25 | "64": "icons/icon.png" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const child_process = require("child_process"); 3 | const TerserPlugin = require("terser-webpack-plugin"); 4 | 5 | console.log("Building"); 6 | console.log( 7 | child_process.execSync( 8 | "nim js -d:release -o:./src/___nimBuild___.js ./src/main.nim" 9 | ) 10 | ); 11 | 12 | module.exports = { 13 | mode: "production", 14 | entry: { 15 | background: "./src/background.js", 16 | injector: "./src/injector.js", 17 | loadInjector: "./src/loadInjector.js", 18 | runInjectors: "./src/runInjectors.js", 19 | }, 20 | output: { 21 | filename: "[name].js", 22 | path: path.resolve(__dirname, "dist"), 23 | }, 24 | optimization: { 25 | minimize: Boolean(process.env.WEBPACK_MINIMIZE), 26 | minimizer: [ 27 | new TerserPlugin({ 28 | terserOptions: { 29 | mangle: false, 30 | }, 31 | }), 32 | ], 33 | }, 34 | performance: { 35 | maxEntrypointSize: 1e6, 36 | maxAssetSize: 1e6, 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /generateUserscript.nims: -------------------------------------------------------------------------------- 1 | import json, strformat 2 | 3 | let 4 | manifest = parseJson(readFile("./dist/manifest.json")) 5 | userScriptSrc = &""" 6 | // ==UserScript== 7 | // @name {manifest["name"].getStr()} 8 | // @version {manifest["version"].getStr()} 9 | // @author {manifest["author"].getStr()} 10 | // @namespace https://github.com/kklkkj/ 11 | // @description {manifest["description"].getStr()} 12 | // @homepage {manifest["homepage_url"].getStr()} 13 | // @match https://*.bonk.io/gameframe-release.html 14 | // @match https://*.bonkisback.io/gameframe-release.html 15 | // @match https://*.multiplayer.gg/physics/gameframe-release.html 16 | // @run-at document-start 17 | // @grant none 18 | // ==/UserScript== 19 | 20 | /* 21 | This userscript requires: 22 | https://greasyfork.org/en/scripts/433861-code-injector-bonk-io 23 | (or another browser extension mod) 24 | */ 25 | {readFile("./dist/injector.js")} 26 | """ 27 | 28 | let version = manifest["version"].getStr() 29 | writeFile(&"./build/kklee.user.js", 30 | userScriptSrc) 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 kklkkj 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/transferOwnership.nim: -------------------------------------------------------------------------------- 1 | import 2 | std/[dom, strformat], 3 | pkg/karax/[karax, karaxdsl, vdom, vstyles], 4 | kkleeApi, bonkElements 5 | 6 | template m: untyped = mapObject.m 7 | 8 | proc transferUsernames: seq[cstring] = 9 | # I can't use the add proc inside builtHtml..? 10 | var transferUsernames = m.cr 11 | if m.a notin transferUsernames: transferUsernames.add m.a 12 | if m.rxa notin transferUsernames and m.rxa != "": transferUsernames.add m.rxa 13 | return transferUsernames 14 | 15 | proc transferOwnership*: VNode = buildHtml tdiv( 16 | style = "display: flex; flex-flow: column".toCss): 17 | if not canTransferOwnership(): 18 | text "You must be the original author to transfer ownership." 19 | else: 20 | var username {.global.} = "" 21 | 22 | span text "Map contributors:" 23 | select: 24 | option(disabled = "true", hidden = "true", selected = "true", value = ""): 25 | text "-" 26 | for c in transferUsernames(): 27 | option text c 28 | proc onInput(e: Event; n: VNode) = 29 | username = $e.target.OptionElement.value 30 | bonkButton(&"Transfer ownership", proc = 31 | if username.cstring notin transferUsernames(): 32 | username = "" 33 | return 34 | m.a = cstring username 35 | m.rxa = "" 36 | m.rxdb = 1 37 | m.rxid = 0 38 | m.rxn = "" 39 | m.cr = @[] 40 | username = "" 41 | , username == "") 42 | 43 | -------------------------------------------------------------------------------- /src/kkleeStyles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --kkleeMultiSelectColour: blue; 3 | --kkleeErrorColour: rgb(204, 68, 68); 4 | --kkleeMultiSelectPropHighlightColour: blueviolet; 5 | 6 | --kkleeCheckboxUnchecked: #586e77; 7 | --kkleeCheckboxChecked: #59b0d6; 8 | --kkleeCheckboxTfsTrue: #59d65e; 9 | --kkleeCheckboxTfsFalse: #d65959; 10 | --kkleeCheckboxTfsSame: #d6bd59; 11 | 12 | --kkleePreviewSliderDefaultMarkerColour: #431; 13 | --kkleePreviewSliderMarkerBackground: linear-gradient( 14 | 90deg, 15 | transparent 36%, 16 | var(--kkleePreviewSliderDefaultMarkerColour), 17 | transparent 42% 18 | ); 19 | } 20 | .kkleeCheckbox { 21 | width: 12px; 22 | height: 12px; 23 | text-align: center; 24 | border: 2px solid #111; 25 | margin: auto 3px; 26 | color: black; 27 | font-size: 12px; 28 | line-height: 12px; 29 | } 30 | 31 | .kkleeCheckbox:hover { 32 | filter: brightness(1.4); 33 | } 34 | 35 | .kkleeMultiSelectPlatformIndexLabel { 36 | color: var(--kkleeMultiSelectColour); 37 | font-size: 12px; 38 | float: left; 39 | } 40 | .kkleeMultiSelectShapeIndexLabel { 41 | color: var(--kkleeMultiSelectColour); 42 | font-size: 12px; 43 | } 44 | .kkleeMultiSelectShapeTextBox { 45 | border: 3px solid var(--kkleeMultiSelectColour) !important; 46 | } 47 | .kkleeMultiSelectPlatform { 48 | outline: 2px solid var(--kkleeMultiSelectColour) !important; 49 | outline-offset: -1px; 50 | } 51 | 52 | .kkleeColourInput { 53 | width: 18px; 54 | height: 13px; 55 | display: inline-block; 56 | cursor: pointer; 57 | border: 1px solid white; 58 | outline: 2px solid black; 59 | margin: auto 3px; 60 | } 61 | 62 | .kkleeMultiSelectPropHighlight { 63 | color: var(--kkleeMultiSelectPropHighlightColour); 64 | font-weight: bold; 65 | } 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kklee 2 | 3 | A [bonk.io](https://bonk.io) mod that extends the functionality of the map 4 | editor. 5 | 6 | [Guide](./guide.md) 7 | 8 | [Bonk.io Modding Discord server](https://discord.gg/PHtG6qN3qj) 9 | 10 | ## Installing as a userscript 11 | 12 | Userscripts require a userscript manager such as Violentmonkey or Tampermonkey, 13 | and [Excigma's code injector userscript](https://greasyfork.org/en/scripts/433861-code-injector-bonk-io). 14 | 15 | The userscript is available at 16 | 17 | 18 | ## Installing as an extension 19 | 20 | Download 21 | 22 |
23 | In Firefox 24 | 25 | **Note:** You will have to do this after every time you restart the browser. 26 | 27 | 1. Go to `about:debugging#/runtime/this-firefox` 28 | 2. Click `Load temporary addon` and open the zip file. 29 | 30 |
31 | 32 |
33 | In Chrome (and other Chromium-based browsers, hopefully) 34 | 35 | 1. Go to `chrome://extensions/` 36 | 2. Enable `Developer mode` in the top-right corner of the page. 37 | 3. Drag and drop the zip file into the page. 38 | 39 |
40 | 41 | ## "It doesn't work" 42 | 43 | Did you: 44 | 45 | - Disable any mods that are incompatible with kklee, such as 46 | Bonk Leagues Client 47 | - Refresh Bonk.io after installing 48 | - Download the correct file from Releases? 49 | 50 | It is also possible that a recent bonk.io update broke the mod and it needs to 51 | be fixed. 52 | 53 | --- 54 | 55 | ## Building 56 | 57 |
58 | Ignore this if you just want to install the mod 59 | 60 | 1. Install the following: 61 | - [Node.js](https://nodejs.org/) (v16.3.0) 62 | - [Nim](https://nim-lang.org/) (v1.6.4) 63 | 2. Run `npm ci` to install npm dependecies. 64 | 3. Run `nimble install -d` to install nimble dependencies. 65 | 4. Run either: 66 | 67 | - `npm run buildDev` (no minfication so build is quicker) 68 | - `npm run buildRelease` (minified) 69 | 70 | The files will be saved in the `build` directory. 71 | 72 |
73 | -------------------------------------------------------------------------------- /src/kkleeMain.nim: -------------------------------------------------------------------------------- 1 | import 2 | std/[dom, sugar], 3 | pkg/karax/[karax, karaxdsl, vdom, vstyles], 4 | kkleeApi, bonkElements, vertexEditor, shapeGenerator, shapeMultiSelect, 5 | transferOwnership, platformMultiSelect, mapSizeInfo, mapBackups, 6 | editorImageOverlay, kkleeSettings 7 | 8 | let root = document.createElement("div") 9 | let karaxRoot = document.createElement("div") 10 | karaxRoot.id = "kkleeRoot" 11 | root.appendChild(karaxRoot) 12 | let st = root.style 13 | st.width = "0px" 14 | st.height = "100%" 15 | st.transition = "width 0.5s" 16 | st.position = "relative" 17 | st.backgroundColor = "#cfd8dc" 18 | st.borderTopLeftRadius = "3px" 19 | st.borderTopRightRadius = "3px" 20 | root.class = "buttonShadow" 21 | document.getElementById("mapeditor").appendChild(root) 22 | 23 | let midboxst = document.getElementById("mapeditor_midbox").style 24 | midboxst.width = "calc(100% - 415px)" 25 | midboxst.transition = "width 0.5s" 26 | 27 | type 28 | StateKindEnum* = enum 29 | seHidden = "", 30 | seVertexEditor = "Vertex editor", 31 | seShapeGenerator = "Shape generator", 32 | seShapeMultiSelect = "Shape multiselect", 33 | seTransferOwnership = "Transfer map ownership", 34 | sePlatformMultiSelect = "Platform multiselect", 35 | seMapSizeInfo = "Map size info", 36 | seBackups = "Map backup loader", 37 | seEditorImageOverlay = "Editor image overlay" 38 | seKkleeSettings = "kklee settings" 39 | StateObject* = ref object 40 | kind*: StateKindEnum 41 | fx*: MapFixture 42 | b*: MapBody 43 | 44 | 45 | 46 | var state* = StateObject(kind: seHidden) 47 | 48 | proc hide* = 49 | state = StateObject(kind: seHidden) 50 | kxi.redraw() 51 | proc rerender* = 52 | kxi.avoidDomDiffing() 53 | kxi.redraw() 54 | # let s = state 55 | # hide() 56 | # discard window.requestAnimationFrame(proc(_: float) = 57 | # state = s 58 | # kxi.redraw() 59 | # ) 60 | 61 | var exportedRerenderKklee {.importc: "window.kklee.rerenderKklee".}: proc() 62 | exportedRerenderKklee = rerender 63 | 64 | proc render: VNode = 65 | st.width = "200px" 66 | midboxst.width = "calc(100% - 600px)" 67 | 68 | buildHtml tdiv(style = 69 | "display: flex; flex-direction: column; height: 100%".toCss): 70 | tdiv(class = "windowTopBar windowTopBar_classic", 71 | style = "position: static".toCss): 72 | text "kklee" 73 | 74 | tdiv(style = ( 75 | "margin: 3px; flex: auto; display: flex; flex-direction: column; " & 76 | "min-height: 0px; overflow-y: auto; overflow-x: hidden").toCss): 77 | 78 | span(style = "margin-bottom: 12px".toCss): 79 | text $state.kind 80 | case state.kind 81 | of seHidden: 82 | st.width = "0px" 83 | midboxst.width = "calc(100% - 415px)" 84 | of seVertexEditor: vertexEditor(state.b, state.fx) 85 | of seShapeGenerator: shapeGenerator(state.b) 86 | of seShapeMultiSelect: shapeMultiSelect() 87 | of seTransferOwnership: transferOwnership() 88 | of sePlatformMultiSelect: platformMultiSelect() 89 | of seMapSizeInfo: mapSizeInfo() 90 | of seBackups: mapBackupLoader() 91 | of seEditorImageOverlay: editorImageOverlay() 92 | of seKkleeSettings: kkleeSettings() 93 | 94 | tdiv(style = "margin: 3px".toCss): 95 | bonkButton("Close", () => (state.kind = seHidden)) 96 | 97 | setRenderer(render, karaxRoot.id) 98 | 99 | 100 | rerender() 101 | -------------------------------------------------------------------------------- /src/editorImageOverlay.nim: -------------------------------------------------------------------------------- 1 | import 2 | std/[strutils, strformat], 3 | pkg/karax/[vdom, kdom, vstyles, karax, karaxdsl], 4 | bonkElements 5 | 6 | 7 | # Load an image from an 's onInput or onChange event 8 | proc loadEditorImageOverlay*(e: Event) 9 | {.importc: "window.kklee.editorImageOverlay.loadImage".} 10 | # If there are no parameters, it will reset the image to nothing 11 | proc loadEditorImageOverlay*() 12 | {.importc: "window.kklee.editorImageOverlay.loadImage".} 13 | proc updateSpriteSettings*() 14 | {.importc: "window.kklee.editorImageOverlay.updateSpriteSettings".} 15 | 16 | type editorImageOverlayObject = ref object 17 | x, y, w, h, ogW, ogH, angle, opacity: float 18 | imageState: cstring 19 | 20 | var st* 21 | {.importc: "window.kklee.editorImageOverlay".}: editorImageOverlayObject 22 | 23 | 24 | proc editorImageOverlay*: VNode = buildHtml tdiv(style = 25 | "display: flex; flex-flow: column; font-size: 16px; row-gap: 5px".toCss): 26 | 27 | span text "Select an image to overlay onto the editor preview." 28 | 29 | # Add a bonk themed label to the file input 30 | # it can handle the events for the input 31 | # Hide the original file input so only the bonk themed label shows 32 | label(`for` = "kkleeEditorImageOverlayInput"): 33 | # Button is a noop - the should handle it 34 | span bonkButton("Choose image", proc = return) 35 | input( 36 | `type` = "file", 37 | id = "kkleeEditorImageOverlayInput", 38 | accept = "image/*", 39 | style = "display: none".toCss 40 | ): 41 | proc oninput(e: Event; n: VNode) = 42 | loadEditorImageOverlay(e) 43 | proc onclick(e: Event; n: VNode) = 44 | # This will reset the input's value to an empty string 45 | # so if the user picks another image with the same file name, oninput() 46 | # will be triggered and the image will be overlayed again 47 | n.value = "" 48 | 49 | if st.imageState == "error": 50 | span(style = "color: rgb(204, 68, 68)".toCss): 51 | text "An error occurred" 52 | 53 | if st.imageState == "image": 54 | # Calling the function without any parameters will clear the image 55 | bonkButton("Clear image", proc () = loadEditorImageOverlay()) 56 | 57 | span text &"Image res.: {st.ogW.int}x{st.ogH.int}" 58 | 59 | # Opacity slider - Value from 0 to 1 60 | label(`for` = "kkleeEditorImageOverlayOpacity"): 61 | span text "Overlay opacity:" 62 | input( 63 | id = "kkleeEditorImageOverlayOpacity", 64 | title = "Overlay opacity", 65 | class = "compactSlider compactSlider_classic", 66 | style = "background: transparent; width: 99%".toCss, 67 | `type` = "range", 68 | min = "0", 69 | max = "1", 70 | step = "0.05" 71 | ): 72 | proc oninput(e: Event; n: VNode) = 73 | st.opacity = parseFloat($n.value) 74 | updateSpriteSettings() 75 | 76 | prop "X:", 77 | bonkInput(st.x, parseFloat, updateSpriteSettings, niceFormatFloat) 78 | prop "Y:", 79 | bonkInput(st.y, parseFloat, updateSpriteSettings, niceFormatFloat) 80 | prop "Width:", 81 | bonkInput(st.w, parseFloat, updateSpriteSettings, niceFormatFloat) 82 | prop "Height:", 83 | bonkInput(st.h, parseFloat, updateSpriteSettings, niceFormatFloat) 84 | prop "Angle:", 85 | bonkInput(st.angle, parseFloat, updateSpriteSettings, niceFormatFloat) 86 | bonkButton "Fit to screen", proc = 87 | st.angle = 0 88 | st.x = 0 89 | st.y = 0 90 | let s = min(730 / st.ogW, 500 / st.ogH) 91 | st.w = s * st.ogW 92 | st.h = s * st.ogH 93 | updateSpriteSettings() 94 | -------------------------------------------------------------------------------- /src/colours.nim: -------------------------------------------------------------------------------- 1 | import 2 | std/[math], 3 | chroma 4 | 5 | type 6 | Colour* = distinct int 7 | GradientPos* = range[0.0..1.0] 8 | EasingType* = enum 9 | easeNone = "None", easeInSine = "Sine in", easeOutSine = "Sine out", 10 | easeInOutSine = "Sine in out" 11 | ColourSpace* {.pure.} = enum 12 | RGB, HSL, HCL 13 | MultiColourGradientColour* = tuple[colour: Colour; pos: GradientPos] 14 | MultiColourGradient* = object 15 | colours*: seq[MultiColourGradientColour] 16 | easing*: EasingType 17 | colourSpace*: ColourSpace 18 | 19 | func getRGB(colour: Colour): ColorRGB = 20 | let colour = colour.int 21 | rgb(uint8(colour shr 16 and 255), uint8(colour shr 8 and 255), uint8( 22 | colour and 255)) 23 | 24 | func rgbToColour(c: ColorRGB): Colour = 25 | # Colors need to be converted to unit32 26 | # otherwise an 8 bit ´& 0xff´ bitmask will cause red and green to be 0 27 | # in newer versions of nim. 28 | let r: uint32 = c.r 29 | let g: uint32 = c.g 30 | let b: uint32 = c.b 31 | return Colour(r shl 16 or g shl 8 or b) 32 | 33 | func calculateEase(pos: GradientPos; ease: EasingType): GradientPos = 34 | case ease 35 | of easeNone: pos 36 | of easeInSine: 1 - cos(pos * PI / 2) 37 | of easeOutSine: sin(pos * PI / 2) 38 | of easeInOutSine: -0.5 * (cos(pos * PI) - 1) 39 | 40 | func getGradientColourAt*( 41 | colour1, colour2: Colour; pos: GradientPos; ease: EasingType; 42 | colourSpace: ColourSpace 43 | ): Colour = 44 | let 45 | colour1 = getRGB(colour1) 46 | colour2 = getRGB(colour2) 47 | pos = calculateEase(pos, ease) 48 | 49 | func mix(a, b: uint8): uint8 = 50 | uint8(a.float * (1.0 - pos.float) + b.float * pos.float) 51 | func mix(a, b: float): float = 52 | a * (1.0 - pos.float) + b * pos.float 53 | func mixHue(a, b: float): float = 54 | let diff = b - a 55 | var p: float 56 | # Decide direction of hue transition 57 | if diff > 180.0: 58 | p = a - (360 - diff) * pos 59 | elif diff < -180.0: 60 | p = a + (360 + diff) * pos 61 | else: 62 | p = a + diff * pos 63 | 64 | if p > 360.0: 65 | p -= 360.0 66 | if p < 0.0: 67 | p += 360.0 68 | p 69 | 70 | case colourSpace 71 | of ColourSpace.RGB: 72 | let colour = rgb(mix(colour1.r, colour2.r), mix(colour1.g, colour2.g), mix( 73 | colour1.b, colour2.b)) 74 | result = rgbToColour colour 75 | of ColourSpace.HSL: 76 | var colour1 = colour1.asHSL 77 | var colour2 = colour2.asHSL 78 | 79 | # Make hue the same if there isn't much colour 80 | if colour1.s < 0.1 or colour1.l < 0.2 or colour1.l > 99.8: 81 | colour1.h = colour2.h 82 | if colour2.s < 0.2 or colour2.l < 0.2 or colour2.l > 99.8: 83 | colour2.h = colour1.h 84 | 85 | let colour = hsl(mixHue(colour1.h, colour2.h), mix(colour1.s, 86 | colour2.s), mix(colour1.l, colour2.l)) 87 | result = rgbToColour colour.asRgb 88 | 89 | of ColourSpace.HCL: 90 | var colour1 = colour1.asPolarLuv 91 | var colour2 = colour2.asPolarLuv 92 | 93 | # Make hue the same if there isn't much colour 94 | if colour1.c < 0.2 or colour1.l < 0.2 or colour1.l > 99.8: 95 | colour1.h = colour2.h 96 | if colour2.c < 0.2 or colour2.l < 0.2 or colour2.l > 99.8: 97 | colour2.h = colour1.h 98 | 99 | let colour = polarLUV(mixHue(colour1.h, colour2.h), mix(colour1.c, 100 | colour2.c), mix(colour1.l, colour2.l)) 101 | result = rgbToColour colour.asRgb 102 | 103 | func getColourAt*( 104 | gradient: MultiColourGradient; pos: GradientPos 105 | ): Colour = 106 | var 107 | colour1 = Colour 0 108 | colour2 = Colour 0 109 | gradientPos = GradientPos 0.0 110 | for i in 0..gradient.colours.high: 111 | let c2 = gradient.colours[i] 112 | if c2.pos >= pos: 113 | if i == 0: 114 | return c2.colour 115 | let c1 = gradient.colours[i-1] 116 | colour2 = c2.colour 117 | colour1 = c1.colour 118 | gradientPos = (pos - c1.pos) / (c2.pos - c1.pos) 119 | break 120 | if i == gradient.colours.high: 121 | return c2.colour 122 | return getGradientColourAt( 123 | colour1, colour2, gradientPos, gradient.easing, gradient.colourSpace 124 | ) 125 | 126 | func defaultMultiColourGradient*: MultiColourGradient = 127 | MultiColourGradient( 128 | colours: @[ 129 | (Colour 0x2222ff, GradientPos 0), (Colour 0xff2222, GradientPos 1.0) 130 | ], 131 | easing: easeNone, 132 | colourSpace: ColourSpace.RGB 133 | ) 134 | -------------------------------------------------------------------------------- /guide.md: -------------------------------------------------------------------------------- 1 | # kklee guide 2 | 3 | ## Minor features 4 | 5 | - The chat is visible in the editor. 6 | - You can use your browser's colour picker to choose colours (this allows you to 7 | specify exact colour values). 8 | - When you try to exit bonk.io, the page will prompt you to confirm that you 9 | want to close the page. 10 | - Rectangles can be quickly converted to polygons. 11 | - You can quickly open a shape's capture zone settings. 12 | 13 | ## Keyboard shortcuts 14 | 15 | - Save map: `Ctrl + S` 16 | - Start/stop preview: `Space` 17 | - Play: `Shift + Space`, 18 | - Exit game: `Shift + Esc` 19 | - When editing a number field, you can use up/down arrows to increase/decrease 20 | the value. For example, when you are editing a shape's X coordinate, pressing 21 | `up` will increase the value by 10 and `down` will decrease it. 22 | 23 | Shortcut modifiers for changing amount: 24 | 25 | - Just Arrow: `10` 26 | - Shift + Arrow: `1` 27 | - Ctrl + Arrow: `100` 28 | - Ctrl + Shift + Arrow: `0.1` 29 | 30 | - Arrow to pan the editor preview when it is focused. 31 | 32 | Shortcut modifiers for changing pan amount: 33 | 34 | - Just Arrow: `50` 35 | - Shift + Arrow: `25` 36 | - Ctrl + Arrow: `150` 37 | - Ctrl + Shift + Arrow: `10` 38 | 39 | ## Shape generator 40 | 41 | You can access the shape generator by pressing "Generate shape" at the top of 42 | the shapes list. It can generate regular polygons/ellipses/spirals, sine waves, 43 | linear gradients, radial gradients and custom parametric equations. 44 | 45 | ## Vertex editor 46 | 47 | You can manually edit a polygon's vertices by opening the vertex editor in the 48 | shape's properties. 49 | 50 | Vertices that cause the polygon to be concave will be outlined in red. 51 | 52 | Make sure that vertices are specified in a clockwise order, otherwise the 53 | polygon will be counted as concave. 54 | 55 | There are also some additional features such as: 56 | 57 | - Rounding corners 58 | - Splitting a conave polygon into multiple convex polygons 59 | - (BUGGY) merging of multiple no physics shapes with the same colour into one 60 | polygon. 61 | 62 | ## Multiselect shapes and platforms 63 | 64 | You can select multiple shapes or platforms and mass-modify their properties. 65 | 66 | To select platforms, shift + click the platform's name in the platforms list. 67 | 68 | To select shapes, shift + click the shape's name textbox. 69 | 70 | Use the "apply" button to apply changes. 71 | 72 | ### Selecting shapes in collection 73 | 74 | The "Include shapes from" option changes what set of shapes the "Select all", 75 | etc buttons will act on. 76 | 77 | ### Mathematical expression evaluation in multiselect 78 | 79 | [(More info about mathematical expression evaluation)](#mathematical-expression-evaluator) 80 | 81 | `x` is equal to the property's current value. 82 | 83 | Example: `x+50` will increase the value by 50. 84 | 85 | (Note: "item" will refer to the shape/platform) 86 | 87 | `i` is equal to the items index in the list of selected items. This 88 | is indicated by the blue number next to the item. The 1st selected item has 89 | i=0, 2nd has i=1, etc. 90 | 91 | Example: setting the X position in shape multiselect to `i*50` will set the 1st 92 | shape's X to `0`, 2nd shape's X to `50`, 3rd shape's X to `100`, etc. 93 | 94 | ### Name property 95 | 96 | In the name property, anything between a pair of `||`s will be treated as a 97 | mathematical expression to evaluate. The `i` variable is available but `x` 98 | isn't. 99 | 100 | ### Checkboxes 101 | 102 | - Yellow (-) = unchanged 103 | - Green (tick) = enable 104 | - Red (X) = disable 105 | 106 | ### Copy and pasting in platform multiselect 107 | 108 | If you use `Instant copy&paste`, joints on the pasted platforms will be attached 109 | to the original platforms, not the new pasted platforms. 110 | 111 | For example, if you have platforms `A1` and `B1` where `A1` has a joint attached 112 | to `B1`, and you `instant copy&paste` both platforms to produce `A2` and `B2`, 113 | `A2`'s joint will be attached to the original `B1`, not the new `B2`. 114 | 115 | If you use the separate `copy` and `paste` buttons, `A2`'s joint will be 116 | attached to the new `B2`. 117 | 118 | ## Automatic backups 119 | 120 | kklee automatically backs up your maps to your browser's offline storage. 121 | To access your backups, go to map settings (gear icon) and click 122 | "Load map backup". 123 | 124 | kklee will use a maximum of 1 MB of storage to store backups. Older backups are 125 | deleted once that limit is reached. 126 | 127 | ## Automatic update checking 128 | 129 | kklee can automatically check if new versions of itself are available. To enable 130 | this option, go to map settings (gear icon) and then "kklee settings". 131 | 132 | This will send a HTTP request to GitHub.com when the page is loaded at a maximum 133 | of once per hour. 134 | 135 | ## Image overlay 136 | 137 | You can overlay an image over the map preview to help you trace it. This feature 138 | can be access in map settings (gear icon) -> "Image overlay". 139 | 140 | ## Transfer map ownership 141 | 142 | You can transfer ownership of your map to another user to let them save the map 143 | without the map being marked as an edit. This will not transfer the map's likes 144 | or creation date. 145 | 146 | You can do this if your account's username is the author or `Original author` of 147 | the map. You can transfer ownership to yourself or a user in the `Contributors` 148 | list. 149 | 150 | ## Change speed of map preview 151 | 152 | A slider is added next to the play preview button at the top that allows you to 153 | change the speed of the preview. The vertical bar indicates normal speed. 154 | 155 | ## Evaluate maths in number fields 156 | 157 | When editing number fields, you can quickly calculate mathematical expressions 158 | [(more info)](#mathematical-expression-evaluator) with `Shift + Enter`. 159 | 160 | For example, entering `100+30*2` into X position field and pressing 161 | `Shift + Enter` will change the value to `160`. 162 | 163 | ## Mathematical expression evaluator 164 | 165 | Supported operators include `+`, `-`, `/`, `*`, `%`, `^` 166 | 167 | Predefined constants: `pi`, `tau`, `e` 168 | 169 |
170 | Implemented functions: 171 | 172 | **Note:** angles are in radians 173 | 174 | - `rand()` - a random number in the range 0 to less than 1 175 | - `abs(x)` - the absolute value of `x` 176 | - `acos(x)` or `arccos(x)` - the arccosine (in radians) of `x` 177 | - `asin(x)` or `arcsin(x)` - the arcsine (in radians) of `x` 178 | - `atan(x)` or `arctan(x)` or `arctg(x)` - the arctangent (in radians) of `x` 179 | - `atan2(x, y)` or `arctan2(x, y)` - the arctangent of the \ 180 | quotient from provided `x` and `y` 181 | - `ceil(x)` - the smallest integer greater than or equal to `x` 182 | - `cos(x)` - the cosine of `x` 183 | - `cosh(x)` - the hyperbolic cosine of `x` 184 | - `deg(x)` - converts `x` in radians to degrees 185 | - `exp(x)` - the exponential function of `x` 186 | - `sgn(x)` - the sign of `x` 187 | - `sqrt(x)` - the square root of `x` 188 | - `sum(x, y, z, ...)` - sum of all passed arguments 189 | - `fac(x)` - the factorial of `x` 190 | - `floor(x)` - the largest integer not greater than `x` 191 | - `ln(x)` - the natural log of `x` 192 | - `log(x)` or `log10(x)` - the common logarithm (base 10) of `x` 193 | - `log2(x)` - the binary logarithm (base 2) of `x` 194 | - `max(x, y, z, ...)` - biggest argument from any number of arguments 195 | - `min(x, y, z, ...)` - smallest argument from any number of arguments 196 | - `ncr(x, y)` or `binom(x, y)` - the number of ways a sample of \ 197 | `y` elements can be obtained from a larger set of `x` distinguishable \ 198 | objects where order does not matter and repetitions are not allowed 199 | - `npr(x, y)` - the number of ways of obtaining an ordered subset of `y` \ 200 | elements from a set of `x` elements 201 | - `rad(x)` - converts `x` in degrees to radians 202 | - `pow(x, y)` - the `x` to the `y` power 203 | - `sin(x)` - the sine of `x` 204 | - `sinh(x)` - the hyperbolic sine of `x` 205 | - `tg(x)` or `tan(x)` - the tangent of `x` 206 | - `tanh(x)` - the hyperbolic tangent of `x` 207 |
208 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.1 4 | 5 | - Made the mod work on the latest bonk.io update 6 | - Added polygon option in the parametric equation generator 7 | - Fixed some bugs in the shape generator and gradient pickers 8 | - Added a shape count indicator at the top of the shapes list 9 | - Added a button that selects shapes with capture zones in shape multiselect 10 | - Added a way to delete capture zones in shape multiselect 11 | - Increased the shape pasting limit from 100 to 1000 12 | - Fixed scroll zoom sensitivity on trackpads (taken from Excigma's userscript) 13 | - Added a button that resets multiselect properties 14 | - Added a colour space option in gradient pickers with options for 15 | RGB, HSL and HCL 16 | - Shapes and platforms can be dragged in their list to move them 17 | 18 | ## v1.0 19 | 20 | - Made the mod work on the new bonk.io update 21 | 22 | ## v0.29 23 | 24 | - Added sounds for clicking and hovering over buttons. 25 | - Automatic update checking is now an option that can be toggled in 26 | map settings (gear icon) -> "kklee settings". 27 | - Multiple colours can now be specified in gradients. 28 | - Made a kklee guide that is available on the GitHub page. 29 | 30 | ## v0.28 31 | 32 | - Improved verification of being the original map author in the transfer 33 | ownership feature 34 | - Made selection buttons in shape multiselect simpler by adding a dropdown for 35 | specifying whether to include shapes from the current, all or multiselected 36 | platforms 37 | - Added a button in shape multiselect that selects physics shapes 38 | 39 | ## v0.27 40 | 41 | - Fixed colour picker and image overlay after being broken by bonk.io update 42 | - Minor improvements 43 | - Name property in multiselect will now evaluate arithmetic 44 | 45 | ## v0.26 46 | 47 | - Prevented arrow keys from scrolling page while panning editor preview 48 | - Added a button that splits a concave polygon into convex polygons 49 | 50 | ## v0.25 51 | 52 | - Added arrow key shortcuts to pan the camera in the editor preview 53 | - Removed the fullscreen button, use 54 | instead 55 | 56 | ## v0.24 57 | 58 | - Some minor UI improvements 59 | - Added more buttons for multiselecting shapes, such as selecting all shapes 60 | from all platforms 61 | - Added capture zone settings to shape multiselect 62 | - Changed the default capture zone type to Instant Red Win when pressing the 63 | "Capzone" button in shape properties 64 | 65 | ## v0.23 66 | 67 | - Some minor UI improvements and bug fixes 68 | - Pasting platforms in multiselect will paste them in the opposite order to 69 | to how they were pasted previously 70 | - Added a way to multi-select shapes by colour 71 | - Colour pickers in kklee will use Bonk's colour picker 72 | - Added another copy & paste option to platform multi-select in which joints 73 | on pasted platforms will be attached to the original platforms 74 | 75 | ## v0.22 76 | 77 | - Added a way to overlay an image over the editor preview 78 | - Added an indicator on the preview speed slider to show where normal speed is 79 | 80 | ## v0.21 81 | 82 | - Copying shapes in multi-select will also copy capzones 83 | - Added copy & paste to platform multi-select (though joints aren't copied 84 | properly) 85 | - Fixed a minor bug when making the chat box visible in editor 86 | 87 | ## v0.20 88 | 89 | - Fixed bug in ellipse generator 90 | - Made the existing colours container in the colour picker have a maximum height 91 | and a scroll bar 92 | - Added 0 as a value to preview speed slider 93 | - Fixed the chat box autofill bug 94 | - Added up/down arrow shortcuts for changing number number field value with 95 | Ctrl, Shift and Ctrl+Shift modifiers 96 | 97 | ## v0.19 98 | 99 | - Changed how map backups are stored 100 | - Changed some UI styles 101 | - Increased shape generator shapes/vertices limit from 99 to 999 102 | - Added polygon scale property to shape multi-select 103 | - Added a way to rotate and scale multi-selected shapes around a point 104 | 105 | ## v0.18 106 | 107 | - Fixed a bug in the ellipse generator that caused the first vertex to be 108 | repeated 109 | - Added a colour gradient option to shape multi-select 110 | - Added automatic backups of maps to the browser's offline storage 111 | 112 | ## v0.17 113 | 114 | - Fixed bugs in the Shift+Space shortcut 115 | - Shift+Esc will be usable even if the game wasn't started from the editor 116 | - Fixed the bug in the gradient generator that caused the final colour to be 117 | part of the transition instead of the actual colour you chose 118 | - Added a button that reverses the selection order in multi-select 119 | - Added a button that reverses the order of selected items in multi-select 120 | - Added a map size info panel that shows the total number of shapes, platforms, 121 | etc 122 | - Fixed a bug in the corner rounder 123 | 124 | ## v0.16 125 | 126 | - Added a variable for the number of selected items in multi-select 127 | - Added rand function to arithmetic evaluators 128 | - Added a link to the list of supported functions in multi-select 129 | - Added ability to multi-select by item name 130 | - Added a button to make the game frame fill the entire page 131 | 132 | ## v0.15 133 | 134 | - Fixed bug in shape capture zone adder 135 | - No physics shapes won't be counted in total platform mass 136 | - Added inner grapple and shrink properties to shape multi-select 137 | - Added ability to move shapes and platforms up/down in multi-select 138 | - Highlighted properties in multi-select will be purple and bold instead of red 139 | - Shift+Click will automatically open multi-select if it isn't already open 140 | - Added a button that converts rectangles to polygons 141 | 142 | ## v0.14 143 | 144 | - Fixed the custom colour picker that was broken in the latest Bonk.io update 145 | - The browser's default action for Shift+Esc will be prevented when you use it 146 | return to the editor 147 | - Properties in multi-select will be highlighted in red if they are modified 148 | 149 | ## v0.13 150 | 151 | - Added platform multi-select 152 | - Added a button in multi-select to invert selection 153 | - Added index labels to selected shapes 154 | - Added a label for a platform's total mass 155 | 156 | ## v0.12 157 | 158 | - Added ability to transfer map ownership to a contributor of a map if you are 159 | the original author 160 | - Added ability to round the corners of a polygon in the vertex editor 161 | - Added the shape name property to the shape multi-select editor 162 | 163 | ## v0.11 164 | 165 | - Fixed bugs in the vertex editor 166 | - Added a button in shape properties that adds a new or views an existing 167 | capture zone for that shape 168 | - Negative numbers can now be used in vertex scaling 169 | - Changed shortcut for returning to editor after pressing play to Shift+Esc 170 | 171 | ## v0.10 172 | 173 | - Removed the "move to platform" feature in multi-select 174 | - Removed multi-duplicate and added option to specify how many times copied 175 | shapes should be pasted 176 | - Changed multi-select angle unit from radians to degrees 177 | - Added buttons to select or deselect all shapes 178 | - Added option to automatically multi-select generated shapes 179 | - Fixed a bug in multi-select that caused kklee to crash 180 | - Made Shift+Space also return you back to the editor 181 | 182 | ## v0.9 183 | 184 | - After applying in multi-select, shape properties will be updated immediately 185 | - Added shape multi-duplicate. This lets you duplicate a shape a specified 186 | number of times and the duplicates will be automatically multi-selected 187 | - Added keyboard shortcuts: Save - Ctrl+S, Preview - Space, Play - Shift+Space 188 | - Moving shapes to other platforms is now an option in multi-select 189 | - Added ability to copy and paste shapes in multi-select 190 | 191 | ## v0.8 192 | 193 | - Fixed bug in the shape generator that caused the editor to break 194 | 195 | ## v0.7 196 | 197 | - Added shape colour option to multi-select 198 | - Added rect height option to ellipse and sine wave generators 199 | - Added parametric equation generator 200 | 201 | ## v0.6 202 | 203 | - Improved description for the shape multi-selection panel 204 | - Added a label for the preview speed slider 205 | - Added a tip about arithmetic evaluation 206 | - Added ability to delete selected shapes in multi-select 207 | 208 | ## v0.5 209 | 210 | - Make polygon merger work with scaled polygons 211 | - Fix bug where chat scrolls to the top when you enter the map editor 212 | - Added multi-select for shapes 213 | 214 | ## v0.4 215 | 216 | - Fix annoying bug where chat would scroll up when you click it 217 | 218 | ## v0.3 219 | 220 | - Moved the chat box to a better place 221 | - Fixed the bug where the chat box disappears if you get disconnected from 222 | the server while editing a map 223 | - Added option for easing gradients 224 | 225 | ## v0.2 226 | 227 | - Added automatic checking of updates (you'll still have to install them 228 | manually) 229 | - Added generators for linear and radial gradients 230 | - Fixed bug in checkboxes 231 | - Added changelog.md 232 | 233 | ## v0.1 234 | 235 | First release 236 | 237 | Features: 238 | 239 | - The chat is visible in the map editor 240 | - Vertex editor 241 | - (Buggy) Merge multiple polygons into one 242 | - Easily generate ellipses, spirals and sine waves 243 | - Move shapes to other platforms 244 | - Ability to use your browser's colour picker for changing colours 245 | - Evaluate arithmetic in number fields by pressing Shift+Enter 246 | (example: type `100*2+50` into X position field and press Shift+Enter) 247 | - Change the speed of map testing in the editor 248 | -------------------------------------------------------------------------------- /src/bonkElements.nim: -------------------------------------------------------------------------------- 1 | import 2 | std/[strformat, dom, sugar, options, strutils], 3 | pkg/karax/[karax, karaxdsl, vdom, vstyles], 4 | pkg/mathexpr, 5 | kkleeApi, colours 6 | 7 | proc bonkButton*(label: string; onClick: proc; disabled: bool = false): VNode = 8 | let disabledClass = if disabled: "brownButtonDisabled" else: "" 9 | buildHtml(tdiv(class = 10 | cstring &"brownButton brownButton_classic buttonShadow {disabledClass}" 11 | )): 12 | text label 13 | if not disabled: 14 | proc onClick = onClick() 15 | proc onMouseDown = playBonkButtonClickSound() 16 | proc onMouseEnter = playBonkButtonHoverSound() 17 | 18 | 19 | func defaultFormat*[T](v: T) = $v 20 | func niceFormatFloat*(f: float): string = {.cast(noSideEffect).}: 21 | if f != jsNull: f.formatFloat(precision = -1) 22 | else: "0" 23 | 24 | # Note: there is bonkInputWide in shapeGenerator... 25 | proc bonkInput*[T](variable: var T; parser: string -> T; 26 | afterInput: proc(): void = nil; stringify: T -> 27 | string): VNode = 28 | buildHtml: 29 | input(class = "mapeditor_field mapeditor_field_spacing_bodge fieldShadow", 30 | value = cstring variable.stringify, style = "width: 50px".toCss): 31 | proc onInput(e: Event; n: VNode) = 32 | try: 33 | variable = parser $n.value 34 | e.target.style.color = "" 35 | if not afterInput.isNil: 36 | afterInput() 37 | except CatchableError: 38 | e.target.style.color = "var(--kkleeErrorColour)" 39 | 40 | proc colourInput*(variable: var int; afterInput: proc(): void = nil): VNode = 41 | let hexColour = "#" & variable.toHex(6) 42 | buildHtml: 43 | tdiv(class = "kkleeColourInput", 44 | style = "background-color: {hexColour}".fmt.toCss 45 | ): 46 | proc onClick = 47 | bonkShowColorPicker(variable, moph.fixtures, proc (c: int) = 48 | variable = c 49 | if not afterInput.isNil: 50 | afterInput() 51 | , nil) 52 | 53 | func prsFLimited*(s: string): float = 54 | result = s.parseFloat 55 | if result notin -1e6..1e6: raise newException(ValueError, "prsFLimited") 56 | 57 | func prsFLimitedPositive*(s: string): float = 58 | result = s.prsFLimited 59 | if result < 0.0: raise newException(ValueError, "prsFLimitedPostive") 60 | 61 | type boolPropValue* = enum 62 | tfsSame, tfsTrue, tfsFalse 63 | 64 | proc checkbox*(variable: var bool; afterInput: proc(): void = nil): VNode = 65 | let things = 66 | if variable: ("Checked", "✔") 67 | else: ("Unchecked", "") 68 | buildHtml tdiv(class = "kkleeCheckbox", 69 | style = "background-color: var(--kkleeCheckbox{things[0]})".fmt.toCss 70 | ): 71 | text things[1] 72 | proc onClick = 73 | variable = not variable 74 | if not afterInput.isNil: 75 | afterInput() 76 | 77 | proc tfsCheckbox*( 78 | inp: var boolPropValue; afterInput: proc(): void = nil 79 | ): VNode = 80 | let things = case inp 81 | of tfsTrue: ("True", "✔") 82 | of tfsFalse: ("False", "✖") 83 | of tfsSame: ("Same", "━") 84 | buildHtml tdiv(class = "kkleeCheckbox", 85 | style = "background-color: var(--kkleeCheckboxTfs{things[0]})".fmt.toCss 86 | ): 87 | text things[1] 88 | proc onClick = 89 | inp = case inp 90 | of tfsSame: tfsTrue 91 | of tfsTrue: tfsFalse 92 | of tfsFalse: tfsSame 93 | if not afterInput.isNil: 94 | afterInput() 95 | 96 | func prop*(name: string; field: VNode; highlight = false): VNode = 97 | buildHtml: tdiv(style = 98 | "display:flex; flex-flow: row wrap; justify-content: space-between" 99 | .toCss): 100 | span(class = ( 101 | if highlight: cstring "kkleeMultiSelectPropHighlight" else: "" 102 | )): 103 | text name 104 | field 105 | 106 | func floatNop*(f: float): float = f 107 | 108 | proc floatPropInput*(inp: var string): VNode = 109 | buildHtml: bonkInput(inp, proc(parserInput: string): string = 110 | let evtor = newEvaluator() 111 | evtor.addVars {"x": 0.0, "i": 0.0, "n": 0.0} 112 | evtor.addFunc("rand", mathExprJsRandom, 0) 113 | discard evtor.eval parserInput 114 | return parserInput 115 | , nil, s=>s) 116 | 117 | proc floatPropApplier*(inp: string; i: int; n: int; prop: float): float = 118 | let evtor = newEvaluator() 119 | evtor.addVars {"x": prop, "i": i.float, "n": n.float} 120 | evtor.addFunc("rand", mathExprJsRandom, 0) 121 | result = evtor.eval(inp).clamp(-1e6, 1e6) 122 | if result.isNaN: result = 0 123 | 124 | # Optional input 125 | proc dropDownPropSelect*[T]( 126 | inp: var Option[T]; 127 | options: seq[tuple[label: string; value: T]]; 128 | afterInput: proc(): void = nil 129 | ): VNode = 130 | buildHtml: 131 | select(style = "width: 80px".toCss): 132 | if inp.isNone: 133 | option(selected = ""): text "Unchanged" 134 | else: 135 | option: text "Unchanged" 136 | 137 | for o in options: 138 | let selected = inp.isSome and inp.get == o[1] 139 | if selected: 140 | option(selected = ""): text o[0] 141 | else: 142 | option: text o[0] 143 | 144 | proc onInput(e: Event; n: VNode) = 145 | let i = e.target.OptionElement.selectedIndex 146 | inp = 147 | if i == 0: none T.typedesc 148 | else: some options[i - 1][1] 149 | if not afterInput.isNil: 150 | afterInput() 151 | 152 | # Not optional 153 | proc dropDownPropSelect*[T]( 154 | inp: var T; 155 | options: seq[tuple[label: string; value: T]]; 156 | afterInput: proc(): void = nil 157 | ): VNode = 158 | buildHtml: 159 | select(style = "width: 80px".toCss): 160 | for o in options: 161 | if inp == o[1]: 162 | option(selected = ""): text o[0] 163 | else: 164 | option: text o[0] 165 | 166 | proc onInput(e: Event; n: VNode) = 167 | let i = e.target.OptionElement.selectedIndex 168 | inp = options[i][1] 169 | if not afterInput.isNil: 170 | afterInput() 171 | 172 | proc gradientPropImpl( 173 | gradient: var MultiColourGradient; selectedIndex: var int; 174 | afterInput: proc(): void = nil 175 | ): VNode = 176 | buildHtml tdiv( 177 | style = "display: flex; flex-flow: column; margin: 10px 0px".toCss 178 | ): 179 | let runAfterInput = proc = 180 | if not afterInput.isNil: 181 | afterInput() 182 | let cssGradient = block: 183 | var r = "linear-gradient(90deg" 184 | for i in 0..10: 185 | r &= ", #" & gradient.getColourAt(i.float / 10.0).int.toHex(6) & 186 | " " & $(i * 10) & "%" 187 | r &= ")" 188 | r 189 | tdiv(style = ("width: 100%; height: 15px; background:" & cssGradient).toCss) 190 | 191 | var inputPos = gradient.colours[selectedIndex].pos 192 | 193 | prop "Easing", dropDownPropSelect(gradient.easing, @[ 194 | ($easeNone, easeNone), ($easeInSine, easeInSine), 195 | ($easeOutSine, easeOutSine), ($easeInOutSine, easeInOutSine) 196 | ], afterInput) 197 | 198 | prop "Colour space", dropDownPropSelect(gradient.colourSpace, @[ 199 | ("RGB", ColourSpace.RGB), ("HSL", ColourSpace.HSL), 200 | ("HCL (CIELUV)", ColourSpace.HCL) 201 | ], afterInput) 202 | 203 | tdiv(style = "display: flex; flex-flow: row wrap;".toCss): 204 | bonkButton("Delete", proc = 205 | gradient.colours.delete(selectedIndex) 206 | selectedIndex = 0 207 | runAfterInput() 208 | , gradient.colours.len < 2) 209 | 210 | span(style = "margin-left: 10px".toCss): text "Pos:" 211 | bonkInput(inputPos, proc(s: string): GradientPos = 212 | let n = parseFloat(s) 213 | if n notin 0.0..1.0: 214 | raise ValueError.newException("n notin 0.0..1.0") 215 | n.GradientPos 216 | , proc() = 217 | var gradientColours = gradient.colours 218 | var movingColour = gradientColours[selectedIndex] 219 | 220 | if movingColour.pos == inputPos: 221 | return 222 | 223 | movingColour.pos = inputPos 224 | gradientColours.delete selectedIndex 225 | var newIndex = gradientColours.len 226 | 227 | for i, gradientColour in gradientColours.pairs: 228 | if gradientColour.pos > movingColour.pos: 229 | newIndex = i 230 | break 231 | 232 | gradientColours.insert(movingColour, newIndex) 233 | 234 | for i in 0..gradientColours.len - 1: 235 | gradient.colours[i].pos = gradientColours[i].pos 236 | gradient.colours[i].colour = gradientColours[i].colour 237 | 238 | selectedIndex = newIndex 239 | runAfterInput() 240 | , g => g.float.niceFormatFloat) 241 | 242 | # ugh 243 | proc colourProp( 244 | selected: bool; gradientColour: var MultiColourGradientColour 245 | ): VNode = 246 | buildHtml tdiv(style = "display: flex; flex-flow: row wrap;".toCss): 247 | block: 248 | # bug: sometimes the colour can't be changed until element is rerendered 249 | var ic = gradientColour.colour.int 250 | colourInput(ic, proc = 251 | gradientColour.colour = ic.Colour 252 | runAfterInput() 253 | ) 254 | let fontCss = if selected: 255 | "color: var(--kkleeMultiSelectPropHighlightColour);" 256 | else: "" 257 | span(style = fontCss.toCss, class = "kkleeMultiColourGradientSpan"): 258 | text $gradientColour.pos 259 | 260 | tdiv: 261 | for i, gradientColour in gradient.colours.mpairs: 262 | # Smh, I can't use capture 263 | colourProp(i == selectedIndex, gradientColour) 264 | proc onClick(e: Event; n: VNode) = 265 | let t = e.target 266 | if t.isNil: 267 | return 268 | if t.class == "kkleeMultiColourGradientSpan": 269 | let i = t.parentNode.parentNode.children.find t.parentNode 270 | selectedIndex = i 271 | 272 | bonkButton "Add colour", proc = 273 | gradient.colours.add (Colour 0, GradientPos 1.0) 274 | runAfterInput() 275 | 276 | template gradientProp*( 277 | gradient: var MultiColourGradient; afterInput: proc(): void = nil 278 | ): VNode = 279 | var selectedIndex {.global.} = 0 280 | gradientPropImpl(gradient, selectedIndex, afterInput) 281 | -------------------------------------------------------------------------------- /src/kkleeApi.nim: -------------------------------------------------------------------------------- 1 | when not defined js: 2 | {.error: "This module only works on the JavaScipt platform".} 3 | 4 | import 5 | std/[strutils, sequtils, dom, math, sugar], 6 | pkg/[mathexpr] 7 | 8 | # Import functions that update or hook into updates of parts of the map editor 9 | # UI 10 | 11 | template importUpdateFunction(name: untyped; 12 | procType: type = proc(): void) = 13 | let `update name`* {.importc: "window.kklee.$1"inject.}: procType 14 | var `afterUpdate name`* {.importc: "window.kklee.$1"inject.}: procType 15 | 16 | importUpdateFunction(LeftBox) 17 | importUpdateFunction(RightBoxBody, proc(fx: int): void) 18 | # Argument b: if true, rerender shapes 19 | # if false, only update preview position and scale 20 | importUpdateFunction(Renderer, proc(b: bool): void) 21 | importUpdateFunction(Warnings) 22 | importUpdateFunction(UndoButtons) 23 | importUpdateFunction(ModeDropdown) 24 | 25 | var afterNewMapObject* {.importc: "window.kklee.$1".}: proc(): void 26 | let saveToUndoHistory* {.importc: "window.kklee.$1".}: proc(): void 27 | 28 | # Import getters and setters for IDs of currently selected elements 29 | 30 | template importCurrentThing(name: untyped) = 31 | let `getCurrent name`* {.importc: "window.kklee.$1"inject.}: proc(): int 32 | let `setCurrent name`* {.importc: "window.kklee.$1"inject.}: proc(i: int) 33 | 34 | importCurrentThing(Body) 35 | importCurrentThing(Spawn) 36 | importCurrentThing(CapZone) 37 | 38 | proc setColourPickerColour*(colour: int) {.importc: "window.kklee.$1".} 39 | proc dataLimitInfo*: cstring {.importc: "kklee.dataLimitInfo".} 40 | proc panStage*(deltaX, deltaY: int) {.importc: "kklee.stageRenderer.panStage".} 41 | proc scaleStage*(scale: float) {.importc: "kklee.stageRenderer.scaleStage".} 42 | 43 | type 44 | MapPosition* = array[2, float] # X and Y 45 | MapData* = ref object 46 | v*: int # Map format version 47 | m*: MapMetaData 48 | spawns*: seq[MapSpawn] 49 | capZones*: seq[MapCapZone] 50 | physics*: MapPhysics 51 | MapMetaData* = ref object 52 | # Name, author, date, mode 53 | n*, a*, date*, mo*: cstring 54 | # Original name and author if map is an edit 55 | rxn*, rxa*: cstring 56 | # Database ID, bonk version (1 or 2), author's account DBID 57 | dbid*, dbv*, authid*: int 58 | # Original map's bonk version and ID 59 | rxdb*, rxid*: int 60 | # Is map public 61 | pub*: bool 62 | # List of contributors' usernames 63 | cr*: seq[cstring] 64 | 65 | MapSpawn* = ref object 66 | # Name 67 | n*: cstring 68 | priority*: int 69 | # FFA, red, blue, green, yellow 70 | f*, r*, b*, gr*, ye*: bool 71 | # Position and starting velocity 72 | x*, y*, xv*, yv*: float 73 | MapCapZone* = ref object 74 | # Name 75 | n*: cstring 76 | # Type 77 | ty*: MapCapZoneType 78 | # Fixture ID 79 | i*: int 80 | # Time 81 | l*: float 82 | MapCapZoneType* = enum 83 | cztNormal = 1, cztRed, cztBlue, cztGreen, cztYellow 84 | 85 | MapPhysics* = ref object 86 | # Player radius = ppm 87 | ppm*: float 88 | fixtures*: seq[MapFixture] 89 | shapes*: seq[MapShape] 90 | bodies*: seq[MapBody] 91 | # Array of body IDs, specifies order of bodies 92 | bro*: seq[int] 93 | joints*: seq[MapJoint] 94 | 95 | MapBody* = ref object 96 | # Angle and angular velocity 97 | a*, av*: float 98 | # Position, starting velocity 99 | p*, lv*: MapPosition 100 | # Fixture IDs of shapes on platform 101 | fx*: seq[int] 102 | cf*: MapBodyCf 103 | fz*: MapBodyFz 104 | s*: MapSettings 105 | # Body settings? 106 | MapSettings* = ref object 107 | n*: cstring 108 | # Type: "s" (stationary), "d" (free-moving) or "k" (kinematic) 109 | # Type is a keyword in Nim 110 | btype* {.extern: "type".}: cstring 111 | # Angular drag 112 | ad*: float 113 | # Density, friction, linear drag, bounciness 114 | de*, fric*, ld*, re*: float 115 | # Collide with groups: A, B, C, D, players 116 | f_1*, f_2*, f_3*, f_4*, f_p*: bool 117 | # Collision group 118 | f_c*: MapBodyCollideGroup 119 | # Fixed rotation, fric players, anti-tunnel 120 | fr*, fricp*, bu*: bool 121 | # Force zone props 122 | MapBodyFz* = ref object 123 | # x, y 124 | x*, y*: float 125 | # enabled 126 | on*: bool 127 | # affect discs, platforms, arrows 128 | d*, p*, a*: bool 129 | # Constant force 130 | MapBodyCf* = ref object 131 | # x, y, torque 132 | x*, y*, ct*: float 133 | # Force direction - true: absolute, false: relative 134 | w*: bool 135 | MapBodyCollideGroup* {.pure.} = enum 136 | A = 1, B, C, D 137 | MapBodyType* = enum 138 | btStationary = "s", btDynamic = "d", btKinematic = "k" 139 | 140 | MapFixture* = ref object 141 | # Name 142 | n*: cstring 143 | # Death, no grapple, no physics, fric players, inner grapple 144 | d*, ng*, np*, fp*, ig*: bool 145 | # Density, bounciness, friction 146 | # Set to null for no value 147 | de*, re*, fr*: float 148 | # Colour ("fill") 149 | f*: int 150 | # Shape ID 151 | sh*: int 152 | 153 | MapShapeType* = enum 154 | stypeBx = "bx", stypeCi = "ci", stypePo = "po" 155 | MapShape* = ref object 156 | # Shape type 157 | stype* {.exportc: "type".}: cstring 158 | # Position 159 | c*: MapPosition 160 | # Angle 161 | a* {.exportc: "a".}: float 162 | # Shrink, not available for polygons 163 | sk* {.exportc: "sk".}: bool 164 | # Rectangle width and height 165 | bxW* {.exportc: "w".}: float 166 | bxH* {.exportc: "h".}: float 167 | # Circle radius 168 | ciR* {.exportc: "r".}: float 169 | # Polygon scale and vertices 170 | poS* {.exportc: "s".}: float 171 | poV* {.exportc: "v".}: seq[MapPosition] 172 | 173 | MapJoint* = ref object 174 | # ba: Joint body 175 | # bb: attached body, -1 if none 176 | ba*, bb*: int 177 | 178 | func shapeType*(s: MapShape): MapShapeType = parseEnum[MapShapeType]($s.stype) 179 | 180 | var mapObject* {.importc: "window.kklee.mapObject".}: MapData 181 | 182 | proc fxShape*(fxo: MapFixture): MapShape = mapObject.physics.shapes[fxo.sh] 183 | proc getFx*(fxId: int): MapFixture = mapObject.physics.fixtures[fxId] 184 | proc getBody*(bi: int): MapBody = mapObject.physics.bodies[bi] 185 | 186 | template x*(arr: MapPosition): untyped = arr[0] 187 | template `x=`*(arr: MapPosition; v): untyped = arr[0] = v 188 | template y*(arr: MapPosition): untyped = arr[1] 189 | template `y=`*(arr: MapPosition; v): untyped = arr[1] = v 190 | 191 | func rotatePoint*(p: MapPosition; a: float): MapPosition = 192 | [ 193 | p.x * cos(a) - p.y * sin(a), 194 | p.x * sin(a) + p.y * cos(a) 195 | ] 196 | 197 | template moph*: untyped = mapObject.physics 198 | 199 | # Delete a fixture from the map 200 | proc deleteFx*(fxId: int) = 201 | let shId = fxId.getFx.sh 202 | moph.fixtures.delete fxId 203 | moph.shapes.delete shId 204 | for b in moph.bodies: 205 | b.fx.keepItIf it != fxId 206 | for f in b.fx.mitems: 207 | if f > fxId: dec f 208 | for c in mapObject.capZones.mitems: 209 | if c.i == fxId: c.i = -1 210 | if c.i > fxId: dec c.i 211 | for f in moph.fixtures.mitems: 212 | if f.sh > shId: dec f.sh 213 | 214 | proc deleteBody*(bId: int) = 215 | while bId.getBody.fx.len > 0: 216 | deleteFx bId.getBody.fx[0] 217 | moph.bodies.delete bId 218 | moph.bro.keepItIf it != bId 219 | for otherBId in moph.bro.mitems: 220 | if otherBId > bId: dec otherBId 221 | block: 222 | var jId = 0 223 | while jId < moph.joints.len: 224 | let j = moph.joints[jId] 225 | if j.ba == bId: 226 | moph.joints.delete jId 227 | continue 228 | if j.bb == bId: 229 | j.bb = -1 230 | if j.ba > bId: 231 | dec j.ba 232 | if j.bb > bId: 233 | dec j.bb 234 | inc jId 235 | 236 | var editorPreviewTimeMs* {.importc: "window.kklee.$1".}: float 237 | 238 | func copyObject*[T: ref](x: T): T = 239 | proc structuredClone(_: T): T {.importc: "window.structuredClone".} 240 | x.structuredClone 241 | 242 | let jsNull* {.importc: "null".}: float 243 | 244 | proc docElemById*(s: cstring): Element = 245 | document.getElementById(s) 246 | 247 | # Set explanation text at bottom middle of editor 248 | proc setEditorExplanation*(text: string) = 249 | docElemById("mapeditor_midbox_explain").innerText = text 250 | 251 | proc mathExprJsRandom*(_: seq[float]): float {.importc: "window.Math.random".} 252 | 253 | type 254 | MapBackupObject* = ref object 255 | 256 | let mapBackups* {.importc: "window.kklee.backups".}: seq[MapBackupObject] 257 | func getBackupLabel*(b: MapBackupObject): cstring 258 | {.importc: "window.kklee.getBackupLabel".} 259 | proc loadBackup*(b: MapBackupObject) {.importc: "window.kklee.loadBackup".} 260 | 261 | proc dispatchInputEvent*(n: Node) {.importc: "window.kklee.dispatchInputEvent".} 262 | 263 | proc bonkShowColorPicker*(firstColour: int; fixtureSeq: seq[MapFixture]; 264 | onInput: int -> void; onSave: int -> void 265 | ) {.importc: "window.kklee.bonkShowColorPicker".} # onSave can be nil 266 | 267 | proc splitConcaveIntoConvex*(v: seq[MapPosition]): seq[seq[MapPosition]] 268 | {.importc: "window.kklee.splitConcaveIntoConvex".} 269 | 270 | proc multiSelectNameChanger*(input: string; thingIndex: int): string = 271 | let evtor = newEvaluator() 272 | evtor.addVar("i", thingIndex.float) 273 | evtor.addFunc("rand", mathExprJsRandom, 0) 274 | var nameParts = input.split("||") 275 | if nameParts.len mod 2 == 0: 276 | raise CatchableError.newException( 277 | "multiSelectNameChanger nameParts.len is even") 278 | for i in countup(1, nameParts.high, 2): 279 | nameParts[i] = evtor.eval(nameParts[i]).formatFloat(precision = -1) 280 | return nameParts.join("") 281 | 282 | proc multiSelectNameChangerCheck*(input: string): string = 283 | discard multiSelectNameChanger(input, 0) 284 | input 285 | 286 | proc canTransferOwnership*: bool 287 | {.importc: "window.kklee.canTransferOwnership".} 288 | 289 | proc playBonkButtonClickSound* {.importc: "window.kklee.scopedData.bcs".} 290 | proc playBonkButtonHoverSound* {.importc: "window.kklee.scopedData.bhs".} 291 | 292 | proc setEnableUpdateChecks*(enable: bool) 293 | {.importc: "window.kklee.setEnableUpdateChecks".} 294 | func areUpdateChecksEnabled*: bool 295 | {.importc: "window.kklee.areUpdateChecksEnabled".} 296 | -------------------------------------------------------------------------------- /src/vertexEditor.nim: -------------------------------------------------------------------------------- 1 | import 2 | std/[dom, sugar, strutils, options, math, algorithm, sequtils, strformat], 3 | pkg/karax/[karax, karaxdsl, vdom, vstyles], 4 | kkleeApi, bonkElements 5 | 6 | proc splitIntoConvex(b: MapBody; veFx: MapFixture; veSh: MapShape) = 7 | let newPolygons = splitConcaveIntoConvex(veSh.poV) 8 | veFx.np = false 9 | for i, v in newPolygons: 10 | if i == 0: 11 | veSh.poV = v 12 | continue 13 | let 14 | newSh = copyObject(veSh) 15 | newFx = copyObject(veFx) 16 | insertIndex = b.fx.find(moph.fixtures.find(veFx)) 17 | newSh.poV = v 18 | moph.shapes.add newSh 19 | newFx.sh = moph.shapes.high 20 | moph.fixtures.add newFx 21 | b.fx.insert moph.fixtures.high, insertIndex 22 | 23 | saveToUndoHistory() 24 | 25 | proc mergeShapes(b: MapBody; veFx: MapFixture; veSh: MapShape) = 26 | # This is buggy because the output vertices might be ordered in a way 27 | # that causes it to be not rendered correctly... 28 | veSh.poV.applyIt [it.x * veSh.poS, it.y * veSh.poS].MapPosition 29 | veSh.poS = 1.0 30 | 31 | var i = 0 32 | while i < b.fx.len: 33 | let 34 | fxId = b.fx[i] 35 | cfx = fxId.getFx 36 | csh = cfx.fxShape 37 | if cfx.f != veFx.f or cfx == veFx or 38 | not cfx.np: 39 | inc i 40 | continue 41 | 42 | var npoV: seq[MapPosition] 43 | case csh.shapeType 44 | of stypePo: 45 | npoV = csh.poV.mapIt [it.x * csh.poS, it.y * csh.poS].MapPosition 46 | of stypeBx: 47 | npoV = @[ 48 | [csh.bxW / -2, csh.bxH / -2], [csh.bxW / 2, csh.bxH / -2], 49 | [csh.bxW / 2, csh.bxH / 2], [csh.bxW / -2, csh.bxH / 2] 50 | ] 51 | else: 52 | inc i 53 | continue 54 | 55 | for c in npoV.mitems: 56 | c = c.rotatePoint(csh.a) 57 | c = [c.x + csh.c.x - veSh.c.x, c.y + csh.c.y - veSh.c.y] 58 | c = c.rotatePoint(-veSh.a) 59 | npoV &= [npoV[0], veSh.poV[^1]] 60 | veSh.poV.add(npoV) 61 | deleteFx fxId 62 | 63 | saveToUndoHistory() 64 | 65 | proc roundCorners(poV: seq[MapPosition]; r: float; prec: float): 66 | seq[MapPosition] = 67 | for i, p in poV: 68 | let 69 | p1 = poV[if i == 0: poV.high else: i - 1] 70 | p2 = poV[if i == poV.high: 0 else: i + 1] 71 | dp1 = [p.x - p1.x, p.y - p1.y].MapPosition 72 | dp2 = [p.x - p2.x, p.y - p2.y].MapPosition 73 | pp1 = hypot(dp1.x, dp1.y) 74 | pp2 = hypot(dp2.x, dp2.y) 75 | angle = arctan2(dp1.y, dp1.x) - 76 | arctan2(dp2.y, dp2.x) 77 | var 78 | radius = r 79 | segment = radius / abs(tan(angle / 2)) 80 | segmentMax = min(pp1, pp2) / 2 81 | if segment > segmentMax: 82 | segment = segmentMax 83 | radius = segment * abs(tan(angle / 2)) 84 | let 85 | po = hypot(radius, segment) 86 | c1 = [p.x - dp1.x * segment / pp1, 87 | p.y - dp1.y * segment / pp1].MapPosition 88 | c2 = [p.x - dp2.x * segment / pp2, 89 | p.y - dp2.y * segment / pp2].MapPosition 90 | d = [p.x * 2 - c1.x - c2.x, 91 | p.y * 2 - c1.y - c2.y].MapPosition 92 | pc = hypot(d.x, d.y) 93 | o = [p.x - d.x * po / pc, 94 | p.y - d.y * po / pc].MapPosition 95 | var 96 | startAngle = arctan2(c1.y - o.y, c1.x - o.x) 97 | endAngle = arctan2(c2.y - o.y, c2.x - o.x) 98 | sweepAngle = endAngle - startAngle 99 | if sweepAngle > PI: 100 | sweepAngle -= 2 * PI 101 | elif sweepAngle < -PI: 102 | sweepAngle += 2 * PI 103 | 104 | result.add c1 105 | let pointsCount = abs(sweepAngle * prec.float / (2 * PI)).ceil.int 106 | for pointI in 1..pointsCount: 107 | let 108 | t = pointI / (pointsCount + 1) 109 | a = startAngle + sweepAngle * t 110 | result.add [o.x + radius * cos(a), 111 | o.y + radius * sin(a)].MapPosition 112 | result.add c2 113 | var i = 0 114 | while i < result.len: 115 | let ni = if i == result.high: 0 116 | else: i + 1 117 | if result[i] == result[ni]: 118 | result.delete i 119 | else: 120 | inc i 121 | 122 | proc isNotAnticlockwise(p1, p2, p3: MapPosition): bool = 123 | (p2.x - p1.x) * (p3.y - p1.y) - (p2.y - p1.y) * (p3.x - p1.x) >= 0 124 | 125 | proc vertexEditor*(veB: MapBody; veFx: MapFixture): VNode = 126 | var markerFx {.global.}: Option[MapFixture] 127 | 128 | proc removeVertexMarker = 129 | if markerFx.isNone: return 130 | let fxId = moph.fixtures.find markerFx.get 131 | if fxId == -1: return 132 | deleteFx fxId 133 | markerFx = none MapFixture 134 | updateRenderer(true) 135 | 136 | if veFx notin moph.fixtures: 137 | removeVertexMarker() 138 | return buildHtml(tdiv): discard 139 | let veSh = veFx.fxShape 140 | 141 | proc setVertexMarker(vId: int) = 142 | removeVertexMarker() 143 | if vId notin 0..veSh.poV.high: 144 | return 145 | let 146 | v = veSh.poV[vId] 147 | # Only scaled marker positions 148 | smp: MapPosition = [ 149 | v.x * veSh.poS, 150 | v.y * veSh.poS 151 | ] 152 | var markerPos: MapPosition = smp.rotatePoint(veSh.a) 153 | markerPos.x += veSh.c.x 154 | markerPos.y += veSh.c.y 155 | moph.shapes.add MapShape( 156 | stype: "ci", ciR: 3.0, sk: false, c: markerPos 157 | ) 158 | moph.fixtures.add MapFixture( 159 | n: "temp marker", np: true, f: 0xff0000, 160 | sh: moph.shapes.high 161 | ) 162 | let fxId = moph.fixtures.high 163 | veB.fx.add fxId 164 | markerFx = some moph.fixtures[fxId] 165 | updateRenderer(true) 166 | 167 | proc vertex(i: int; v: var MapPosition; poV: var seq[MapPosition]): VNode = 168 | let border = if isNotAntiClockwise( 169 | poV[if i == 0: poV.high else: i - 1], 170 | v, 171 | poV[if i == poV.high: 0 else: i + 1] 172 | ): "none" else: "1px red solid" 173 | buildHtml tdiv( 174 | style = toCss &"display: flex; flex-flow: row wrap; border: {border}"): 175 | span(style = "width: 27px; font-size: 12;".toCss): 176 | text $i 177 | template cbi(va): untyped = bonkInput(va, prsFLimited, proc = 178 | if markerFx.isSome: 179 | removeVertexMarker() 180 | saveToUndoHistory() 181 | setVertexMarker(i) 182 | else: 183 | saveToUndoHistory() 184 | , niceFormatFloat) 185 | text "x:" 186 | cbi v.x 187 | text "y:" 188 | cbi v.y 189 | 190 | bonkButton("X", proc = 191 | removeVertexMarker() 192 | poV.delete(i); 193 | saveToUndoHistory() 194 | ) 195 | 196 | proc onMouseEnter = setVertexMarker(i) 197 | proc onMouseLeave = 198 | removeVertexMarker() 199 | 200 | updateRenderer(true) 201 | updateRightBoxBody(moph.fixtures.find(veFx)) 202 | 203 | template poV: untyped = veSh.poV 204 | if poV.len == 0: 205 | poV.add [0.0, 0.0].MapPosition 206 | 207 | return buildHtml tdiv(style = ("flex: auto; overflow-y: auto; " & 208 | "display: flex; flex-flow: column; row-gap: 2px").toCss 209 | ): 210 | ul(style = "font-size:11px; padding-left: 10px; margin: 3px".toCss): 211 | li text "Note: the list of vertices must be in a clockwise direction" 212 | for i, v in poV.mpairs: 213 | vertex(i, v, poV) 214 | tdiv(style = "margin: 3px".toCss): 215 | bonkButton("Add vertex", proc = 216 | poV.add([0.0, 0.0]) 217 | removeVertexMarker() 218 | saveToUndoHistory() 219 | ) 220 | 221 | tdiv(style = "display: flex; flex-flow: row wrap".toCss): 222 | tdiv(style = "width: 100%".toCss): text "Scale vertices:" 223 | var scale {.global.}: MapPosition = [1.0, 1.0] 224 | text "x:" 225 | bonkInput scale.x, prsFLimited, nil, niceFormatFloat 226 | text "y:" 227 | bonkInput scale.y, prsFLimited, nil, niceFormatFloat 228 | 229 | bonkButton "Apply", proc(): void = 230 | for v in poV.mitems: 231 | v.x = (v.x * scale.x).clamp(-1e6, 1e6) 232 | v.y = (v.y * scale.y).clamp(-1e6, 1e6) 233 | removeVertexMarker() 234 | saveToUndoHistory() 235 | 236 | tdiv(style = 237 | "display: flex; flex-flow: row wrap; justify-content: space-between" 238 | .toCss): 239 | tdiv(style = "width: 100%".toCss): text "Move vertex:" 240 | var vId {.global.}: int 241 | if vId notin 0..poV.high: 242 | vId = 0 243 | bonkInput(vId, proc(s: string): int = 244 | result = s.parseInt 245 | if result notin 0..poV.high: 246 | raise newException(ValueError, "Invalid vertex ID") 247 | , nil, v => $v) 248 | 249 | let stuh = proc = 250 | removeVertexMarker() 251 | saveToUndoHistory() 252 | setVertexMarker vId 253 | 254 | bonkButton("Down", proc(): void = 255 | swap poV[vId], poV[vId + 1] 256 | inc vId 257 | stuh() 258 | , vId == poV.high) 259 | bonkButton("Up", proc(): void = 260 | swap poV[vId], poV[vId - 1] 261 | dec vId 262 | stuh() 263 | , vId == poV.low) 264 | bonkButton("Bottom", proc(): void = 265 | let v = poV[vId] 266 | poV.delete vId 267 | poV.insert v, poV.high + 1 268 | vId = poV.high 269 | stuh() 270 | , vId == poV.high) 271 | bonkButton("Top", proc(): void = 272 | let v = poV[vId] 273 | poV.delete vId 274 | poV.insert v, poV.low 275 | vId = poV.low 276 | stuh() 277 | , vId == poV.low) 278 | 279 | proc onMouseEnter = setVertexMarker vId 280 | proc onMouseLeave = removeVertexMarker() 281 | 282 | bonkButton("Reverse order", proc = 283 | poV.reverse() 284 | saveToUndoHistory() 285 | ) 286 | bonkButton("Set no physics", proc = 287 | veFx.np = true 288 | saveToUndoHistory() 289 | ) 290 | bonkButton("(BUGGY!) Merge with no-physics shapes of same colour", () => 291 | mergeShapes(veB, veFx, veSh)) 292 | 293 | bonkButton("Split into convex polygons", () => 294 | splitIntoConvex(veB, veFx, veSh)) 295 | 296 | tdiv(style = "padding: 5px 0px".toCss): 297 | var 298 | prec {.global.}: float = 20.0 299 | radius {.global.}: float = 20.0 300 | text "Radius" 301 | bonkInput(radius, prsFLimitedPositive, nil, niceFormatFloat) 302 | br() 303 | text "Precision" 304 | bonkInput(prec, prsFLimitedPositive, nil, niceFormatFloat) 305 | bonkButton("Round corners", proc = 306 | poV = roundCorners(poV, radius, prec) 307 | saveToUndoHistory() 308 | updateRenderer(true) 309 | ) 310 | -------------------------------------------------------------------------------- /src/platformMultiSelect.nim: -------------------------------------------------------------------------------- 1 | import 2 | std/[sugar, strutils, algorithm, sequtils, dom, math, options], 3 | pkg/karax/[karax, karaxdsl, vdom, vstyles], 4 | kkleeApi, bonkElements 5 | 6 | var selectedBodies*: seq[MapBody] 7 | 8 | proc removeDeletedBodies = 9 | var i = 0 10 | while i < selectedBodies.len: 11 | let b = selectedBodies[i] 12 | if b notin moph.bodies: 13 | selectedBodies.delete i 14 | else: 15 | inc i 16 | 17 | proc platformMultiSelectElementBorders * = 18 | removeDeletedBodies() 19 | let platformsContainer = docElemById("mapeditor_leftbox_platformtable") 20 | if platformsContainer.isNil: return 21 | let platformElements = platformsContainer.children[0].children 22 | 23 | for i, bodyElementParent in platformElements: 24 | let be = bodyElementParent.children[0].Element 25 | 26 | if be.children.len >= 2 and 27 | be.children[1].class == "kkleeMultiSelectPlatformIndexLabel": 28 | be.children[1].remove() 29 | 30 | let selectedId = selectedBodies.find(moph.bro[i].getBody) 31 | if selectedId == -1: 32 | be.classList.remove("kkleeMultiSelectPlatform") 33 | else: 34 | be.classList.add("kkleeMultiSelectPlatform") 35 | 36 | let indexLabel = document.createElement("span") 37 | indexLabel.innerText = cstring $selectedId 38 | indexLabel.class = "kkleeMultiSelectPlatformIndexLabel" 39 | be.appendChild(indexLabel) 40 | 41 | proc platformMultiSelectDelete: VNode = 42 | buildHtml bonkButton "Delete platforms", proc = 43 | for b in selectedBodies: 44 | let bId = moph.bodies.find b 45 | if bId == -1: continue 46 | deleteBody bId 47 | saveToUndoHistory() 48 | selectedBodies = @[] 49 | updateRenderer(true) 50 | updateLeftBox() 51 | setCurrentBody(-1) 52 | updateRightBoxBody(-1) 53 | 54 | proc platformMultiSelectSelectAll: VNode = buildHtml tdiv: 55 | bonkButton "Select all", proc = 56 | selectedBodies = collect(newSeq): 57 | for bId in moph.bro: bId.getBody 58 | platformMultiSelectElementBorders() 59 | bonkButton "Deselect all", proc = 60 | selectedBodies = @[] 61 | platformMultiSelectElementBorders() 62 | bonkButton "Invert selection", proc = 63 | selectedBodies = collect(newSeq): 64 | for bId in moph.bro: 65 | let b = bId.getBody 66 | if b notin selectedBodies: 67 | b 68 | platformMultiSelectElementBorders() 69 | bonkButton "Reverse selection order", proc = 70 | selectedBodies.reverse() 71 | platformMultiSelectElementBorders() 72 | 73 | tdiv(style = "margin: 5px 0px".toCss): 74 | var searchString {.global.} = "" 75 | prop "Start of name", bonkInput(searchString, s => s, nil, s => s) 76 | bonkButton "Select by name", proc = 77 | selectedBodies = collect(newSeq): 78 | for bId in moph.bro: 79 | let b = bId.getBody 80 | if b.s.n.`$`.startsWith(searchString): 81 | b 82 | platformMultiSelectElementBorders() 83 | 84 | proc platformMultiSelectEdit: VNode = buildHtml tdiv( 85 | style = "display: flex; flex-flow: column".toCss): 86 | 87 | proc onMouseEnter = 88 | setEditorExplanation(""" 89 | [kklee] 90 | Shift+click platform elements to select platforms 91 | Variables: 92 | - x is the current value 93 | - i is the index in list of selected platforms (the first platform you selected will have i=0, the next one i=1, i=2, etc) 94 | Mathematical expressions, such as x*2+50, will be evaluated 95 | - n is number of platforms selected 96 | List of supported functions: 97 | https://github.com/BonkModdingCommunity/kklee/blob/master/guide.md#mathematical-expression-evaluator 98 | """) 99 | 100 | var appliers {.global.}: seq[(int, MapBody) -> void] 101 | var resetters {.global.}: seq[() -> void] 102 | 103 | bonkButton "Reset", proc = 104 | for fn in resetters: 105 | fn() 106 | 107 | template floatProp( 108 | name: string; mapBProp: untyped; 109 | inpToProp = floatNop; 110 | propToInp = floatNop; 111 | ): untyped = 112 | let 113 | inpToPropF = inpToProp 114 | propToInpF = propToInp 115 | var inp {.global.}: string = "x" 116 | 117 | once: 118 | appliers.add proc (i: int; b {.inject.}: MapBody) = 119 | mapBProp = inpToPropF floatPropApplier( 120 | inp, i, selectedBodies.len, propToInpF mapBProp 121 | ) 122 | resetters.add proc = 123 | inp = "x" 124 | 125 | buildHtml: 126 | prop name, floatPropInput(inp), inp != "x" 127 | 128 | template boolProp(name: string; mapBProp: untyped): untyped = 129 | var inp {.global.}: boolPropValue 130 | once: 131 | appliers.add proc(i: int; b {.inject.}: MapBody) = 132 | case inp 133 | of tfsFalse: mapBProp = false 134 | of tfsTrue: mapBProp = true 135 | of tfsSame: discard 136 | resetters.add proc = 137 | inp = tfsSame 138 | buildHtml: 139 | prop name, tfsCheckbox(inp), inp != tfsSame 140 | 141 | template dropDownProp[T]( 142 | name: string; 143 | mapBProp: untyped; 144 | options: openArray[tuple[label: string; value: T]] 145 | ): untyped = 146 | var 147 | inp {.global.}: Option[T] 148 | once: 149 | appliers.add proc(i: int; b {.inject.}: MapBody) = 150 | if inp.isSome: 151 | mapBProp = inp.get 152 | resetters.add proc = 153 | inp = default Option[T] 154 | buildHtml: 155 | prop name, dropDownPropSelect(inp, @options), inp.isSome 156 | 157 | 158 | template nameChanger: untyped = 159 | var 160 | canChange {.global.} = false 161 | inp {.global.}: string = "Platform ||i||" 162 | once: 163 | appliers.add proc(i: int; b: MapBody) = 164 | if canChange: 165 | b.s.n = cstring multiSelectNameChanger(inp, i) 166 | resetters.add proc = 167 | canChange = false 168 | inp = "Platform ||i||" 169 | buildHtml: 170 | let field = buildHtml tdiv(style = "display: flex".toCss): 171 | checkbox(canChange) 172 | bonkInput(inp, multiSelectNameChangerCheck, nil, s => s) 173 | prop "Name", field, canChange 174 | 175 | dropDownProp("Type", b.s.btype, [ 176 | ("Stationary", cstring $btStationary), ("Free moving", cstring $btDynamic), 177 | ("Kinematic", cstring $btKinematic) 178 | ]) 179 | nameChanger() 180 | floatProp("x", b.p.x) 181 | floatProp("y", b.p.y) 182 | block: 183 | let 184 | d2r = proc(f: float): float = degToRad(f) 185 | r2d = proc(f: float): float = radToDeg(f) 186 | floatProp("Angle", b.a, d2r, r2d) 187 | floatProp("Bounciness", b.s.re) 188 | floatProp("Density", b.s.de) 189 | floatProp("Friction", b.s.fric) 190 | boolProp("Fric players", b.s.fricp) 191 | boolProp("Anti-tunnel", b.s.bu) 192 | type cg = MapBodyCollideGroup 193 | dropDownProp("Col. group", b.s.f_c, [ 194 | ("A", cg.A), ("B", cg.B), ("C", cg.C), ("D", cg.D) 195 | ]) 196 | boolProp("Col. players", b.s.f_p) 197 | boolProp("Col. A", b.s.f_1) 198 | boolProp("Col. B", b.s.f_2) 199 | boolProp("Col. C", b.s.f_3) 200 | boolProp("Col. D", b.s.f_4) 201 | floatProp("Start speed x", b.lv.x) 202 | floatProp("Start speed y", b.lv.y) 203 | floatProp("Start spin", b.av) 204 | floatProp("Linear drag", b.s.ld) 205 | floatProp("Spin drag", b.s.ad) 206 | boolProp("Fixed rotation", b.s.fr) 207 | floatProp("Apply x force", b.cf.x) 208 | floatProp("Apply y force", b.cf.y) 209 | dropDownProp("Force dir.", b.cf.w, [ 210 | ("Absolute", true), ("Relative", false) 211 | ]) 212 | floatProp("Apply torque", b.cf.ct) 213 | boolProp("Force zone", b.fz.on) 214 | floatProp("Force zone x", b.fz.x) 215 | floatProp("Force zone y", b.fz.y) 216 | boolProp("Push players", b.fz.d) 217 | boolProp("Push platforms", b.fz.p) 218 | boolProp("Push arrows", b.fz.a) 219 | 220 | bonkButton "Apply", proc = 221 | removeDeletedBodies() 222 | for i, b in selectedBodies: 223 | for a in appliers: a(i, b) 224 | saveToUndoHistory() 225 | updateRenderer(true) 226 | updateLeftBox() 227 | updateRightBoxBody(-1) 228 | 229 | proc platformMultiSelectMove: VNode = buildHtml tdiv(style = 230 | "display: flex; flex-flow: row wrap; justify-content: space-between;".toCss 231 | ): 232 | text "Move" 233 | template bro: untyped = moph.bro 234 | proc update = 235 | updateLeftBox() 236 | updateRenderer(true) 237 | saveToUndoHistory() 238 | proc getSelectedBIds: seq[int] = 239 | selectedBodies.mapIt moph.bodies.find(it) 240 | bonkButton "Down", proc = 241 | let selectedBIds = getSelectedBIds() 242 | for i in countdown(bro.high - 1, 0): 243 | if bro[i] in selectedBIds and 244 | bro[i + 1] notin selectedBIds: 245 | swap(bro[i], bro[i + 1]) 246 | update() 247 | bonkButton "Up", proc = 248 | let selectedBIds = getSelectedBIds() 249 | for i in countup(1, bro.high): 250 | if bro[i] in selectedBIds and 251 | bro[i - 1] notin selectedBIds: 252 | swap(bro[i], bro[i - 1]) 253 | update() 254 | bonkButton "Bottom", proc = 255 | let selectedBIds = getSelectedBIds() 256 | var moveIndex = bro.high 257 | for i in countdown(bro.high, 0): 258 | if bro[i] notin selectedBIds: continue 259 | dec moveIndex 260 | for j in countup(i, moveIndex): 261 | swap bro[j], bro[j + 1] 262 | update() 263 | bonkButton "Top", proc = 264 | let selectedBIds = getSelectedBIds() 265 | var moveIndex = 0 266 | for i in countup(0, bro.high): 267 | if bro[i] notin selectedBIds: continue 268 | inc moveIndex 269 | for j in countdown(i, moveIndex): 270 | swap bro[j], bro[j - 1] 271 | update() 272 | bonkButton "Reverse", proc = 273 | var selectedBIds = getSelectedBIds() 274 | let bIdPositions = collect(newSeq): 275 | for i, bId in bro: 276 | let si = selectedBIds.find bId 277 | if si == -1: continue 278 | selectedBIds.del si 279 | i 280 | for i in 0..bIdPositions.len div 2 - 1: 281 | swap bro[bIdPositions[i]], 282 | bro[bIdPositions[bIdPositions.high - i]] 283 | update() 284 | 285 | proc platformMultiSelectCopy: VNode = buildHtml tdiv( 286 | style = "display: flex; flex-flow: column".toCss): 287 | var 288 | copyPlats {.global.}: seq[tuple[ 289 | b: MapBody; 290 | shapes: seq[tuple[fx: MapFixture; sh: MapShape; 291 | cz: Option[MapCapZone]]]; 292 | joints: seq[tuple[j: MapJoint; b2Id: Option[int]]]; 293 | ]] 294 | pasteAmount {.global.} = 1 295 | prop "Paste amount", bonkInput(pasteAmount, parseInt, nil, i => $i) 296 | bonkButton ("Instant copy&paste (joints will be attached to original " & 297 | "platforms)"), proc = 298 | removeDeletedBodies() 299 | 300 | let sblen = selectedBodies.len 301 | for _ in 1..pasteAmount: 302 | var sbi = sbLen 303 | while sbi > 0: 304 | dec sbi 305 | let b = selectedBodies[sbi] 306 | let bId = moph.bodies.find(b) 307 | let newB = b.copyObject() 308 | newB.fx = @[] 309 | moph.bodies.add newB 310 | moph.bro.insert(moph.bodies.high, 0) 311 | selectedBodies.add newB 312 | 313 | for fxId in b.fx: 314 | let 315 | newFx = fxId.getFx.copyObject() 316 | newSh = fxId.getFx.fxShape.copyObject() 317 | moph.shapes.add newSh 318 | newFx.sh = moph.shapes.high 319 | moph.fixtures.add newFx 320 | newB.fx.add moph.fixtures.high 321 | 322 | var czi = 0 323 | let czlen = mapObject.capZones.len 324 | while czi < czlen: 325 | let cz = mapObject.capZones[czi] 326 | if cz.i != fxId: 327 | inc czi 328 | continue 329 | let newCz = cz.copyObject() 330 | newCz.i = moph.fixtures.high 331 | mapObject.capZones.add newCz 332 | break 333 | 334 | var ji = 0 335 | var jlen = moph.joints.len 336 | while ji < jlen: 337 | let j = moph.joints[ji] 338 | if j.ba != bId: 339 | inc ji 340 | continue 341 | let newJ = j.copyObject 342 | newJ.ba = moph.bodies.high 343 | moph.joints.add newJ 344 | inc ji 345 | 346 | saveToUndoHistory() 347 | updateRenderer(true) 348 | updateLeftBox() 349 | bonkButton "Copy (joints will be attached to new pasted platforms)", proc = 350 | removeDeletedBodies() 351 | copyPlats = @[] 352 | for b in selectedBodies: 353 | let 354 | bId = moph.bodies.find(b) 355 | copyB = b.copyObject() 356 | copyShapes = b.fx.mapIt ( 357 | fx: it.getFx.copyObject(), 358 | sh: it.getFx.fxShape.copyObject(), 359 | cz: block: 360 | var r = none MapCapZone 361 | for cz in mapObject.capZones: 362 | if cz.i == it: 363 | r = some cz.copyObject() 364 | break 365 | r 366 | ) 367 | copyJoints = collect(newSeq): 368 | for j in moph.joints: 369 | if j.ba != bId: 370 | continue 371 | var b2Id = none int 372 | if j.bb != -1: 373 | let t = selectedBodies.find j.bb.getBody 374 | if t != -1: 375 | b2Id = some t 376 | (j: j.copyObject(), b2Id: b2Id) 377 | copyPlats.add (b: copyB, shapes: copyShapes, joints: copyJoints) 378 | 379 | bonkButton "Paste platforms", proc = 380 | let ogBodiesLen = moph.bodies.len 381 | var i = 0 382 | for _ in 1..pasteAmount: 383 | for cp in copyPlats: 384 | let newB = cp.b.copyObject() 385 | newB.fx = @[] 386 | moph.bodies.add newB 387 | moph.bro.insert(moph.bodies.high, i) 388 | selectedBodies.add newB 389 | for cs in cp.shapes: 390 | let 391 | newFx = cs.fx.copyObject() 392 | newSh = cs.sh.copyObject() 393 | moph.shapes.add newSh 394 | newFx.sh = moph.shapes.high 395 | moph.fixtures.add newFx 396 | newB.fx.add moph.fixtures.high 397 | if cs.cz.isSome: 398 | let newCz = cs.cz.get.copyObject() 399 | newCz.i = moph.fixtures.high 400 | mapObject.capZones.add newCz 401 | for cj in cp.joints: 402 | let newJ = cj.j.copyObject() 403 | newJ.ba = moph.bodies.high 404 | newJ.bb = if cj.b2Id.isNone: -1 405 | else: cj.b2Id.get + ogBodiesLen 406 | moph.joints.add newJ 407 | inc i 408 | 409 | saveToUndoHistory() 410 | updateRenderer(true) 411 | updateLeftBox() 412 | 413 | proc platformMultiSelect*: VNode = buildHtml(tdiv( 414 | style = "display: flex; flex-flow: column; row-gap: 10px".toCss)): 415 | platformMultiSelectSelectAll() 416 | platformMultiSelectEdit() 417 | platformMultiSelectMove() 418 | platformMultiSelectDelete() 419 | platformMultiSelectCopy() 420 | -------------------------------------------------------------------------------- /src/shapeGenerator.nim: -------------------------------------------------------------------------------- 1 | import 2 | std/[dom, strutils, math, sugar, strformat], 3 | pkg/karax/[karax, karaxdsl, vdom, vstyles], 4 | pkg/mathexpr, 5 | kkleeApi, bonkElements, shapeMultiSelect, colours 6 | 7 | type 8 | ShapeGeneratorType = enum 9 | sgsEllipse = "Polygon/Ellipse/Spiral", sgsSine = "Sine wave", 10 | sgsLinearGradient = "Linear gradient", 11 | sgsRadialGradient = "Radial gradient", sgsEquation = "Parametric equation" 12 | GeneratedShapes = seq[tuple[shape: MapShape, fixture: MapFixture]] 13 | 14 | func dtr(f: float): float = f.degToRad 15 | 16 | func safeFloat(n: float): float = 17 | if n.isNaN: 18 | 0.0 19 | else: 20 | n.clamp(-1e6, 1e6) 21 | func safePos(p: MapPosition): MapPosition = 22 | [p.x.safeFloat, p.y.safeFloat].MapPosition 23 | 24 | type LinesShapeSettings = ref object 25 | colour: int 26 | precision: int 27 | rectHeight: float 28 | 29 | proc genLinesShape( 30 | settings: LinesShapeSettings, getPos: float -> MapPosition 31 | ): GeneratedShapes = 32 | for n in 0..settings.precision-1: 33 | let 34 | p1 = getPos(n / settings.precision).safePos 35 | p2 = getPos((n + 1) / settings.precision).safePos 36 | 37 | let shape = MapShape( 38 | stype: "bx", 39 | c: [(p1.x + p2.x) / 2, (p1.y + p2.y) / 2].MapPosition, 40 | bxH: settings.rectHeight, 41 | bxW: sqrt((p1.x - p2.x) ^ 2 + (p1.y - p2.y) ^ 2), 42 | a: arctan((p1.y - p2.y) / (p1.x - p2.x)).safeFloat 43 | ) 44 | 45 | let fixture = MapFixture( 46 | n: cstring &"rect{n}", de: jsNull, re: jsNull, 47 | fr: jsNull, f: settings.colour 48 | ) 49 | result.add((shape, fixture)) 50 | 51 | type EllipseSettings = ref object 52 | linesShape: LinesShapeSettings 53 | widthRadius, heightRadius, angleStart, angleEnd, spiralStart: float 54 | hollow: bool 55 | 56 | proc generateEllipse(settings: EllipseSettings): GeneratedShapes = 57 | if settings.hollow: 58 | proc getPos(x: float): MapPosition = 59 | let 60 | a = settings.angleEnd.dtr - 61 | (settings.angleEnd.dtr - settings.angleStart.dtr) * x 62 | s = settings.spiralStart + (1 - settings.spiralStart) * x 63 | return [ 64 | settings.widthRadius * sin(a) * s, 65 | settings.heightRadius * cos(a) * s 66 | ] 67 | 68 | return genLinesShape(settings.linesShape, getPos) 69 | else: 70 | let shape = MapShape( 71 | stype: "po", poS: 1.0, a: 0, 72 | c: [0.0, 0.0].MapPosition 73 | ) 74 | 75 | for n in 0..settings.linesShape.precision: 76 | let a = settings.angleEnd.dtr - 77 | (settings.angleEnd.dtr - settings.angleStart.dtr) / 78 | settings.linesShape.precision.float * n.float 79 | shape.poV.add [ 80 | sin(a) * settings.widthRadius, cos(a) * settings.heightRadius 81 | ].MapPosition 82 | # Delete superfluous shape 83 | if abs(settings.angleEnd - settings.angleStart) == 360: 84 | shape.poV.delete shape.poV.high 85 | 86 | let fixture = MapFixture( 87 | n: "ellipse", de: jsNull, re: jsNull, fr: jsNull, 88 | f: settings.linesShape.colour 89 | ) 90 | return @[(shape, fixture)] 91 | 92 | type SineSettings = ref object 93 | linesShape: LinesShapeSettings 94 | width, height, oscillations, start: float 95 | 96 | proc generateSine(settings: SineSettings): GeneratedShapes = 97 | proc getPos(x: float): MapPosition = 98 | let 99 | sx = x * 2 * PI * settings.oscillations + settings.start 100 | asx = sx + sin(2 * sx) / 2.7 101 | return [ 102 | settings.width * (asx - settings.start) / settings.oscillations / 2 / PI, 103 | sin(asx) * settings.height 104 | ] 105 | 106 | return genLinesShape(settings.linesShape, getPos) 107 | 108 | type EquationSettings = ref object 109 | linesShape: LinesShapeSettings 110 | inputX, inputY: string 111 | polygon: bool 112 | 113 | proc generateEquation(settings: EquationSettings): GeneratedShapes = 114 | proc getPos(x: float): MapPosition = 115 | let ev = newEvaluator() 116 | ev.addVar("t", x) 117 | return [ev.eval settings.inputX, ev.eval settings.inputY] 118 | 119 | if settings.polygon: 120 | let shape = MapShape( 121 | stype: "po", poS: 1.0, a: 0, 122 | c: [0.0, 0.0].MapPosition 123 | ) 124 | 125 | for n in 0..settings.linesShape.precision: 126 | let 127 | ev = newEvaluator() 128 | x = n / settings.linesShape.precision * 0.999999999 129 | ev.addVar("t", x) 130 | shape.poV.add [ 131 | ev.eval settings.inputX, ev.eval settings.inputY 132 | ].MapPosition 133 | 134 | let fixture = MapFixture(n: "equation", de: jsNull, re: jsNull, fr: jsNull, 135 | f: settings.linesShape.colour 136 | ) 137 | return @[(shape, fixture)] 138 | else: 139 | return genLinesShape(settings.linesShape, getPos) 140 | 141 | type 142 | GradientSettings = ref object 143 | precision: int 144 | # Linear if true, radial if false. I should improve this code later. 145 | linear: bool 146 | rectWidth, rectHeight: float 147 | circleRadius1, circleRadius2: float 148 | gradient: MultiColourGradient 149 | 150 | proc generateGradient(settings: GradientSettings): GeneratedShapes = 151 | for i in 0..settings.precision-1: 152 | var shape: MapShape 153 | 154 | if settings.linear: 155 | shape = MapShape( 156 | stype: "bx", bxW: settings.rectWidth / settings.precision.float, 157 | bxH: settings.rectHeight, 158 | a: 0, 159 | c: [ 160 | settings.rectWidth * i.float / settings.precision.float - 161 | settings.rectWidth / 2.0 + settings.rectWidth / 162 | settings.precision.float / 2, 163 | 0 164 | ].MapPosition 165 | ) 166 | else: 167 | let crm = 168 | if settings.precision == 1: 1.0 169 | else: i / (settings.precision - 1) 170 | shape = MapShape( 171 | stype: "ci", ciR: settings.circleRadius1 * crm + 172 | settings.circleRadius2 * (1.0 - crm), 173 | c: [0.0, 0.0].MapPosition 174 | ) 175 | 176 | let 177 | colour = getColourAt( 178 | settings.gradient, GradientPos( 179 | if settings.precision == 1: 1.0 180 | else: i / (settings.precision - 1) 181 | ) 182 | ).int 183 | fixture = MapFixture( 184 | n: cstring &"gradient{i}", de: jsNull, re: jsNull, 185 | fr: jsNull, f: colour 186 | ) 187 | result.add((shape, fixture)) 188 | 189 | proc shapeGenerator*(body: MapBody): VNode = 190 | buildHtml(tdiv(style = "display: flex; flex-flow: column".toCss)): 191 | var 192 | inFocus {.global.}: bool 193 | generatedShapes {.global.}: GeneratedShapes 194 | generateProc: () -> GeneratedShapes 195 | selecting {.global.}: bool = true 196 | previousBody {.global.}: MapBody 197 | generatorType {.global.}: ShapeGeneratorType 198 | multiSelectShapes {.global.}: bool 199 | shapesX {.global.}, shapesY {.global.}, shapesAngle {.global.}: float 200 | shapesNoPhysics {.global.}: bool 201 | 202 | if body != previousBody: 203 | selecting = true 204 | previousBody = body 205 | 206 | let 207 | addShapesToMap = proc = 208 | if not inFocus: 209 | return 210 | for (shape, fixture) in generatedShapes: 211 | let 212 | shape = copyObject(shape) 213 | fixture = copyObject(fixture) 214 | shape.c = shape.c.rotatePoint(shapesAngle.dtr) 215 | shape.c.x += shapesX 216 | shape.c.y += shapesY 217 | shape.a += shapesAngle.dtr 218 | fixture.np = shapesNoPhysics 219 | 220 | moph.shapes.add shape 221 | fixture.sh = moph.shapes.high 222 | moph.fixtures.add fixture 223 | body.fx.add moph.fixtures.high 224 | 225 | updateRenderer(true) 226 | updateRightBoxBody(-1) 227 | remove = proc = 228 | if not inFocus: 229 | return 230 | # Remove shapes from map 231 | body.fx.setLen(body.fx.len - generatedShapes.len) 232 | moph.fixtures.setLen(moph.fixtures.len - generatedShapes.len) 233 | moph.shapes.setLen(moph.shapes.len - generatedShapes.len) 234 | updateRenderer(true) 235 | updateRightBoxBody(-1) 236 | updateWithoutRegenerating = proc = 237 | # Regeneration is not needed when X, Y, angle or no physics is changed 238 | remove() 239 | addShapesToMap() 240 | update = proc = 241 | remove() 242 | generatedShapes = generateProc() 243 | addShapesToMap() 244 | 245 | if selecting: 246 | template sb(s): untyped = 247 | bonkButton($s, proc = 248 | selecting = false 249 | generatorType = s 250 | ) 251 | sb sgsEllipse 252 | sb sgsSine 253 | sb sgsLinearGradient 254 | sb sgsRadialGradient 255 | sb sgsEquation 256 | 257 | else: 258 | bonkButton("Back", proc = 259 | selecting = true 260 | remove() 261 | generatedShapes = @[] 262 | ) 263 | 264 | template pbi(va): untyped = 265 | bonkInput(va, prsFLimited, update, niceFormatFloat) 266 | template pbiWithoutRegenerating(va): untyped = 267 | bonkInput(va, prsFLimited, updateWithoutRegenerating, niceFormatFloat) 268 | 269 | template precisionInput(va): untyped = 270 | bonkInput(va, proc(s: string): int = 271 | let res = s.parseInt 272 | if res notin 1..999: 273 | raise newException(ValueError, "prec notin 1..999") 274 | res 275 | , update, i => $i) 276 | 277 | prop("Multi-select", checkbox(multiSelectShapes)) 278 | prop("x", pbiWithoutRegenerating shapesX) 279 | prop("y", pbiWithoutRegenerating shapesY) 280 | prop("Angle", pbiWithoutRegenerating shapesAngle) 281 | 282 | case generatorType 283 | of sgsEllipse: 284 | var settings {.global.} = EllipseSettings( 285 | linesShape: LinesShapeSettings( 286 | colour: 0xffffff, precision: 20, rectHeight: 1 287 | ), 288 | widthRadius: 100, heightRadius: 100, angleStart: 0, angleEnd: 360, 289 | spiralStart: 1, hollow: false 290 | ) 291 | 292 | generateProc = () => generateEllipse(settings) 293 | prop("Colour", colourInput(settings.linesShape.colour, update)) 294 | prop("Shapes/vertices", precisionInput settings.linesShape.precision) 295 | prop("Width radius", pbi settings.widthRadius) 296 | prop("Height radius", pbi settings.heightRadius) 297 | prop("Angle start", pbi settings.angleStart) 298 | prop("Angle end", pbi settings.angleEnd) 299 | prop("Hollow", checkbox(settings.hollow, update)) 300 | 301 | if settings.hollow: 302 | prop("Spiral start", pbi settings.spiralStart) 303 | prop("Rect height", pbi settings.linesShape.rectHeight) 304 | 305 | of sgsSine: 306 | var settings {.global.} = SineSettings( 307 | linesShape: LinesShapeSettings( 308 | colour: 0xffffff, precision: 20, rectHeight: 1 309 | ), 310 | width: 300, height: 75, oscillations: 2, start: 0 311 | ) 312 | 313 | generateProc = () => generateSine(settings) 314 | prop("Colour", colourInput(settings.linesShape.colour, update)) 315 | prop("Shapes", precisionInput settings.linesShape.precision) 316 | prop("Width", pbi settings.width) 317 | prop("Height", pbi settings.height) 318 | prop("Oscillations", pbi settings.oscillations) 319 | prop("Rect height", pbi settings.linesShape.rectHeight) 320 | of sgsLinearGradient, sgsRadialGradient: 321 | var settings {.global.} = GradientSettings( 322 | precision: 16, 323 | rectWidth: 150, rectHeight: 150, 324 | circleRadius1: 30, circleRadius2: 150, 325 | gradient: defaultMultiColourGradient() 326 | ) 327 | shapesNoPhysics = true 328 | 329 | generateProc = () => generateGradient(settings) 330 | gradientProp(settings.gradient, update) 331 | prop("Shapes", precisionInput settings.precision) 332 | case generatorType 333 | of sgsLinearGradient: 334 | settings.linear = true 335 | prop("Width", pbi settings.rectWidth) 336 | prop("Height", pbi settings.rectHeight) 337 | of sgsRadialGradient: 338 | settings.linear = false 339 | prop("Inner circle radius", pbi settings.circleRadius1) 340 | prop("Outer circle radius", pbi settings.circleRadius2) 341 | else: discard 342 | 343 | of sgsEquation: 344 | var settings {.global.} = EquationSettings( 345 | linesShape: LinesShapeSettings( 346 | colour: 0xffffff, precision: 20, rectHeight: 1 347 | ), 348 | inputX: "(t-0.5)*100", inputY: "-((t*2-1)^2)*100", 349 | polygon: false 350 | ) 351 | 352 | generateProc = () => generateEquation(settings) 353 | prop("Colour", colourInput(settings.linesShape.colour, update)) 354 | prop("Shapes", precisionInput settings.linesShape.precision) 355 | prop("Rect height", pbi settings.linesShape.rectHeight) 356 | prop("Polygon", checkbox(settings.polygon, update)) 357 | 358 | # bonkInput but with a width of 150px 359 | proc bonkInputWide[T](variable: var T; parser: string -> T; 360 | afterInput: proc(): void = nil; stringify: T -> 361 | string): VNode = 362 | buildHtml: 363 | input(class = 364 | "mapeditor_field mapeditor_field_spacing_bodge fieldShadow", 365 | value = cstring variable.stringify, style = "width: 150px".toCss 366 | ): 367 | proc onInput(e: Event; n: VNode) = 368 | try: 369 | variable = parser $n.value 370 | e.target.style.color = "" 371 | if not afterInput.isNil: 372 | afterInput() 373 | except CatchableError: 374 | e.target.style.color = "var(--kkleeErrorColour)" 375 | template eqInp(va): untyped = 376 | bonkInputWide(va, proc(s: string): string = 377 | # Check for error 378 | let ev = newEvaluator() 379 | ev.addVar("t", 0.0) 380 | discard ev.eval s 381 | return s 382 | , update, s=>s) 383 | prop("X", eqInp(settings.inputX)) 384 | prop("Y", eqInp(settings.inputY)) 385 | 386 | ul(style = "font-size:11px; padding-left: 10px; margin: 3px".toCss): 387 | li text ( 388 | "It is recommended that you experiment with equations on " & 389 | "a graphing calculator like Desmos before using them here") 390 | 391 | # Workaround to avoid errors from karax 392 | discard (proc: int = 393 | if generatedShapes.len == 0: 394 | update() 395 | 1 396 | )() 397 | 398 | bonkButton(&"Save {$generatorType}", proc = 399 | updateWithoutRegenerating() 400 | if multiSelectShapes: 401 | shapeMultiSelectSwitchPlatform() 402 | for fxId in ( 403 | (moph.fixtures.len - generatedShapes.len)..moph.fixtures.high 404 | ): 405 | selectedFixtures.add fxId.getFx 406 | shapeMultiSelectElementBorders() 407 | generatedShapes = @[] 408 | saveToUndoHistory() 409 | selecting = true 410 | ) 411 | 412 | proc onMouseEnter = 413 | inFocus = true 414 | if not selecting: 415 | addShapesToMap() 416 | proc onMouseLeave = 417 | if not selecting: 418 | remove() 419 | inFocus = false 420 | -------------------------------------------------------------------------------- /src/shapeMultiSelect.nim: -------------------------------------------------------------------------------- 1 | import 2 | std/[sugar, strutils, sequtils, algorithm, dom, math, options, strformat], 3 | pkg/karax/[karax, karaxdsl, vdom, vstyles], 4 | kkleeApi, bonkElements, platformMultiSelect, colours 5 | 6 | var 7 | selectedFixtures*: seq[MapFixture] 8 | fixturesBody*: MapBody 9 | 10 | proc removeDeletedFixtures = 11 | var i = 0 12 | while i < selectedFixtures.len: 13 | let fx = selectedFixtures[i] 14 | if fx notin moph.fixtures: 15 | selectedFixtures.delete i 16 | else: 17 | inc i 18 | 19 | proc shapeMultiSelectElementBorders* = 20 | removeDeletedFixtures() 21 | if getCurrentBody() == -1: return 22 | let 23 | shapeElements = document 24 | .getElementById("mapeditor_rightbox_shapetablecontainer") 25 | .getElementsByClassName("mapeditor_rightbox_table_shape_headerfield") 26 | .reversed 27 | body = getCurrentBody().getBody 28 | for i, se in shapeElements: 29 | let prevNode = se.previousSibling 30 | if not prevNode.isNil and prevNode.class == 31 | "kkleeMultiSelectShapeIndexLabel": 32 | prevNode.remove() 33 | 34 | let selectedId = selectedFixtures.find(body.fx[i].getFx) 35 | if selectedId == -1: 36 | se.classList.remove("kkleeMultiSelectShapeTextBox") 37 | else: 38 | se.classList.add("kkleeMultiSelectShapeTextBox") 39 | 40 | let indexLabel = document.createElement("span") 41 | indexLabel.innerText = cstring $selectedId 42 | indexLabel.class = "kkleeMultiSelectShapeIndexLabel" 43 | se.parentNode.insertBefore(indexLabel, se) 44 | 45 | proc shapeMultiSelectSwitchPlatform* = 46 | if getCurrentBody() == -1: return 47 | removeDeletedFixtures() 48 | shapeMultiSelectElementBorders() 49 | fixturesBody = getCurrentBody().getBody 50 | 51 | 52 | proc shapeMultiSelectEdit: VNode = buildHtml tdiv( 53 | style = "display: flex; flex-flow: column".toCss): 54 | 55 | proc onMouseEnter = 56 | setEditorExplanation(""" 57 | [kklee] 58 | Shift+click shape name fields to select shapes 59 | Variables: 60 | - x is the current value 61 | - i is the index in list of selected shapes (the first shape you selected will have i=0, the next one i=1, i=2, etc) 62 | Mathematical expressions, such as x*2+50, will be evaluated 63 | - n is number of shapes selected 64 | List of supported functions: 65 | https://github.com/BonkModdingCommunity/kklee/blob/master/guide.md#mathematical-expression-evaluator 66 | """) 67 | 68 | var appliers {.global.}: seq[(int, MapFixture) -> void] 69 | var resetters {.global.}: seq[() -> void] 70 | 71 | bonkButton "Reset", proc = 72 | for fn in resetters: 73 | fn() 74 | 75 | template floatProp( 76 | name: string; mapFxProp: untyped; 77 | inpToProp = floatNop; 78 | propToInp = floatNop; 79 | ): untyped = 80 | let 81 | inpToPropF = inpToProp 82 | propToInpF = propToInp 83 | var inp {.global.}: string = "x" 84 | 85 | once: 86 | appliers.add proc (i: int; fx {.inject.}: MapFixture) = 87 | mapFxProp = inpToPropF floatPropApplier( 88 | inp, i, selectedFixtures.len, propToInpF mapFxProp 89 | ) 90 | resetters.add proc = 91 | inp = "x" 92 | 93 | buildHtml: 94 | prop name, floatPropInput(inp), inp != "x" 95 | 96 | template boolProp(name: string; mapFxProp: untyped): untyped = 97 | var inp {.global.}: boolPropValue 98 | once: 99 | appliers.add proc(i: int; fx {.inject.}: MapFixture) = 100 | case inp 101 | of tfsFalse: mapFxProp = false 102 | of tfsTrue: mapFxProp = true 103 | of tfsSame: discard 104 | resetters.add proc = 105 | inp = tfsSame 106 | buildHtml: 107 | prop name, tfsCheckbox(inp), inp != tfsSame 108 | 109 | template colourChanger: untyped = 110 | type InputType = enum 111 | Unchanged, OneColour, Gradient 112 | var 113 | inputType {.global.} = Unchanged 114 | oneColourInp {.global.}: int = 0 115 | multiColourGradientInp {.global.} = defaultMultiColourGradient() 116 | once: 117 | appliers.add proc(i: int; fx: MapFixture) = 118 | case inputType 119 | of Unchanged: return 120 | of OneColour: fx.f = oneColourInp 121 | of Gradient: 122 | fx.f = getColourAt( 123 | multiColourGradientInp, 124 | GradientPos( 125 | if selectedFixtures.high == 0: 1.0 126 | else: i / selectedFixtures.high 127 | ) 128 | ).int 129 | resetters.add proc = 130 | inputType = Unchanged 131 | oneColourInp = 0 132 | multiColourGradientInp = defaultMultiColourGradient() 133 | 134 | let 135 | dropdown = dropDownPropSelect(inputType, @[ 136 | ("Unchanged", Unchanged), ("One colour", OneColour), 137 | ("Gradient", Gradient) 138 | ]) 139 | buildHtml tdiv: 140 | prop "Colour", dropdown, inputType != Unchanged 141 | case inputType 142 | of Unchanged: discard 143 | of OneColour: 144 | colourInput(oneColourInp) 145 | of Gradient: 146 | gradientProp(multiColourGradientInp) 147 | 148 | template nameChanger: untyped = 149 | var 150 | canChange {.global.} = false 151 | inp {.global.}: string = "Shape ||i||" 152 | once: 153 | appliers.add proc(i: int; fx: MapFixture) = 154 | if canChange: 155 | fx.n = cstring multiSelectNameChanger(inp, i) 156 | resetters.add proc = 157 | canChange = false 158 | inp = "Shape ||i||" 159 | buildHtml: 160 | let field = buildHtml tdiv(style = "display: flex".toCss): 161 | checkbox(canChange) 162 | bonkInput(inp, multiSelectNameChangerCheck, nil, s => s) 163 | prop "Name", field, canChange 164 | 165 | template rotateAndScale: untyped = 166 | var 167 | point {.global.} = [0.0, 0.0].MapPosition 168 | degrees {.global.} = 0.0 169 | scale {.global.} = 1.0 170 | once: 171 | appliers.add proc(i: int; fx: MapFixture) = 172 | template sh: untyped = fx.fxShape 173 | var p = sh.c 174 | p.x -= point.x 175 | p.y -= point.y 176 | if degrees != 0.0: 177 | p = rotatePoint(p, degrees.degToRad) 178 | sh.a += degrees.degToRad 179 | p.x *= scale 180 | p.y *= scale 181 | p.x += point.x 182 | p.y += point.y 183 | sh.c = p 184 | case sh.shapeType 185 | of stypeBx: 186 | sh.bxW *= scale 187 | sh.bxH *= scale 188 | of stypeCi: 189 | sh.ciR *= scale 190 | of stypePo: 191 | sh.poS *= scale 192 | resetters.add proc = 193 | point = [0.0, 0.0].MapPosition 194 | degrees = 0.0 195 | scale = 1.0 196 | buildHtml tdiv: 197 | let pointInput = buildHtml tdiv: 198 | bonkInput(point[0], prsFLimited, nil, niceFormatFloat) 199 | bonkInput(point[1], prsFLimited, nil, niceFormatFloat) 200 | prop "Rotate by", bonkInput(degrees, prsFLimited, nil, niceFormatFloat), 201 | degrees != 0.0 202 | prop "Scale by", bonkInput(scale, prsFLimited, nil, niceFormatFloat), 203 | scale != 1.0 204 | prop "Around point", pointInput, false 205 | 206 | template editCapZone: untyped = 207 | var 208 | tfsCheckboxValue {.global.} = tfsSame 209 | capZoneType {.global.}: Option[MapCapZoneType] 210 | capZoneTime {.global.}: float = 10.0 211 | capZoneTimeCanChange {.global.} = false 212 | once: 213 | appliers.add proc(i: int; fx: MapFixture) = 214 | case tfsCheckboxValue 215 | of tfsSame: 216 | discard 217 | of tfsTrue: 218 | let fxId = moph.fixtures.find fx 219 | var cz: MapCapZone 220 | for ocz in mapObject.capZones: 221 | if ocz.i == fxId: 222 | cz = ocz 223 | break 224 | 225 | if cz.isNil and capZoneType.isNone: 226 | return 227 | if cz.isNil: 228 | mapObject.capZones.add MapCapZone( 229 | n: "Cap Zone", ty: capZoneType.get, l: capZoneTime, i: fxId) 230 | else: 231 | if capZoneType.isSome: 232 | cz.ty = capZoneType.get 233 | if capZoneTimeCanChange: 234 | cz.l = capZoneTime 235 | of tfsFalse: 236 | let fxId = moph.fixtures.find fx 237 | var i = 0 238 | while i < mapObject.capZones.len: 239 | let cz = mapObject.capZones[i] 240 | if cz.i == fxId: 241 | mapObject.capZones.del i 242 | else: 243 | inc i 244 | resetters.add proc = 245 | tfsCheckboxValue = tfsSame 246 | capZoneType = none(MapCapZoneType) 247 | capZoneTime = 10.0 248 | capZoneTimeCanChange = false 249 | buildHtml tdiv: 250 | prop "Capzone", tfsCheckbox(tfsCheckboxValue), tfsCheckboxValue != tfsSame 251 | if tfsCheckboxValue == tfsTrue: 252 | let typeDropdown = buildHtml dropDownPropSelect(capZoneType, @[ 253 | ("Normal", cztNormal), ("Red", cztRed), ("Blue", cztBlue), 254 | ("Green", cztGreen), ("Yellow", cztYellow) 255 | ]) 256 | prop "Cz. Type", typeDropdown, capZoneType.isSome 257 | let timeInput = buildHtml tdiv: 258 | checkbox(capZoneTimeCanChange) 259 | bonkInput(capZoneTime, prsFLimited, nil, niceFormatFloat) 260 | prop "Cz. time", timeInput, capZoneTimeCanChange 261 | 262 | nameChanger() 263 | floatProp("x", fx.fxShape.c.x) 264 | floatProp("y", fx.fxShape.c.y) 265 | block: 266 | let 267 | d2r = proc(f: float): float = degToRad(f) 268 | r2d = proc(f: float): float = radToDeg(f) 269 | floatProp("Angle", fx.fxShape.a, d2r, r2d) 270 | floatProp("Rect. width", fx.fxShape.bxW) 271 | floatProp("Rect. height", fx.fxShape.bxH) 272 | floatProp("Circle radius", fx.fxShape.ciR) 273 | floatProp("Poly. scale", fx.fxShape.poS) 274 | floatProp("Density", fx.de) 275 | floatProp("Bounciness", fx.re) 276 | floatProp("Friction", fx.fr) 277 | 278 | boolProp("No physics", fx.np) 279 | boolProp("No grapple", fx.ng) 280 | boolProp("Inner grapple", fx.ig) 281 | boolProp("Death", fx.d) 282 | boolProp("Shrink", fx.fxShape.sk) 283 | 284 | colourChanger() 285 | rotateAndScale() 286 | editCapZone() 287 | 288 | bonkButton "Apply", proc = 289 | removeDeletedFixtures() 290 | for i, f in selectedFixtures: 291 | for a in appliers: a(i, f) 292 | saveToUndoHistory() 293 | updateRenderer(true) 294 | updateRightBoxBody(-1) 295 | updateLeftBox() 296 | 297 | 298 | proc shapeMultiSelectCopy: VNode = buildHtml tdiv( 299 | style = "display: flex; flex-flow: column".toCss): 300 | var 301 | copyShapes {.global.}: seq[tuple[fx: MapFixture; sh: MapShape; 302 | cz: Option[MapCapZone]]] 303 | pasteAmount {.global.} = 1 304 | bonkButton "Copy shapes", proc = 305 | removeDeletedFixtures() 306 | copyShapes = @[] 307 | for fx in selectedFixtures: 308 | let fxId = moph.fixtures.find(fx) 309 | var copiedCz = none MapCapZone 310 | for cz in mapObject.capZones: 311 | if cz.i == fxId: 312 | copiedCz = some cz.copyObject() 313 | break 314 | copyShapes.add ( 315 | fx: fx.copyObject(), 316 | sh: fx.fxShape.copyObject(), 317 | cz: copiedCz 318 | ) 319 | 320 | prop "Paste amount", bonkInput(pasteAmount, parseInt, nil, i => $i) 321 | bonkButton "Paste shapes", proc = 322 | shapeMultiSelectSwitchPlatform() 323 | block outer: 324 | for _ in 1..pasteAmount: 325 | for (fx, sh, cz) in copyShapes.mitems: 326 | if fixturesBody.fx.len > 1000: 327 | break outer 328 | moph.shapes.add sh.copyObject() 329 | let newFx = fx.copyObject() 330 | moph.fixtures.add newFx 331 | newFx.sh = moph.shapes.high 332 | selectedFixtures.add newFx 333 | fixturesBody.fx.add moph.fixtures.high 334 | if cz.isSome: 335 | let newCz = cz.get.copyObject() 336 | newCz.i = moph.fixtures.high 337 | mapObject.capZones.add newCz 338 | saveToUndoHistory() 339 | updateRenderer(true) 340 | updateRightBoxBody(-1) 341 | updateLeftBox() 342 | 343 | proc shapeMultiSelectSelectAll: VNode = buildHtml tdiv( 344 | style = "font-size: 13px".toCss 345 | ): 346 | tdiv text &"{selectedFixtures.len} shapes selected" 347 | block: 348 | let warningColour = 349 | if selectedFixtures.anyIt (let fxId = moph.fixtures.find(it); 350 | fxId != -1 and fxId notin fixturesBody.fx): 351 | "var(--kkleeErrorColour)" else: "transparent" 352 | tdiv(style = "color: {warningColour}; font-weight: bold".fmt.toCss): 353 | text &"Shapes from multiple platforms are selected" 354 | 355 | bonkButton "Reverse selection order", proc = 356 | selectedFixtures.reverse() 357 | shapeMultiSelectElementBorders() 358 | 359 | type IncludedPlatforms = enum 360 | IncludeCurrent, IncludeAll, IncludeMultiSelected 361 | var includedPlatforms {.global.} = IncludeCurrent 362 | 363 | span text "Include shapes from:" 364 | let includedPlatformsDropdown = dropDownPropSelect(includedPlatforms, @[ 365 | ("Current platform", IncludeCurrent), 366 | ("All platforms", IncludeAll), 367 | ("Multiselected platforms", IncludeMultiSelected) 368 | ]) 369 | 370 | discard (includedPlatformsDropdown.style.setAttr("width", "100%"); 0) # >:( 371 | includedPlatformsDropdown 372 | 373 | proc includedFixtures: seq[MapFixture] = 374 | case includedPlatforms 375 | of IncludeAll: moph.fixtures 376 | of IncludeCurrent: fixturesBody.fx.mapIt it.getFx 377 | of IncludeMultiSelected: 378 | selectedBodies.mapIt(it.fx).concat().mapIt(it.getFx) 379 | 380 | bonkButton "Select all", proc = 381 | shapeMultiSelectSwitchPlatform() 382 | if includedPlatforms == IncludeAll: 383 | selectedFixtures = moph.fixtures 384 | else: 385 | for fx in includedFixtures(): 386 | if fx notin selectedFixtures: 387 | selectedFixtures.add fx 388 | shapeMultiSelectElementBorders() 389 | bonkButton "Deselect all", proc = 390 | shapeMultiSelectSwitchPlatform() 391 | if includedPlatforms == IncludeAll: 392 | selectedFixtures = @[] 393 | else: 394 | for fx in includedFixtures(): 395 | let i = selectedFixtures.find fx 396 | if i != -1: 397 | selectedFixtures.delete i 398 | shapeMultiSelectElementBorders() 399 | bonkButton "Invert selection", proc = 400 | shapeMultiSelectSwitchPlatform() 401 | for fx in includedFixtures(): 402 | let i = selectedFixtures.find fx 403 | if i != -1: 404 | selectedFixtures.delete i 405 | else: 406 | selectedFixtures.add fx 407 | shapeMultiSelectElementBorders() 408 | bonkButton "Select physics shapes", proc = 409 | shapeMultiSelectSwitchPlatform() 410 | for fx in includedFixtures(): 411 | if not fx.np and fx notin selectedFixtures: 412 | selectedFixtures.add fx 413 | shapeMultiSelectElementBorders() 414 | bonkButton "Select shapes with capzone", proc = 415 | shapeMultiSelectSwitchPlatform() 416 | for cz in mapObject.capZones: 417 | let fx = cz.i.getFx 418 | if fx in includedFixtures() and fx notin selectedFixtures: 419 | selectedFixtures.add fx 420 | shapeMultiSelectElementBorders() 421 | 422 | tdiv(style = "margin: 5px 0px".toCss): 423 | var searchString {.global.} = "" 424 | prop "Start of name", bonkInput(searchString, s => s, nil, s => s) 425 | bonkButton "Select by name", proc = 426 | shapeMultiSelectSwitchPlatform() 427 | for fx in includedFixtures(): 428 | if fx.n.`$`.startsWith(searchString) and fx notin selectedFixtures: 429 | selectedFixtures.add fx 430 | shapeMultiSelectElementBorders() 431 | 432 | tdiv(style = "margin: 5px 0px".toCss): 433 | var 434 | searchColour {.global.}: int = 0 435 | prop "Colour", colourInput(searchColour) 436 | bonkButton "Select by colour", proc = 437 | for fx in includedFixtures(): 438 | if fx.f == searchColour and fx notin selectedFixtures: 439 | selectedFixtures.add fx 440 | shapeMultiSelectElementBorders() 441 | 442 | proc shapeMultiSelectDelete: VNode = 443 | buildHtml bonkButton "Delete shapes", proc = 444 | for f in selectedFixtures: 445 | let fxId = moph.fixtures.find f 446 | if fxId == -1: continue 447 | deleteFx(fxId) 448 | saveToUndoHistory() 449 | selectedFixtures = @[] 450 | updateRenderer(true) 451 | updateRightBoxBody(-1) 452 | 453 | proc shapeMultiSelectMove: VNode = buildHtml tdiv(style = 454 | "display: flex; flex-flow: row wrap; justify-content: space-between;".toCss 455 | ): 456 | text "Move" 457 | template fx: untyped = fixturesBody.fx 458 | proc update = 459 | updateRightBoxBody(-1) 460 | updateRenderer(true) 461 | saveToUndoHistory() 462 | proc getSelectedFxIds: seq[int] = 463 | selectedFixtures.mapIt moph.fixtures.find(it) 464 | bonkButton "Down", proc = 465 | let selectedFxIds = getSelectedFxIds() 466 | for i in countup(1, fx.high): 467 | if fx[i] in selectedFxIds and 468 | fx[i - 1] notin selectedFxIds: 469 | swap(fx[i], fx[i - 1]) 470 | update() 471 | bonkButton "Up", proc = 472 | let selectedFxIds = getSelectedFxIds() 473 | for i in countdown(fx.high - 1, 0): 474 | if fx[i] in selectedFxIds and 475 | fx[i + 1] notin selectedFxIds: 476 | swap(fx[i], fx[i + 1]) 477 | update() 478 | bonkButton "Bottom", proc = 479 | let selectedFxIds = getSelectedFxIds() 480 | var moveIndex = 0 481 | for i in countup(0, fx.high): 482 | if fx[i] notin selectedFxIds: continue 483 | inc moveIndex 484 | for j in countdown(i, moveIndex): 485 | swap fx[j], fx[j - 1] 486 | update() 487 | bonkButton "Top", proc = 488 | let selectedFxIds = getSelectedFxIds() 489 | var moveIndex = fx.high 490 | for i in countdown(fx.high, 0): 491 | if fx[i] notin selectedFxIds: continue 492 | dec moveIndex 493 | for j in countup(i, moveIndex): 494 | swap fx[j], fx[j + 1] 495 | update() 496 | bonkButton "Reverse", proc = 497 | var selectedFxIds = getSelectedFxIds() 498 | let fxIdPositions = collect(newSeq): 499 | for i, fxId in fx: 500 | let si = selectedFxIds.find fxId 501 | if si == -1: continue 502 | selectedFxIds.del si 503 | i 504 | for i in 0..fxIdPositions.len div 2 - 1: 505 | swap fx[fxIdPositions[i]], 506 | fx[fxIdPositions[fxIdPositions.high - i]] 507 | update() 508 | 509 | proc shapeMultiSelect*: VNode = 510 | shapeMultiSelectSwitchPlatform() 511 | buildHtml(tdiv( 512 | style = "display: flex; flex-flow: column; row-gap: 10px".toCss)): 513 | shapeMultiSelectSelectAll() 514 | shapeMultiSelectEdit() 515 | shapeMultiSelectMove() 516 | shapeMultiSelectDelete() 517 | shapeMultiSelectCopy() 518 | -------------------------------------------------------------------------------- /src/injector.js: -------------------------------------------------------------------------------- 1 | function injector(bonkCode) { 2 | window.onbeforeunload = function () { 3 | if (document.getElementById("mainmenuelements").style.display === "none") { 4 | return "Are you sure?"; 5 | } 6 | return null; 7 | }; 8 | 9 | const kklee = {}; 10 | window.kklee = kklee; 11 | 12 | kklee.polyDecomp = require("poly-decomp"); 13 | kklee.splitConcaveIntoConvex = (v) => { 14 | kklee.polyDecomp.makeCCW(v); 15 | // Normal .decomp is VERY slow with a high amount of vertices so 16 | // .quickDecomp is used 17 | let convexPolygons = kklee.polyDecomp.quickDecomp(v); 18 | for (let i = 0; i < convexPolygons.length; i++) { 19 | kklee.polyDecomp.removeCollinearPoints(convexPolygons[i], 0); 20 | } 21 | return convexPolygons; 22 | }; 23 | 24 | let src = bonkCode; 25 | 26 | let prevSrc = src; 27 | function checkSrcChange() { 28 | if (prevSrc == src) throw new Error("src didn't change"); 29 | prevSrc = src; 30 | } 31 | function replace() { 32 | src = src.replace(...arguments); 33 | checkSrcChange(); 34 | } 35 | function assert(condition) { 36 | if (!condition) throw new Error("assertion failed"); 37 | } 38 | 39 | // Variable that stores map object, such as abc[123] 40 | const mapObjectName = src.match(/rxid:.{3}\[\d+\]/)[0].split(":")[1]; 41 | // Escape regex special characters for use in regexes 42 | const monEsc = mapObjectName.replace(/([.?*+^$[\]\\(){}|-])/g, "\\$1"); 43 | const varArrName = mapObjectName.split("[")[0]; 44 | 45 | // When a new map object is created, also assign it to a global variable 46 | replace( 47 | new RegExp(`(${monEsc}=[^;]+;)`, "g"), 48 | `$1window.kklee.mapObject=${mapObjectName};\ 49 | if(window.kklee.afterNewMapObject)window.kklee.afterNewMapObject();` 50 | ); 51 | 52 | // MapEncoder object that contains methods such as 53 | // .getBlankMap and .decodeFromDatabase 54 | const mapEncoderName = src.match( 55 | new RegExp(`${monEsc}=(.)\\[.{1,25}\\]\\(\\);`) 56 | )[1]; 57 | 58 | replace( 59 | new RegExp(`function ${mapEncoderName}\\(\\)\\{\\}`, "g"), 60 | `function ${mapEncoderName}(){};\ 61 | window.kklee.mapEncoder=${mapEncoderName};` 62 | ); 63 | 64 | /* 65 | Map editor reset function 66 | This function contains some useful stuff 67 | function j0Z() { 68 | z5i[977] = -1; // selected body 69 | z5i[450] = -1; // selected spawn 70 | z5i[462] = -1; // selected capzone 71 | p4Z(); // update left box 72 | v4Z(); // update right box, takes parameter for selected fixture 73 | n4V.a1V(); 74 | B4Z(true); // update rendering stuff. I'll use "true" as the parameter 75 | M4Z(); // spawns and physics shapes warnings 76 | y0Z(); // update undo and redo buttons 77 | I6s(); // update mode dropdown selection 78 | } 79 | */ 80 | const theResetFunction = src.match( 81 | new RegExp( 82 | "function ...\\(\\){.{0,40}\ 83 | (...\\[\\d+\\]=-1;){2}.{0,40}(...\\(true\\);).{0,40}(...\\(\\);){2}[^}]+\\}" 84 | ) 85 | )[0]; 86 | 87 | const resetFunctionNames = theResetFunction 88 | // Function body excluding last semicolon 89 | .match(/(?<=\{).+(?=;\})/)[0] 90 | .split(";") 91 | // Exclude the weird obfuscation function 92 | .filter((s) => !s.match(/.+(\.).+\(\)/)); 93 | const updateFunctionNames = resetFunctionNames 94 | .slice(4) 95 | .map((s) => s.split("(")[0]); 96 | const currentlySelectedNames = resetFunctionNames 97 | .slice(0, 4) 98 | .map((s) => s.split("=")[0]); 99 | assert(resetFunctionNames.length == 10); 100 | 101 | let ufInj = ""; 102 | 103 | const apiUpdateFunctionNames = [ 104 | "LeftBox", 105 | "RightBoxBody", 106 | "Renderer", 107 | "Warnings", 108 | "UndoButtons", 109 | "ModeDropdown", 110 | ]; 111 | // Create functions that update or hook into updates of parts of the map 112 | // editor UI 113 | for (const i in updateFunctionNames) { 114 | const on = updateFunctionNames[i], 115 | nn = apiUpdateFunctionNames[i]; 116 | 117 | ufInj += `let ${on}OLD=${on};${on}=function(){${on}OLD(...arguments);\ 118 | if(window.kklee.afterUpdate${nn})window.kklee.afterUpdate${nn}(...arguments);};\ 119 | window.kklee.update${nn}=${on};`; 120 | } 121 | 122 | // Creates functions to get or set IDs of currently selected elements in the 123 | // elements list on the left of the editor 124 | const apiCurrentlySelectedNames = ["Body", "Spawn", "CapZone", "Joint"]; 125 | for (const i in currentlySelectedNames) { 126 | const on = currentlySelectedNames[i], 127 | nn = apiCurrentlySelectedNames[i]; 128 | 129 | ufInj += `window.kklee.getCurrent${nn}=function(){return ${on};};\ 130 | window.kklee.setCurrent${nn}=function(v){return ${on}=v;};`; 131 | } 132 | 133 | replace(theResetFunction, `${theResetFunction};{${ufInj}};`); 134 | 135 | /* 136 | Function that saves map to undo history 137 | function t$S() { 138 | var H$l = [arguments]; 139 | H$l[7] = r8no$; 140 | while (Q6N[84] > 0) { 141 | Q6N[45]["shift"](); 142 | Q6N[84]--; 143 | } 144 | U3ndn.Y$U(); 145 | Q6N[45]["unshift"](JSON["stringify"](Q6N[22])); 146 | while (Q6N[45]["length"] > Q6N[61]) { 147 | Q6N[45]["pop"](); 148 | } 149 | s7e(); 150 | } 151 | */ 152 | const saveHistoryFunction = src.match( 153 | new RegExp( 154 | `function ...\\(\\)\\{.{1,170}${varArrName}\\[\\d{1,3}\\]--;\\}\ 155 | .{0,100}${varArrName}\\[\\d{1,3}\\].{1,40}\\]\\(\ 156 | JSON\\[.{1,40}\\]\\(${monEsc}\\)` 157 | ) 158 | )[0]; 159 | const saveHistoryFunctionName = saveHistoryFunction.match( 160 | /(?<=function )...(?=\(\))/ 161 | )[0]; 162 | const newSaveHistoryFunction = saveHistoryFunction.replace( 163 | new RegExp("(function ...\\(\\)\\{)"), 164 | "$1window.kklee.afterSaveHistory();" 165 | ); 166 | // Add function that sets new map object and expose function that saves map 167 | // to undo history 168 | replace( 169 | saveHistoryFunction, 170 | `;window.kklee.setMapObject=\ 171 | function(m){${mapObjectName}=m;window.kklee.mapObject=m;};\ 172 | window.kklee.saveToUndoHistoryOLD=${saveHistoryFunctionName};\ 173 | ${newSaveHistoryFunction}` 174 | ); 175 | 176 | // Map Backups 177 | // Backups are stored in IndexedDB rather than localStorage because 178 | // localStorage has a size limit of 5MB while IndexedDB doesn't 179 | const dbOpenRequest = window.indexedDB.open("kkleeStorage_347859220", 1); 180 | let db; 181 | kklee.backups = []; 182 | 183 | dbOpenRequest.onsuccess = () => { 184 | db = dbOpenRequest.result; 185 | db.transaction("backups"); 186 | const transaction = db.transaction("backups"); 187 | const getRequest = transaction.objectStore("backups").get(1); 188 | getRequest.onsuccess = () => { 189 | kklee.backups = getRequest.result; 190 | }; 191 | getRequest.onerror = (event) => { 192 | console.error(event); 193 | alert("kklee: unable to get backups from database"); 194 | }; 195 | }; 196 | function saveBackups() { 197 | if (!db) return; 198 | const transaction = db.transaction("backups", "readwrite"); 199 | transaction.objectStore("backups").put(kklee.backups, 1); 200 | db.onerror = console.error; 201 | } 202 | dbOpenRequest.onerror = (event) => { 203 | console.error(event); 204 | alert("kklee: unable to open IndexedDB"); 205 | }; 206 | dbOpenRequest.onupgradeneeded = (event) => { 207 | const db = event.target.result; 208 | const b = db.createObjectStore("backups"); 209 | // Previous versions of kklee stored backups in localStorage 210 | b.put(JSON.parse(localStorage.kkleeMapBackups || "[]"), 1); 211 | delete localStorage.kkleeMapBackups; 212 | }; 213 | 214 | // Label used in backup loader UI 215 | kklee.getBackupLabel = (b) => 216 | `${b.mapLabel} - ${new Date(b.timestamp).toLocaleString()}`; 217 | kklee.loadBackup = (b) => 218 | kklee.setMapObject(kklee.mapEncoder.decodeFromDatabase(b.mapData)); 219 | 220 | // A session ID is used so only 1 backup from each editing session is saved 221 | function newBackupSessionId() { 222 | kklee.backupSessionId = 223 | Date.now().toString(36) + Math.random().toString(36); 224 | } 225 | function backUpMap() { 226 | const mapLabel = `${kklee.mapObject.m.n} by ${kklee.mapObject.m.a}`; 227 | const mapData = kklee.mapEncoder.encodeToDatabase(kklee.mapObject); 228 | const lastBackup = kklee.backups[kklee.backups.length - 1]; 229 | 230 | if ( 231 | // Check if it is the same map from the same editing session 232 | lastBackup && 233 | lastBackup.sessionId == kklee.backupSessionId && 234 | lastBackup.mapLabel == mapLabel 235 | ) { 236 | lastBackup.mapData = mapData; 237 | lastBackup.timestamp = Date.now(); 238 | } else { 239 | kklee.backups.push({ 240 | sessionId: kklee.backupSessionId, 241 | mapLabel: mapLabel, 242 | timestamp: Date.now(), 243 | mapData: mapData, 244 | }); 245 | } 246 | 247 | // Remove older backups if backup database is larger than 1 MB 248 | let i = kklee.backups.length - 1; 249 | let size = 0; 250 | while (i >= 0) { 251 | size += kklee.backups[i].mapData.length; 252 | if (size > 1e6) break; 253 | else i--; 254 | } 255 | kklee.backups = kklee.backups.slice(i + 1); 256 | 257 | saveBackups(); 258 | } 259 | newBackupSessionId(); 260 | // ID will be different every time a new room is made 261 | document 262 | .getElementById("mainmenuelements") 263 | .addEventListener("mousemove", () => newBackupSessionId()); 264 | 265 | window.kklee.afterSaveHistory = () => { 266 | backUpMap(); 267 | }; 268 | 269 | // Replace Float64Array instances with normal arrays because Nim does some 270 | // weird stuff when storing arrays of numbers 271 | window.kklee.saveToUndoHistory = () => { 272 | function fix(obj) { 273 | for (const k of Object.keys(obj)) { 274 | if (obj[k] instanceof Float64Array) obj[k] = [...obj[k]]; 275 | else if (obj[k] instanceof Object) fix(obj[k]); 276 | } 277 | } 278 | fix(kklee.mapObject); 279 | window.kklee.saveToUndoHistoryOLD(); 280 | }; 281 | 282 | /* 283 | Prevent removal of event listener for activating chat with enter key when 284 | lobby is hidden. This allows the chat to be used in the editor. 285 | */ 286 | replace( 287 | new RegExp( 288 | //"\\$\\(docu[^;]{0,400};(.{0,1000}?Date.{0,500}?anime.{0,500}?\\:150)" 289 | // The regex above was matching the show function and extending to the hide function instead of matching only the hide function 290 | "\\$\\(docu[^;]{0,400};(.{0,800}?Date.{0,500}?anime.{0,200}?\\:150)" 291 | /* 292 | "(?<=this\\[.{10,20}\\]=function\\(\\)\\{.{20,90}\ 293 | this\\[.{10,20}\\]=false;.{0,11})\\$\\(document\\)\\[.{10,20}\\]\\(.{10,20},\ 294 | .{3,4}\\);"*/ 295 | ), 296 | "$1" 297 | ); 298 | 299 | /* 300 | Colour picker 301 | this["showColorPicker"] = function(H0R, k0R, C0R, u0R) { 302 | var Z8D = [arguments]; 303 | Z8D[6] = E8TT; 304 | j8D[8]["style"]["backgroundColor"] = j7S[29]["numToHex"](Z8D[0][0]); 305 | Z8D[2] = K8u(Z8D[0][0]); 306 | j8D[41] = Z8D[2]["hue"]; 307 | j8D[26] = Z8D[2]["brightness"]; 308 | j8D[38] = Z8D[2]["saturation"]; 309 | j8D[88] = Z8D[0][2]; 310 | j8D[22] = Z8D[0][3]; 311 | j8D[32] = Z8D[0][0]; 312 | M8u(false); 313 | e8u(Z8D[0][1]); 314 | j8D[1]["style"]["display"] = "block"; 315 | } 316 | */ 317 | replace( 318 | new RegExp( 319 | "((?<=this\\[.{10,25}\\]=function\\(.{3,4},.{3,4}\ 320 | ,.{3,4},.{3,4}\\)\\{).{50,250}(.{3,4}\\[.{0,25}\\]=.{3,4}\\[.{0,30}\\];){3}\ 321 | .{0,75}.{3,4}\\(false\\).{0,75};\\};)", 322 | "g" 323 | ), 324 | `window.kklee.showColourPickerArguments=[...arguments];\ 325 | document.getElementById("kkleeColourInput").value="#"+arguments[0]\ 326 | .toString(16).padStart(6,"0");$1;\ 327 | let Kscpa=this["showColorPicker"];window.kklee.setColourPickerColour=\ 328 | function(c){Kscpa(c,...window.kklee.showColourPickerArguments.slice(1));};\ 329 | window.kklee.bonkShowColorPicker=Kscpa;` 330 | ); 331 | // Map editor preview test time between each frame 332 | window.kklee.editorPreviewTimeMs = 30; 333 | replace( 334 | new RegExp( 335 | "(?<=(? 354 | !kklee.scopedData.guest && 355 | (kklee.mapObject.m.rxa == "" || 356 | kklee.mapObject.m.rxa == kklee.scopedData.userName); 357 | 358 | /* 359 | Stage renderer that contains methods that is used for the 360 | Map editor preview, tutorial, replays etc.. 361 | - panStage(-xDelta, yDelta) // (Positive xDelta will move left) 362 | - scaleStage(scale) // Scales the stage by scale 363 | - resetStage() // Resets the stage zoom 364 | - getCanvas() // Returns the HTMLCanvas element 365 | - ...and more 366 | */ 367 | replace( 368 | new RegExp("(.{3}\\[.{1,3}\\]=new .{1,3}\\(document)"), 369 | "window.kklee.stageRenderer=$1" 370 | ); 371 | 372 | /* 373 | Map editor rectangle overlay drawing 374 | if (C3V[22]) { 375 | C3V[38] = new PIXI.Graphics(); // Exported as 376 | // kklee.editorImageOverlay.background 377 | C3V[38].lineStyle(4, 16776960); // Set the outline to yellow (0xffff00) 378 | S9L.u1R(15); 379 | C3V[38].drawRect(-2, -2, S9L.N1R(4, 730), S9L.g1R(4, 500)); // Draw rect 380 | C3V[19].addChild(C3V[38]); 381 | C3V[92] = new PIXI.Graphics(); 382 | C3V[19].addChild(C3V[92]); 383 | } 384 | */ 385 | 386 | // Exposes variable used for map editor preview overlay drawing 387 | kklee.editorImageOverlay = { 388 | opacity: 0.3, 389 | x: 0, 390 | y: 0, 391 | w: 0, 392 | h: 0, 393 | angle: 0, 394 | ogW: 0, 395 | ogH: 0, 396 | sprite: null, 397 | imageState: "none", 398 | }; 399 | replace( 400 | // The new regex makes sure that x = new PIXI is the same object 401 | // as the one that has its line colour set to 0xffff00 402 | new RegExp( 403 | "((.{1,3}\\[.{1,3}\\])=new PIXI\\[.{1,3}\\[.{1,3}\\]\\[.{1,3}\\]\\]\ 404 | \\(\\);.{0,500}\\2\\[.{1,3}\\[.{1,3}\\]\\[.{1,3}\\]\\]\\(4,0xffff00\\);)" 405 | ), 406 | "window.kklee.editorImageOverlay.background=$1" 407 | ); 408 | 409 | kklee.editorImageOverlay.updateSpriteSettings = () => { 410 | const e = kklee.editorImageOverlay, 411 | p = e.sprite; 412 | p.x = e.x + 365; 413 | p.y = e.y + 250; 414 | p.width = e.w; 415 | p.height = e.h; 416 | p.alpha = e.opacity; 417 | p.angle = e.angle; 418 | kklee.updateRenderer(true); 419 | }; 420 | kklee.editorImageOverlay.loadImage = (event) => { 421 | // If nothing is passed, then reset the image 422 | if (!event || !event.target || !event.target.files.length) { 423 | if (kklee.editorImageOverlay.sprite) 424 | kklee.editorImageOverlay.sprite.destroy(); 425 | kklee.editorImageOverlay.sprite = null; 426 | kklee.updateRenderer(true); 427 | kklee.editorImageOverlay.imageState = "none"; 428 | kklee.rerenderKklee(); 429 | return; 430 | } 431 | 432 | const target = event.target; 433 | const img = new Image(); 434 | 435 | // If someone tries something that an can't handle 436 | img.onerror = () => { 437 | if (kklee.editorImageOverlay.sprite) 438 | kklee.editorImageOverlay.sprite.destroy(); 439 | kklee.editorImageOverlay.sprite = null; 440 | kklee.updateRenderer(true); 441 | 442 | kklee.editorImageOverlay.imageState = "error"; 443 | kklee.rerenderKklee(); 444 | }; 445 | img.onload = () => { 446 | try { 447 | const e = kklee.editorImageOverlay; 448 | if (e.sprite) e.sprite.destroy(); 449 | e.sprite = window.PIXI.Sprite.from(window.PIXI.Texture.from(img)); 450 | e.background.addChild(e.sprite); 451 | 452 | e.sprite.anchor.set(0.5); 453 | e.ogW = e.sprite.texture.width; 454 | e.ogH = e.sprite.texture.height; 455 | e.w = e.ogW; 456 | e.h = e.ogH; 457 | e.updateSpriteSettings(); 458 | 459 | e.imageState = "image"; 460 | kklee.rerenderKklee(); 461 | } catch (er) { 462 | console.error(er); 463 | if (kklee.editorImageOverlay.sprite) 464 | kklee.editorImageOverlay.sprite.destroy(); 465 | kklee.editorImageOverlay.sprite = null; 466 | kklee.updateRenderer(true); 467 | 468 | kklee.editorImageOverlay.imageState = "error"; 469 | kklee.rerenderKklee(); 470 | } 471 | }; 472 | 473 | // Load the image from file picker to the element 474 | img.src = URL.createObjectURL(target.files[0]); 475 | }; 476 | 477 | kklee.dataLimitInfo = () => { 478 | try { 479 | // Check how many bytes the decompressed map is 480 | const d = atob( 481 | window.LZString.decompressFromEncodedURIComponent( 482 | kklee.mapEncoder.encodeToDatabase(kklee.mapObject) 483 | ) 484 | ).length; 485 | return `${d}/102400 bytes`; 486 | } catch { 487 | return "Over data limit"; 488 | } 489 | }; 490 | 491 | kklee.dispatchInputEvent = (el) => el.dispatchEvent(new InputEvent("input")); 492 | 493 | kklee.setEnableUpdateChecks = (enable) => { 494 | if (enable) { 495 | window.localStorage["kkleeEnableUpdateChecks"] = true; 496 | } else { 497 | delete window.localStorage["kkleeEnableUpdateChecks"]; 498 | } 499 | }; 500 | kklee.areUpdateChecksEnabled = () => 501 | Boolean(window.localStorage["kkleeEnableUpdateChecks"]); 502 | 503 | // Load kklee 504 | require("./___nimBuild___.js"); 505 | 506 | console.log("kklee injector run"); 507 | return src; 508 | } 509 | 510 | if (!window.bonkCodeInjectors) window.bonkCodeInjectors = []; 511 | window.bonkCodeInjectors.push((bonkCode) => { 512 | try { 513 | return injector(bonkCode); 514 | } catch (error) { 515 | alert( 516 | `Whoops! kklee was unable to load. 517 | 518 | 519 | This may be due to an update to Bonk.io. If so, please report this error! 520 | 521 | 522 | This could also be because you have an extension that is incompatible with \ 523 | kklee, such as the Bonk Leagues Client. You would have to disable it to use \ 524 | kklee. 525 | ` 526 | ); 527 | throw error; 528 | } 529 | }); 530 | console.log("kklee injector loaded"); 531 | 532 | // Automatic update checking 533 | 534 | const currentVersion = require("../dist/manifest.json") 535 | .version.split(".") 536 | // "0.10" --> [0,10] 537 | .map(Number); 538 | 539 | (async () => { 540 | if ( 541 | !window.localStorage["kkleeEnableUpdateChecks"] || 542 | // Check if there already was a check within the last hour 543 | Date.now() - 544 | (Number(window.localStorage["kkleeLastUpdateCheckTimestamp"]) || 0) < 545 | 1000 * 60 * 60 * 1 546 | ) { 547 | return; 548 | } 549 | console.log("Checking for new kklee updates"); 550 | window.localStorage["kkleeLastUpdateCheckTimestamp"] = Date.now(); 551 | 552 | let message = null; 553 | 554 | try { 555 | const req = await fetch( 556 | "https://api.github.com/repos/BonkModdingCommunity/kklee/releases" 557 | ); 558 | const releases = await req.json(); 559 | for (const r of releases) { 560 | // "v0.10" --> [0,10] 561 | const version = r.tag_name.substr(1).split(".").map(Number); 562 | if (version.length != 2 || isNaN(version[0]) || isNaN(version[1])) 563 | continue; 564 | if ( 565 | version[0] > currentVersion[0] || 566 | (version[0] == currentVersion[0] && version[1] > currentVersion[1]) 567 | ) { 568 | message = "A new version of kklee is available! Click this"; 569 | break; 570 | } 571 | } 572 | } catch (error) { 573 | console.error(error); 574 | message = "Something went wrong with checking for new versions of kklee."; 575 | } 576 | if (message === null) return; 577 | 578 | try { 579 | // Add update notification at the top of the page 580 | const el = document.createElement("span"); 581 | el.textContent = message; 582 | el.style = 583 | "position: absolute; background: linear-gradient(#33a, #d53);\ 584 | line-height: normal; cursor: pointer;"; 585 | el.onclick = () => window.open("https://github.com/BonkModdingCommunity/kklee"); 586 | parent.document.getElementById("bonkioheader").appendChild(el); 587 | } catch (error) { 588 | console.error(error); 589 | alert( 590 | `Something went wrong with displaying this message normally: 591 | ${message} 592 | https://github.com/BonkModdingCommunity/kklee` 593 | ); 594 | } 595 | })(); 596 | -------------------------------------------------------------------------------- /src/main.nim: -------------------------------------------------------------------------------- 1 | import 2 | std/[dom, algorithm, sugar, strutils, math, strformat], 3 | kkleeApi, kkleeMain, bonkElements, shapeMultiSelect, platformMultiSelect 4 | 5 | proc shapeTableCell(label: string; cell: Element): Element = 6 | result = document.createElement("tr") 7 | 8 | let labelNode = document.createElement("td") 9 | labelNode.innerText = label 10 | labelNode.class = "mapeditor_rightbox_table_leftcell" 11 | result.appendChild(labelNode) 12 | 13 | let cellNode = document.createElement("td") 14 | cellNode.appendChild(cell) 15 | cellNode.class = "mapeditor_rightbox_table_rightcell" 16 | result.appendChild(cellNode) 17 | 18 | proc createBonkButton(label: string; onclick: proc: void): Element = 19 | result = document.createElement("div") 20 | result.innerText = label 21 | result.class = "brownButton brownButton_classic buttonShadow" 22 | result.onclick = proc(e: Event) = onclick() 23 | result.onmousedown = proc(e: Event) = playBonkButtonClickSound() 24 | result.onmouseover = proc(e: Event) = playBonkButtonHoverSound() 25 | 26 | proc isSimulating*(): bool = docElemById("mapeditor_midbox_playbutton").classList.contains("mapeditor_midbox_playbutton_stop") 27 | 28 | afterNewMapObject = hide 29 | 30 | let 31 | rightBoxShapeTableContainer = 32 | docElemById("mapeditor_rightbox_shapetablecontainer") 33 | mapEditorDiv = 34 | docElemById("mapeditor") 35 | 36 | # Shape count indicator on platform 37 | 38 | let shapeCount = document.createElement("span") 39 | shapeCount.style.margin = cstring "10px 10px" 40 | rightBoxShapeTableContainer.insertBefore( 41 | shapeCount, docElemById("mapeditor_rightbox_shapeaddcontainer") 42 | ) 43 | 44 | var 45 | bi: int 46 | body: MapBody 47 | 48 | afterUpdateRightBoxBody = proc(fx: int) = 49 | if getCurrentBody() notin 0..moph.bodies.high: 50 | return 51 | let shapeElements = rightBoxShapeTableContainer 52 | .getElementsByClassName("mapeditor_rightbox_table_shape") 53 | 54 | bi = getCurrentBody() 55 | body = bi.getBody 56 | 57 | # Update shape count indicator 58 | var shapeCountText = &"{body.fx.len}/100 shapes" 59 | if body.fx.len > 100: 60 | shapeCountText &= 61 | "\nWARNING: shapes will be deleted when you save the map!" 62 | shapeCount.style.color = "var(--kkleeErrorColour)" 63 | else: 64 | shapeCount.style.color = "" 65 | shapeCount.innerText = cstring shapeCountText 66 | 67 | for i, se in shapeElements.reversed: 68 | let 69 | fxId = bi.getBody.fx[i] 70 | fixture = getFx fxId 71 | capture fixture, body, fxId: 72 | if fixture.fxShape.shapeType == stypePo: 73 | proc editVerticies = 74 | state = StateObject( 75 | kind: seVertexEditor, 76 | b: body, fx: fixture 77 | ) 78 | rerender() 79 | se.appendChild shapeTableCell("", 80 | createBonkButton("Edit vertices", editVerticies)) 81 | 82 | proc editCapZone = 83 | var shapeCzId = -1 84 | for i, cz in mapObject.capZones: 85 | if cz.i == fxId: 86 | shapeCzId = i 87 | break 88 | if shapeCzId == -1: 89 | mapObject.capZones.add MapCapZone( 90 | n: "Cap Zone", ty: cztRed, l: 10, i: fxId) 91 | shapeCzId = mapObject.capZones.high 92 | updateLeftBox() 93 | updateRenderer(true) 94 | saveToUndoHistory() 95 | document.getElementsByClassName("mapeditor_listtable")[^1] 96 | .children[0].children[shapeCzId].Element.click() 97 | se.appendChild shapeTableCell("", 98 | createBonkButton("Capzone", editCapZone)) 99 | 100 | if fixture.fxShape.shapeType == stypeBx: 101 | proc rectToPoly = 102 | let bx = fixture.fxShape 103 | moph.shapes[fixture.sh] = MapShape( 104 | stype: $stypePo, 105 | a: bx.a, 106 | c: bx.c, 107 | poS: 1.0, 108 | poV: @[[-bx.bxW/2, -bx.bxH/2], [bx.bxW/2, -bx.bxH/2], 109 | [bx.bxW/2, bx.bxH/2], [-bx.bxW/2, bx.bxH/2]] 110 | ) 111 | saveToUndoHistory() 112 | updateRightBoxBody(fxId) 113 | se.appendChild shapeTableCell("", 114 | createBonkButton("To polygon", rectToPoly)) 115 | 116 | 117 | shapeMultiSelectElementBorders() 118 | 119 | # Dragging shapes in shapes list 120 | 121 | var draggedShapeElement: Element = nil 122 | var draggedShapeUpdated = false 123 | 124 | proc styleDraggedShapeElement = 125 | draggedShapeUpdated = true 126 | draggedShapeElement.style.boxShadow = "0px 0px 50px 1px" 127 | draggedShapeElement.style.zIndex = "99" 128 | draggedShapeElement.style.backdropFilter = "blur(10px)" 129 | # Prevent preview being updated due to hovering over shapes while 130 | # dragging to reduce lag 131 | for el in rightBoxShapeTableContainer.children: 132 | if not el.Element.classList.contains( 133 | "mapeditor_rightbox_table_shape_container" 134 | ): 135 | continue 136 | for el in el.children: 137 | if el.Element.classList.contains( 138 | "mapeditor_rightbox_table_shape_headerfield" 139 | ): 140 | el.onmouseover = nil 141 | el.onmouseout = nil 142 | 143 | if not draggedShapeElement.isNil: 144 | styleDraggedShapeElement() 145 | 146 | rightBoxShapeTableContainer.addEventListener("mousedown", proc(e: Event) = 147 | let e = e.MouseEvent 148 | let target = e.target.Element 149 | if target.classList.contains("mapeditor_rightbox_table_shape_headerfield") and 150 | target.parentElement.classList.contains( 151 | "mapeditor_rightbox_table_shape_container" 152 | ): 153 | draggedShapeElement = e.target.parentElement 154 | ) 155 | 156 | document.addEventListener("mousemove", proc(e: Event) = 157 | if draggedShapeElement.isNil: 158 | return 159 | let e = e.MouseEvent 160 | draggedShapeElement.style.translate = "" 161 | 162 | var rect = draggedShapeElement.getBoundingClientRect() 163 | let translateY = e.clientY.float - rect.y 164 | if abs(translateY) < 20: 165 | return 166 | styleDraggedShapeElement() 167 | draggedShapeElement.style.translate = cstring &"0px {translateY - 6}px" 168 | 169 | # Collapse all shape elements so that they all have the same height 170 | for node in rightBoxShapeTableContainer.children: 171 | let classList = node.Element.classList 172 | if (classList.contains("mapeditor_rightbox_table_shape_container") and 173 | not classList.contains( 174 | "mapeditor_rightbox_table_shape_container_collapsed")): 175 | for childNode in node.children: 176 | if childNode.class == "mapeditor_rightbox_table_shape_pm": 177 | childNode.Element.click() 178 | rect = draggedShapeElement.getBoundingClientRect() 179 | 180 | if abs(translateY) > rect.height: 181 | let body = getCurrentBody().getBody 182 | 183 | # Shapes list is .fx reversed 184 | let moveCount = -int(translateY / rect.height) 185 | # Index in .fx, not .fixtures 186 | let fxIndex = rightBoxShapeTableContainer.children.reversed.find( 187 | draggedShapeElement) 188 | let newFxIndex = fxIndex + moveCount 189 | if newFxIndex notin 0..body.fx.high: 190 | return 191 | 192 | let fxId = body.fx[fxIndex] 193 | 194 | body.fx.delete(fxIndex) 195 | body.fx.insert(fxId, newFxIndex) 196 | 197 | updateRightBoxBody(-1) 198 | 199 | draggedShapeElement = rightBoxShapeTableContainer.children[ 200 | rightBoxShapeTableContainer.children.high - newFxIndex].Element 201 | styleDraggedShapeElement() 202 | ) 203 | document.addEventListener("mouseup", proc(e: Event) = 204 | if not draggedShapeUpdated: 205 | draggedShapeElement = nil 206 | return 207 | draggedShapeUpdated = false 208 | draggedShapeElement.style.translate = "" 209 | draggedShapeElement.style.boxShadow = "" 210 | draggedShapeElement.style.zIndex = "" 211 | draggedShapeElement = nil 212 | saveToUndoHistory() 213 | updateRightBoxBody(-1) 214 | updateRenderer(true) 215 | ) 216 | 217 | 218 | # Platform multiselect 219 | 220 | let platformMultiSelectButton = createBonkButton("Multiselect", proc = 221 | state = StateObject(kind: sePlatformMultiSelect) 222 | rerender() 223 | ) 224 | platformMultiSelectButton.style.margin = "3px" 225 | platformMultiSelectButton.style.width = "100px" 226 | 227 | proc initPlatformMultiSelect = 228 | let platformsContainer = docElemById("mapeditor_leftbox_platformtable") 229 | if platformsContainer.isNil: return 230 | platformsContainer.appendChild(platformMultiSelectButton) 231 | platformMultiSelectElementBorders() 232 | 233 | platformsContainer.children[0].addEventListener("click", proc(e: Event) = 234 | let e = e.MouseEvent 235 | if not e.shiftKey: return 236 | if state.kind != sePlatformMultiSelect: 237 | state = StateObject(kind: sePlatformMultiSelect) 238 | rerender() 239 | 240 | let index = platformsContainer.children[0].children.find e.target.parentNode 241 | if index == -1: return 242 | let b = moph.bro[index].getBody 243 | 244 | if b notin selectedBodies: 245 | selectedBodies.add b 246 | else: 247 | selectedBodies.delete(selectedBodies.find b) 248 | platformMultiSelectElementBorders() 249 | ) 250 | 251 | # Dragging platforms in platform list 252 | 253 | var draggedPlatformUpdated = false 254 | var draggedPlatformElement: Element = nil 255 | 256 | proc styleDraggedPlatformElement = 257 | draggedPlatformUpdated = true 258 | draggedPlatformElement.style.boxShadow = "0px 0px 50px 1px" 259 | draggedPlatformElement.style.zIndex = "99" 260 | # Prevent preview being updated due to hovering over platforms while 261 | # dragging to reduce lag 262 | for el in docElemById( 263 | "mapeditor_leftbox_platformtable" 264 | ).children[0].children: 265 | if el.nodeName == "TR": 266 | el.onmouseover = nil 267 | el.onmouseout = nil 268 | 269 | proc initPlatformDragging = 270 | let platformsContainer = docElemById("mapeditor_leftbox_platformtable") 271 | if platformsContainer.isNil: return 272 | 273 | platformsContainer.addEventListener("mousedown", proc(e: Event) = 274 | let e = e.MouseEvent 275 | if e.target.nodeName == "TD": 276 | draggedPlatformElement = e.target.parentElement 277 | ) 278 | 279 | document.addEventListener("mousemove", proc(e: Event) = 280 | if draggedPlatformElement.isNil: 281 | return 282 | let e = e.MouseEvent 283 | draggedPlatformElement.style.translate = "" 284 | 285 | let rect = draggedPlatformElement.getBoundingClientRect() 286 | let translateY = e.clientY.float - rect.y 287 | if abs(translateY) < 3: 288 | return 289 | styleDraggedPlatformElement() 290 | draggedPlatformElement.style.translate = cstring &"0px {translateY - 6}px" 291 | 292 | if abs(translateY) > rect.height: 293 | let moveCount = int(translateY / rect.height) 294 | # Index in .bro, not .bodies 295 | let bodyIndex = docElemById("mapeditor_leftbox_platformtable").children[0] 296 | .children.find(draggedPlatformElement) 297 | 298 | let newBodyIndex = bodyIndex + moveCount 299 | if newBodyIndex notin 0..moph.bro.high: 300 | return 301 | 302 | let bodyId = moph.bro[bodyIndex] 303 | 304 | moph.bro.delete(bodyIndex) 305 | moph.bro.insert(bodyId, newBodyIndex) 306 | 307 | updateLeftBox() 308 | 309 | draggedPlatformElement = docElemById("mapeditor_leftbox_platformtable") 310 | .children[0].children[newBodyIndex].Element 311 | styleDraggedPlatformElement() 312 | ) 313 | document.addEventListener("mouseup", proc(e: Event) = 314 | if not draggedPlatformUpdated: 315 | draggedPlatformElement = nil 316 | return 317 | draggedPlatformUpdated = false 318 | draggedPlatformElement.style.translate = "" 319 | draggedPlatformElement.style.boxShadow = "" 320 | draggedPlatformElement.style.zIndex = "" 321 | draggedPlatformElement = nil 322 | saveToUndoHistory() 323 | updateLeftBox() 324 | updateRenderer(true) 325 | ) 326 | 327 | afterUpdateLeftBox = proc = 328 | # This fixes the bug where shapeMultiSelectElementBorders would throw an 329 | # error when the right box was not updated to show the currently selected 330 | # platform. This would occur when the user creates a new platform while 331 | # shape multi-select is open. 332 | if docElemById("mapeditor_rightbox_platformparams").style.visibility != 333 | "none" and 334 | getCurrentBody() in 0..moph.bodies.high and 335 | body != getCurrentBody().getBody: 336 | updateRightBoxBody(-1) 337 | 338 | initPlatformMultiSelect() 339 | initPlatformDragging() 340 | 341 | 342 | # Generate shape button 343 | 344 | let shapeGeneratorButton = createBonkButton("Generate shape", proc = 345 | state = StateObject( 346 | kind: seShapeGenerator, 347 | b: body 348 | ) 349 | rerender() 350 | ) 351 | shapeGeneratorButton.setAttr("style", 352 | "float: left; margin-bottom: 5px; margin-left: 10px; width: 190px") 353 | 354 | rightBoxShapeTableContainer 355 | .insertBefore( 356 | shapeGeneratorButton, 357 | docElemById("mapeditor_rightbox_shapeaddcontainer").nextSibling 358 | ) 359 | 360 | # Shape multiselect 361 | 362 | let shapeMultiSelectButton = createBonkButton("Multiselect shapes", proc = 363 | state = StateObject(kind: seShapeMultiSelect) 364 | rerender() 365 | ) 366 | 367 | shapeMultiSelectButton.setAttr "style", 368 | "float: left; margin-bottom: 5px; margin-left: 10px; width: 190px" 369 | 370 | rightBoxShapeTableContainer 371 | .insertBefore( 372 | shapeMultiSelectButton, 373 | docElemById("mapeditor_rightbox_shapeaddcontainer").nextSibling 374 | ) 375 | 376 | rightBoxShapeTableContainer 377 | .addEventListener("click", proc(e: Event) = 378 | let e = e.MouseEvent 379 | if not e.shiftKey: return 380 | fixturesBody = getCurrentBody().getBody 381 | if state.kind != seShapeMultiSelect: 382 | state = StateObject(kind: seShapeMultiSelect) 383 | rerender() 384 | 385 | let 386 | shapeElements = rightBoxShapeTableContainer 387 | .getElementsByClassName("mapeditor_rightbox_table_shape_headerfield") 388 | .reversed() 389 | body = getCurrentBody().getBody 390 | index = shapeElements.find e.target.Element 391 | 392 | if index == -1: return 393 | let fx = moph.fixtures[body.fx[index]] 394 | 395 | if not selectedFixtures.contains(fx): 396 | selectedFixtures.add fx 397 | else: 398 | selectedFixtures.delete(selectedFixtures.find fx) 399 | shapeMultiSelectElementBorders() 400 | rerender() 401 | ) 402 | 403 | # Total mass of platform value textbox 404 | 405 | let totalMassTextbox = document.createElement("input") 406 | totalMassTextbox.style.width = "60px" 407 | totalMassTextbox.style.backgroundColor = "gray" 408 | docElemById("mapeditor_rightbox_table_dynamic").children[0] 409 | .appendChild shapeTableCell("Platform mass", totalMassTextbox) 410 | totalMassTextbox.addEventListener("mouseenter", proc(e: Event) = 411 | setEditorExplanation( 412 | "[kklee]\n" & 413 | "This shows the total mass of the platform. You can't edit this directly." 414 | ) 415 | ) 416 | 417 | totalMassTextbox.addEventListener("mousemove", proc(e: Event) = 418 | var totalMass = 0.0 419 | let body = getCurrentBody().getBody 420 | for fxId in body.fx: 421 | let 422 | fx = fxId.getFx 423 | if fx.np: 424 | continue 425 | let 426 | sh = fx.fxShape 427 | density = if fx.de == jsNull: body.s.de 428 | else: fx.de 429 | area = case sh.shapeType 430 | of stypeBx: 431 | sh.bxH * sh.bxW 432 | of stypeCi: 433 | PI * sh.ciR ^ 2 434 | of stypePo: 435 | var area = 0.0 436 | for i, p1 in sh.poV: 437 | let p2 = sh.poV[if i == sh.poV.high: 0 else: i + 1] 438 | area += p1.x * p2.y - p2.x * p1.y 439 | area / 2 440 | mass = area * density 441 | totalMass += mass 442 | totalMassTextbox.value = cstring $totalMass 443 | ) 444 | 445 | # See chat in editor 446 | 447 | let chat = docElemById("newbonklobby_chatbox") 448 | let parentDocument {.importc: "parent.document".}: Document 449 | var isChatInEditor = false 450 | 451 | proc moveChatToEditor(e: Event) = 452 | if isChatInEditor: return 453 | isChatInEditor = true; 454 | mapEditorDiv.insertBefore( 455 | chat, 456 | docElemById("mapeditor_leftbox") 457 | ) 458 | chat.setAttribute("style", 459 | ("position: fixed; left: 0%; top: 0%; width: calc((20% - 100px) * 0.9); " & 460 | "height: 81%; margin: 10vh 1%;") 461 | ) 462 | parentDocument.getElementById("adboxverticalleftCurse").style.display = "none" 463 | # Modifying scrollTop immediately won't work, so I used setTimeout 0ms 464 | discard setTimeout(proc = docElemById( 465 | "newbonklobby_chat_content").scrollTop = 1e7.int, 0) 466 | 467 | proc restoreChat(e: Event) = 468 | if not isChatInEditor: return 469 | isChatInEditor = false 470 | docElemById("newbonklobby").insertBefore( 471 | chat, docElemById("newbonklobby_settingsbox") 472 | ) 473 | chat.setAttribute("style", "") 474 | parentDocument.getElementById("adboxverticalleftCurse").style.display = "" 475 | 476 | docElemById("newbonklobby_editorbutton") 477 | .addEventListener("click", moveChatToEditor) 478 | mapEditorDiv.addEventListener("mouseover", moveChatToEditor) 479 | 480 | docElemById("mapeditor_close") 481 | .addEventListener("click", restoreChat) 482 | docElemById("hostleaveconfirmwindow_endbutton") 483 | .addEventListener("click", restoreChat) 484 | docElemById("hostleaveconfirmwindow_okbutton") 485 | .addEventListener("click", restoreChat) 486 | 487 | docElemById("newbonklobby") 488 | .addEventListener("mouseover", restoreChat) 489 | docElemById("gamerenderer") 490 | .addEventListener("mouseover", restoreChat) 491 | 492 | docElemById("mapeditor_midbox_testbutton") 493 | .addEventListener("click", proc(e: Event) = 494 | chat.style.visibility = "hidden" 495 | ) 496 | docElemById("pretty_top_exit").addEventListener("click", proc(e: Event) = 497 | chat.style.visibility = "" 498 | ) 499 | 500 | # New platform type 501 | 502 | var newPlatformType = "s" 503 | var newPlatformNp = false 504 | let createMenu = docElemById("mapeditor_leftbox_createmenucontainerleft") 505 | createMenu.addEventListener("click", proc(e: Event) = 506 | # Assume everything else to be "s" 507 | if e.target.id == "mapeditor_leftbox_createmenu_platform_d": 508 | newPlatformType = "d" 509 | else: 510 | newPlatformType = "s" 511 | 512 | # No physics 513 | newPlatformNp = e.target.id == "mapeditor_leftbox_createmenu_platform_np" 514 | ) 515 | 516 | # Blank platform 517 | 518 | let platformMenu = docElemById("mapeditor_leftbox_createmenu_platformmenu") 519 | let blankPlatform = document.createElement("div") 520 | blankPlatform.classList.add("mapeditor_leftbox_createbutton") 521 | blankPlatform.classList.add("brownButton") 522 | blankPlatform.classList.add("brownButton_classic") 523 | blankPlatform.classList.add("buttonShadow") 524 | blankPlatform.textContent = "Blank" 525 | platformMenu.insertBefore(blankPlatform, platformMenu.firstChild) 526 | blankPlatform.addEventListener("click", proc(e: Event) = 527 | type cg = MapBodyCollideGroup 528 | 529 | moph.bodies.add MapBody( 530 | cf: MapBodyCf( 531 | w: true 532 | ), 533 | fz: MapBodyFz( 534 | d: true, 535 | p: true, 536 | a: true 537 | ), 538 | s: MapSettings( 539 | btype: newPlatformType, 540 | n: "Unnamed", 541 | de: 0.3, 542 | fric: 0.3, 543 | re: 0.8, 544 | f_p: true, 545 | f_1: true, 546 | f_2: true, 547 | f_3: true, 548 | f_4: true, 549 | f_c: cg.A 550 | ) 551 | ) 552 | 553 | let bodyId = moph.bodies.high 554 | moph.bro.insert(bodyId, 0) 555 | saveToUndoHistory() 556 | updateLeftBox() 557 | # Close new platform menu 558 | docElemById("mapeditor_leftbox_addbutton").click() 559 | let platformsContainer = docElemById("mapeditor_leftbox_platformtable") 560 | # Select the new platform 561 | platformsContainer.querySelector("tr").click() 562 | ) 563 | 564 | # Colour picker 565 | 566 | let colourPicker = docElemById("mapeditor_colorpicker") 567 | let colourInput = document.createElement("input") 568 | colourInput.setAttribute("type", "color") 569 | colourInput.id = "kkleeColourInput" 570 | colourPicker.appendChild(colourInput) 571 | colourInput.addEventListener("change", proc(e: Event) = 572 | let strVal = $colourInput.value 573 | setColourPickerColour(parseHexInt(strVal[1..^1])) 574 | saveToUndoHistory() 575 | docElemById("mapeditor_colorpicker_cancelbutton").click() 576 | ) 577 | 578 | # Arithmetic in fields 579 | 580 | import mathexpr 581 | let myEvaluator = newEvaluator() 582 | myEvaluator.addFunc("rand", mathExprJsRandom, 0) 583 | 584 | mapEditorDiv.addEventListener("keydown", proc(e: Event) = 585 | let e = e.KeyboardEvent 586 | if not (e.shiftKey and e.key == "Enter"): 587 | return 588 | let el = document.activeElement 589 | if not el.classList.contains("mapeditor_field"): 590 | return 591 | 592 | try: 593 | let evalRes = myEvaluator.eval($el.value) 594 | if evalRes.isNaN or evalRes > 1e6 or evalRes < -1e6: 595 | raise ValueError.newException("Number is NaN or is too big") 596 | el.value = cstring evalRes.niceFormatFloat() 597 | el.dispatchEvent(newEvent("input")) 598 | saveToUndoHistory() 599 | except CatchableError: 600 | discard 601 | ) 602 | 603 | # Editor test speed slider 604 | 605 | let speedSlider = document.createElement("input").InputElement 606 | speedSlider.`type` = "range" 607 | speedSlider.min = "0" 608 | speedSlider.max = "8" 609 | speedSlider.step = "1" 610 | speedSlider.value = "3" 611 | speedSlider.class = "compactSlider compactSlider_classic" 612 | speedSlider.style.width = "100px" 613 | speedSlider.style.background = "var(--kkleePreviewSliderMarkerBackground)" 614 | speedSlider.setAttr("title", "Preview speed") 615 | speedSlider.addEventListener("input", proc(e: Event) = 616 | # Default is 30 617 | let n = parseFloat($speedSlider.value) 618 | editorPreviewTimeMs = if n == 0.0: 0.0 619 | else: n ^ 3 + 3.0 620 | ) 621 | let rightButtonContainer = 622 | docElemById("mapeditor_midbox_rightbuttoncontainer") 623 | rightButtonContainer.insertBefore( 624 | speedSlider, 625 | docElemById("mapeditor_midbox_playbutton") 626 | ) 627 | 628 | # Tips 629 | 630 | let 631 | tipsList = document.createElement("ul") 632 | 633 | proc addTip(t: string) = 634 | let el = document.createElement("ul") 635 | el.innerText = t 636 | tipsList.appendChild(el) 637 | addTip( 638 | "You can enter arithmetic into fields, such as 100*2+50, and evaluate it " & 639 | "with Shift+Enter" 640 | ) 641 | addTip( 642 | "Keyboard shortcuts: Save - Ctrl+S, Preview - Space, Play - Shift+Space, " & 643 | "Return to editor after play - Shift+Esc" 644 | ) 645 | addTip( 646 | "Use up/down arrows in number fields to increase/decrease value - " & 647 | "Just arrow: 10, Shift+Arrow: 1, Ctrl+Arrow: 100, Ctrl+Shift+Arrow: 0.1" 648 | ) 649 | 650 | tipsList.setAttr("style", "font-size: 11px;padding: 10px 15px;") 651 | docElemById("mapeditor_rightbox_platformparams").appendChild(tipsList) 652 | 653 | # Keyboard shortcuts 654 | 655 | var mouseIsOverPreview = false 656 | let mapeditorcontainer = docElemById("mapeditorcontainer") 657 | let previewContainer = docElemById("mapeditor_midbox_previewcontainer") 658 | 659 | previewContainer.addEventListener("mouseenter", proc(ev: Event) = 660 | mouseIsOverPreview = true 661 | ) 662 | previewContainer.addEventListener("mouseleave", proc(ev: Event) = 663 | mouseIsOverPreview = false 664 | ) 665 | 666 | mapEditorDiv.setAttr("tabindex", "0") 667 | mapEditorDiv.addEventListener("keydown", proc(e: Event) = 668 | let e = e.KeyboardEvent 669 | block keybindTarget: 670 | if e.target != mapEditorDiv or 671 | docElemById("gamerenderer").style.visibility == "inherit": 672 | break keybindTarget 673 | if e.ctrlKey and e.key == "s": 674 | docElemById("mapeditor_midbox_savebutton").click() 675 | docElemById("mapeditor_save_window_save").click() 676 | elif e.shiftKey and e.key == " ": 677 | docElemById("mapeditor_midbox_testbutton").click() 678 | elif e.key == " ": 679 | docElemById("mapeditor_midbox_playbutton").click() 680 | else: 681 | break keybindTarget 682 | e.preventDefault() 683 | block fieldValueChange: 684 | if not document.activeElement.classList.contains("mapeditor_field"): 685 | break fieldValueChange 686 | let val = try: parseFloat $e.target.value 687 | except: break fieldValueChange 688 | let amount = 689 | if e.ctrlKey and e.shiftKey: 0.1 690 | elif e.shiftKey: 1 691 | elif e.ctrlKey: 100 692 | else: 10 693 | if e.key == "ArrowUp": 694 | e.target.value = cstring $(val + amount) 695 | elif e.key == "ArrowDown": 696 | e.target.value = cstring $(val - amount) 697 | dispatchInputEvent(e.target) 698 | block cameraPan: 699 | if not mouseIsOverPreview: 700 | break cameraPan 701 | let amount = 702 | if e.ctrlKey and e.shiftKey: 10 703 | elif e.shiftKey: 25 704 | elif e.ctrlKey: 150 705 | else: 50 706 | if e.key == "ArrowLeft": 707 | panStage(amount, 0) 708 | elif e.key == "ArrowRight": 709 | panStage(-amount, 0) 710 | elif e.key == "ArrowUp": 711 | panStage(0, amount) 712 | elif e.key == "ArrowDown": 713 | panStage(0, -amount) 714 | else: 715 | break cameraPan 716 | e.preventDefault() 717 | if not isSimulating(): 718 | updateRenderer(true) 719 | ) 720 | 721 | # Return to map editor after clicking play 722 | document.addEventListener("keydown", proc(e: Event) = 723 | let e = e.KeyboardEvent 724 | if docElemById("gamerenderer").style.visibility == "inherit" and 725 | e.shiftKey and e.key == "Escape": 726 | e.preventDefault() 727 | docElemById("pretty_top_exit").click() 728 | mapEditorDiv.focus() 729 | ) 730 | 731 | # Transfer map ownership button 732 | proc openTransferOwnership = 733 | state = StateObject(kind: seTransferOwnership) 734 | rerender() 735 | docElemById("mapeditor_rightbox_mapparams").appendChild( 736 | shapeTableCell("", createBonkButton("Transfer ownership", 737 | openTransferOwnership))) 738 | 739 | # Map size info 740 | proc openMapSizeInfo = 741 | state = StateObject(kind: seMapSizeInfo) 742 | rerender() 743 | docElemById("mapeditor_rightbox_mapparams").appendChild( 744 | shapeTableCell("", createBonkButton("Map size info", 745 | openMapSizeInfo))) 746 | 747 | # Map backup loader 748 | proc openBackupLoader = 749 | state = StateObject(kind: seBackups) 750 | rerender() 751 | docElemById("mapeditor_rightbox_mapparams").appendChild( 752 | shapeTableCell("", createBonkButton("Load map backup", 753 | openBackupLoader))) 754 | 755 | 756 | # Editor preview image overlay 757 | proc openEditorImageOverlay = 758 | state = StateObject(kind: seEditorImageOverlay) 759 | rerender() 760 | docElemById("mapeditor_rightbox_mapparams").appendChild( 761 | shapeTableCell("", createBonkButton("Image overlay", 762 | openEditorImageOverlay))) 763 | 764 | # kklee settings button 765 | proc openKkleeSettings = 766 | state = StateObject(kind: seKkleeSettings) 767 | rerender() 768 | docElemById("mapeditor_rightbox_mapparams").appendChild( 769 | shapeTableCell("", createBonkButton("kklee settings", 770 | openKkleeSettings))) 771 | 772 | # Make map editor explanation text selectable 773 | docElemById("mapeditor_midbox_explain").setAttr("style", "user-select: text") 774 | 775 | # Fix chat box autofill 776 | docElemById("newbonklobby_chat_input").setAttr("autocomplete", "off") 777 | docElemById("ingamechatinputtext").setAttr("autocomplete", "off") 778 | 779 | # Add max height for colour picker's existing colours container 780 | 781 | let existingColoursContainer = 782 | docElemById("mapeditor_colorpicker_existingcontainer") 783 | existingColoursContainer.style.maxHeight = "150px" 784 | existingColoursContainer.style.overflowY = "scroll" 785 | 786 | 787 | # CSS style 788 | 789 | let styleSheet = document.createElement("style") 790 | styleSheet.innerText = static: cstring staticRead("./kkleeStyles.css") 791 | document.head.appendChild(styleSheet) 792 | 793 | # Info about arrow shortcuts when hovering over map preview 794 | 795 | previewContainer.addEventListener( 796 | "mouseenter", (proc(e: Event) = 797 | setEditorExplanation( 798 | "[kklee]\n" & 799 | "You can use arrow keys to pan around the editor preview.\n" & 800 | "Shortcut modified to change pan amount:\nJust Arrow: 50\n" & 801 | "Shift + Arrow: 25\nCtrl + Arrow: 150\nCtrl + Shift + Arrow: 10" 802 | ) 803 | ) 804 | ) 805 | 806 | # Fix scroll zoom sensitivity in editor preview when using a trackpad 807 | 808 | var scrollAmount = 0.0 809 | previewContainer.addEventListener("wheel", proc(e: Event) = 810 | var deltaY = 0.0 811 | {.emit: [deltaY, "=", e, ".deltaY;"].} 812 | scrollAmount += deltaY * 0.025 813 | if scrollAmount < -1: 814 | scaleStage(1.25) 815 | if not isSimulating(): 816 | updateRenderer(false) 817 | scrollAmount = 0 818 | elif scrollAmount > 1: 819 | scaleStage(0.8) 820 | if not isSimulating(): 821 | updateRenderer(false) 822 | scrollAmount = 0 823 | 824 | e.preventDefault() 825 | e.stopImmediatePropagation() 826 | ) 827 | 828 | # Fix map preview canvas becoming black on resize 829 | 830 | var canvas = previewContainer.querySelector("canvas") 831 | var canvasSize = (0, 0) 832 | proc checkResize(_: float) = 833 | if mapeditorcontainer.style.display != "none": 834 | if isNil(canvas): 835 | canvas = previewContainer.querySelector("canvas") 836 | else: 837 | let size = (canvas.clientWidth, canvas.clientHeight) 838 | if canvasSize != size and not isSimulating(): 839 | updateRenderer(false) 840 | canvasSize = size 841 | discard window.requestAnimationFrame(checkResize) 842 | 843 | discard window.requestAnimationFrame(checkResize) 844 | --------------------------------------------------------------------------------