├── CustomBrushOptions.lua └── README.md /CustomBrushOptions.lua: -------------------------------------------------------------------------------- 1 | --[[------------------------------------------------------------------------------- 2 | Aseprite script that provides some more functions for custom brushes: 3 | - Scaling 4 | - Flipping 5 | - Rotation in 90 degree increments/decrements 6 | - Select multiple brushes at once and either incrementally or randomly cycle 7 | between them. 8 | 9 | Note that the multiple brush selection must be done using the rectangular selection 10 | tool, and the separate selections must be separated by at least one pixel otherwise 11 | it won't work. 12 | 13 | By Thoof (@Thoof4 on twitter) 14 | -------------------------------------------------------------------------------]]-- 15 | 16 | local brush_cap = 32 17 | 18 | local sprite = app.activeSprite 19 | 20 | local base_images = {} -- The base image of whatever brushes we're currently using 21 | 22 | local current_image_index = 0 -- The current brush image we're using 23 | 24 | local fgcolor_changed = false 25 | local bgcolor_changed = false 26 | 27 | local recolored_base_images = {} -- The recolored version of the base images, for if the user changes fgcolor/bgcolor 28 | 29 | local resized_last_update = false 30 | local last_image_scale_x = 100 31 | local last_image_scale_y = 100 32 | 33 | local rotation = 0 -- The rotation (Only goes in increments/decrements of 90 degrees) 34 | 35 | local curr_offset = Point(0, 0) 36 | 37 | local color_mode = app.activeSprite.colorMode 38 | 39 | -- If we've got a custom brush currently then store it 40 | if app.activeBrush.type == BrushType.IMAGE then 41 | base_images[0] = app.activeBrush.image 42 | 43 | end 44 | 45 | local function reset_brush_and_stop_events() 46 | 47 | reset_brush() 48 | app.events:off(on_fgcolorchange) 49 | app.events:off(on_bgcolorchange) 50 | if sprite ~= nil then 51 | sprite.events:off(on_sprite_change) 52 | end 53 | end 54 | 55 | 56 | 57 | -- Initialize the dialog -- 58 | local dlg = Dialog { title = "Custom Brush Options", onclose = reset_brush_and_stop_events } 59 | 60 | 61 | local function convert_color_to_app_pixelcolor(color) 62 | return app.pixelColor.rgba(color.red, color.green, color.blue, color.alpha) 63 | end 64 | 65 | 66 | 67 | 68 | local function get_pixel_alpha(pixel) 69 | local alpha = app.pixelColor.rgbaA(pixel) 70 | if app.activeSprite.colorMode == ColorMode.INDEXED then 71 | if pixel == app.activeSprite.spec.transparentColor then 72 | alpha = 0 73 | else 74 | alpha = 255 75 | end 76 | elseif app.activeSprite.colorMode == ColorMode.GRAY then 77 | alpha = app.pixelColor.grayaA(pixel) 78 | end 79 | 80 | return alpha 81 | end 82 | 83 | local function get_pixel_alpha_for_colormode(pixel, colormode) 84 | local alpha = app.pixelColor.rgbaA(pixel) 85 | if colormode == ColorMode.INDEXED then 86 | if pixel == app.activeSprite.spec.transparentColor then 87 | alpha = 0 88 | else 89 | alpha = 255 90 | end 91 | elseif colormode == ColorMode.GRAY then 92 | alpha = app.pixelColor.grayaA(pixel) 93 | end 94 | 95 | return alpha 96 | end 97 | 98 | local function get_pxc_as_color(pxc) 99 | return Color(app.pixelColor.rgbaR(pxc), app.pixelColor.rgbaG(pxc), app.pixelColor.rgbaB(pxc), get_pixel_alpha(pxc)) 100 | end 101 | 102 | local function get_image_alpha_array(image) 103 | local a = {} 104 | local count = 0 105 | 106 | for it in image:pixels() do 107 | local pixelValue = it() 108 | 109 | local alpha = get_pixel_alpha(pixelValue) 110 | 111 | a[count] = alpha 112 | 113 | end 114 | 115 | return a 116 | end 117 | 118 | -- Compares the alphas of two images and returns true if they're the same -- 119 | local function image_alpha_comparison(image1, image2) 120 | if (image1 == nil or image2 == nil) then 121 | return false 122 | end 123 | 124 | if (image1.width ~= image2.width) or (image1.height ~= image2.height) then 125 | return false 126 | end 127 | 128 | 129 | -- Wasn't sure how to use the api iterator to compare two images so this is not efficient 130 | local alpha_array_1 = get_image_alpha_array(image1) 131 | local alpha_array_2 = get_image_alpha_array(image2) 132 | 133 | for i = 1, #alpha_array_1 do 134 | if alpha_array_1[i] ~= alpha_array_2[i] then 135 | return false 136 | end 137 | end 138 | 139 | return true 140 | 141 | end 142 | 143 | local function does_image_have_transparency(image) 144 | for it in image:pixels() do 145 | local pixelValue = it() 146 | 147 | local alpha = get_pixel_alpha(pixelValue) 148 | 149 | 150 | if alpha < 255 then 151 | return true 152 | end 153 | end 154 | return false 155 | end 156 | 157 | -- Flips image and returns a copy -- 158 | local function get_flipped_image(image, flipX, flipY) 159 | local image_copy = Image(image.width, image.height, image.colorMode) 160 | 161 | for it in image:pixels() do 162 | local pixelValue = it() 163 | local x = it.x 164 | local y = it.y 165 | if flipX then 166 | x = image.width - it.x 167 | end 168 | if flipY then 169 | y = image.height - it.y 170 | end 171 | 172 | image_copy:drawPixel(x, y, pixelValue) 173 | end 174 | 175 | return image_copy 176 | 177 | 178 | end 179 | 180 | -- Note that this is only for rotation of increments of 90 degrees up to 270, positive and negative -- 181 | local function get_point_after_rotation(rotation, currX, currY, currWidth, currHeight) 182 | if (rotation == 90 or rotation == -270) then 183 | return Point(currY, currWidth - currX) 184 | elseif (rotation == -90 or rotation == 270) then 185 | return Point(currHeight - currY, currX) 186 | elseif (rotation == 180 or rotation == -180) then 187 | return Point(currWidth - currX, currHeight - currY) 188 | end 189 | 190 | return Point(0, 0) 191 | 192 | end 193 | 194 | -- Rotates the image, based on a rotation that must be some increment of 90 degrees (up to 270) -- 195 | local function rotate_image(image, rotation) 196 | local image_new = Image(image.height, image.width, image.colorMode) 197 | 198 | for it in image:pixels() do 199 | local pixelValue = it() 200 | local x = it.x 201 | local y = it.y 202 | 203 | local point_after_rot = get_point_after_rotation(rotation, x, y, image.width, image.height) 204 | 205 | image_new:drawPixel(point_after_rot.x, point_after_rot.y, pixelValue) 206 | end 207 | 208 | return image_new 209 | end 210 | 211 | local function get_color_difference(color1, color2) 212 | local r = app.pixelColor.rgbaR(color1) 213 | local g = app.pixelColor.rgbaG(color1) 214 | local b = app.pixelColor.rgbaB(color1) 215 | local alpha = get_pixel_alpha(color1) 216 | 217 | local r2 = app.pixelColor.rgbaR(color2) 218 | local g2 = app.pixelColor.rgbaG(color2) 219 | local b2 = app.pixelColor.rgbaB(color2) 220 | local alpha2 = get_pixel_alpha(color2) 221 | 222 | local val = math.sqrt((r - r2)^2 + (g - g2)^2 + (b - b2)^2) 223 | return val 224 | end 225 | 226 | local function get_closest_color_in_palette(app_pixel_color) 227 | local palette = app.activeSprite.palettes[1] 228 | 229 | local min_value = math.huge 230 | local index = 0 231 | for i = 0, #palette - 1 do 232 | local palette_color = convert_color_to_app_pixelcolor(palette:getColor(i)) 233 | 234 | local diff = get_color_difference(app_pixel_color, palette_color) 235 | 236 | if diff < min_value then 237 | min_value = diff 238 | index = i 239 | end 240 | 241 | end 242 | 243 | return index 244 | end 245 | 246 | -- Colors a whole image with the specified color, without changing any alpha values -- 247 | local function color_whole_image_rgb(image, app_pixel_color) 248 | 249 | for it in image:pixels() do 250 | local pixelValue = it() 251 | 252 | local alpha = get_pixel_alpha(pixelValue) 253 | 254 | if alpha ~= 0 then 255 | it(app_pixel_color) -- Set pixel 256 | end 257 | 258 | end 259 | 260 | end 261 | 262 | 263 | 264 | --[[ Applies the current foreground color to the image, in the same/a similar way to how brush colors change 265 | Rules for color change (These are only via my observations so may be somewhat inaccurate): 266 | - If you have an image with any transparency at all, the foreground color is applied to all pixels in the image. 267 | Semitransparent pixels also get the same color but maintain their alpha value. When bg color is changed in this situation, nothing happens. 268 | - If the image is a full image with no transparency, then: 269 | The foreground color will change the color of everything EXCEPT the first color found in the image. 270 | The background color will change the color of only pixels that were the first color found in the image. ]]-- 271 | 272 | local function apply_selected_colors_to_image(image, apply_foreground, apply_background) 273 | 274 | local current_fgcolor = convert_color_to_app_pixelcolor(app.fgColor) 275 | local current_bgcolor = convert_color_to_app_pixelcolor(app.bgColor) 276 | 277 | local closest_fg = get_closest_color_in_palette(current_fgcolor) 278 | local closest_bg = get_closest_color_in_palette(current_bgcolor) 279 | 280 | if app.activeSprite.colorMode == ColorMode.INDEXED then 281 | current_fgcolor = closest_fg 282 | current_bgcolor = closest_bg 283 | elseif app.activeSprite.colorMode == ColorMode.GRAY then 284 | current_fgcolor = app.pixelColor.graya(current_fgcolor) 285 | current_bgcolor = app.pixelColor.graya(current_bgcolor) 286 | end 287 | 288 | local image_has_transparency = does_image_have_transparency(image) 289 | 290 | -- Image transparent, so just apply the foreground color if applicable 291 | if image_has_transparency then 292 | 293 | if apply_foreground == false then 294 | return 295 | end 296 | 297 | color_whole_image_rgb(image, current_fgcolor) 298 | else 299 | local first_color = nil -- First color in the image, starting from the top left 300 | local second_color = nil -- Second color in the image, starting from the top left 301 | 302 | for it in image:pixels() do 303 | local pixelValue = it() 304 | 305 | -- Determine the first and second colors in the image 306 | if first_color == nil then 307 | first_color = pixelValue 308 | elseif (second_color == nil and pixelValue ~= first_color) then 309 | second_color = pixelValue 310 | end 311 | 312 | -- Apply the fgcolor to any pixels that are not the first color found in the image 313 | if (apply_foreground and second_color ~= nil and pixelValue ~= first_color) then 314 | it(current_fgcolor) 315 | elseif (apply_background and first_color ~= nil and pixelValue == first_color) then 316 | it(current_bgcolor) 317 | end 318 | 319 | end 320 | end 321 | 322 | end 323 | 324 | local function convert_color_to_colormode(pixelColor) 325 | 326 | if color_mode == ColorMode.RGB then 327 | local a = app.pixelColor.rgbaA(pixelColor) 328 | 329 | 330 | if app.activeSprite.colorMode == ColorMode.INDEXED then 331 | if a == 0 then 332 | return app.activeSprite.spec.transparentColor 333 | end 334 | 335 | return get_closest_color_in_palette(pixelColor) 336 | elseif app.activeSprite.colorMode == ColorMode.GRAY then 337 | return app.pixelColor.graya(pixelColor) 338 | end 339 | 340 | elseif color_mode == ColorMode.GRAY then 341 | local value = app.pixelColor.grayaV(pixelColor) 342 | local a = app.pixelColor.grayaA(pixelColor) 343 | 344 | if app.activeSprite.colorMode == ColorMode.RGB then 345 | return app.pixelColor.rgba(value, value, value, a) 346 | elseif app.activeSprite.colorMode == ColorMode.INDEXED then 347 | 348 | return get_closest_color_in_palette(app.pixelColor.rgba(value, value, value, a)) 349 | end 350 | elseif color_mode == ColorMode.INDEXED then 351 | 352 | if pixelColor == app.activeSprite.spec.transparentColor then 353 | return app.pixelColor.rgba(0, 0, 0, 0) 354 | end 355 | 356 | local non_indexed_color = convert_color_to_app_pixelcolor(app.activeSprite.palettes[1]:getColor(pixelColor)) 357 | 358 | local r = app.pixelColor.rgbaR(non_indexed_color) 359 | local g = app.pixelColor.rgbaG(non_indexed_color) 360 | local b = app.pixelColor.rgbaB(non_indexed_color) 361 | local a = app.pixelColor.rgbaA(non_indexed_color) 362 | 363 | if app.activeSprite.colorMode == ColorMode.RGB then 364 | return non_indexed_color 365 | elseif app.activeSprite.colorMode == ColorMode.GRAY then 366 | return app.pixelColor.graya(non_indexed_color) 367 | end 368 | end 369 | end 370 | 371 | local function convert_image_to_colormode(image) 372 | local new_image = Image(image.width, image.height, app.activeSprite.colorMode) 373 | 374 | for it in new_image:pixels() do 375 | local oldValue = image:getPixel(it.x, it.y) 376 | local new_pixelValue = convert_color_to_colormode(oldValue) 377 | 378 | 379 | it(new_pixelValue) -- Set pixel 380 | 381 | 382 | end 383 | 384 | return new_image 385 | end 386 | 387 | local function convert_base_images_to_colormode() 388 | for i = 0, #base_images do 389 | base_images[i] = convert_image_to_colormode(base_images[i]) 390 | end 391 | end 392 | 393 | app.events:on('sitechange', 394 | function() 395 | if sprite ~= nil then 396 | sprite.events:off(on_sprite_change) 397 | end 398 | sprite = app.activeSprite 399 | 400 | if sprite ~= nil then 401 | 402 | sprite.events:on('change', on_sprite_change) 403 | 404 | if color_mode ~= app.activeSprite.colorMode then 405 | convert_base_images_to_colormode() 406 | recolored_base_images = {} 407 | color_mode = app.activeSprite.colorMode 408 | end 409 | end 410 | 411 | end) 412 | 413 | 414 | -- Detects if a new brush is found -- 415 | -- Called any time the user interacts with the widgets or changes fgcolor or bgcolor -- 416 | local function detect_new_brush_and_update() 417 | -- Scale up the base image to the previous scale, and compare alphas. If they are the same, it's the same brush 418 | 419 | if color_mode ~= app.activeSprite.colorMode then 420 | convert_base_images_to_colormode() 421 | recolored_base_images = {} 422 | color_mode = app.activeSprite.colorMode 423 | end 424 | 425 | 426 | local former_slider_val_percent_x = 0 427 | local former_slider_val_percent_y = 0 428 | local width = 0 429 | local height = 0 430 | local base_copy = nil 431 | 432 | if base_images[current_image_index] ~= nil then 433 | former_slider_val_percent_x = last_image_scale_x / 100 434 | former_slider_val_percent_y = last_image_scale_y / 100 435 | base_copy = base_images[current_image_index]:clone() 436 | 437 | width = math.floor(base_copy.width * former_slider_val_percent_x) 438 | height = math.floor(base_copy.height * former_slider_val_percent_y) 439 | 440 | base_copy:resize(width, height) 441 | 442 | if rotation ~= 0 then 443 | base_copy = rotate_image(base_copy, -rotation) 444 | end 445 | 446 | end 447 | 448 | if (image_alpha_comparison(app.activeBrush.image, base_copy) == false or base_images[current_image_index] == nil) then 449 | -- Update the brush parameters, as we've switched brushes entirely since the last update -- 450 | 451 | base_images = {} 452 | current_image_index = 0 453 | base_images[current_image_index] = app.activeBrush.image 454 | 455 | recolored_base_images = {} 456 | fgcolor_changed = false 457 | bgcolor_changed = false 458 | 459 | dlg:modify {id = "flipx", 460 | selected = false } 461 | 462 | dlg:modify {id = "flipy", 463 | selected = false } 464 | 465 | rotation = 0 466 | end 467 | 468 | end 469 | 470 | 471 | -- Resets the brush to the base brush, setting the scale & color back to the original -- 472 | function reset_brush() 473 | 474 | -- If the brush isn't an image brush we want nothing to do with it 475 | if app.activeBrush.type ~= BrushType.IMAGE then 476 | return 477 | end 478 | 479 | detect_new_brush_and_update() 480 | 481 | app.activeBrush = Brush(base_images[0]) 482 | current_image_index = 0 483 | recolored_base_images = {} 484 | 485 | dlg:modify {id = "flipx", 486 | selected = false } 487 | 488 | dlg:modify {id = "flipy", 489 | selected = false } 490 | 491 | rotation = 0 492 | 493 | last_image_scale_x = 100 494 | last_image_scale_y = 100 495 | resized_last_update = false 496 | fgcolor_changed = false 497 | bgcolor_changed = false 498 | 499 | end 500 | 501 | 502 | 503 | 504 | local function randomize_size() 505 | local min_size = dlg.data.randsizemin 506 | local max_size = dlg.data.randsizemax 507 | 508 | local size = math.random(min_size, max_size) 509 | 510 | dlg:modify {id = "size_x", 511 | value = size } 512 | 513 | if dlg.data.check == true then 514 | dlg:modify {id = "size_y", 515 | value = size } 516 | else 517 | local size_y = math.random(min_size, max_size) 518 | dlg:modify {id = "size_y", 519 | value = size_y } 520 | end 521 | end 522 | 523 | local function get_random_offset() 524 | local max_offset = dlg.data.randoffsetlength 525 | 526 | local offset = math.random(0, max_offset) 527 | 528 | local rand_angle = math.random() * math.pi * 2 529 | local offset_x = math.cos(rand_angle) * offset 530 | local offset_y = math.sin(rand_angle) * offset 531 | 532 | return Point(offset_x, offset_y) 533 | end 534 | 535 | 536 | -- Resizes the current brush based on the current slider values -- 537 | local function resize_brush() 538 | 539 | 540 | local slider_val_x = dlg.data.size_x 541 | local slider_val_y = dlg.data.size_y 542 | 543 | local image_copy 544 | -- If we've got a recolored image (from changing fg/bgcolor) then use that instead of the base 545 | if recolored_base_images[current_image_index] ~= nil then 546 | image_copy = recolored_base_images[current_image_index]:clone() 547 | else 548 | image_copy = base_images[current_image_index]:clone() 549 | end 550 | 551 | local slider_val_x_percent = slider_val_x / 100 552 | local slider_val_y_percent = slider_val_y / 100 553 | 554 | 555 | local width = math.floor(image_copy.width * slider_val_x_percent) 556 | local height = math.floor(image_copy.height * slider_val_y_percent) 557 | 558 | image_copy:resize(width, height) 559 | 560 | if dlg.data.flipx == true or dlg.data.flipy == true then 561 | image_copy = get_flipped_image(image_copy, dlg.data.flipx, dlg.data.flipy) 562 | end 563 | 564 | if rotation ~= 0 then 565 | image_copy = rotate_image(image_copy, rotation) 566 | end 567 | 568 | 569 | resized_last_update = true 570 | 571 | if dlg.data.randoffsetcheck == false then 572 | curr_offset = Point(image_copy.width / 2, image_copy.height / 2) 573 | 574 | end 575 | 576 | --app.activeBrush = Brush(image_copy) 577 | app.activeBrush = Brush{ 578 | type = BrushType.IMAGE, 579 | center = curr_offset, 580 | image = image_copy 581 | } 582 | 583 | last_image_scale_x = slider_val_x 584 | last_image_scale_y = slider_val_y 585 | end 586 | 587 | 588 | dlg:slider { 589 | id = "size_x", 590 | label = "Width (%): ", 591 | min = 1, 592 | max = 200, 593 | value = 100, 594 | onchange = function() 595 | 596 | if dlg.data.check == true then 597 | dlg:modify {id = "size_y", 598 | value = dlg.data.size_x } 599 | end 600 | 601 | -- If the brush isn't an image brush we want nothing to do with it 602 | if app.activeBrush.type ~= BrushType.IMAGE then 603 | return 604 | end 605 | 606 | detect_new_brush_and_update() 607 | 608 | resize_brush() 609 | 610 | end 611 | } 612 | 613 | dlg:slider { 614 | id = "size_y", 615 | label = "Height (%): ", 616 | min = 1, 617 | max = 200, 618 | value = 100, 619 | onchange = function() 620 | 621 | if dlg.data.check == true then 622 | dlg:modify {id = "size_x", 623 | value = dlg.data.size_y } 624 | end 625 | 626 | -- If the brush isn't an image brush we want nothing to do with it 627 | if app.activeBrush.type ~= BrushType.IMAGE then 628 | return 629 | end 630 | 631 | detect_new_brush_and_update() 632 | 633 | resize_brush() 634 | 635 | end 636 | } 637 | 638 | dlg:check { 639 | id = "check", 640 | label = string, 641 | text = "Keep aspect", 642 | selected = boolean, 643 | onclick = function() 644 | if app.activeBrush.type ~= BrushType.IMAGE then 645 | return 646 | end 647 | 648 | detect_new_brush_and_update() 649 | 650 | if dlg.data.check == true then 651 | dlg:modify {id = "size_y", 652 | value = dlg.data.size_x } 653 | end 654 | 655 | resize_brush() 656 | end 657 | } 658 | 659 | dlg:check { 660 | id = "flipx", 661 | label = string, 662 | text = "Flip X", 663 | selected = boolean, 664 | onclick = function() 665 | if app.activeBrush.type ~= BrushType.IMAGE then 666 | return 667 | end 668 | 669 | 670 | detect_new_brush_and_update() 671 | 672 | resize_brush() 673 | end 674 | } 675 | 676 | dlg:check { 677 | id = "flipy", 678 | label = string, 679 | text = "Flip Y", 680 | selected = boolean, 681 | onclick = function() 682 | if app.activeBrush.type ~= BrushType.IMAGE then 683 | return 684 | end 685 | 686 | detect_new_brush_and_update() 687 | 688 | resize_brush() 689 | end 690 | } 691 | 692 | dlg:button { 693 | id = "rotate", 694 | text = "Rotate 90 CW", 695 | onclick = function() 696 | 697 | if app.activeBrush.type ~= BrushType.IMAGE then 698 | return 699 | end 700 | 701 | detect_new_brush_and_update() 702 | 703 | rotation = rotation - 90 704 | 705 | if rotation == -360 then rotation = 0 end 706 | 707 | resize_brush() 708 | end 709 | } 710 | 711 | dlg:button { 712 | id = "rotate", 713 | text = "Rotate 90 CCW", 714 | onclick = function() 715 | 716 | if app.activeBrush.type ~= BrushType.IMAGE then 717 | return 718 | end 719 | 720 | detect_new_brush_and_update() 721 | 722 | rotation = rotation + 90 723 | 724 | if rotation == 360 then rotation = 0 end 725 | 726 | resize_brush() 727 | end 728 | } 729 | 730 | 731 | 732 | 733 | -- When the fgcolor/bgcolor change, we want to store a version of the brush image at the original image scale 734 | 735 | function on_fgcolorchange() 736 | if app.activeSprite.colorMode == ColorMode.INDEXED then 737 | app.fgColor = get_closest_color_in_palette(convert_color_to_app_pixelcolor(app.fgColor)) 738 | end 739 | 740 | -- If the brush isn't an image brush we want nothing to do with it 741 | if app.activeBrush.type ~= BrushType.IMAGE then 742 | return 743 | end 744 | 745 | detect_new_brush_and_update() 746 | 747 | fgcolor_changed = true 748 | for i = 0, #base_images do 749 | recolored_base_images[i] = base_images[i]:clone() 750 | apply_selected_colors_to_image(recolored_base_images[i], fgcolor_changed, bgcolor_changed) 751 | end 752 | 753 | end 754 | 755 | function on_bgcolorchange() 756 | 757 | if app.activeSprite.colorMode == ColorMode.INDEXED then 758 | app.bgColor = get_closest_color_in_palette(convert_color_to_app_pixelcolor(app.bgColor)) 759 | end 760 | -- If the brush isn't an image brush we want nothing to do with it 761 | if app.activeBrush.type ~= BrushType.IMAGE then 762 | return 763 | end 764 | 765 | detect_new_brush_and_update() -- So we save the new brush here, but it's already a different color 766 | 767 | bgcolor_changed = true 768 | 769 | 770 | for i = 0, #base_images do 771 | recolored_base_images[i] = base_images[i]:clone() 772 | apply_selected_colors_to_image(recolored_base_images[i], fgcolor_changed, bgcolor_changed) 773 | end 774 | 775 | 776 | end 777 | 778 | 779 | app.events:on('fgcolorchange', on_fgcolorchange) 780 | 781 | app.events:on('bgcolorchange', on_bgcolorchange) 782 | 783 | function on_sprite_change() 784 | if app.activeBrush.type ~= BrushType.IMAGE then 785 | return 786 | end 787 | 788 | detect_new_brush_and_update() 789 | 790 | -- If we've got multiple brushes, change to either the next brush or a random one -- 791 | if #base_images > 0 then 792 | 793 | if dlg.data.randomizebrush == true then 794 | current_image_index = math.random(0, #base_images) 795 | else 796 | current_image_index = current_image_index + 1 797 | if current_image_index > #base_images then 798 | current_image_index = 0 799 | end 800 | end 801 | else 802 | current_image_index = 0 803 | end 804 | 805 | if dlg.data.randsizecheck then 806 | randomize_size() 807 | end 808 | 809 | if dlg.data.randoffsetcheck then 810 | curr_offset = get_random_offset() 811 | end 812 | 813 | resize_brush() 814 | 815 | end 816 | 817 | sprite.events:on('change', on_sprite_change) 818 | 819 | local function point_in_rectangle(x, y, rectangle) 820 | if (x >= rectangle.x and x <= rectangle.x + rectangle.width and y >= rectangle.y and y <= rectangle.y + rectangle.height) then 821 | return true 822 | end 823 | 824 | return false 825 | end 826 | 827 | local function get_image_from_rect(rect) 828 | local image = Image(rect.width, rect.height, app.activeSprite.colorMode) 829 | 830 | local current_image = app.activeImage 831 | local image_bounds = current_image.cel.bounds 832 | 833 | for y = rect.y, rect.y + rect.height do 834 | for x = rect.x, rect.x + rect.width do 835 | 836 | local point_rel_imagebound = Point(x - image_bounds.x, y - image_bounds.y) 837 | 838 | if point_rel_imagebound.x >= 0 and point_rel_imagebound.y >= 0 and point_rel_imagebound.x < image_bounds.width and point_rel_imagebound.y < image_bounds.height then 839 | local pixelValue = current_image:getPixel(x - image_bounds.x, y - image_bounds.y) 840 | local c = get_pxc_as_color(pixelValue) 841 | image:drawPixel(x - rect.x, y - rect.y, pixelValue) 842 | end 843 | end 844 | end 845 | 846 | return image 847 | 848 | 849 | end 850 | 851 | dlg:separator{ id=string, 852 | label=string, 853 | text=string } 854 | 855 | dlg:newrow() 856 | 857 | -- Random size ui -- 858 | 859 | dlg:newrow { always = false } 860 | 861 | dlg:check { 862 | id = "randsizecheck", 863 | label = "Randomize size", 864 | text = string, 865 | selected = boolean 866 | 867 | } 868 | 869 | dlg:number{ id="randsizemin", 870 | label="Min size %", 871 | text="50", 872 | decimals=50 873 | } 874 | 875 | dlg:number{ id="randsizemax", 876 | label="Max size %", 877 | text="100", 878 | decimals=100 879 | } 880 | 881 | dlg:separator{ id=string, 882 | label=string, 883 | text=string } 884 | 885 | dlg:newrow() 886 | 887 | dlg:check { 888 | id = "randoffsetcheck", 889 | label = "Randomize offset", 890 | text = string, 891 | selected = boolean, 892 | onclick = function() 893 | 894 | if dlg.data.randoffsetcheck == false then 895 | curr_offset = Point(0, 0) 896 | resize_brush() 897 | end 898 | 899 | end 900 | } 901 | 902 | dlg:slider { 903 | id = "randoffsetlength", 904 | label = "Offset amount", 905 | min = 0, 906 | max = 100, 907 | value = 20, 908 | 909 | } 910 | 911 | 912 | dlg:separator{ id=string, 913 | label=string, 914 | text=string } 915 | 916 | dlg:button{ id="brush_start", 917 | label=string, 918 | text="Brushes from selection", 919 | selected=boolean, 920 | focus=boolean, 921 | onclick=function() 922 | 923 | if app.activeImage == nil or app.activeSprite.selection == nil then 924 | 925 | return 926 | end 927 | 928 | current_image_index = 0 929 | detect_new_brush_and_update() 930 | 931 | base_images = {} 932 | 933 | --[[ Loop through the entire selection and find the start and end points of each subselection 934 | When we hit a pixel that is in the selection and is not within the bounds of any already saved subselections, then 935 | that is the first pixel of a new selection. Then, loop through the x until we find the right side of that selection, and then 936 | loop through the y until we find the bottom right point. Then save that pair of points and continue. ]]-- 937 | 938 | local subselections = {} 939 | local subselection_count = 0 940 | 941 | local subselection_start 942 | 943 | local selection = app.activeSprite.selection 944 | -- selection.bounds.width 945 | for y = 0, selection.bounds.height do -- Would be width-1, but we want to test a pixel on the outside so for subselections on the right we can find the end 946 | for x = 0, selection.bounds.width do 947 | 948 | -- Check if we should skip this point because we've already found this selection 949 | local point_in_existing_subselection = false 950 | for i = 1, #subselections do 951 | if point_in_rectangle(selection.bounds.x + x, selection.bounds.y + y, subselections[i]) then 952 | point_in_existing_subselection = true 953 | break 954 | end 955 | end 956 | 957 | 958 | if point_in_existing_subselection then 959 | goto continue 960 | end 961 | 962 | 963 | if (point_in_existing_subselection == false and selection:contains(selection.bounds.x + x, selection.bounds.y + y)) then 964 | subselection_start = Point(x, y) 965 | 966 | 967 | local end_x = 0 968 | -- Find the right side of the rect 969 | for x2 = x, selection.bounds.width do 970 | 971 | if selection:contains(selection.bounds.x + x2, selection.bounds.y + y) == false then 972 | end_x = x2 - 1 973 | break 974 | end 975 | end 976 | 977 | local end_y = 0 978 | -- Find the bottom of the rect 979 | for y2 = y, selection.bounds.height do 980 | 981 | if selection:contains(selection.bounds.x + end_x, selection.bounds.y + y2) == false then 982 | end_y = y2 - 1 983 | break 984 | end 985 | end 986 | 987 | 988 | -- Add subselection 989 | local rect = Rectangle(selection.bounds.x + x, selection.bounds.y + y, (end_x - x + 1), (end_y - y + 1)) 990 | subselection_count = subselection_count + 1 991 | subselections[subselection_count] = rect 992 | 993 | 994 | end 995 | 996 | if subselection_count >= brush_cap then 997 | break 998 | end 999 | 1000 | ::continue:: 1001 | end 1002 | 1003 | if subselection_count >= brush_cap then 1004 | break 1005 | end 1006 | 1007 | end 1008 | 1009 | local image_count = 0 1010 | for i = 1, #subselections do 1011 | local image = get_image_from_rect(subselections[i]) 1012 | base_images[image_count] = image 1013 | image_count = image_count + 1 1014 | end 1015 | 1016 | recolored_base_images = {} 1017 | 1018 | if image_count > 0 then 1019 | resize_brush() 1020 | end 1021 | 1022 | end 1023 | } 1024 | 1025 | dlg:newrow() 1026 | 1027 | dlg:check { 1028 | id = "randomizebrush", 1029 | label = string, 1030 | text = "Randomize brush order", 1031 | selected = boolean, 1032 | onclick = function() 1033 | if app.activeBrush.type ~= BrushType.IMAGE then 1034 | return 1035 | end 1036 | 1037 | detect_new_brush_and_update() 1038 | 1039 | resize_brush() 1040 | end } 1041 | 1042 | 1043 | dlg:newrow() 1044 | 1045 | dlg:button { 1046 | id = "reset", 1047 | text = "Reset brush", 1048 | onclick = function() 1049 | dlg:modify {id = "size_x", 1050 | value = 100 } 1051 | dlg:modify {id = "size_y", 1052 | value = 100 } 1053 | 1054 | reset_brush() 1055 | end 1056 | } 1057 | 1058 | dlg:show { 1059 | wait = false 1060 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aseprite-custombrushoptions 2 | 3 | ![ezgif com-gif-maker(1)](https://user-images.githubusercontent.com/5313706/156926743-f9d3f31d-2ddc-4a82-9096-5ad9c4799c88.gif) 4 | 5 | A script for aseprite that adds some additional functions to custom brushes. Note that in the above gif I'm using an auto clicker macro in addition to the script to mimic the spray can tool (But with randomized brush images). 6 | 7 | **HOW TO USE** 8 | 9 | 1. In aseprite, go File->Scripts->Open scripts folder 10 | 2. Download the zip file. Put CustomBrushOptions.lua into the scripts folder. 11 | 3. File->Scripts->Rescan scripts folder 12 | 4. File->Scripts->CustomBrushOptions 13 | 5. Should be good to go! 14 | 15 | **BRUSH FROM SELECTION** 16 | 17 | To use brushes from selection, you need to make multiple selections with the rectangular marquee tool. The selections should have at least a 1px margin from each other so that they register as separate brushes. Then, press "Brushes from selection" and now every time you click the brush will change. 18 | 19 | You can also check "Randomize brush order" to randomize which brush gets selected. 20 | 21 | NOTE: Unfortunately multiple brushes won't work with the spray can tool :( 22 | --------------------------------------------------------------------------------