├── free.png ├── icon.png ├── screenshots ├── demo.gif ├── desktop.png ├── mobile.png ├── animatedTiles.gif ├── demo-tilemaps.gif ├── flipTileOnX.gif └── randomTileFrame.gif ├── importers └── tiled.js ├── .travis.yml ├── itchIframe.html ├── updatePwa.js ├── manifest.webmanifest ├── .github └── FUNDING.yml ├── sw.js ├── package.json ├── LICENSE ├── .gitignore ├── src ├── styles.css └── tilemap-editor.js ├── README.md └── index.html /free.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blurymind/tilemap-editor/HEAD/free.png -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blurymind/tilemap-editor/HEAD/icon.png -------------------------------------------------------------------------------- /screenshots/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blurymind/tilemap-editor/HEAD/screenshots/demo.gif -------------------------------------------------------------------------------- /screenshots/desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blurymind/tilemap-editor/HEAD/screenshots/desktop.png -------------------------------------------------------------------------------- /screenshots/mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blurymind/tilemap-editor/HEAD/screenshots/mobile.png -------------------------------------------------------------------------------- /screenshots/animatedTiles.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blurymind/tilemap-editor/HEAD/screenshots/animatedTiles.gif -------------------------------------------------------------------------------- /screenshots/demo-tilemaps.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blurymind/tilemap-editor/HEAD/screenshots/demo-tilemaps.gif -------------------------------------------------------------------------------- /screenshots/flipTileOnX.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blurymind/tilemap-editor/HEAD/screenshots/flipTileOnX.gif -------------------------------------------------------------------------------- /screenshots/randomTileFrame.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/blurymind/tilemap-editor/HEAD/screenshots/randomTileFrame.gif -------------------------------------------------------------------------------- /importers/tiled.js: -------------------------------------------------------------------------------- 1 | const importTiledJson = ()=>{ 2 | console.log("TEST ======================") 3 | } 4 | 5 | export default importTiledJson; -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 14.17.4 4 | cache: yarn 5 | 6 | install: 7 | - yarn install 8 | script: 9 | - node updatePwa.js 10 | 11 | deploy: 12 | provider: pages 13 | skip_cleanup: true 14 | github-token: $GITHUB_TOKEN 15 | on: 16 | branch: main -------------------------------------------------------------------------------- /itchIframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tilemap Editor 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /updatePwa.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | fs.readFile("./sw.js", 'utf8', function (err,data) { 4 | if (err) return console.log(err); 5 | const result = data.replace(/const cacheName = ".*";/, 6 | `const cacheName = "${Date.now()}";`); 7 | fs.writeFile("./sw.js", result, 'utf8', (err) => { 8 | if (err) return console.log(err); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "background_color": "black", 3 | "description": "Let's you create and edit tile maps anywhere", 4 | "display": "fullscreen", 5 | "icons": [ 6 | { 7 | "src": "icon.png", 8 | "sizes": "512x512", 9 | "type": "image/png", 10 | "purpose": "any maskable" 11 | } 12 | ], 13 | "name": "TileMap Editor", 14 | "short_name": "Tile map", 15 | "start_url": "/tilemap-editor/index.html", 16 | "theme_color": "purple" 17 | } 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [blurymind] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: blurymind 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /sw.js: -------------------------------------------------------------------------------- 1 | const cacheName = "1629829956916"; 2 | 3 | self.addEventListener('install', (e) => { 4 | e.waitUntil( 5 | caches.open(cacheName).then((cache) => cache.addAll([ 6 | '/tilemap-editor/', 7 | '/tilemap-editor/index.html', 8 | '/tilemap-editor/src/tilemap-editor.js', 9 | '/tilemap-editor/src/styles.css', 10 | ])), 11 | ); 12 | }); 13 | 14 | self.addEventListener('message', (e) => { 15 | if (e.data.action === 'skipWaiting') { 16 | self.skipWaiting(); 17 | } 18 | }); 19 | 20 | self.addEventListener('fetch', (e) => { 21 | if (e.request.url.match( /^.*(imgur=).*$/) ) { 22 | return false; 23 | } 24 | console.log(e.request.url); 25 | e.respondWith( 26 | caches.match(e.request).then((response) => fetch(e.request) || response), 27 | ); 28 | }); 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tilemap-editor", 3 | "version": "0.7.8", 4 | "description": "A fat-free tilemap editor with zero dependencies and a scalable, mobile-friendly interface!", 5 | "main": "src/tilemap-editor.js", 6 | "scripts": { 7 | "start": "serve", 8 | "predeploy": "node updatePwa.js", 9 | "deploy": "gh-pages -d ." 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/blurymind/tilemap-editor.git" 14 | }, 15 | "keywords": [ 16 | "tilemap", 17 | "level", 18 | "editor", 19 | "gamedev", 20 | "editor", 21 | "kaboomjs" 22 | ], 23 | "author": "Todor Imreorov", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/blurymind/tilemap-editor/issues" 27 | }, 28 | "homepage": "https://github.com/blurymind/tilemap-editor#readme", 29 | "devDependencies": { 30 | "gh-pages": "^3.2.2", 31 | "serve": "^12.0.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Todor Imreorov 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | 107 | # intellij 108 | .idea/ 109 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* Minimal dropdown */ 2 | .menu:hover .dropdown { 3 | max-height: 700px; 4 | } 5 | .menu { 6 | position: relative; 7 | display: flex; 8 | background: #00000033; 9 | padding: 3px; 10 | border-radius: 3px; 11 | z-index: 400; 12 | } 13 | .menu.parameters{ 14 | display: contents; 15 | font-size: 1.1rem !important; 16 | } 17 | .menu.file{ 18 | min-width: 70px; 19 | } 20 | .menu .dropdown { 21 | clear: left; 22 | overflow: auto; 23 | max-height: 0px; 24 | transition: max-height 0.5s; 25 | -ms-overflow-style: none; 26 | scrollbar-width: none; 27 | position: absolute; 28 | background: #000000e0; 29 | color: white; 30 | min-width: 245px; 31 | border-radius: 5px; 32 | top: 25px; 33 | left: 3px; 34 | } 35 | .menu .dropdown.left { 36 | right: 0; 37 | top: unset; 38 | left: unset; 39 | position: fixed; 40 | } 41 | .menu:hover { 42 | background: rgba(0, 0, 0, 0.88); 43 | } 44 | .menu .dropdown .item{ 45 | display: flex; 46 | gap: 3px; 47 | box-sizing: border-box; 48 | padding: 7px; 49 | } 50 | .menu .dropdown .item:not(.nohover):hover { 51 | color: black; 52 | background: rgba(219, 198, 198, 0.88); 53 | } 54 | 55 | /*POPUP*/ 56 | .overlay { 57 | position: fixed; 58 | top: 0; 59 | bottom: 0; 60 | left: 0; 61 | right: 0; 62 | background: rgba(0, 0, 0, 0.7); 63 | transition: opacity 500ms; 64 | visibility: hidden; 65 | opacity: 0; 66 | z-index: 100; 67 | } 68 | .overlay:target { 69 | visibility: visible; 70 | opacity: 1; 71 | } 72 | .popup { 73 | margin: 30% auto; 74 | padding: 10px; 75 | background: #000000; 76 | border: 1px solid white; 77 | border-radius: 5px; 78 | width: 50%; 79 | position: relative; 80 | transition: all 2s ease-in-out; 81 | } 82 | .popup .close { 83 | position: absolute; 84 | top: 0; 85 | right: 5px; 86 | transition: all 200ms; 87 | font-size: 30px; 88 | font-weight: bold; 89 | text-decoration: none; 90 | color: #b0b0b0; 91 | } 92 | .popup .content { 93 | max-height: 30%; 94 | overflow: auto; 95 | } 96 | 97 | /* Other */ 98 | .card { 99 | height: 100%; 100 | } 101 | 102 | .card header { 103 | display: flex; 104 | justify-content: space-between; 105 | align-items: center; 106 | border-bottom: 1px solid #ddd; 107 | margin-bottom: 3px; 108 | padding: 3px; 109 | } 110 | 111 | .card header h1 { 112 | margin: 0; 113 | } 114 | 115 | .card header .button-as-link { 116 | margin-right: 1em; 117 | } 118 | 119 | .card aside { 120 | flex: 1; 121 | } 122 | 123 | .card_body { 124 | display: flex; 125 | flex: 1; 126 | height: 100%; 127 | } 128 | 129 | .card_right-column { 130 | flex: 3; 131 | overflow: auto; 132 | padding-right: 16px; 133 | padding-bottom: 50px; 134 | } 135 | .card_right-column.layers { 136 | flex: 1; 137 | position: absolute; 138 | right: 0; 139 | background: #ffffffb0; 140 | height: 50vh; 141 | border-radius: 3px; 142 | user-select: none; 143 | padding-right: 0; 144 | } 145 | .card_right-column.layers:hover{ 146 | background: rgba(0, 0, 0, 0.69); 147 | } 148 | #mapCanvas { 149 | border:1px solid cyan; 150 | border-radius: 3px; 151 | touch-action: none; 152 | /*touch-action: auto;*/ 153 | } 154 | #tileset-source { 155 | pointer-events: none; 156 | } 157 | .canvas_resizer{ 158 | user-select: none; 159 | /*cursor: row-resize;*/ 160 | touch-action: none; 161 | background: #ffffff42; 162 | color: white; 163 | padding: 3px; 164 | border-radius: 3px; 165 | width: 50px; 166 | } 167 | 168 | .tileset_opt_field input, 169 | .canvas_resizer input { 170 | max-width: 45px; 171 | } 172 | 173 | 174 | .tileset_opt_field.header{ 175 | padding: 3px; 176 | } 177 | .canvas_resizer.vertical{ 178 | position: absolute; 179 | transform-origin: left; 180 | /*cursor: col-resize;*/ 181 | bottom: 0; 182 | } 183 | .canvas_wrapper{ 184 | position: relative; 185 | top:0; 186 | display: table; 187 | } 188 | 189 | .tileset-container { 190 | position: relative; 191 | display: table; 192 | margin-left: 1px; 193 | user-select: none; 194 | } 195 | 196 | .tileset-container-selection { 197 | position: absolute; 198 | pointer-events: none; 199 | outline: 1px solid cyan; 200 | left: 0; 201 | top: 0; 202 | width: 32px; 203 | height: 32px; 204 | color: white; 205 | text-shadow: 1px 1px #0a0101; 206 | background-color: rgba(66, 255, 222, 0.29); 207 | text-align: center; 208 | } 209 | 210 | .tilemapjs_root { 211 | font-family: Arial, Helvetica, sans-serif; 212 | touch-action: manipulation; 213 | } 214 | 215 | .layers button { 216 | -webkit-appearance: none; 217 | -moz-appearance: none; 218 | appearance: none; 219 | font-family: inherit; 220 | outline: 0; 221 | background: transparent; 222 | border: 0; 223 | padding: 2px 0; 224 | display: block; 225 | text-align: left; 226 | cursor: pointer; 227 | } 228 | 229 | .layers button.active { 230 | font-weight: bold; 231 | color: #0884f1; 232 | background-color: rgba(255, 255, 0, 0.05); 233 | } 234 | 235 | .button-as-link { 236 | -webkit-appearance: none; 237 | -moz-appearance: none; 238 | appearance: none; 239 | text-decoration: underline; 240 | background: transparent; 241 | color: #7f808e; 242 | border: 0; 243 | outline: 0; 244 | cursor: pointer; 245 | } 246 | .active-tool{ 247 | background-color: yellow !important; 248 | border-radius: 3px; 249 | } 250 | .primary-button { 251 | border: 0; 252 | background: #4e84fa; 253 | color: #fff; 254 | border-radius: 6px; 255 | outline: 0; 256 | cursor: pointer; 257 | } 258 | 259 | .tileset_opt_field{ 260 | font-size: 0.9em; 261 | display:flex; 262 | flex-direction: row; 263 | height:30px; 264 | gap: 3px; 265 | align-items: center; 266 | padding: 0 2px; 267 | justify-content: space-between; 268 | } 269 | 270 | .tilemap_editor_root{ 271 | height: 100%; 272 | overflow: hidden; 273 | } 274 | 275 | .details_container { 276 | border: 1px #797979 solid; 277 | padding: 2px; 278 | } 279 | .card_left_column{ 280 | min-width: 240px; 281 | max-width: 285px; 282 | overflow: auto; 283 | padding-right: 3px; 284 | max-height: 90vh; 285 | margin: 2px; 286 | } 287 | 288 | .layers { 289 | height: 100%; 290 | width: 200px; 291 | overflow: auto; 292 | display: flex; 293 | flex-direction: column; 294 | } 295 | .layer { 296 | display: flex; 297 | gap: 1px; 298 | height: 30px; 299 | align-items: center; 300 | padding: 0 3px; 301 | border-bottom: 1px solid #80808045; 302 | } 303 | .tileset_info{ 304 | margin-left: 3px; 305 | color: #ececcd; 306 | font-size: 0.8em; 307 | -webkit-line-clamp: 3; 308 | display: -webkit-box; 309 | -webkit-box-orient: vertical; 310 | } 311 | .tileset_info, 312 | #activeLayerLabel, 313 | .select_layer{ 314 | flex:1; 315 | /*max-width: 200px;*/ 316 | /*white-space: nowrap;*/ 317 | overflow: hidden; 318 | text-overflow: ellipsis; 319 | } 320 | #tilesetDescriptionLabel{ 321 | color: #91b2ff; 322 | } 323 | .layer:hover{ 324 | background-color: rgba(255, 255, 0, 0.05); 325 | } 326 | .add_layer { 327 | display: flex; 328 | justify-content: space-between; 329 | align-items: center; 330 | padding-left: 3px; 331 | } 332 | .add_layer button{ 333 | /*width: 110px;*/ 334 | margin-right: 7px; 335 | } 336 | @media only screen and (max-width: 600px) { 337 | .card_body { 338 | /*padding-top: 1px;*/ 339 | flex-direction: column; 340 | } 341 | .card aside { 342 | overflow: hidden; 343 | } 344 | .card_right-column{ 345 | border-top: solid grey 1px; 346 | padding-right: 0; 347 | padding-top: 3px; 348 | flex: 6; 349 | } 350 | .card_right-column.layers{ 351 | padding-bottom: 25px; 352 | position: relative; 353 | } 354 | .card_left_column{ 355 | flex: 2; 356 | overflow: auto; 357 | padding-right: 0; 358 | max-width: unset; 359 | transition-duration: 500ms; 360 | transition-property: flex; 361 | } 362 | .card_left_column:hover{ 363 | flex: 6; 364 | } 365 | .tileset_opt_field { 366 | font-size: 0.7em; 367 | } 368 | .layers{ 369 | width:100%; 370 | } 371 | .card_right-column.layers{ 372 | transition-duration: 500ms; 373 | transition-property: flex; 374 | } 375 | .card_right-column.layers:hover { 376 | flex: 3; 377 | } 378 | } 379 | 380 | .sticky { 381 | background: #000000; 382 | color: #ffffff; 383 | position: sticky; 384 | top: 0; 385 | } 386 | .tileset_grid_container { 387 | position: absolute; 388 | text-align: center; 389 | top: 0; 390 | left: 0; 391 | flex: 1; 392 | display: flex; 393 | flex-wrap: wrap; 394 | pointer-events: none; 395 | } 396 | .tileset_grid_tile{ 397 | box-shadow: inset 0 0 1px yellow, inset 0 0 1px yellow, inset 0 0 1px yellow; 398 | pointer-events: none; 399 | opacity: 0.5; 400 | color: white; 401 | text-shadow: 1px 1px black; 402 | font-size: 0.7rem; 403 | } 404 | 405 | .select_container select { 406 | flex: 1; 407 | } 408 | 409 | /*dark mode*/ 410 | .tilemapjs_root { 411 | color: #fbd5ff; 412 | background-color: #0a0101; 413 | background: linear-gradient(0deg, rgb(0 0 0) 0%, rgb(84 84 84) 100%); 414 | } 415 | .card_right-column.layers { 416 | background: #ffffff14; 417 | } 418 | .layer.select_layer{ 419 | color: antiquewhite; 420 | } 421 | .layers .active { 422 | color: #88c6ff; 423 | background-color: rgba(255, 255, 0, 0.05); 424 | } 425 | .sticky_top{ 426 | position: sticky; 427 | top: 0; 428 | z-index: 1; 429 | } 430 | .sticky_top2{ 431 | position: sticky; 432 | top: 30px; 433 | z-index: 1; 434 | } 435 | .sticky_left { 436 | position: sticky; 437 | left: 0; 438 | } 439 | .sticky_settings{ 440 | background-color: #17171fc7 !important; 441 | border-radius: 2px; 442 | padding: 2px; 443 | } 444 | .sticky_settings select{ 445 | max-width: 35%; 446 | } 447 | .tilemaps_selector{ 448 | display: flex; 449 | flex-direction: row; 450 | gap: 5px; 451 | background-color: #00000094; 452 | padding: 2px; 453 | } 454 | 455 | #tilemapjs_root button { 456 | background-color: #6b6b6b; 457 | color: white; 458 | border: 1px solid #757575; 459 | margin: 2px; 460 | } 461 | #tilemapjs_root button:hover{ 462 | background-color: #858585; 463 | } 464 | .add_layer button, 465 | .tilemaps_selector button { 466 | min-width: 20px; 467 | text-align: -webkit-center; 468 | } 469 | .tilemaps_selector > button{ 470 | border-radius: 10px; 471 | } 472 | 473 | .tilemaps_selector select{ 474 | width: 100%; 475 | } 476 | .limited_select { 477 | width: 50%; 478 | } 479 | 480 | .toggleFlipX::after { 481 | content: "⭕"; 482 | } 483 | #toggleFlipX:checked ~ .toggleFlipX::after { 484 | content: "🔛"; 485 | } 486 | 487 | 488 | #animLoop ~ .animLoop::after { 489 | content: "🌀"; 490 | opacity: 0.4; 491 | } 492 | #animLoop:checked ~ .animLoop::after { 493 | opacity: 1; 494 | } 495 | 496 | .hidden { 497 | display: none; 498 | } 499 | input[name='tool']+label{ 500 | background: unset; 501 | } 502 | .tool_wrapper>*:hover, 503 | input[name='tool']:checked+label{ 504 | background-color: #2d2906; 505 | border-radius: 3px; 506 | padding: 2px; 507 | } 508 | .tool_wrapper{ 509 | display: flex; 510 | gap: 7px; 511 | } 512 | .tool_wrapper >label { 513 | padding: 2px; 514 | } 515 | 516 | a:link { 517 | color: #ffc300; 518 | } 519 | a:visited { 520 | color: #98ff98; 521 | } 522 | a:hover { 523 | color: hotpink; 524 | } 525 | a:active { 526 | color: #ffae00; 527 | } 528 | 529 | .two-digit-width{ 530 | max-width: 38px !important; 531 | } 532 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

TilemapEditor

2 | 3 | try it online at https://blurymind.github.io/tilemap-editor/ 4 | 5 | The online demo is an installable pwa, which has as a goal to demonstrate integration of the editor in other projects. 6 | You can use the pwa as a way of sharing a demo of tilesets and tilemaps you have created! 7 | 8 | As an example this url: 9 | https://blurymind.github.io/tilemap-editor/?imgur=SjjsjTm 10 | the imgur=ID at the end tells tilemap-editor to use as tilesets an imgur uploads gallery with the same id in its url: 11 | https://imgur.com/a/SjjsjTm 12 | 13 | In the same way you can also store your tilemaps in github gists! 14 | as an example this url: 15 | https://blurymind.github.io/tilemap-editor/?gist=e81f38830a67444c54adfb4f69c6538d 16 | the gist=ID at the end tells tilemap-editor to load the timap data (which also has the tilesets in it) from a gist at github with the same id in its url: 17 | https://gist.github.com/blurymind/e81f38830a67444c54adfb4f69c6538d 18 | 19 | To store a tilemap in a gist, export it with file>downloadjson file, open the file and copy its contents, then paste them into a gist. Or alternatively just upload the file to a gist. Finally copy the gist's ID and then append the gist=yourGistId to the tilemap-editor url. 20 | 21 | I plan to make this a simpler process in the future if there is enough interest. 22 | 23 | --- 24 | 25 |

26 | About | 27 | Features | 28 | Reason | 29 | Getting started | 30 | Api use | 31 | How to Contribute | 32 |

33 | 34 | --- 35 | 36 | ## :space_invader: About 37 | 38 | TileMap Editor is a fat-free tile map editor with zero dependencies and a scalable, mobile-friendly interface. 39 | 40 | ## :gift: features 41 | 42 | - Multiple tileset support 43 | - Multiple tilemap support 44 | - Multi-tile selection and painting (drag select multiple tiles from the tileset) 45 | - Tileset meta-data editing (Assign tags to tiles, automatic assignment of symbols to tiles) 46 | - Animated tiles support 47 | - Flipped tiles support 48 | - Tilemap layers (as many as you like) with opacity and visibility 49 | - Export boilerplate code for kaboomjs https://kaboomjs.com/ (wip) 50 | - Customizable export data 51 | - Resizable tilemap - non destructive too 52 | - Paint tool, Pan tool, eraser tool, Bucket fill tool, Random tile tool, Pick tile tool 53 | - Undo/redo system 54 | - Responsive interface (scales down to portrait mode on mobile) 55 | - Tiny footprint 56 | - Easy I/O api that lets you transform and save data with ease 57 | 58 | Planned: 59 | - Paint tool modes (line, square, circle,etc) 60 | - tiled i/o 61 | 62 |

63 | 64 |

65 | 66 | Multiple tilemaps and tilesets are supported in one file/session 67 |

68 | 69 |

70 | 71 | It also scales all the way down to a smartphone screen in portrait mode 72 |

73 | 74 |

75 | 76 | You can flip tiles 77 |

78 | 79 |

80 | 81 | It can even do animated tiles 82 |

83 | 84 |

85 | 86 | The random tile brush can also use animation frames to place a random frame 87 |

88 | 89 |

90 | 91 | ## :cyclone: Reason 92 | 93 | But Todor, why are you making another tilemap editor with all these other ones out there? 94 | 95 | While I am a big fan of Tiled and LdTk, for my case I was looking for something that neither had: 96 | - Tiny footprint. Other tilemap editors are 60-100+ mb and require installation. Tilemap-editor is 30kb as of the time of writing this. 97 | - Can be used by other js projects/web apps/websites. It has been designed to be a module, which you can plug in your project easily. 98 | - No build process required, no webpack, no transpiling. Thats true, it's a single js+css file with no external dependencies! 99 | - Runs everywhere - mobile too. The other available options can not run on android or ios. 100 | - Responsive interface that scales all the way down to a portrait mode smartphone. Thats right, one of the goals is to let you make maps on your phone. 101 | - Again it just uses vanilla javascript, no react, no webpack, no 1gb+ eaten by the node modules folder. Inspect its code in the browser and it all there clear as a day. 102 | - No complicated build processes. Since it's just a js file, you don't need to wait for it to rebuild every time you change it 103 | 104 | ## :eyeglasses: Getting started 105 | 106 | ```bash 107 | $ git clone https://github.com/blurymind/tilemap-editor.git 108 | $ yarn 109 | $ yarn start 110 | ``` 111 | 112 | ## :book: Api 113 | 114 | To get it from npm, you can run 115 | 116 | ```bash 117 | $ npm i tilemap-editor 118 | or 119 | $ yarn add tilemap-editor 120 | ``` 121 | 122 | To use it, you can import it via require or in the index file like so 123 | 124 | ```js 125 | // include the js and css files 126 | 127 | 128 | 129 | 218 | ``` 219 | 220 | 221 | ## :wrench: How to Contribute 222 | 223 | You are welcome to add new features or fix some bugs: 224 | 225 | 1. Fork this repository 226 | 227 | 2. Clone your fork 228 | ```bash 229 | $ git clone https://github.com/blurymind/tilemap-editor.git 230 | ``` 231 | 232 | - Create a branch with your changes 233 | 234 | ```bash 235 | $ git checkout -b my-awesome-changes 236 | ``` 237 | 238 | - Make the commit with your changes 239 | 240 | ```bash 241 | $ git commit -m 'feat: add a shortcut to copy a tile of the canvas' 242 | ``` 243 | 244 | - Push your branch 245 | 246 | ```bash 247 | # Send the code to your remote branch 248 | $ git push origin my-awesome-changes 249 | ``` 250 | 251 | - Create a _Pull Request_ 252 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 71 | 72 | 73 | TileMapEditor.js 74 | 75 | 76 |
77 | 78 |
A new version of this app is available. Click here to update.
79 | 80 | 81 | 436 | 437 | 449 | 450 | 451 | 452 | -------------------------------------------------------------------------------- /src/tilemap-editor.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | (function (root, factory) { 3 | // @ts-ignore 4 | if (typeof exports === 'object' && typeof exports.nodeName !== 'string') { 5 | // CommonJS 6 | factory(exports); 7 | } else { 8 | // Browser globals 9 | // @ts-ignore 10 | factory((root.TilemapEditor = {})); 11 | } 12 | })(typeof self !== 'undefined' ? self : this, function (exports) { 13 | // Call once on element to add behavior, toggle on/off isDraggable attr to enable 14 | const draggable = ({element, onElement = null, isDrag = false, onDrag = null, 15 | limitX = false, limitY = false, onRelease = null}) => { 16 | element.setAttribute("isDraggable", isDrag); 17 | let isMouseDown = false; 18 | let mouseX; 19 | let mouseY; 20 | let elementX = 0; 21 | let elementY = 0; 22 | const onMouseMove = (event) => { 23 | if (!isMouseDown || element.getAttribute("isDraggable") === "false") return; 24 | const deltaX = event.clientX - mouseX; 25 | const deltaY = event.clientY - mouseY; 26 | // element.style.position = "relative" 27 | if(!limitX) element.style.left = elementX + deltaX + 'px'; 28 | if(!limitY) element.style.top = elementY + deltaY + 'px'; 29 | console.log("DRAGGING", {deltaX, deltaY, x: elementX + deltaX, y:elementY + deltaY}) 30 | if(onDrag) onDrag({deltaX, deltaY, x: elementX + deltaX, y:elementY + deltaY, mouseX, mouseY}); 31 | } 32 | const onMouseDown = (event) => { 33 | if(element.getAttribute("isDraggable") === "false") return; 34 | 35 | mouseX = event.clientX; 36 | mouseY = event.clientY; 37 | console.log("MOUSEX", mouseX) 38 | isMouseDown = true; 39 | } 40 | const onMouseUp = () => { 41 | if(!element.getAttribute("isDraggable") === "false") return; 42 | isMouseDown = false; 43 | elementX = parseInt(element.style.left) || 0; 44 | elementY = parseInt(element.style.top) || 0; 45 | if(onRelease) onRelease({x:elementX,y:elementY}) 46 | } 47 | (onElement || element).addEventListener('pointerdown', onMouseDown); 48 | document.addEventListener('pointerup', onMouseUp); 49 | document.addEventListener('pointermove', onMouseMove); 50 | } 51 | const drawGrid = (w, h,ctx, step = 16, color='rgba(0,255,217,0.5)') => { 52 | ctx.strokeStyle = color; 53 | ctx.lineWidth = 0.5; 54 | ctx.beginPath(); 55 | for (let x = 0; x < w + 1; x += step) { 56 | ctx.moveTo(x, 0.5); 57 | ctx.lineTo(x, h + 0.5); 58 | } 59 | for (let y = 0; y < h +1; y += step) { 60 | ctx.moveTo(0, y + 0.5); 61 | ctx.lineTo(w, y + 0.5); 62 | } 63 | ctx.stroke(); 64 | } 65 | const toBase64 = file => new Promise((resolve, reject) => { 66 | const reader = new FileReader(); 67 | reader.readAsDataURL(file); 68 | reader.onload = () => resolve(reader.result); 69 | reader.onerror = error => reject(error); 70 | }); 71 | exports.toBase64 = toBase64; 72 | 73 | const decoupleReferenceFromObj = (obj) => JSON.parse(JSON.stringify(obj)); 74 | const getHtml = (width, height) =>{ 75 | return ` 76 |
77 | 78 |
79 | 104 |
105 |
106 | 107 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 |
129 |
130 | 131 |
132 | 133 | 134 | 135 | 136 | 137 |
138 | 139 |
140 | 141 |
142 | 143 |
144 |
145 |
146 |
147 | 148 | 149 | | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 |
158 |
159 | Tile size: 160 | 161 |
162 |
163 | Tileset loader: 164 | 165 |
166 |
167 | 168 |
169 |
170 | 171 |
172 |
173 | 👓️ 174 | 175 | 178 | 179 | 180 |
181 | 182 | 237 | 238 |
239 |
240 | 241 | 242 | 243 |
244 |
245 |
246 |
247 | 248 |
-y-
249 |
-x-
250 |
251 |
252 |
253 |
254 | 255 | 256 | 257 | 258 | 🎚️ 259 |
260 | 277 |
278 |
279 | 280 | 286 |
287 |
288 |
289 |
290 | ` 291 | } 292 | const getEmptyLayer = (name="layer")=> ({tiles:{}, visible: true, name, animatedTiles: {}, opacity: 1}); 293 | let tilesetImage, canvas, tilesetContainer, tilesetSelection, cropSize, 294 | confirmBtn, tilesetGridContainer, 295 | layersElement, resizingCanvas, mapTileHeight, mapTileWidth, tileDataSel,tileFrameSel,tileAnimSel, 296 | tilesetDataSel, mapsDataSel, objectParametersEditor; 297 | 298 | const el = {tileFrameCount:"", animStart:"", animEnd:"",renameTileFrameBtn:"",renameTileAnimBtn:"", animSpeed: "", animLoop:""}; 299 | Object.keys(el).forEach(key=>{ 300 | el[key] = () => document.getElementById(key); 301 | }) 302 | 303 | let TILESET_ELEMENTS = []; 304 | let IMAGES = [{src:''}]; 305 | let ZOOM = 1; 306 | let SIZE_OF_CROP = 32; 307 | let WIDTH = 0; 308 | let HEIGHT = 0; 309 | const TOOLS = { 310 | BRUSH: 0, 311 | ERASE: 1, 312 | PAN: 2, 313 | PICK: 3, 314 | RAND: 4, 315 | FILL: 5 316 | } 317 | let PREV_ACTIVE_TOOL = 0; 318 | let ACTIVE_TOOL = 0; 319 | let ACTIVE_MAP = ""; 320 | let DISPLAY_SYMBOLS = false; 321 | let SHOW_GRID = false; 322 | const getEmptyMap = (name="map", mapWidth =20, mapHeight=20, tileSize = 32, gridColor="#00FFFF") => 323 | ({layers: [getEmptyLayer("bottom"), getEmptyLayer("middle"), getEmptyLayer("top")], name, 324 | mapWidth, mapHeight, tileSize, width: mapWidth * SIZE_OF_CROP,height: mapHeight * SIZE_OF_CROP, gridColor }); 325 | 326 | const getEmptyTilesetTag = (name, code, tiles ={}) =>({name,code,tiles}); 327 | 328 | const getEmptyTileSet = ({ 329 | src, 330 | name = "tileset", 331 | gridWidth, 332 | gridHeight, 333 | tileData = {}, 334 | symbolStartIdx, 335 | tileSize = SIZE_OF_CROP, 336 | tags = {}, 337 | frames = {}, 338 | width, 339 | height, 340 | description = "n/a" 341 | }) => { 342 | return { src, name, gridWidth, gridHeight, tileCount: gridWidth * gridHeight, tileData, symbolStartIdx,tileSize, tags, frames, description, width, height} 343 | } 344 | 345 | const getSnappedPos = (pos) => (Math.round(pos / (SIZE_OF_CROP)) * (SIZE_OF_CROP)); 346 | let selection = []; 347 | let currentLayer = 0; 348 | let isMouseDown = false; 349 | let maps = {}; 350 | let tileSets = {}; 351 | 352 | let apiTileSetLoaders = {}; 353 | let selectedTileSetLoader = {}; 354 | let apiTileMapExporters = {}; 355 | let apiTileMapImporters = {}; 356 | let apiOnUpdateCallback = () => {}; 357 | let apiOnMouseUp = () => {}; 358 | 359 | let editedEntity 360 | 361 | const getContext = () => canvas.getContext('2d'); 362 | 363 | const setLayer = (newLayer) => { 364 | currentLayer = Number(newLayer); 365 | 366 | const oldActivedLayer = document.querySelector('.layer.active'); 367 | if (oldActivedLayer) { 368 | oldActivedLayer.classList.remove('active'); 369 | } 370 | 371 | document.querySelector(`.layer[tile-layer="${newLayer}"]`)?.classList.add('active'); 372 | document.getElementById("activeLayerLabel").innerHTML = ` 373 | Editing Layer: ${maps[ACTIVE_MAP].layers[newLayer]?.name} 374 | 384 | `; 385 | document.getElementById("layerOpacitySlider").value = maps[ACTIVE_MAP].layers[newLayer]?.opacity; 386 | document.getElementById("layerOpacitySlider").addEventListener("change", e =>{ 387 | addToUndoStack(); 388 | document.getElementById("layerOpacitySliderValue").innerText = e.target.value; 389 | maps[ACTIVE_MAP].layers[currentLayer].opacity = Number(e.target.value); 390 | draw(); 391 | updateLayers(); 392 | }) 393 | } 394 | 395 | const setLayerIsVisible = (layer, override = null) => { 396 | const layerNumber = Number(layer); 397 | maps[ACTIVE_MAP].layers[layerNumber].visible = override ?? !maps[ACTIVE_MAP].layers[layerNumber].visible; 398 | document 399 | .getElementById(`setLayerVisBtn-${layer}`) 400 | .innerHTML = maps[ACTIVE_MAP].layers[layerNumber].visible ? "👁️": "👓"; 401 | draw(); 402 | } 403 | 404 | const trashLayer = (layer) => { 405 | const layerNumber = Number(layer); 406 | maps[ACTIVE_MAP].layers.splice(layerNumber, 1); 407 | updateLayers(); 408 | setLayer(maps[ACTIVE_MAP].layers.length - 1); 409 | draw(); 410 | } 411 | 412 | const addLayer = () => { 413 | const newLayerName = prompt("Enter layer name", `Layer${maps[ACTIVE_MAP].layers.length + 1}`); 414 | if(newLayerName !== null) { 415 | maps[ACTIVE_MAP].layers.push(getEmptyLayer(newLayerName)); 416 | updateLayers(); 417 | } 418 | } 419 | 420 | const updateLayers = () => { 421 | layersElement.innerHTML = maps[ACTIVE_MAP].layers.map((layer, index)=>{ 422 | return ` 423 |
424 |
${layer.name} ${layer.opacity < 1 ? ` (${layer.opacity})` : ""}
425 | 426 |
1 ? "":`disabled="true"`}>🗑️
427 |
428 | ` 429 | }).reverse().join("\n") 430 | 431 | maps[ACTIVE_MAP].layers.forEach((_,index)=>{ 432 | document.getElementById(`selectLayerBtn-${index}`).addEventListener("click",e=>{ 433 | setLayer(e.target.getAttribute("tile-layer")); 434 | addToUndoStack(); 435 | }) 436 | document.getElementById(`setLayerVisBtn-${index}`).addEventListener("click",e=>{ 437 | setLayerIsVisible(e.target.getAttribute("vis-layer")) 438 | addToUndoStack(); 439 | }) 440 | document.getElementById(`trashLayerBtn-${index}`).addEventListener("click",e=>{ 441 | trashLayer(e.target.getAttribute("trash-layer")) 442 | addToUndoStack(); 443 | }) 444 | setLayerIsVisible(index, true); 445 | }) 446 | setLayer(currentLayer); 447 | } 448 | 449 | const getTileData = (x= null,y= null) =>{ 450 | const tilesetTiles = tileSets[tilesetDataSel.value].tileData; 451 | let data; 452 | if(x === null && y === null){ 453 | const {x: sx, y: sy} = selection[0]; 454 | return tilesetTiles[`${sx}-${sy}`]; 455 | } else { 456 | data = tilesetTiles[`${x}-${y}`] 457 | } 458 | return data; 459 | } 460 | const setTileData = (x = null,y = null,newData, key= "") =>{ 461 | const tilesetTiles = tileSets[tilesetDataSel.value].tileData; 462 | if(x === null && y === null){ 463 | const {x:sx, y:sy} = selection[0]; 464 | tilesetTiles[`${sx}-${sy}`] = newData; 465 | } 466 | if(key !== ""){ 467 | tilesetTiles[`${x}-${y}`][key] = newData; 468 | }else{ 469 | tilesetTiles[`${x}-${y}`] = newData; 470 | } 471 | } 472 | 473 | const setActiveTool = (toolIdx) => { 474 | ACTIVE_TOOL = toolIdx; 475 | const actTool = document.getElementById("toolButtonsWrapper").querySelector(`input[id="tool${toolIdx}"]`); 476 | if (actTool) actTool.checked = true; 477 | document.getElementById("canvas_wrapper").setAttribute("isDraggable", ACTIVE_TOOL === TOOLS.PAN); 478 | draw(); 479 | } 480 | 481 | let selectionSize = [1,1]; 482 | const updateSelection = (autoSelectTool = true) => { 483 | if(!tileSets[tilesetDataSel.value]) return; 484 | const selected = selection[0]; 485 | if(!selected) return; 486 | const {x, y} = selected; 487 | const {x: endX, y: endY} = selection[selection.length - 1]; 488 | const selWidth = endX - x + 1; 489 | const selHeight = endY - y + 1; 490 | selectionSize = [selWidth, selHeight] 491 | console.log(tileSets[tilesetDataSel.value].tileSize) 492 | const tileSize = tileSets[tilesetDataSel.value].tileSize; 493 | tilesetSelection.style.left = `${x * tileSize * ZOOM}px`; 494 | tilesetSelection.style.top = `${y * tileSize * ZOOM}px`; 495 | tilesetSelection.style.width = `${selWidth * tileSize * ZOOM}px`; 496 | tilesetSelection.style.height = `${selHeight * tileSize * ZOOM}px`; 497 | 498 | // Autoselect tool upon selecting a tile 499 | if(autoSelectTool && ![TOOLS.BRUSH, TOOLS.RAND, TOOLS.FILL].includes(ACTIVE_TOOL)) setActiveTool(TOOLS.BRUSH); 500 | 501 | // show/hide param editor 502 | if(tileDataSel.value === "frames" && editedEntity) objectParametersEditor.classList.add('entity'); 503 | else objectParametersEditor.classList.remove('entity'); 504 | onUpdateState(); 505 | } 506 | 507 | const randomLetters = new Array(10680).fill(1).map((_, i) => String.fromCharCode(165 + i)); 508 | 509 | const shouldHideSymbols = () => SIZE_OF_CROP < 10 && ZOOM < 2; 510 | const updateTilesetGridContainer = () =>{ 511 | const viewMode = tileDataSel.value; 512 | const tilesetData = tileSets[tilesetDataSel.value]; 513 | if(!tilesetData) return; 514 | 515 | const {tileCount, gridWidth, tileData, tags} = tilesetData; 516 | // console.log("COUNT", tileCount) 517 | const hideSymbols = !DISPLAY_SYMBOLS || shouldHideSymbols(); 518 | const canvas = document.getElementById("tilesetCanvas"); 519 | const img = TILESET_ELEMENTS[tilesetDataSel.value]; 520 | canvas.width = img.width * ZOOM; 521 | canvas.height = img.height * ZOOM; 522 | const ctx = canvas.getContext('2d'); 523 | if (ZOOM !== 1){ 524 | ctx.webkitImageSmoothingEnabled = false; 525 | ctx.mozImageSmoothingEnabled = false; 526 | ctx.msImageSmoothingEnabled = false; 527 | ctx.imageSmoothingEnabled = false; 528 | } 529 | ctx.drawImage(img,0,0,canvas.width ,canvas.height); 530 | // console.log("WIDTH EXCEEDS?", canvas.width % SIZE_OF_CROP) 531 | const tileSizeSeemsIncorrect = canvas.width % SIZE_OF_CROP !== 0; 532 | drawGrid(ctx.canvas.width, ctx.canvas.height, ctx,SIZE_OF_CROP * ZOOM, tileSizeSeemsIncorrect ? "red":"cyan"); 533 | Array.from({length: tileCount}, (x, i) => i).map(tile=>{ 534 | if (viewMode === "frames") { 535 | const frameData = getCurrentFrames(); 536 | if(!frameData || Object.keys(frameData).length === 0) return; 537 | 538 | const {width, height, start, tiles,frameCount} = frameData; 539 | selection = [...tiles]; 540 | ctx.lineWidth = 0.5; 541 | ctx.strokeStyle = "red"; 542 | ctx.strokeRect(SIZE_OF_CROP * ZOOM * (start.x + width), SIZE_OF_CROP * ZOOM * start.y, SIZE_OF_CROP * ZOOM * (width * (frameCount - 1)), SIZE_OF_CROP * ZOOM * height); 543 | } else if (!hideSymbols) { 544 | const x = tile % gridWidth; 545 | const y = Math.floor(tile / gridWidth); 546 | const tileKey = `${x}-${y}`; 547 | const innerTile = viewMode === "" ? 548 | tileData[tileKey]?.tileSymbol : 549 | viewMode === "frames" ? tile :tags[viewMode]?.tiles[tileKey]?.mark || "-"; 550 | 551 | ctx.fillStyle = 'white'; 552 | ctx.font = '11px arial'; 553 | ctx.shadowColor="black"; 554 | ctx.shadowBlur=4; 555 | ctx.lineWidth=2; 556 | const posX = (x * SIZE_OF_CROP * ZOOM) + ((SIZE_OF_CROP * ZOOM) / 3); 557 | const posY = (y * SIZE_OF_CROP * ZOOM) + ((SIZE_OF_CROP * ZOOM) / 2); 558 | ctx.fillText(innerTile,posX,posY); 559 | } 560 | }) 561 | } 562 | 563 | let tileSelectStart = null; 564 | const getSelectedTile = (event) => { 565 | const { x, y } = event.target.getBoundingClientRect(); 566 | const tileSize = tileSets[tilesetDataSel.value].tileSize * ZOOM; 567 | const tx = Math.floor(Math.max(event.clientX - x, 0) / tileSize); 568 | const ty = Math.floor(Math.max(event.clientY - y, 0) / tileSize); 569 | // add start tile, add end tile, add all tiles inbetween 570 | const newSelection = []; 571 | if (tileSelectStart !== null){ 572 | for (let ix = tileSelectStart.x; ix < tx + 1; ix++) { 573 | for (let iy = tileSelectStart.y; iy < ty + 1; iy++) { 574 | const data = getTileData(ix,iy); 575 | newSelection.push({...data, x:ix,y:iy}) 576 | } 577 | } 578 | } 579 | if (newSelection.length > 0) return newSelection; 580 | 581 | const data = getTileData(tx, ty); 582 | return [{...data, x:tx,y:ty}]; 583 | } 584 | 585 | const draw = (shouldDrawGrid = true) =>{ 586 | const ctx = getContext(); 587 | ctx.clearRect(0, 0, WIDTH, HEIGHT); 588 | ctx.canvas.width = WIDTH; 589 | ctx.canvas.height = HEIGHT; 590 | if(shouldDrawGrid && !SHOW_GRID)drawGrid(WIDTH, HEIGHT, ctx,SIZE_OF_CROP * ZOOM, maps[ACTIVE_MAP].gridColor); 591 | const shouldHideHud = shouldHideSymbols(); 592 | 593 | maps[ACTIVE_MAP].layers.forEach((layer) => { 594 | if(!layer.visible) return; 595 | ctx.globalAlpha = layer.opacity; 596 | if (ZOOM !== 1){ 597 | ctx.webkitImageSmoothingEnabled = false; 598 | ctx.mozImageSmoothingEnabled = false; 599 | ctx.msImageSmoothingEnabled = false; 600 | ctx.imageSmoothingEnabled = false; 601 | } 602 | //static tiles on this layer 603 | Object.keys(layer.tiles).forEach((key) => { 604 | const [positionX, positionY] = key.split('-').map(Number); 605 | const {x, y, tilesetIdx, isFlippedX} = layer.tiles[key]; 606 | const tileSize = tileSets[tilesetIdx]?.tileSize || SIZE_OF_CROP; 607 | 608 | if(!(tilesetIdx in TILESET_ELEMENTS)) { //texture not found 609 | ctx.fillStyle = 'red'; 610 | ctx.fillRect(positionX * SIZE_OF_CROP * ZOOM, positionY * SIZE_OF_CROP * ZOOM, SIZE_OF_CROP * ZOOM, SIZE_OF_CROP * ZOOM); 611 | return; 612 | } 613 | if(isFlippedX){ 614 | ctx.save();//Special canvas crap to flip a slice, cause drawImage cant do it 615 | ctx.translate(ctx.canvas.width, 0); 616 | ctx.scale(-1, 1); 617 | ctx.drawImage( 618 | TILESET_ELEMENTS[tilesetIdx], 619 | x * tileSize, 620 | y * tileSize, 621 | tileSize, 622 | tileSize, 623 | ctx.canvas.width - (positionX * SIZE_OF_CROP * ZOOM) - SIZE_OF_CROP * ZOOM, 624 | positionY * SIZE_OF_CROP * ZOOM, 625 | SIZE_OF_CROP * ZOOM, 626 | SIZE_OF_CROP * ZOOM 627 | ); 628 | ctx.restore(); 629 | } else { 630 | ctx.drawImage( 631 | TILESET_ELEMENTS[tilesetIdx], 632 | x * tileSize, 633 | y * tileSize, 634 | tileSize, 635 | tileSize, 636 | positionX * SIZE_OF_CROP * ZOOM, 637 | positionY * SIZE_OF_CROP * ZOOM, 638 | SIZE_OF_CROP * ZOOM, 639 | SIZE_OF_CROP * ZOOM 640 | ); 641 | } 642 | }); 643 | // animated tiles 644 | Object.keys(layer.animatedTiles || {}).forEach((key) => { 645 | const [positionX, positionY] = key.split('-').map(Number); 646 | const {start, width, height, frameCount, isFlippedX} = layer.animatedTiles[key]; 647 | const {x, y, tilesetIdx} = start; 648 | const tileSize = tileSets[tilesetIdx]?.tileSize || SIZE_OF_CROP; 649 | 650 | if(!(tilesetIdx in TILESET_ELEMENTS)) { //texture not found 651 | ctx.fillStyle = 'yellow'; 652 | ctx.fillRect(positionX * SIZE_OF_CROP * ZOOM, positionY * SIZE_OF_CROP * ZOOM, SIZE_OF_CROP * ZOOM * width, SIZE_OF_CROP * ZOOM * height); 653 | ctx.fillStyle = 'blue'; 654 | ctx.fillText("X",positionX * SIZE_OF_CROP * ZOOM + 5,positionY * SIZE_OF_CROP * ZOOM + 10); 655 | return; 656 | } 657 | const frameIndex = tileDataSel.value === "frames" || frameCount === 1 ? Math.round(Date.now()/120) % frameCount : 1; //30fps 658 | 659 | if(isFlippedX) { 660 | ctx.save();//Special canvas crap to flip a slice, cause drawImage cant do it 661 | ctx.translate(ctx.canvas.width, 0); 662 | ctx.scale(-1, 1); 663 | 664 | const positionXFlipped = ctx.canvas.width - (positionX * SIZE_OF_CROP * ZOOM) - SIZE_OF_CROP * ZOOM; 665 | if(shouldDrawGrid && !shouldHideHud) { 666 | ctx.beginPath(); 667 | ctx.lineWidth = 1; 668 | ctx.strokeStyle = 'rgba(250,240,255, 0.7)'; 669 | ctx.rect(positionXFlipped, positionY * SIZE_OF_CROP * ZOOM, SIZE_OF_CROP * ZOOM * width, SIZE_OF_CROP * ZOOM * height); 670 | ctx.stroke(); 671 | } 672 | ctx.drawImage( 673 | TILESET_ELEMENTS[tilesetIdx], 674 | x * tileSize + (frameIndex * tileSize * width), 675 | y * tileSize, 676 | tileSize * width,// src width 677 | tileSize * height, // src height 678 | positionXFlipped, 679 | positionY * SIZE_OF_CROP * ZOOM, //target y 680 | SIZE_OF_CROP * ZOOM * width, // target width 681 | SIZE_OF_CROP * ZOOM * height // target height 682 | ); 683 | if(shouldDrawGrid && !shouldHideHud) { 684 | ctx.fillStyle = 'white'; 685 | ctx.fillText("🔛",positionXFlipped + 5,positionY * SIZE_OF_CROP * ZOOM + 10); 686 | } 687 | ctx.restore(); 688 | }else { 689 | if(shouldDrawGrid && !shouldHideHud) { 690 | ctx.beginPath(); 691 | ctx.lineWidth = 1; 692 | ctx.strokeStyle = 'rgba(250,240,255, 0.7)'; 693 | ctx.rect(positionX * SIZE_OF_CROP * ZOOM, positionY * SIZE_OF_CROP * ZOOM, SIZE_OF_CROP * ZOOM * width, SIZE_OF_CROP * ZOOM * height); 694 | ctx.stroke(); 695 | } 696 | ctx.drawImage( 697 | TILESET_ELEMENTS[tilesetIdx], 698 | x * tileSize + (frameIndex * tileSize * width),//src x 699 | y * tileSize,//src y 700 | tileSize * width,// src width 701 | tileSize * height, // src height 702 | positionX * SIZE_OF_CROP * ZOOM, //target x 703 | positionY * SIZE_OF_CROP * ZOOM, //target y 704 | SIZE_OF_CROP * ZOOM * width, // target width 705 | SIZE_OF_CROP * ZOOM * height // target height 706 | ); 707 | if(shouldDrawGrid && !shouldHideHud) { 708 | ctx.fillStyle = 'white'; 709 | ctx.fillText("⭕",positionX * SIZE_OF_CROP * ZOOM + 5,positionY * SIZE_OF_CROP * ZOOM + 10); 710 | } 711 | } 712 | }) 713 | }); 714 | if(SHOW_GRID)drawGrid(WIDTH, HEIGHT, ctx,SIZE_OF_CROP * ZOOM, maps[ACTIVE_MAP].gridColor); 715 | onUpdateState(); 716 | } 717 | 718 | const setMouseIsTrue=(e)=> { 719 | if(e.button === 0) { 720 | isMouseDown = true; 721 | } 722 | else if(e.button === 1){ 723 | PREV_ACTIVE_TOOL = ACTIVE_TOOL; 724 | setActiveTool(TOOLS.PAN) 725 | } 726 | } 727 | 728 | const setMouseIsFalse=(e)=> { 729 | if(e.button === 0) { 730 | isMouseDown = false; 731 | } 732 | else if(e.button === 1 && ACTIVE_TOOL === TOOLS.PAN){ 733 | setActiveTool(PREV_ACTIVE_TOOL) 734 | } 735 | } 736 | 737 | const removeTile=(key) =>{ 738 | delete maps[ACTIVE_MAP].layers[currentLayer].tiles[key]; 739 | if (key in (maps[ACTIVE_MAP].layers[currentLayer].animatedTiles || {})) delete maps[ACTIVE_MAP].layers[currentLayer].animatedTiles[key]; 740 | } 741 | 742 | const isFlippedOnX = () => document.getElementById("toggleFlipX").checked; 743 | const addSelectedTiles = (key, tiles) => { 744 | const [x, y] = key.split("-"); 745 | const tilesPatch = tiles || selection; // tiles is opt override for selection for fancy things like random patch of tiles 746 | const {x: startX, y: startY} = tilesPatch[0];// add selection override 747 | const selWidth = selectionSize[0]; 748 | const selHeight = selectionSize[1]; 749 | maps[ACTIVE_MAP].layers[currentLayer].tiles[key] = tilesPatch[0]; 750 | const isFlippedX = isFlippedOnX(); 751 | for (let ix = 0; ix < selWidth; ix++) { 752 | for (let iy = 0; iy < selHeight; iy++) { 753 | const tileX = isFlippedX ? Number(x)-ix : Number(x)+ix;//placed in reverse when flipped on x 754 | const coordKey = `${tileX}-${Number(y)+iy}`; 755 | maps[ACTIVE_MAP].layers[currentLayer].tiles[coordKey] = { 756 | ...tilesPatch 757 | .find(tile => tile.x === startX + ix && tile.y === startY + iy), 758 | isFlippedX 759 | }; 760 | } 761 | } 762 | } 763 | const getCurrentFrames = () => tileSets[tilesetDataSel.value]?.frames[tileFrameSel.value]; 764 | const getSelectedFrameCount = () => getCurrentFrames()?.frameCount || 1; 765 | const shouldNotAddAnimatedTile = () => (tileDataSel.value !== "frames" && getSelectedFrameCount() !== 1) || Object.keys(tileSets[tilesetDataSel.value]?.frames).length === 0; 766 | const addTile = (key) => { 767 | if (shouldNotAddAnimatedTile()) { 768 | addSelectedTiles(key); 769 | } else { 770 | // if animated tile mode and has more than one frames, add/remove to animatedTiles 771 | if(!maps[ACTIVE_MAP].layers[currentLayer].animatedTiles) maps[ACTIVE_MAP].layers[currentLayer].animatedTiles = {}; 772 | const isFlippedX = isFlippedOnX(); 773 | const [x,y] = key.split("-"); 774 | maps[ACTIVE_MAP].layers[currentLayer].animatedTiles[key] = { 775 | ...getCurrentFrames(), 776 | isFlippedX, layer: currentLayer, 777 | xPos: Number(x) * SIZE_OF_CROP, yPos: Number(y) * SIZE_OF_CROP 778 | }; 779 | } 780 | } 781 | 782 | const addRandomTile = (key) =>{ 783 | // TODO add probability for empty 784 | if (shouldNotAddAnimatedTile()) { 785 | maps[ACTIVE_MAP].layers[currentLayer].tiles[key] = selection[Math.floor(Math.random()*selection.length)]; 786 | }else { 787 | // do the same, but add random from frames instead 788 | const tilesetTiles = tileSets[tilesetDataSel.value].tileData; 789 | const {frameCount, tiles, width} = getCurrentFrames(); 790 | const randOffset = Math.floor(Math.random()*frameCount); 791 | const randXOffsetTiles = tiles.map(tile=>tilesetTiles[`${tile.x + randOffset * width}-${tile.y}`]); 792 | addSelectedTiles(key,randXOffsetTiles); 793 | } 794 | 795 | } 796 | 797 | const fillEmptyOrSameTiles = (key) => { 798 | const pickedTile = maps[ACTIVE_MAP].layers[currentLayer].tiles[key]; 799 | Array.from({length: mapTileWidth * mapTileHeight}, (x, i) => i).map(tile=>{ 800 | const x = tile % mapTileWidth; 801 | const y = Math.floor(tile / mapTileWidth); 802 | const coordKey = `${x}-${y}`; 803 | const filledTile = maps[ACTIVE_MAP].layers[currentLayer].tiles[coordKey]; 804 | 805 | if(pickedTile && filledTile && filledTile.x === pickedTile.x && filledTile.y === pickedTile.y){ 806 | maps[ACTIVE_MAP].layers[currentLayer].tiles[coordKey] = selection[0];// Replace all clicked on tiles with selected 807 | } 808 | else if(!pickedTile && !(coordKey in maps[ACTIVE_MAP].layers[currentLayer].tiles)) { 809 | maps[ACTIVE_MAP].layers[currentLayer].tiles[coordKey] = selection[0]; // when clicked on empty, replace all empty with selection 810 | } 811 | }) 812 | } 813 | 814 | const selectMode = (mode = null) => { 815 | if (mode !== null) tileDataSel.value = mode; 816 | document.getElementById("tileFrameSelContainer").style.display = tileDataSel.value === "frames" ? 817 | "flex":"none" 818 | // tilesetContainer.style.top = tileDataSel.value === "frames" ? "45px" : "0"; 819 | updateTilesetGridContainer(); 820 | } 821 | const getTile =(key, allLayers = false)=> { 822 | const layers = maps[ACTIVE_MAP].layers; 823 | editedEntity = undefined; 824 | const clicked = allLayers ? 825 | [...layers].reverse().find((layer,index)=> { 826 | if(layer.animatedTiles && key in layer.animatedTiles) { 827 | setLayer(layers.length - index - 1); 828 | editedEntity = layer.animatedTiles[key]; 829 | } 830 | if(key in layer.tiles){ 831 | setLayer(layers.length - index - 1); 832 | return layer.tiles[key] 833 | } 834 | })?.tiles[key] //TODO this doesnt work on animatedTiles 835 | : 836 | layers[currentLayer].tiles[key]; 837 | 838 | if (clicked && !editedEntity) { 839 | selection = [clicked]; 840 | 841 | // console.log("clicked", clicked, "entity data",editedEntity) 842 | document.getElementById("toggleFlipX").checked = !!clicked?.isFlippedX; 843 | // TODO switch to different tileset if its from a different one 844 | // if(clicked.tilesetIdx !== tilesetDataSel.value) { 845 | // tilesetDataSel.value = clicked.tilesetIdx; 846 | // reloadTilesets(); 847 | // updateTilesetGridContainer(); 848 | // } 849 | selectMode(""); 850 | updateSelection(); 851 | return true; 852 | } else if (editedEntity){ 853 | // console.log("Animated tile found", editedEntity) 854 | selection = editedEntity.tiles; 855 | document.getElementById("toggleFlipX").checked = editedEntity.isFlippedX; 856 | setLayer(editedEntity.layer); 857 | tileFrameSel.value = editedEntity.name; 858 | updateSelection(); 859 | selectMode("frames"); 860 | return true; 861 | }else { 862 | return false; 863 | } 864 | } 865 | 866 | const toggleTile=(event)=> { 867 | if(ACTIVE_TOOL === TOOLS.PAN || !maps[ACTIVE_MAP].layers[currentLayer].visible) return; 868 | 869 | const {x,y} = getSelectedTile(event)[0]; 870 | const key = `${x}-${y}`; 871 | 872 | // console.log(event.button) 873 | if (event.shiftKey) { 874 | removeTile(key); 875 | } else if (event.ctrlKey || event.button === 2 || ACTIVE_TOOL === TOOLS.PICK) { 876 | const pickedTile = getTile(key, true); 877 | if(ACTIVE_TOOL === TOOLS.BRUSH && !pickedTile) setActiveTool(TOOLS.ERASE); //picking empty tile, sets tool to eraser 878 | else if(ACTIVE_TOOL === TOOLS.FILL || ACTIVE_TOOL === TOOLS.RAND) setActiveTool(TOOLS.BRUSH); // 879 | } else { 880 | if(ACTIVE_TOOL === TOOLS.BRUSH){ 881 | addTile(key);// also works with animated 882 | } else if(ACTIVE_TOOL === TOOLS.ERASE) { 883 | removeTile(key);// also works with animated 884 | } else if (ACTIVE_TOOL === TOOLS.RAND){ 885 | addRandomTile(key); 886 | } else if (ACTIVE_TOOL === TOOLS.FILL){ 887 | fillEmptyOrSameTiles(key); 888 | } 889 | } 890 | draw(); 891 | addToUndoStack(); 892 | } 893 | 894 | const clearCanvas = () => { 895 | addToUndoStack(); 896 | maps[ACTIVE_MAP].layers = [getEmptyLayer("bottom"), getEmptyLayer("middle"), getEmptyLayer("top")]; 897 | setLayer(0); 898 | updateLayers(); 899 | draw(); 900 | addToUndoStack(); 901 | } 902 | 903 | const downloadAsTextFile = (input, fileName = "tilemap-editor.json") =>{ 904 | const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(typeof input === "string" ? input : JSON.stringify(input)); 905 | const dlAnchorElem = document.getElementById('downloadAnchorElem'); 906 | dlAnchorElem.setAttribute("href", dataStr ); 907 | dlAnchorElem.setAttribute("download", fileName); 908 | dlAnchorElem.click(); 909 | } 910 | const exportJson = () => { 911 | downloadAsTextFile({tileSets, maps}); 912 | } 913 | 914 | const exportImage = () => { 915 | draw(false); 916 | const data = canvas.toDataURL(); 917 | const image = new Image(); 918 | image.src = data; 919 | image.crossOrigin = "anonymous"; 920 | const w = window.open(''); 921 | w.document.write(image.outerHTML); 922 | draw(); 923 | } 924 | 925 | const getTilesAnalisis = (ctx, width, height, sizeOfTile) =>{ 926 | const analizedTiles = {}; 927 | let uuid = 0; 928 | for (let y = 0; y < height; y += sizeOfTile) { 929 | for (let x = 0; x < width; x += sizeOfTile) { 930 | // console.log(x, y); 931 | const tileData = ctx.getImageData(x, y, sizeOfTile, sizeOfTile); 932 | const index = tileData.data.toString(); 933 | if (analizedTiles[index]) { 934 | analizedTiles[index].coords.push({ x: x, y: y }); 935 | analizedTiles[index].times++; 936 | } else { 937 | analizedTiles[index] = { 938 | uuid: uuid++, 939 | coords: [{ x: x, y: y }], 940 | times: 1, 941 | tileData: tileData 942 | }; 943 | } 944 | } 945 | } 946 | const uniqueTiles = Object.values(analizedTiles).length - 1; 947 | // console.log("TILES:", {analizedTiles, uniqueTiles}) 948 | return {analizedTiles, uniqueTiles}; 949 | } 950 | const drawAnaliticsReport = () => { 951 | const prevZoom = ZOOM; 952 | ZOOM = 1;// needed for correct eval 953 | updateZoom(); 954 | draw(false); 955 | const {analizedTiles, uniqueTiles} = getTilesAnalisis(getContext(), WIDTH, HEIGHT, SIZE_OF_CROP); 956 | const data = canvas.toDataURL(); 957 | const image = new Image(); 958 | image.src = data; 959 | const ctx = getContext(); 960 | ZOOM = prevZoom; 961 | updateZoom(); 962 | draw(false); 963 | Object.values(analizedTiles).map((t) => { 964 | // Fill the heatmap 965 | t.coords.forEach((c, i) => { 966 | const fillStyle = `rgba(255, 0, 0, ${(1/t.times) - 0.35})`; 967 | ctx.fillStyle = fillStyle; 968 | ctx.fillRect(c.x * ZOOM, c.y * ZOOM, SIZE_OF_CROP * ZOOM, SIZE_OF_CROP * ZOOM); 969 | }); 970 | }) 971 | drawGrid(WIDTH, HEIGHT, ctx,SIZE_OF_CROP * ZOOM,'rgba(255,213,0,0.5)') 972 | ctx.fillStyle = 'white'; 973 | ctx.font = 'bold 17px arial'; 974 | ctx.shadowColor="black"; 975 | ctx.shadowBlur=5; 976 | ctx.lineWidth=3; 977 | ctx.fillText(`Unique tiles: ${uniqueTiles}`,4,HEIGHT - 30); 978 | ctx.fillText(`Map size: ${mapTileWidth}x${mapTileHeight}`,4,HEIGHT - 10); 979 | } 980 | const exportUniqueTiles = () => { 981 | const ctx = getContext(); 982 | const prevZoom = ZOOM; 983 | ZOOM = 1;// needed for correct eval 984 | updateZoom(); 985 | draw(false); 986 | const {analizedTiles} = getTilesAnalisis(getContext(), WIDTH, HEIGHT, SIZE_OF_CROP); 987 | ctx.clearRect(0, 0, WIDTH, HEIGHT); 988 | const gridWidth = tilesetImage.width / SIZE_OF_CROP; 989 | Object.values(analizedTiles).map((t, i) => { 990 | const positionX = i % gridWidth; 991 | const positionY = Math.floor(i / gridWidth); 992 | const tileCanvas = document.createElement("canvas"); 993 | tileCanvas.width = SIZE_OF_CROP; 994 | tileCanvas.height = SIZE_OF_CROP; 995 | const tileCtx = tileCanvas.getContext("2d"); 996 | tileCtx.putImageData(t.tileData, 0, 0); 997 | ctx.drawImage( 998 | tileCanvas, 999 | 0, 1000 | 0, 1001 | SIZE_OF_CROP, 1002 | SIZE_OF_CROP, 1003 | positionX * SIZE_OF_CROP, 1004 | positionY * SIZE_OF_CROP, 1005 | SIZE_OF_CROP, 1006 | SIZE_OF_CROP 1007 | ); 1008 | }); 1009 | const data = canvas.toDataURL(); 1010 | const image = new Image(); 1011 | image.src = data; 1012 | image.crossOrigin = "anonymous"; 1013 | const w = window.open(''); 1014 | w.document.write(image.outerHTML); 1015 | ZOOM = prevZoom; 1016 | updateZoom(); 1017 | draw(); 1018 | } 1019 | 1020 | exports.getLayers = ()=> { 1021 | return maps[ACTIVE_MAP].layers; 1022 | } 1023 | 1024 | const renameCurrentTileSymbol = ()=>{ 1025 | const {x, y, tileSymbol} = selection[0]; 1026 | const newSymbol = window.prompt("Enter tile symbol", tileSymbol || "*"); 1027 | if(newSymbol !== null) { 1028 | setTileData(x,y,newSymbol, "tileSymbol"); 1029 | updateSelection(); 1030 | updateTilesetGridContainer(); 1031 | addToUndoStack(); 1032 | } 1033 | } 1034 | 1035 | const getFlattenedData = () => { 1036 | const result = Object.entries(maps).map(([key, map])=>{ 1037 | console.log({map}) 1038 | const layers = map.layers; 1039 | const flattenedData = Array(layers.length).fill([]).map(()=>{ 1040 | return Array(map.mapHeight).fill([]).map(row=>{ 1041 | return Array(map.mapWidth).fill([]).map(column => ({ 1042 | tile: null, 1043 | tileSymbol: " "// a space is an empty tile 1044 | })) 1045 | }) 1046 | }); 1047 | layers.forEach((layerObj,lrIndex) => { 1048 | Object.entries(layerObj.tiles).forEach(([key,tile])=>{ 1049 | const [x,y] = key.split("-"); 1050 | if(Number(y) < map.mapHeight && Number(x) < map.mapWidth) { 1051 | flattenedData[lrIndex][Number(y)][Number(x)] = {tile, tileSymbol: tile.tileSymbol || "*"}; 1052 | } 1053 | }) 1054 | }); 1055 | return {map:key, tileSet: map.tileSet,flattenedData}; 1056 | }); 1057 | return result; 1058 | }; 1059 | const getExportData = () => { 1060 | const exportData = {maps, tileSets, flattenedData: getFlattenedData(), activeMap: ACTIVE_MAP, downloadAsTextFile}; 1061 | console.log("Exported ", exportData); 1062 | return exportData; 1063 | } 1064 | 1065 | const updateMapSize = (size) =>{ 1066 | if(size?.mapWidth && size?.mapWidth > 1){ 1067 | mapTileWidth = size?.mapWidth; 1068 | WIDTH = mapTileWidth * SIZE_OF_CROP * ZOOM; 1069 | maps[ACTIVE_MAP].mapWidth = mapTileWidth; 1070 | document.querySelector(".canvas_resizer[resizerdir='x']").style=`left:${WIDTH}px`; 1071 | document.querySelector(".canvas_resizer[resizerdir='x'] input").value = String(mapTileWidth); 1072 | document.getElementById("canvasWidthInp").value = String(mapTileWidth); 1073 | } 1074 | if(size?.mapHeight && size?.mapHeight > 1){ 1075 | mapTileHeight = size?.mapHeight; 1076 | HEIGHT = mapTileHeight * SIZE_OF_CROP * ZOOM; 1077 | maps[ACTIVE_MAP].mapHeight = mapTileHeight; 1078 | document.querySelector(".canvas_resizer[resizerdir='y']").style=`top:${HEIGHT}px`; 1079 | document.querySelector(".canvas_resizer[resizerdir='y'] input").value = String(mapTileHeight); 1080 | document.getElementById("canvasHeightInp").value = String(mapTileHeight); 1081 | } 1082 | draw(); 1083 | } 1084 | 1085 | const setActiveMap =(id) =>{ 1086 | ACTIVE_MAP = id; 1087 | document.getElementById("gridColorSel").value = maps[ACTIVE_MAP].gridColor || "#00FFFF"; 1088 | draw(); 1089 | updateMapSize({mapWidth: maps[ACTIVE_MAP].mapWidth, mapHeight: maps[ACTIVE_MAP].mapHeight}) 1090 | updateLayers(); 1091 | } 1092 | 1093 | 1094 | let undoStepPosition = -1; 1095 | let undoStack = []; 1096 | const clearUndoStack = () => { 1097 | undoStack = []; 1098 | undoStepPosition = -1; 1099 | } 1100 | const getAppState = () => { 1101 | // TODO we need for tilesets to load - rapidly refreshing the browser may return empty tilesets object! 1102 | if(Object.keys(tileSets).length === 0 && tileSets.constructor === Object) return null; 1103 | return { 1104 | tileMapData: {tileSets, maps}, 1105 | appState: { 1106 | undoStack, 1107 | undoStepPosition, 1108 | currentLayer, 1109 | PREV_ACTIVE_TOOL, 1110 | ACTIVE_TOOL, 1111 | ACTIVE_MAP, 1112 | SHOW_GRID, 1113 | selection 1114 | } 1115 | //Todo tileSize and the others 1116 | // undo stack is lost 1117 | }; 1118 | } 1119 | const onUpdateState = () => { 1120 | apiOnUpdateCallback(getAppState()) 1121 | } 1122 | const addToUndoStack = () => { 1123 | if(Object.keys(tileSets).length === 0 || Object.keys(maps).length === 0) return; 1124 | const oldState = undoStack.length > 0 ? JSON.stringify( 1125 | { 1126 | maps: undoStack[undoStepPosition].maps, 1127 | tileSets: undoStack[undoStepPosition].tileSets, 1128 | currentLayer:undoStack[undoStepPosition].currentLayer, 1129 | ACTIVE_MAP:undoStack[undoStepPosition].ACTIVE_MAP, 1130 | IMAGES:undoStack[undoStepPosition].IMAGES 1131 | }) : undefined; 1132 | const newState = JSON.stringify({maps,tileSets,currentLayer,ACTIVE_MAP,IMAGES}); 1133 | if (newState === oldState) return; // prevent updating when no changes are present in the data! 1134 | 1135 | undoStepPosition += 1; 1136 | undoStack.length = undoStepPosition; 1137 | undoStack.push(JSON.parse(JSON.stringify({maps,tileSets, currentLayer, ACTIVE_MAP, IMAGES, undoStepPosition}))); 1138 | // console.log("undo stack updated", undoStack, undoStepPosition) 1139 | } 1140 | const restoreFromUndoStackData = () => { 1141 | maps = decoupleReferenceFromObj(undoStack[undoStepPosition].maps); 1142 | const undoTileSets = decoupleReferenceFromObj(undoStack[undoStepPosition].tileSets); 1143 | const undoIMAGES = decoupleReferenceFromObj(undoStack[undoStepPosition].IMAGES); 1144 | if(JSON.stringify(IMAGES) !== JSON.stringify(undoIMAGES)){ // images needs to happen before tilesets 1145 | IMAGES = undoIMAGES; 1146 | reloadTilesets(); 1147 | } 1148 | if(JSON.stringify(undoTileSets) !== JSON.stringify(tileSets)) { // done to prevent the below, which is expensive 1149 | tileSets = undoTileSets; 1150 | updateTilesetGridContainer(); 1151 | } 1152 | tileSets = undoTileSets; 1153 | updateTilesetDataList(); 1154 | 1155 | const undoLayer = decoupleReferenceFromObj(undoStack[undoStepPosition].currentLayer); 1156 | const undoActiveMap = decoupleReferenceFromObj(undoStack[undoStepPosition].ACTIVE_MAP); 1157 | if(undoActiveMap !== ACTIVE_MAP){ 1158 | setActiveMap(undoActiveMap) 1159 | updateMaps(); 1160 | } 1161 | updateLayers(); // needs to happen after active map is set and maps are updated 1162 | setLayer(undoLayer); 1163 | draw(); 1164 | } 1165 | const undo = () => { 1166 | if (undoStepPosition === 0) return; 1167 | undoStepPosition -= 1; 1168 | restoreFromUndoStackData(); 1169 | } 1170 | const redo = () => { 1171 | if (undoStepPosition === undoStack.length - 1) return; 1172 | undoStepPosition += 1; 1173 | restoreFromUndoStackData(); 1174 | } 1175 | const zoomLevels = [0.25, 0.5, 1, 2, 3, 4]; 1176 | let zoomIndex = 1 1177 | const updateZoom = () => { 1178 | tilesetImage.style = `transform: scale(${ZOOM});transform-origin: left top;image-rendering: auto;image-rendering: crisp-edges;image-rendering: pixelated;`; 1179 | tilesetContainer.style.width = `${tilesetImage.width * ZOOM}px`; 1180 | tilesetContainer.style.height = `${tilesetImage.height * ZOOM}px`; 1181 | document.getElementById("zoomLabel").innerText = `${ZOOM}x`; 1182 | updateTilesetGridContainer(); 1183 | updateSelection(false); 1184 | updateMapSize({mapWidth: mapTileWidth, mapHeight: mapTileHeight}); 1185 | WIDTH = mapTileWidth * SIZE_OF_CROP * ZOOM;// needed when setting zoom? 1186 | HEIGHT = mapTileHeight * SIZE_OF_CROP * ZOOM; 1187 | zoomIndex = zoomLevels.indexOf(ZOOM) === -1 ? 0: zoomLevels.indexOf(ZOOM); 1188 | } 1189 | const zoomIn = () => { 1190 | if(zoomIndex >= zoomLevels.length - 1) return; 1191 | zoomIndex += 1; 1192 | ZOOM = zoomLevels[zoomIndex]; 1193 | updateZoom(); 1194 | } 1195 | const zoomOut = () => { 1196 | if(zoomIndex === 0) return; 1197 | zoomIndex -= 1; 1198 | ZOOM = zoomLevels[zoomIndex]; 1199 | updateZoom(); 1200 | } 1201 | 1202 | const toggleSymbolsVisible = (override=null) => { 1203 | if(override === null) DISPLAY_SYMBOLS = !DISPLAY_SYMBOLS; 1204 | document.getElementById("setSymbolsVisBtn").innerHTML = DISPLAY_SYMBOLS ? "👁️": "👓"; 1205 | updateTilesetGridContainer(); 1206 | } 1207 | 1208 | const getCurrentAnimation = (getAnim) => tileSets[tilesetDataSel.value]?.frames[tileFrameSel.value]?.animations?.[getAnim || tileAnimSel.value]; 1209 | const updateTilesetDataList = (populateFrames = false) => { 1210 | const populateWithOptions = (selectEl, options, newContent)=>{ 1211 | if(!options) return; 1212 | const value = selectEl.value + ""; 1213 | selectEl.innerHTML = newContent; 1214 | Object.keys(options).forEach(opt=>{ 1215 | const newOption = document.createElement("option"); 1216 | newOption.innerText = opt; 1217 | newOption.value = opt; 1218 | selectEl.appendChild(newOption) 1219 | }) 1220 | if (value in options || (["","frames","animations"].includes(value) && !populateFrames)) selectEl.value = value; 1221 | } 1222 | 1223 | if (!populateFrames) populateWithOptions(tileDataSel, tileSets[tilesetDataSel.value]?.tags, ``); 1224 | else { 1225 | populateWithOptions(tileFrameSel, tileSets[tilesetDataSel.value]?.frames, ''); 1226 | populateWithOptions(tileAnimSel, tileSets[tilesetDataSel.value]?.frames[tileFrameSel.value]?.animations, '') 1227 | } 1228 | 1229 | document.getElementById("tileFrameCount").value = getCurrentFrames()?.frameCount || 1; 1230 | const currentAnim = getCurrentAnimation(); 1231 | el.animStart().max = el.tileFrameCount().value; 1232 | el.animEnd().max = el.tileFrameCount().value; 1233 | if(currentAnim){ 1234 | console.log({currentAnim}) 1235 | el.animStart().value = currentAnim.start || 1 1236 | el.animEnd().value = currentAnim.end || 1 1237 | el.animLoop().checked = currentAnim.loop || false 1238 | el.animSpeed().value = currentAnim.speed || 1 1239 | } 1240 | } 1241 | 1242 | const reevaluateTilesetsData = () =>{ 1243 | let symbolStartIdx = 0; 1244 | Object.entries(tileSets).forEach(([key,old])=>{ 1245 | const tileData = {}; 1246 | // console.log("OLD DATA",old) 1247 | const tileSize = old.tileSize || SIZE_OF_CROP; 1248 | const gridWidth = Math.ceil(old.width / tileSize); 1249 | const gridHeight = Math.ceil(old.height / tileSize); 1250 | const tileCount = gridWidth * gridHeight; 1251 | 1252 | Array.from({length: tileCount}, (x, i) => i).map(tile=>{ 1253 | const x = tile % gridWidth; 1254 | const y = Math.floor(tile / gridWidth); 1255 | const oldTileData = old?.[`${x}-${y}`]?.tileData; 1256 | const tileSymbol = randomLetters[Math.floor(symbolStartIdx + tile)]; 1257 | tileData[`${x}-${y}`] = { 1258 | ...oldTileData, x, y, tilesetIdx: key, tileSymbol 1259 | } 1260 | tileSets[key] = {...old, tileSize, gridWidth, gridHeight, tileCount, symbolStartIdx, tileData}; 1261 | }) 1262 | if(key === 0){ 1263 | // console.log({gridWidth,gridHeight,tileCount, tileSize}) 1264 | } 1265 | symbolStartIdx += tileCount; 1266 | 1267 | }) 1268 | // console.log("UPDATED TSETS", tileSets) 1269 | } 1270 | const setCropSize = (newSize) => { 1271 | if(newSize === SIZE_OF_CROP && cropSize.value === newSize) return; 1272 | tileSets[tilesetDataSel.value].tileSize = newSize; 1273 | IMAGES.forEach((ts,idx)=> { 1274 | if (ts.src === tilesetImage.src) IMAGES[idx].tileSize = newSize; 1275 | }); 1276 | SIZE_OF_CROP = newSize; 1277 | cropSize.value = SIZE_OF_CROP; 1278 | document.getElementById("gridCropSize").value = SIZE_OF_CROP; 1279 | // console.log("NEW SIZE", tilesetDataSel.value,tileSets[tilesetDataSel.value], newSize,ACTIVE_MAP, maps) 1280 | updateZoom() 1281 | updateTilesetGridContainer(); 1282 | // console.log(tileSets, IMAGES) 1283 | reevaluateTilesetsData() 1284 | updateTilesetDataList() 1285 | draw(); 1286 | } 1287 | 1288 | // Note: only call this when tileset images have changed 1289 | const reloadTilesets = () =>{ 1290 | TILESET_ELEMENTS = []; 1291 | tilesetDataSel.innerHTML = ""; 1292 | // Use to prevent old data from erasure 1293 | const oldTilesets = {...tileSets}; 1294 | tileSets = {}; 1295 | let symbolStartIdx = 0; 1296 | // Generate tileset data for each of the loaded images 1297 | IMAGES.forEach((tsImage, idx)=>{ 1298 | const newOpt = document.createElement("option"); 1299 | newOpt.innerText = tsImage.name || `tileset ${idx}`; 1300 | newOpt.value = idx; 1301 | tilesetDataSel.appendChild(newOpt); 1302 | const tilesetImgElement = document.createElement("img"); 1303 | tilesetImgElement.src = tsImage.src; 1304 | tilesetImgElement.crossOrigin = "Anonymous"; 1305 | TILESET_ELEMENTS.push(tilesetImgElement); 1306 | }) 1307 | 1308 | Promise.all(Array.from(TILESET_ELEMENTS).filter(img => !img.complete) 1309 | .map(img => new Promise(resolve => { img.onload = img.onerror = resolve; }))) 1310 | .then(() => { 1311 | // console.log("TILESET ELEMENTS", TILESET_ELEMENTS) 1312 | TILESET_ELEMENTS.forEach((tsImage,idx) => { 1313 | const tileSize = tsImage.tileSize || SIZE_OF_CROP; 1314 | tileSets[idx] = getEmptyTileSet( 1315 | { 1316 | tags: oldTilesets[idx]?.tags, frames: oldTilesets[idx]?.frames, tileSize, 1317 | animations: oldTilesets[idx]?.animations, 1318 | src: tsImage.src, name: `tileset ${idx}`, width: tsImage.width, height: tsImage.height 1319 | } 1320 | ); 1321 | }) 1322 | // console.log("POPULATED", tileSets) 1323 | reevaluateTilesetsData(); 1324 | tilesetImage.src = TILESET_ELEMENTS[0].src; 1325 | tilesetImage.crossOrigin = "Anonymous"; 1326 | updateSelection(false); 1327 | updateTilesetGridContainer(); 1328 | }); 1329 | // finally current tileset loaded 1330 | tilesetImage.addEventListener('load', () => { 1331 | draw(); 1332 | updateLayers(); 1333 | if (selection.length === 0) selection = [getTileData(0, 0)]; 1334 | updateSelection(false); 1335 | updateTilesetDataList(); 1336 | updateTilesetDataList(true); 1337 | updateTilesetGridContainer(); 1338 | document.getElementById("tilesetSrcLabel").innerHTML = `src: ${tilesetImage.src}`; 1339 | document.getElementById("tilesetSrcLabel").title = tilesetImage.src; 1340 | const tilesetExtraInfo = IMAGES.find(ts=>ts.src === tilesetImage.src); 1341 | 1342 | // console.log("CHANGED TILESET", tilesetExtraInfo, IMAGES) 1343 | 1344 | if(tilesetExtraInfo) { 1345 | if (tilesetExtraInfo.link) { 1346 | document.getElementById("tilesetHomeLink").innerHTML = `link: ${tilesetExtraInfo.link} `; 1347 | document.getElementById("tilesetHomeLink").title = tilesetExtraInfo.link; 1348 | } else { 1349 | document.getElementById("tilesetHomeLink").innerHTML = ""; 1350 | } 1351 | if (tilesetExtraInfo.description) { 1352 | document.getElementById("tilesetDescriptionLabel").innerText = tilesetExtraInfo.description; 1353 | document.getElementById("tilesetDescriptionLabel").title = tilesetExtraInfo.description; 1354 | } else { 1355 | document.getElementById("tilesetDescriptionLabel").innerText = ""; 1356 | } 1357 | if (tilesetExtraInfo.tileSize ) { 1358 | setCropSize(tilesetExtraInfo.tileSize); 1359 | } 1360 | } 1361 | setCropSize(tileSets[tilesetDataSel.value].tileSize); 1362 | updateZoom(); 1363 | document.querySelector('.canvas_resizer[resizerdir="x"]').style = `left:${WIDTH}px;`; 1364 | 1365 | if (undoStepPosition === -1) addToUndoStack();//initial undo stack entry 1366 | }); 1367 | } 1368 | 1369 | const updateMaps = ()=>{ 1370 | mapsDataSel.innerHTML = ""; 1371 | let lastMap = ACTIVE_MAP; 1372 | Object.keys(maps).forEach((key, idx)=>{ 1373 | const newOpt = document.createElement("option"); 1374 | newOpt.innerText = maps[key].name//`map ${idx}`; 1375 | newOpt.value = key; 1376 | mapsDataSel.appendChild(newOpt); 1377 | if (idx === Object.keys(maps).length - 1) lastMap = key; 1378 | }); 1379 | mapsDataSel.value = lastMap; 1380 | setActiveMap(lastMap); 1381 | document.getElementById("removeMapBtn").disabled = Object.keys(maps).length === 1; 1382 | } 1383 | const loadData = (data) =>{ 1384 | try { 1385 | clearUndoStack(); 1386 | WIDTH = canvas.width * ZOOM; 1387 | HEIGHT = canvas.height * ZOOM; 1388 | selection = [{}]; 1389 | ACTIVE_MAP = data ? Object.keys(data.maps)[0] : "Map_1"; 1390 | maps = data ? {...data.maps} : {[ACTIVE_MAP]: getEmptyMap("Map 1", mapTileWidth, mapTileHeight)}; 1391 | tileSets = data ? {...data.tileSets} : {}; 1392 | reloadTilesets(); 1393 | tilesetDataSel.value = "0"; 1394 | cropSize.value = data ? tileSets[tilesetDataSel.value]?.tileSize || maps[ACTIVE_MAP].tileSize : SIZE_OF_CROP; 1395 | document.getElementById("gridCropSize").value = cropSize.value; 1396 | updateMaps(); 1397 | updateMapSize({mapWidth: maps[ACTIVE_MAP].mapWidth, mapHeight: maps[ACTIVE_MAP].mapHeight}) 1398 | } 1399 | catch(e){ 1400 | console.error(e) 1401 | } 1402 | } 1403 | 1404 | // Create the tilemap-editor in the dom and its events 1405 | exports.init = ( 1406 | attachToId, 1407 | { 1408 | tileMapData, // the main data 1409 | tileSize, 1410 | mapWidth, 1411 | mapHeight, 1412 | tileSetImages, 1413 | applyButtonText, 1414 | onApply, 1415 | tileSetLoaders, 1416 | tileMapExporters, 1417 | tileMapImporters, 1418 | onUpdate = () => {}, 1419 | onMouseUp = null, 1420 | appState 1421 | } 1422 | ) => { 1423 | // Attach 1424 | const attachTo = document.getElementById(attachToId); 1425 | if(attachTo === null) return; 1426 | 1427 | apiTileSetLoaders = tileSetLoaders || {}; 1428 | apiTileSetLoaders.base64 = { 1429 | name: "Fs (as base64)", 1430 | onSelectImage: (setSrc, file, base64) => { 1431 | setSrc(base64); 1432 | }, 1433 | } 1434 | apiTileMapExporters = tileMapExporters; 1435 | apiTileMapExporters.exportAsImage = { 1436 | name: "Export Map as image", 1437 | transformer: exportImage 1438 | } 1439 | apiTileMapExporters.saveData = { 1440 | name: "Download Json file", 1441 | transformer: exportJson 1442 | } 1443 | apiTileMapExporters.analizeTilemap = { 1444 | name: "Analize tilemap", 1445 | transformer: drawAnaliticsReport 1446 | } 1447 | apiTileMapExporters.exportTilesFromMap = { 1448 | name: "Extract tileset from map", 1449 | transformer: exportUniqueTiles 1450 | } 1451 | apiTileMapImporters = tileMapImporters; 1452 | apiTileMapImporters.openData = { 1453 | name: "Open Json file", 1454 | onSelectFiles: (setData, files) => { 1455 | const readFile = new FileReader(); 1456 | readFile.onload = (e) => { 1457 | const json = JSON.parse(e.target.result); 1458 | setData(json); 1459 | }; 1460 | readFile.readAsText(files[0]); 1461 | }, 1462 | acceptFile: "application/JSON" 1463 | } 1464 | apiOnUpdateCallback = onUpdate; 1465 | 1466 | if(onMouseUp){ 1467 | apiOnMouseUp = onMouseUp; 1468 | document.getElementById('tileMapEditor').addEventListener('pointerup', function(){ 1469 | apiOnMouseUp(getAppState(), apiTileMapExporters) 1470 | }) 1471 | } 1472 | 1473 | const importedTilesetImages = (tileMapData?.tileSets && Object.values(tileMapData?.tileSets)) || tileSetImages; 1474 | IMAGES = importedTilesetImages; 1475 | SIZE_OF_CROP = importedTilesetImages?.[0]?.tileSize || tileSize || 32;//to the best of your ability, predict the init tileSize 1476 | mapTileWidth = mapWidth || 12; 1477 | mapTileHeight = mapHeight || 12; 1478 | const canvasWidth = mapTileWidth * tileSize * ZOOM; 1479 | const canvasHeight = mapTileHeight * tileSize * ZOOM; 1480 | 1481 | if (SIZE_OF_CROP < 12) ZOOM = 2;// Automatically start with zoom 2 when the tilesize is tiny 1482 | // Attach elements 1483 | attachTo.innerHTML = getHtml(canvasWidth, canvasHeight); 1484 | attachTo.className = "tilemap_editor_root"; 1485 | tilesetImage = document.createElement('img'); 1486 | cropSize = document.getElementById('cropSize'); 1487 | 1488 | confirmBtn = document.getElementById("confirmBtn"); 1489 | if(onApply){ 1490 | confirmBtn.innerText = applyButtonText || "Ok"; 1491 | } else { 1492 | confirmBtn.style.display = "none"; 1493 | } 1494 | canvas = document.getElementById('mapCanvas'); 1495 | tilesetContainer = document.querySelector('.tileset-container'); 1496 | tilesetSelection = document.querySelector('.tileset-container-selection'); 1497 | tilesetGridContainer = document.getElementById("tilesetGridContainer"); 1498 | layersElement = document.getElementById("layers"); 1499 | objectParametersEditor = document.getElementById("objectParametersEditor"); 1500 | 1501 | tilesetContainer.addEventListener("contextmenu", e => { 1502 | e.preventDefault(); 1503 | }); 1504 | 1505 | tilesetContainer.addEventListener('pointerdown', (e) => { 1506 | tileSelectStart = getSelectedTile(e)[0]; 1507 | }); 1508 | tilesetContainer.addEventListener('pointermove', (e) => { 1509 | if(tileSelectStart !== null){ 1510 | selection = getSelectedTile(e); 1511 | updateSelection(); 1512 | } 1513 | }); 1514 | 1515 | const setFramesToSelection = (objectName, animName = "") =>{ 1516 | console.log({animName, objectName}) 1517 | if(objectName === "" || typeof objectName !== "string") return; 1518 | tileSets[tilesetDataSel.value].frames[objectName] = { 1519 | ...(tileSets[tilesetDataSel.value].frames[objectName]||{}), 1520 | width: selectionSize[0], height:selectionSize[1], start: selection[0], tiles: selection, 1521 | name: objectName, 1522 | //To be set when placing tile 1523 | layer: undefined, isFlippedX: false, xPos: 0, yPos: 0//TODO free position 1524 | } 1525 | } 1526 | tilesetContainer.addEventListener('pointerup', (e) => { 1527 | setTimeout(()=>{ 1528 | document.getElementById("tilesetDataDetails").open = false; 1529 | },100); 1530 | 1531 | selection = getSelectedTile(e); 1532 | updateSelection(); 1533 | selection = getSelectedTile(e); 1534 | tileSelectStart = null; 1535 | 1536 | const viewMode = tileDataSel.value; 1537 | if(viewMode === "" && e.button === 2){ 1538 | renameCurrentTileSymbol(); 1539 | return; 1540 | } 1541 | if (e.button === 0) { 1542 | if(DISPLAY_SYMBOLS && viewMode !== "" && viewMode !== "frames"){ 1543 | selection.forEach(selected=>{ 1544 | addToUndoStack(); 1545 | const {x, y} = selected; 1546 | const tileKey = `${x}-${y}`; 1547 | const tagTiles = tileSets[tilesetDataSel.value]?.tags[viewMode]?.tiles; 1548 | if (tagTiles){ 1549 | if(tileKey in tagTiles) { 1550 | delete tagTiles[tileKey] 1551 | }else { 1552 | tagTiles[tileKey] = { mark: "O"}; 1553 | } 1554 | } 1555 | }); 1556 | } else if (viewMode === "frames") { 1557 | setFramesToSelection(tileFrameSel.value); 1558 | } 1559 | updateTilesetGridContainer(); 1560 | } 1561 | }); 1562 | tilesetContainer.addEventListener('dblclick', (e) => { 1563 | const viewMode = tileDataSel.value; 1564 | if(viewMode === "") { 1565 | renameCurrentTileSymbol(); 1566 | } 1567 | }); 1568 | document.getElementById("addLayerBtn").addEventListener("click",()=>{ 1569 | addToUndoStack(); 1570 | addLayer(); 1571 | }); 1572 | // Maps DATA callbacks 1573 | mapsDataSel = document.getElementById("mapsDataSel"); 1574 | mapsDataSel.addEventListener("change", e=>{ 1575 | addToUndoStack(); 1576 | setActiveMap(e.target.value); 1577 | addToUndoStack(); 1578 | }) 1579 | document.getElementById("addMapBtn").addEventListener("click",()=>{ 1580 | const suggestMapName = `Map ${Object.keys(maps).length + 1}`; 1581 | const result = window.prompt("Enter new map key...", suggestMapName); 1582 | if(result !== null) { 1583 | addToUndoStack(); 1584 | const newMapKey = result.trim().replaceAll(" ","_") || suggestMapName; 1585 | if (newMapKey in maps){ 1586 | alert("A map with this key already exists.") 1587 | return 1588 | } 1589 | maps[newMapKey] = getEmptyMap(result.trim()); 1590 | addToUndoStack(); 1591 | updateMaps(); 1592 | } 1593 | }) 1594 | document.getElementById("duplicateMapBtn").addEventListener("click",()=>{ 1595 | const makeNewKey = (key) => { 1596 | const suggestedNew = `${key}_copy`; 1597 | if (suggestedNew in maps){ 1598 | return makeNewKey(suggestedNew) 1599 | } 1600 | return suggestedNew; 1601 | } 1602 | addToUndoStack(); 1603 | const newMapKey = makeNewKey(ACTIVE_MAP); 1604 | maps[newMapKey] = {...JSON.parse(JSON.stringify(maps[ACTIVE_MAP])), name: newMapKey};// todo prompt to ask for name 1605 | updateMaps(); 1606 | addToUndoStack(); 1607 | }) 1608 | document.getElementById("removeMapBtn").addEventListener("click",()=>{ 1609 | addToUndoStack(); 1610 | delete maps[ACTIVE_MAP]; 1611 | setActiveMap(Object.keys(maps)[0]) 1612 | updateMaps(); 1613 | addToUndoStack(); 1614 | }) 1615 | // Tileset DATA Callbacks //tileDataSel 1616 | tileDataSel = document.getElementById("tileDataSel"); 1617 | tileDataSel.addEventListener("change",()=>{ 1618 | selectMode(); 1619 | }) 1620 | document.getElementById("addTileTagBtn").addEventListener("click",()=>{ 1621 | const result = window.prompt("Name your tag", "solid()"); 1622 | if(result !== null){ 1623 | if (result in tileSets[tilesetDataSel.value].tags) { 1624 | alert("Tag already exists"); 1625 | return; 1626 | } 1627 | tileSets[tilesetDataSel.value].tags[result] = getEmptyTilesetTag(result, result); 1628 | updateTilesetDataList(); 1629 | addToUndoStack(); 1630 | } 1631 | }); 1632 | document.getElementById("removeTileTagBtn").addEventListener("click",()=>{ 1633 | if (tileDataSel.value && tileDataSel.value in tileSets[tilesetDataSel.value].tags) { 1634 | delete tileSets[tilesetDataSel.value].tags[tileDataSel.value]; 1635 | updateTilesetDataList(); 1636 | addToUndoStack(); 1637 | } 1638 | }); 1639 | // Tileset frames 1640 | tileFrameSel = document.getElementById("tileFrameSel"); 1641 | tileFrameSel.addEventListener("change", e =>{ 1642 | el.tileFrameCount().value = getCurrentFrames()?.frameCount || 1; 1643 | updateTilesetDataList(true); 1644 | updateTilesetGridContainer(); 1645 | }); 1646 | el.animStart().addEventListener("change", e =>{ 1647 | getCurrentAnimation().start = Number(el.animStart().value); 1648 | }); 1649 | el.animEnd().addEventListener("change", e =>{ 1650 | getCurrentAnimation().end = Number(el.animEnd().value); 1651 | }); 1652 | document.getElementById("addTileFrameBtn").addEventListener("click",()=>{ 1653 | const result = window.prompt("Name your object", `obj${Object.keys(tileSets[tilesetDataSel.value]?.frames||{}).length}`); 1654 | if(result !== null){ 1655 | if (result in tileSets[tilesetDataSel.value].frames) { 1656 | alert("Object already exists"); 1657 | return; 1658 | } 1659 | tileSets[tilesetDataSel.value].frames[result] = { 1660 | frameCount: Number(el.tileFrameCount().value), 1661 | animations: { 1662 | a1: { 1663 | start: 1, 1664 | end: Number(el.tileFrameCount().value) || 1,//todo move in here 1665 | name: "a1", 1666 | loop: el.animLoop().checked, 1667 | speed: Number(el.animSpeed().value), 1668 | } 1669 | } 1670 | } 1671 | setFramesToSelection(result); 1672 | updateTilesetDataList(true); 1673 | tileFrameSel.value = result; 1674 | updateTilesetGridContainer(); 1675 | } 1676 | }); 1677 | document.getElementById("removeTileFrameBtn").addEventListener("click",()=>{ 1678 | if (tileFrameSel.value && tileFrameSel.value in tileSets[tilesetDataSel.value].frames && confirm(`Are you sure you want to delete ${tileFrameSel.value}`)) { 1679 | delete tileSets[tilesetDataSel.value].frames[tileFrameSel.value]; 1680 | updateTilesetDataList(true); 1681 | updateTilesetGridContainer(); 1682 | } 1683 | }); 1684 | const renameKeyInObjectForSelectElement = (selectElement, objectPath, typeLabel) =>{ 1685 | const oldValue = selectElement.value; 1686 | const result = window.prompt("Rename your animation", `${oldValue}`); 1687 | if(result && result !== oldValue){ 1688 | if (!objectPath) return; 1689 | if(result in objectPath){ 1690 | alert(`${typeLabel} with the ${result} name already exists. Aborted`); 1691 | return; 1692 | } 1693 | if(result.length < 2) { 1694 | alert(`${typeLabel} name needs to be longer than one character. Aborted`); //so animations and objects never overlap with symbols 1695 | return; 1696 | } 1697 | Object.defineProperty(objectPath, result, 1698 | Object.getOwnPropertyDescriptor(objectPath, oldValue)); 1699 | delete objectPath[oldValue]; 1700 | updateTilesetDataList(true); 1701 | selectElement.value = result; 1702 | updateTilesetDataList(true); 1703 | } 1704 | } 1705 | el.renameTileFrameBtn().addEventListener("click", ()=>{ // could be a generic function 1706 | renameKeyInObjectForSelectElement(tileFrameSel, tileSets[tilesetDataSel.value]?.frames, "object"); 1707 | }); 1708 | el.tileFrameCount().addEventListener("change", e=>{ 1709 | if(tileFrameSel.value === "") return; 1710 | getCurrentFrames().frameCount = Number(e.target.value); 1711 | updateTilesetGridContainer(); 1712 | }) 1713 | 1714 | // animations 1715 | tileAnimSel = document.getElementById("tileAnimSel"); 1716 | tileAnimSel.addEventListener("change", e =>{//swap with tileAnimSel 1717 | console.log("anim select", e, tileAnimSel.value) 1718 | el.animStart().value = getCurrentAnimation()?.start || 1; 1719 | el.animEnd().value = getCurrentAnimation()?.end || 1; 1720 | el.animLoop().checked = getCurrentAnimation()?.loop || false; 1721 | el.animSpeed().value = getCurrentAnimation()?.speed || 1; 1722 | updateTilesetGridContainer(); 1723 | }); 1724 | document.getElementById("addTileAnimBtn").addEventListener("click",()=>{ 1725 | const result = window.prompt("Name your animation", `anim${Object.keys(tileSets[tilesetDataSel.value]?.frames[tileFrameSel.value]?.animations || {}).length}`); 1726 | if(result !== null){ 1727 | if(!tileSets[tilesetDataSel.value].frames[tileFrameSel.value]?.animations){ 1728 | tileSets[tilesetDataSel.value].frames[tileFrameSel.value].animations = {} 1729 | } 1730 | if (result in tileSets[tilesetDataSel.value].frames[tileFrameSel.value]?.animations) { 1731 | alert("Animation already exists"); 1732 | return; 1733 | } 1734 | tileSets[tilesetDataSel.value].frames[tileFrameSel.value].animations[result] = { 1735 | start: 1, 1736 | end: Number(el.tileFrameCount().value || 1), 1737 | loop: el.animLoop().checked, 1738 | speed: Number(el.animSpeed().value || 1), 1739 | name: result 1740 | } 1741 | // setFramesToSelection(tileFrameSel.value, result); 1742 | updateTilesetDataList(true); 1743 | tileAnimSel.value = result; 1744 | updateTilesetGridContainer(); 1745 | } 1746 | }); 1747 | document.getElementById("removeTileAnimBtn").addEventListener("click",()=>{ 1748 | console.log("delete", tileAnimSel.value, tileSets[tilesetDataSel.value].frames[tileFrameSel.value].animations) 1749 | if (tileAnimSel.value && tileSets[tilesetDataSel.value].frames[tileFrameSel.value]?.animations 1750 | && tileAnimSel.value in tileSets[tilesetDataSel.value].frames[tileFrameSel.value]?.animations 1751 | && confirm(`Are you sure you want to delete ${tileAnimSel.value}`) 1752 | ) { 1753 | delete tileSets[tilesetDataSel.value].frames[tileFrameSel.value].animations[tileAnimSel.value]; 1754 | updateTilesetDataList(true); 1755 | updateTilesetGridContainer(); 1756 | } 1757 | }); 1758 | el.renameTileAnimBtn().addEventListener("click", ()=>{ 1759 | renameKeyInObjectForSelectElement(tileAnimSel, tileSets[tilesetDataSel.value]?.frames[tileFrameSel.value]?.animations, "animation"); 1760 | }); 1761 | 1762 | el.animLoop().addEventListener("change", ()=>{ 1763 | getCurrentAnimation().loop = el.animLoop().checked; 1764 | }) 1765 | el.animSpeed().addEventListener("change", e=>{ 1766 | getCurrentAnimation().speed = el.animSpeed().value; 1767 | }) 1768 | // Tileset SELECT callbacks 1769 | tilesetDataSel = document.getElementById("tilesetDataSel"); 1770 | tilesetDataSel.addEventListener("change",e=>{ 1771 | tilesetImage.src = TILESET_ELEMENTS[e.target.value].src; 1772 | tilesetImage.crossOrigin = "Anonymous"; 1773 | updateTilesetDataList(); 1774 | }) 1775 | el.tileFrameCount().addEventListener("change",()=>{ 1776 | el.animStart().max = el.tileFrameCount().value; 1777 | el.animEnd().max = el.tileFrameCount().value; 1778 | }) 1779 | 1780 | const replaceSelectedTileSet = (src) => { 1781 | addToUndoStack(); 1782 | IMAGES[Number(tilesetDataSel.value)].src = src; 1783 | reloadTilesets(); 1784 | } 1785 | const addNewTileSet = (src) => { 1786 | console.log("add new tileset"+ src) 1787 | addToUndoStack(); 1788 | IMAGES.push({src}); 1789 | reloadTilesets(); 1790 | } 1791 | exports.addNewTileSet = addNewTileSet; 1792 | // replace tileset 1793 | document.getElementById("tilesetReplaceInput").addEventListener("change",e=>{ 1794 | toBase64(e.target.files[0]).then(base64Src=>{ 1795 | if (selectedTileSetLoader.onSelectImage) { 1796 | selectedTileSetLoader.onSelectImage(replaceSelectedTileSet, e.target.files[0], base64Src); 1797 | } 1798 | }) 1799 | }) 1800 | document.getElementById("replaceTilesetBtn").addEventListener("click",()=>{ 1801 | if (selectedTileSetLoader.onSelectImage) { 1802 | document.getElementById("tilesetReplaceInput").click(); 1803 | } 1804 | if (selectedTileSetLoader.prompt) { 1805 | selectedTileSetLoader.prompt(replaceSelectedTileSet); 1806 | } 1807 | }); 1808 | // add tileset 1809 | document.getElementById("tilesetReadInput").addEventListener("change",e=>{ 1810 | toBase64(e.target.files[0]).then(base64Src=>{ 1811 | if (selectedTileSetLoader.onSelectImage) { 1812 | selectedTileSetLoader.onSelectImage(addNewTileSet, e.target.files[0], base64Src) 1813 | } 1814 | }) 1815 | }) 1816 | // remove tileset 1817 | document.getElementById("addTilesetBtn").addEventListener("click",()=>{ 1818 | if (selectedTileSetLoader.onSelectImage) { 1819 | document.getElementById("tilesetReadInput").click(); 1820 | } 1821 | if (selectedTileSetLoader.prompt) { 1822 | selectedTileSetLoader.prompt(addNewTileSet); 1823 | } 1824 | }); 1825 | const tileSetLoadersSel = document.getElementById("tileSetLoadersSel"); 1826 | Object.entries(apiTileSetLoaders).forEach(([key,loader])=>{ 1827 | const tsLoaderOption = document.createElement("option"); 1828 | tsLoaderOption.value = key; 1829 | tsLoaderOption.innerText = loader.name; 1830 | tileSetLoadersSel.appendChild(tsLoaderOption); 1831 | // apiTileSetLoaders[key].load = () => tileSetLoaders 1832 | }); 1833 | 1834 | tileSetLoadersSel.value = "base64"; 1835 | selectedTileSetLoader = apiTileSetLoaders[tileSetLoadersSel.value]; 1836 | tileSetLoadersSel.addEventListener("change", e=>{ 1837 | selectedTileSetLoader = apiTileSetLoaders[e.target.value]; 1838 | }) 1839 | exports.tilesetLoaders = apiTileSetLoaders; 1840 | 1841 | const deleteTilesetWithIndex = (index, cb = null) => { 1842 | if(confirm(`Are you sure you want to delete this image?`)){ 1843 | addToUndoStack(); 1844 | IMAGES.splice(index,1); 1845 | reloadTilesets(); 1846 | if(cb) cb() 1847 | } 1848 | } 1849 | exports.IMAGES = IMAGES; 1850 | exports.deleteTilesetWithIndex = deleteTilesetWithIndex; 1851 | document.getElementById("removeTilesetBtn").addEventListener("click",()=>{ 1852 | //Remove current tileset 1853 | if (tilesetDataSel.value !== "0") { 1854 | deleteTilesetWithIndex(Number(tilesetDataSel.value)); 1855 | } 1856 | }); 1857 | 1858 | // Canvas callbacks 1859 | canvas.addEventListener('pointerdown', setMouseIsTrue); 1860 | canvas.addEventListener('pointerup', setMouseIsFalse); 1861 | canvas.addEventListener('pointerleave', setMouseIsFalse); 1862 | canvas.addEventListener('pointerdown', toggleTile); 1863 | canvas.addEventListener("contextmenu", e => e.preventDefault()); 1864 | draggable({ onElement: canvas, element: document.getElementById("canvas_wrapper")}); 1865 | canvas.addEventListener('pointermove', (e) => { 1866 | if (isMouseDown && ACTIVE_TOOL !== 2) toggleTile(e) 1867 | }); 1868 | // Canvas Resizer =================== 1869 | document.getElementById("canvasWidthInp").addEventListener("change", e=>{ 1870 | updateMapSize({mapWidth: Number(e.target.value)}) 1871 | }) 1872 | document.getElementById("canvasHeightInp").addEventListener("change", e=>{ 1873 | updateMapSize({mapHeight: Number(e.target.value)}) 1874 | }) 1875 | // draggable({ 1876 | // element: document.querySelector(".canvas_resizer[resizerdir='x']"), 1877 | // onElement: document.querySelector(".canvas_resizer[resizerdir='x'] span"), 1878 | // isDrag: true, limitY: true, 1879 | // onRelease: ({x}) => { 1880 | // const snappedX = getSnappedPos(x); 1881 | // console.log("SNAPPED GRID", x,snappedX) 1882 | // updateMapSize({mapWidth: snappedX }) 1883 | // }, 1884 | // }); 1885 | 1886 | document.querySelector(".canvas_resizer[resizerdir='y'] input").addEventListener("change", e=>{ 1887 | updateMapSize({mapHeight: Number(e.target.value)}) 1888 | }) 1889 | document.querySelector(".canvas_resizer[resizerdir='x'] input").addEventListener("change", e=>{ 1890 | updateMapSize({mapWidth: Number(e.target.value) }) 1891 | }) 1892 | document.getElementById("toolButtonsWrapper").addEventListener("click",e=>{ 1893 | console.log("ACTIVE_TOOL", e.target.value) 1894 | if(e.target.getAttribute("name") === "tool") setActiveTool(Number(e.target.value)); 1895 | }) 1896 | document.getElementById("gridCropSize").addEventListener('change', e=>{ 1897 | setCropSize(Number(e.target.value)); 1898 | }) 1899 | cropSize.addEventListener('change', e=>{ 1900 | setCropSize(Number(e.target.value)); 1901 | }) 1902 | 1903 | document.getElementById("clearCanvasBtn").addEventListener('click', clearCanvas); 1904 | if(onApply){ 1905 | confirmBtn.addEventListener('click', () => onApply.onClick(getExportData())); 1906 | } 1907 | 1908 | document.getElementById("renameMapBtn").addEventListener("click",()=>{ 1909 | const newName = window.prompt("Change map name:", maps[ACTIVE_MAP].name || "Map"); 1910 | if(newName !== null && maps[ACTIVE_MAP].name !== newName){ 1911 | if(Object.values(maps).map(map=>map.name).includes(newName)){ 1912 | alert(`${newName} already exists`); 1913 | return 1914 | } 1915 | maps[ACTIVE_MAP].name = newName; 1916 | updateMaps(); 1917 | } 1918 | }) 1919 | 1920 | const fileMenuDropDown = document.getElementById("fileMenuDropDown"); 1921 | const makeMenuItem = (name, value, description) =>{ 1922 | const menuItem = document.createElement("span"); 1923 | menuItem.className = "item"; 1924 | menuItem.innerText = name; 1925 | menuItem.title = description || name; 1926 | menuItem.value = value; 1927 | fileMenuDropDown.appendChild(menuItem); 1928 | return menuItem; 1929 | } 1930 | Object.entries(tileMapExporters).forEach(([key, exporter])=>{ 1931 | makeMenuItem(exporter.name, key,exporter.description).onclick = () => { 1932 | exporter.transformer(getExportData()); 1933 | } 1934 | apiTileMapExporters[key].getData = () => exporter.transformer(getExportData()); 1935 | }) 1936 | exports.exporters = apiTileMapExporters; 1937 | 1938 | Object.entries(apiTileMapImporters).forEach(([key, importer])=>{ 1939 | makeMenuItem(importer.name, key,importer.description).onclick = () => { 1940 | if(importer.onSelectFiles) { 1941 | const input = document.createElement("input"); 1942 | input.type = "file"; 1943 | input.id = `importerInput-${key}`; 1944 | if(importer.acceptFile) input.accept = importer.acceptFile; 1945 | input.style.display = "none"; 1946 | input.addEventListener("change",e=> { 1947 | importer.onSelectFiles(loadData, e.target.files); 1948 | }) 1949 | input.click(); 1950 | } 1951 | } 1952 | // apiTileMapImporters[key].setData = (files) => importer.onSelectFiles(loadData, files); 1953 | }) 1954 | document.getElementById("toggleFlipX").addEventListener("change",(e)=>{ 1955 | document.getElementById("flipBrushIndicator").style.transform = e.target.checked ? "scale(-1, 1)": "scale(1, 1)" 1956 | }) 1957 | document.addEventListener('keypress', e =>{ 1958 | if(e.ctrlKey){ 1959 | if(e.code === "KeyZ") undo(); 1960 | if(e.code === "KeyY") redo(); 1961 | } 1962 | }) 1963 | document.getElementById("gridColorSel").addEventListener("change", e=>{ 1964 | console.log("grid col",e.target.value) 1965 | maps[ACTIVE_MAP].gridColor = e.target.value; 1966 | draw(); 1967 | }) 1968 | document.getElementById("showGrid").addEventListener("change", e => { 1969 | SHOW_GRID = e.target.checked; 1970 | draw(); 1971 | }) 1972 | 1973 | document.getElementById("undoBtn").addEventListener("click", undo); 1974 | document.getElementById("redoBtn").addEventListener("click", redo); 1975 | document.getElementById("zoomIn").addEventListener("click", zoomIn); 1976 | document.getElementById("zoomOut").addEventListener("click", zoomOut); 1977 | document.getElementById("setSymbolsVisBtn").addEventListener("click", ()=>toggleSymbolsVisible()) 1978 | // Scroll zoom in/out - use wheel instead of scroll event since theres no scrollbar on the map 1979 | canvas.addEventListener('wheel', e=> { 1980 | if (e.deltaY < 0) zoomIn(); 1981 | else zoomOut(); 1982 | }); 1983 | 1984 | loadData(tileMapData) 1985 | if (appState) { 1986 | ACTIVE_MAP = appState.ACTIVE_MAP; 1987 | mapsDataSel.value = ACTIVE_MAP; 1988 | setActiveMap(appState.ACTIVE_MAP) 1989 | PREV_ACTIVE_TOOL = appState.PREV_ACTIVE_TOOL; 1990 | ACTIVE_TOOL = appState.ACTIVE_TOOL; 1991 | setActiveTool(appState.ACTIVE_TOOL) 1992 | setLayer(appState.currentLayer) 1993 | selection = appState.selection; 1994 | updateSelection(false); 1995 | SHOW_GRID = appState.SHOW_GRID; 1996 | } 1997 | 1998 | // Animated tiles when on frames mode 1999 | const animateTiles = () => { 2000 | if (tileDataSel.value === "frames") draw(); 2001 | requestAnimationFrame(animateTiles); 2002 | } 2003 | requestAnimationFrame(animateTiles); 2004 | }; 2005 | 2006 | exports.getState = () => { 2007 | return getAppState(); 2008 | } 2009 | 2010 | exports.onUpdate = apiOnUpdateCallback; 2011 | exports.onMouseUp = apiOnMouseUp; 2012 | 2013 | exports.getTilesets = () => tileSets; 2014 | }); 2015 | --------------------------------------------------------------------------------