diff --git a/code/__DEFINES/colors.dm b/code/__DEFINES/colors.dm index e0cfe5c44dd..59f6ce1471d 100644 --- a/code/__DEFINES/colors.dm +++ b/code/__DEFINES/colors.dm @@ -462,6 +462,8 @@ #define COLOR_AMMO_ARMORPIERCE "#d9d9d9" #define COLOR_AMMO_HOLLOWPOINT "#ff9900" +#define COLOR_DMI_MASK "#a0a0a000" + GLOBAL_LIST_INIT(cable_colors, list( CABLE_COLOR_BLUE = CABLE_HEX_COLOR_BLUE, CABLE_COLOR_CYAN = CABLE_HEX_COLOR_CYAN, diff --git a/code/__DEFINES/dcs/signals/signals_datum.dm b/code/__DEFINES/dcs/signals/signals_datum.dm index 19e28e35984..3668d202a7f 100644 --- a/code/__DEFINES/dcs/signals/signals_datum.dm +++ b/code/__DEFINES/dcs/signals/signals_datum.dm @@ -22,7 +22,9 @@ #define COMSIG_VV_TOPIC "vv_topic" #define COMPONENT_VV_HANDLED (1<<0) /// from datum ui_act (usr, action) -#define COMSIG_UI_ACT "COMSIG_UI_ACT" +#define COMSIG_UI_ACT "ui_act" +/// from datum/tgui/get_payload(user, list/data) +#define COMSIG_UI_DATA "ui_data" /// fires on the target datum when an element is attached to it (/datum/element) #define COMSIG_ELEMENT_ATTACH "element_attach" @@ -53,3 +55,7 @@ ///from /datum/component/bubble_icon_override/get_bubble_icon(): (list/holder) #define COMSIG_GET_BUBBLE_ICON "get_bubble_icon" + +///from /datum/sprite_editor_workspace/is_valid_color(): (color) +#define COMSIG_SPRITE_EDITOR_VALIDATE_COLOR "sprite_editor_validate_color" + #define COLOR_IS_INVALID (1<<0) diff --git a/code/__DEFINES/sprite_editor.dm b/code/__DEFINES/sprite_editor.dm new file mode 100644 index 00000000000..30076b7e2cf --- /dev/null +++ b/code/__DEFINES/sprite_editor.dm @@ -0,0 +1,17 @@ +// Color modes +/// Full RGBA color picker +#define SPRITE_EDITOR_COLOR_MODE_RGBA "rgba" +/// RGB color picker without alpha +#define SPRITE_EDITOR_COLOR_MODE_RGB "rgb" +/// Greyscale color picker with a single value +#define SPRITE_EDITOR_COLOR_MODE_GREYSCALE "greyscale" + +// Config flags +#define SPRITE_EDITOR_ALLOW_LAYERS (1<<0) +#define SPRITE_EDITOR_ALLOW_UNDO (1<<1) + +// Tool flags +#define SPRITE_EDITOR_TOOL_PENCIL (1<<0) +#define SPRITE_EDITOR_TOOL_ERASER (1<<1) +#define SPRITE_EDITOR_TOOL_DROPPER (1<<2) +#define SPRITE_EDITOR_TOOL_BUCKET (1<<3) diff --git a/code/__HELPERS/colors.dm b/code/__HELPERS/colors.dm index 6a5d6025fd4..d9da27f62c5 100644 --- a/code/__HELPERS/colors.dm +++ b/code/__HELPERS/colors.dm @@ -264,3 +264,26 @@ modify.underlays[underlay_index] = filter_appearance_recursive(modify.underlays[underlay_index], filter_to_apply) return modify + +#define ALPHA_COMPOSE(src_a, comp_a, back_ch, src_ch) ((1 - src_a / comp_a) * back_ch + (src_a / comp_a) * src_ch) + +/// Blend two colors using the normal blend mode of the CSS compositing algorithm +/proc/blend_color(backdrop = "#00000000", source) + var/list/rgb_source = split_color(source) + var/source_alpha = rgb_source[4] + if(source_alpha == 0) + return backdrop + if(source_alpha == 255) + return source + var/list/rgb_backdrop = split_color(backdrop) + var/backdrop_alpha = rgb_backdrop[4] / 255 + source_alpha /= 255 + var/output_alpha = source_alpha + backdrop_alpha - source_alpha * backdrop_alpha + return rgb( + ALPHA_COMPOSE(source_alpha, output_alpha, rgb_backdrop[1], rgb_source[1]), + ALPHA_COMPOSE(source_alpha, output_alpha, rgb_backdrop[2], rgb_source[2]), + ALPHA_COMPOSE(source_alpha, output_alpha, rgb_backdrop[3], rgb_source[3]), + output_alpha * 255 + ) + +#undef ALPHA_COMPOSE diff --git a/code/__HELPERS/icons.dm b/code/__HELPERS/icons.dm index ac67848447a..4f23df1d0aa 100644 --- a/code/__HELPERS/icons.dm +++ b/code/__HELPERS/icons.dm @@ -1393,3 +1393,50 @@ GLOBAL_LIST_EMPTY(transformation_animation_objects) icon_cache[job_type] = sechud_icon return icon(icon_cache[job_type]) + +/** + * Copies the pixel colors from the passed in icon `I` to the 2d list `grid` + */ +/proc/fill_grid_from_icon(list/grid, icon/I) + var/width = I.Width() + var/height = I.Height() + for(var/x in 1 to width) + for(var/y in 1 to height) + grid[y][x] = I.GetPixel(x,height+1-y) + +// Given a number of frames for an icon state, and the dimensions of the icon, returns the ideal dimensions for a DMI file +/proc/calculate_optimal_icon_grid_dimensions(width, height, count) + var/grid_width = 1 + var/grid_height = 1 + while(grid_width * grid_height < count) + if(height*grid_height < width*grid_width) + grid_height++ + else + grid_width++ + return list(grid_height, grid_width) + +// Reorder the 2d pixel data of the passed in frames into a data string that can be passed to rustg_dmi_create_png +/proc/reorder_pixels(icon_width, icon_height, grid_width, grid_height, list/frames) + var/file_height = icon_height * grid_height + + // This little trick right here reduces the total iteration of repeat_string from the product of the arguments to their sum. + // Can't be applied to the general case without a complex partitioning algorithm, + // since the count could either be a large prime or have large primes as factors + var/linear_pixels = COLOR_DMI_MASK + for(var/count in list(icon_width, icon_height, grid_width, grid_height)) + if(count == 1) + continue + linear_pixels = repeat_string(count, linear_pixels) + + for(var/i in 1 to length(frames)) + var/list/frame = frames[i] + var/row_index = floor((i-1)/grid_width) + var/column = (i-1)%grid_width + for(var/y in 1 to length(frame)) + var/list/row = jointext(frame[y], "") + var/splice_start = (row_index+y-1)*file_height + column*icon_width + 1 + linear_pixels = splicetext(splice_start*9, (splice_start+icon_width)*9, row) + var/zero_alpha_regex = regex(@@#(?:(?!a0a0a0)([0-9]|[a-f]){6}00)@, "gi") + linear_pixels = replacetext(linear_pixels, zero_alpha_regex, COLOR_DMI_MASK) + return linear_pixels + diff --git a/code/_globalvars/lists/mapping.dm b/code/_globalvars/lists/mapping.dm index 116fb3adbea..0f1eebb3347 100644 --- a/code/_globalvars/lists/mapping.dm +++ b/code/_globalvars/lists/mapping.dm @@ -92,6 +92,16 @@ GLOBAL_LIST_INIT(alldirs, list( SOUTHEAST, SOUTHWEST, )) +GLOBAL_LIST_INIT(alldirs_dmi_order, list( + SOUTH, + NORTH, + EAST, + WEST, + SOUTHEAST, + SOUTHWEST, + NORTHEAST, + NORTHWEST, +)) GLOBAL_LIST_INIT(cardinal_angles, list( "[NORTH]" = 0, diff --git a/code/datums/components/palette.dm b/code/datums/components/palette.dm index 0fb937a3b11..a3551b0b59f 100644 --- a/code/datums/components/palette.dm +++ b/code/datums/components/palette.dm @@ -5,9 +5,10 @@ * and call set_painting_tool_color() on the parent for more specific object behavior. */ /datum/component/palette + /// The maximum number of colors this palette can have. + var/max_colors /* * A list that stores a selection of colors. - * The number of available spaces is defined by the available_space arg of Initialize() */ var/list/colors = list() /* @@ -20,25 +21,20 @@ /// The radial menu choice datums are stored here as a microop to avoid generating new ones every time the menu is opened or updated. var/list/datum/radial_menu_choice/menu_choices -/datum/component/palette/Initialize(available_space, selected_color) +/datum/component/palette/Initialize(max_colors, selected_color) if(!isitem(parent)) return COMPONENT_INCOMPATIBLE - if(!isnum(available_space) || available_space < 1) /// This component means nothing if there's no space for colors - stack_trace("palette component initialized without a proper value for the available_space arg") + if(!isnum(max_colors) || max_colors < 1) /// This component means nothing if there's no space for colors + stack_trace("palette component initialized without a proper value for the max_colors arg") return COMPONENT_INCOMPATIBLE - for(var/index in 1 to available_space) - colors += "#ffffff" - - src.colors = colors + src.max_colors = max_colors src.selected_color = selected_color || "#ffffff" RegisterSignal(parent, COMSIG_ITEM_ATTACK_SELF_SECONDARY, PROC_REF(on_attack_self_secondary)) RegisterSignal(parent, COMSIG_ATOM_EXAMINE, PROC_REF(on_examine)) RegisterSignal(parent, COMSIG_PAINTING_TOOL_SET_COLOR, PROC_REF(on_painting_tool_set_color)) - RegisterSignal(parent, COMSIG_PAINTING_TOOL_GET_ADDITIONAL_DATA, PROC_REF(get_palette_data)) - RegisterSignal(parent, COMSIG_PAINTING_TOOL_PALETTE_COLOR_CHANGED, PROC_REF(palette_color_changed)) /datum/component/palette/Destroy() QDEL_NULL(color_picker_menu) @@ -51,7 +47,7 @@ SIGNAL_HANDLER examine_list += span_notice("Right-Click this item while it's in your active hand to open/close its color picker menu.") - examine_list += span_notice("In the color picker, Left-Click a color button to pick it or Right-Click to edit it.") + examine_list += span_notice("In the color picker, Left-Click a color button to pick it or Right-Click to remove it.") /datum/component/palette/proc/on_attack_self_secondary(datum/source, mob/user) SIGNAL_HANDLER @@ -72,12 +68,27 @@ /datum/component/palette/proc/build_radial_list() var/radial_list = list() - LAZYSETLEN(menu_choices, length(colors)) + var/color_count = length(colors) + LAZYSETLEN(menu_choices, max(color_count+1)) + if(color_count < max_colors && !(selected_color in colors)) + var/datum/radial_menu_choice/add_option = peek(menu_choices) + if(!add_option) + add_option = new + menu_choices[color_count+1] = add_option + var/image/element = image(icon = 'icons/hud/radial.dmi', icon_state = "palette_element") + element.color = selected_color + var/image/plus = image(icon = 'icons/hud/radial.dmi', icon_state = "palette_add") + plus.appearance_flags = /image::appearance_flags | RESET_COLOR + element.add_overlay(plus) + add_option.image = element + add_option.name = "Add Color ([selected_color])" + radial_list["add"] = add_option for(var/index in 1 to length(colors)) var/hexcolor = colors[index] var/datum/radial_menu_choice/option = menu_choices[index] if(!option) option = new + menu_choices[index] = option var/icon_state_to_use = hexcolor == selected_color ? "palette_selected" : "palette_element" var/image/element = image(icon = 'icons/hud/radial.dmi', icon_state = icon_state_to_use) element.color = hexcolor @@ -104,11 +115,14 @@ close_radial_menu() return var/is_right_clicking = LAZYACCESS(params2list(params), RIGHT_CLICK) + if(choice == "add") + if(length(colors) < max_colors) + colors += selected_color + update_radial_list() + return var/index = text2num(choice) if(is_right_clicking) - var/chosen_color = tgui_color_picker(user, "Pick new color", "[parent]", colors[index]) - if(chosen_color && !QDELETED(src) && !IS_DEAD_OR_INCAP(user) && user.is_holding(parent)) - colors[index] = chosen_color + colors.Cut(index, index+1) update_radial_list() else var/obj/item/parent_item = parent @@ -119,24 +133,3 @@ selected_color = chosen_color update_radial_list() - -/datum/component/palette/proc/get_palette_data(datum/source, data) - SIGNAL_HANDLER - var/list/painting_data = list() - for(var/hexcolor in colors) - painting_data += list(list( - "color" = hexcolor, - "is_selected" = hexcolor == selected_color - )) - data["paint_tool_palette"] = painting_data - -/datum/component/palette/proc/palette_color_changed(datum/source, chosen_color, index) - SIGNAL_HANDLER - - var/was_selected_color = selected_color == colors[index] - colors[index] = chosen_color - if(was_selected_color) - var/obj/item/parent_item = parent - parent_item.set_painting_tool_color(chosen_color) - else - update_radial_list() diff --git a/code/game/objects/items/crayons.dm b/code/game/objects/items/crayons.dm index f055aa108bd..9737e7ba1f5 100644 --- a/code/game/objects/items/crayons.dm +++ b/code/game/objects/items/crayons.dm @@ -238,6 +238,8 @@ /// Sets painting color and updates appearance. /obj/item/toy/crayon/set_painting_tool_color(chosen_color) . = ..() + if(!can_change_colour) + return paint_color = chosen_color update_appearance() diff --git a/code/game/objects/items/tools/engineering/painter/decal_painter.dm b/code/game/objects/items/tools/engineering/painter/decal_painter.dm index 35abfb90b94..cfd9a34cc2f 100644 --- a/code/game/objects/items/tools/engineering/painter/decal_painter.dm +++ b/code/game/objects/items/tools/engineering/painter/decal_painter.dm @@ -14,7 +14,7 @@ /// The current base icon state of the decal being printed. VAR_PRIVATE/selected_decal_icon_state = "warningline" /// Current custom color - VAR_PRIVATE/selected_custom_color + var/selected_custom_color /// Current active decal category. Reference to a global singleton VAR_PRIVATE/datum/paintable_decal_category/current_category diff --git a/code/modules/art/paintings.dm b/code/modules/art/paintings.dm index 1c62bc96cb2..eec2f93e5fe 100644 --- a/code/modules/art/paintings.dm +++ b/code/modules/art/paintings.dm @@ -1,4 +1,11 @@ -#define MAX_PAINTING_ZOOM_OUT 3 +GLOBAL_LIST_INIT(canvas_dimensions, init_canvas_dimensions()) + +/proc/init_canvas_dimensions() + . = list() + for(var/obj/item/canvas/canvas_type in typesof(/obj/item/canvas)) + var/width = canvas_type::width + var/height = canvas_type::height + .["[width]x[height]"] = list(width, height) /////////// // EASEL // @@ -16,15 +23,14 @@ var/obj/item/canvas/painting = null //Adding canvases -/obj/structure/easel/attackby(obj/item/I, mob/user, list/modifiers, list/attack_modifiers) - if(istype(I, /obj/item/canvas)) - var/obj/item/canvas/canvas = I +/obj/structure/easel/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(istype(tool, /obj/item/canvas)) + var/obj/item/canvas/canvas = tool user.transfer_item_to_turf(canvas, get_turf(src), silent = FALSE) painting = canvas canvas.layer = layer+0.1 user.visible_message(span_notice("[user] puts \the [canvas] on \the [src]."),span_notice("You place \the [canvas] on \the [src].")) - else - return ..() + return ITEM_INTERACT_SUCCESS //Stick to the easel like glue @@ -46,14 +52,13 @@ interaction_flags_atom = parent_type::interaction_flags_atom | INTERACT_ATOM_ALLOW_USER_LOCATION var/width = 11 var/height = 11 - var/list/grid /// empty canvas color var/canvas_color = "#ffffff" + /// The sprite editor workspace that carries the data for this canvas + var/datum/sprite_editor_workspace/workspace /// Is it clean canvas or was there something painted on it at some point, used to decide when to show wip splotch overlay var/used = FALSE var/finalized = FALSE //Blocks edits - /// Whether a grid should be shown in the UI if the canvas is editable and the viewer is holding a painting tool. - var/show_grid = TRUE var/icon_generated = FALSE var/icon/generated_icon ///boolean that blocks persistence from saving it. enabled from printing copies, because we do not want to save copies. @@ -74,17 +79,20 @@ */ var/pixels_per_unit = 9 - ///A list that keeps track of the current zoom value for each current viewer. - var/list/zoom_by_observer - SET_BASE_PIXEL(11, 10) custom_price = PAYCHECK_CREW /obj/item/canvas/Initialize(mapload) . = ..() - reset_grid() - + workspace = new(width, + height, + color_mode = SPRITE_EDITOR_COLOR_MODE_RGB, + config_flags = NONE, + tool_flags = SPRITE_EDITOR_TOOL_PENCIL | SPRITE_EDITOR_TOOL_BUCKET, + initial_layer_color = "[canvas_color]ff" // To avoid needing to handle strings of mixed lengths, sprite editor workspaces always use the alpha channel + ) + RegisterSignal(workspace, COMSIG_SPRITE_EDITOR_VALIDATE_COLOR, PROC_REF(validate_color)) painting_metadata = new painting_metadata.title = "Untitled Artwork" painting_metadata.creation_round_id = GLOB.round_id @@ -100,12 +108,6 @@ painting_metadata = null return ..() -/obj/item/canvas/proc/reset_grid() - grid = new/list(width,height) - for(var/x in 1 to width) - for(var/y in 1 to height) - grid[x][y] = canvas_color - /obj/item/canvas/attack_self(mob/user) . = ..() ui_interact(user) @@ -116,56 +118,55 @@ return ..() /obj/item/canvas/ui_state(mob/user) - if(isobserver(user)) - return GLOB.observer_state if(finalized) return GLOB.hold_or_view_state return GLOB.default_state -/obj/item/canvas/ui_status(mob/user, datum/ui_state/state) - if(state == GLOB.default_state || !state) - return ..() - //Skip the can_interact() check from atom/ui_status() and let them zoom in/out! - var/src_object = ui_host(user) - return state.can_use_topic(src_object, user) - /obj/item/canvas/ui_interact(mob/user, datum/tgui/ui) ui = SStgui.try_update_ui(user, src, ui) if(!ui) ui = new(user, src, "Canvas", name) ui.open() -/obj/item/canvas/attackby(obj/item/I, mob/living/user, list/modifiers, list/attack_modifiers) - if(!user.combat_mode) - ui_interact(user) - else - return ..() - -/obj/item/canvas/ui_static_data(mob/user) - . = ..() - .["px_per_unit"] = pixels_per_unit - .["max_zoom"] = MAX_PAINTING_ZOOM_OUT +/obj/item/canvas/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + ui_interact(user) + return ITEM_INTERACT_SUCCESS /obj/item/canvas/ui_data(mob/user) . = ..() - .["grid"] = grid - .["zoom"] = LAZYACCESS(zoom_by_observer, user.key) || (finalized ? 1 : MAX_PAINTING_ZOOM_OUT) - .["name"] = painting_metadata.title - .["author"] = painting_metadata.creator_name - .["patron"] = painting_metadata.patron_name - .["medium"] = painting_metadata.medium - .["date"] = painting_metadata.creation_date - .["finalized"] = finalized - .["editable"] = !finalized //Ideally you should be able to draw moustaches on existing paintings in the gallery but that's not implemented yet - .["show_plaque"] = istype(loc,/obj/structure/sign/painting) - .["show_grid"] = show_grid - .["paint_tool_palette"] = null - var/obj/item/painting_implement = user.get_active_held_item() - if(!painting_implement) - .["paint_tool_color"] = null - return - .["paint_tool_color"] = get_paint_tool_color(painting_implement) - SEND_SIGNAL(painting_implement, COMSIG_PAINTING_TOOL_GET_ADDITIONAL_DATA, .) + var/list/metadata = list( + "title" = painting_metadata.title, + "author" = painting_metadata.creator_name, + "patron" = painting_metadata.patron_name, + "medium" = painting_metadata.medium, + "date" = painting_metadata.creation_date, + ) + var/list/editor_data = workspace.sprite_editor_ui_data() + var/can_edit = TRUE + + var/obj/item/implement = user.get_active_held_item() + var/implement_color = get_paint_tool_color(implement) + var/can_change_implement_color = can_change_paint_tool_color(implement) + if(implement_color) + editor_data["serverSelectedColor"] = implement_color + editor_data["serverPalette"] = get_paint_tool_palette(implement) + editor_data["maxServerColors"] = get_paint_tool_palette_capacity(implement) + editor_data["onSelectServerColor"] = "onSelectColor" + editor_data["onAddServerColor"] = "onAddPaletteColor" + editor_data["onRemoveServerColor"] = "onRemovePaletteColor" + if(can_change_implement_color) + editor_data["toolFlags"] |= SPRITE_EDITOR_TOOL_DROPPER + else + can_edit = FALSE + return list( + "metadata" = metadata, + "editorData" = editor_data, + "pixelsPerUnit" = pixels_per_unit, + "finalized" = finalized, + "allowColorPicker" = can_change_implement_color, + "editable" = can_edit && !finalized, //Ideally you should be able to draw moustaches on existing paintings in the gallery but that's not implemented yet + "showPlaque" = istype(loc, /obj/structure/sign/painting) + ) /obj/item/canvas/examine(mob/user) . = ..() @@ -176,87 +177,56 @@ if(.) return var/mob/user = usr - //this is here to allow observers and viewers to zoom in and out regardless of adjacency. - //observers need this special check because we allow them to operate the UI in ui_state - if((action != "zoom_in" && action != "zoom_out") && (isobserver(user) || !can_interact(user))) - return + var/obj/item/implement = user.get_active_held_item() + var/datum/component/palette/palette_comp = implement?.GetComponent(/datum/component/palette) switch(action) - if("paint", "fill") + if("spriteEditorCommand") + . = TRUE if(finalized) - return TRUE - var/obj/item/I = user.get_active_held_item() - var/tool_color = get_paint_tool_color(I) - if(!tool_color) - return FALSE - if(action == "fill") - var/x = params["x"] - var/y = params["y"] - if(!canvas_fill(x, y, tool_color)) - return FALSE - else - var/list/data = params["data"] - for(var/point in data) - var/x = text2num(point["x"]) - var/y = text2num(point["y"]) - grid[x][y] = tool_color - var/medium = get_paint_tool_medium(I) + return + var/command = params["command"] + if(command != "transaction") // Painting only allows transactions, no undo/redo or layer visibility toggling + return + if(!workspace.new_transaction(params["transaction"])) + return + var/medium = get_paint_tool_medium(implement) if(medium && painting_metadata.medium && painting_metadata.medium != medium) painting_metadata.medium = "Mixed medium" else painting_metadata.medium = medium used = TRUE update_appearance() + if("onSelectColor") . = TRUE - if("select_color") - var/obj/item/painting_implement = user.get_active_held_item() - painting_implement?.set_painting_tool_color(params["selected_color"]) + var/paint_color = copytext(params["color"], 1, 8) + implement?.set_painting_tool_color(paint_color) + if("onAddPaletteColor") . = TRUE - if("select_color_from_coords") - var/obj/item/painting_implement = user.get_active_held_item() - if(!painting_implement) - return FALSE - var/x = text2num(params["x"]) - var/y = text2num(params["y"]) - painting_implement.set_painting_tool_color(grid[x][y]) + if(!palette_comp) + return + if(length(palette_comp.colors) >= palette_comp.max_colors) + return + var/paint_color = copytext(params["color"], 1, 8) + palette_comp.colors += paint_color + if("onRemovePaletteColor") . = TRUE - if("change_palette") - var/obj/item/painting_implement = user.get_active_held_item() - if(!painting_implement) - return FALSE - //I'd have this done inside the signal, but that'd have to be asynced, - //while we want the UI to be updated after the color is chosen, not before. - var/chosen_color = tgui_color_picker(user, "Pick new color", painting_implement, params["old_color"]) - if(!chosen_color || IS_DEAD_OR_INCAP(user) || !user.is_holding(painting_implement)) - return FALSE - SEND_SIGNAL(painting_implement, COMSIG_PAINTING_TOOL_PALETTE_COLOR_CHANGED, chosen_color, params["color_index"]) - . = TRUE - if("toggle_grid") - . = TRUE - show_grid = !show_grid + if(!palette_comp) + return + var/color_index = params["index"] + palette_comp.colors.Cut(color_index, color_index+1) if("finalize") . = TRUE finalize(user) if("patronage") . = TRUE patron(user) - if("zoom_in") - . = TRUE - LAZYINITLIST(zoom_by_observer) - if(!zoom_by_observer[user.key]) - zoom_by_observer[user.key] = 2 - else - zoom_by_observer[user.key] = min(zoom_by_observer[user.key] + 1, MAX_PAINTING_ZOOM_OUT) - if("zoom_out") - . = TRUE - LAZYINITLIST(zoom_by_observer) - if(!zoom_by_observer[user.key]) - zoom_by_observer[user.key] = MAX_PAINTING_ZOOM_OUT - 1 - else - zoom_by_observer[user.key] = max(zoom_by_observer[user.key] - 1, 1) -/obj/item/canvas/ui_close(mob/user) - . = ..() - LAZYREMOVE(zoom_by_observer, user.key) +/obj/item/canvas/proc/validate_color(_source, paint_color) + SIGNAL_HANDLER + paint_color = copytext(paint_color, 1, 8) + var/obj/item/implement = usr.get_active_held_item() + if(!implement || !((get_paint_tool_color(implement) == paint_color) || (paint_color in get_paint_tool_palette(implement)))) + return COLOR_IS_INVALID /obj/item/canvas/proc/finalize(mob/user) if(finalized || painting_metadata.loaded_from_json) @@ -401,9 +371,10 @@ /obj/item/canvas/proc/get_data_string() var/list/data = list() + var/list/grid = workspace.layers[1]["data"]["[SOUTH]"] for(var/y in 1 to height) for(var/x in 1 to width) - data += grid[x][y] + data += grid[y][x] return data.Join("") //Todo make this element ? @@ -419,9 +390,38 @@ else if(istype(painting_implement, /obj/item/pen)) var/obj/item/pen/pen = painting_implement return pen.colour + else if (istype(painting_implement, /obj/item/airlock_painter/decal)) + var/obj/item/airlock_painter/decal/painter = painting_implement + return painter.selected_custom_color else if(istype(painting_implement, /obj/item/soap) || istype(painting_implement, /obj/item/rag)) return canvas_color +/obj/item/canvas/proc/get_paint_tool_palette(obj/item/painting_implement) + if(!painting_implement) + return list() + var/datum/component/palette/palette_comp = painting_implement.GetComponent(/datum/component/palette) + if(!palette_comp) + var/implement_color = get_paint_tool_color(painting_implement) + return implement_color ? list(implement_color) : list() + return palette_comp.colors + +/obj/item/canvas/proc/get_paint_tool_palette_capacity(obj/item/painting_implement) + if(!painting_implement) + return + var/datum/component/palette/palette_comp = painting_implement.GetComponent(/datum/component/palette) + if(!palette_comp) + return get_paint_tool_color(painting_implement) ? 1 : 0 + return palette_comp.max_colors + +/obj/item/canvas/proc/can_change_paint_tool_color(obj/item/painting_implement) + if(!painting_implement) + return + if(istype(painting_metadata, /obj/item/paint_palette) || istype(painting_implement, /obj/item/airlock_painter/decal)) + return TRUE + if(istype(painting_implement, /obj/item/toy/crayon)) + var/obj/item/toy/crayon/crayon = painting_implement + return crayon.can_change_colour + /// Generates medium description /obj/item/canvas/proc/get_paint_tool_medium(obj/item/painting_implement) if(!painting_implement) @@ -432,7 +432,7 @@ return "Spraycan on canvas" else if(istype(painting_implement, /obj/item/toy/crayon)) return "Crayon on canvas" - else if(istype(painting_implement, /obj/item/pen)) + else if(istype(painting_implement, /obj/item/pen) || istype(painting_implement, /obj/item/airlock_painter/decal)) return "Ink on canvas" else if(istype(painting_implement, /obj/item/soap) || istype(painting_implement, /obj/item/rag)) return //These are just for cleaning, ignore them @@ -457,147 +457,6 @@ return FALSE -///The pixel to the right matches the previous color we're flooding over -#define CANVAS_FILL_R_MATCH (1<<0) -///The pixel to the left matches the previous color we're flooding over -#define CANVAS_FILL_L_MATCH (1<<1) - -//a macro for the stringized key for coordinates to check later -#define CANVAS_COORD(x, y) "[x]-[y]" -///queues a coordinate on the canvas for future cycles. -#define QUEUE_CANVAS_COORD(x, y, queue) \ - if(y && !queue[CANVAS_COORD(x, y)]) {\ - queue[CANVAS_COORD(x, y)] = list(x, y);\ - } - -/** - * A proc that adopts a span-based, 4-dir (correct me if I'm wrong) flood fill algorithm used - * by the bucked tool in the UI, to facilitate coloring larger portions of the canvas. - * If you have never used the bucket/flood tool on an image editor, I suggest you do it - * now so you know what I'm basically talking about. - * - * @ param x The point on the x axys where we start flooding our canvas. The arg is later used to store the current x - * @ param y The point on the y axys where we start flooding the canvas. The arg is later used to store the current y - * @ param new_color The new color that floods over the old one - */ -/obj/item/canvas/proc/canvas_fill(x, y, new_color) - var/prev_color = grid[x][y] - //If the colors are the same, don't do anything. - if(prev_color == new_color) - return FALSE - - //The queue for coordinates to the right of the current line - var/list/queue_right = list() - //Inversely for those to our left - var/list/queue_left = list() - //Whether we're currently checking the right or left queue. - var/go_right = TRUE - - //The current coordinates. The only reason this is outside the loop - //is because we first go up, then reset our vertical position to just below - //the starting position and go down from there. - var/list/coords = list(x, y) - - //Basically, the way it works is that each cycle we first go up, then down until we - //either reach the vertical borders of the raster or find a pixel that is not of the color we want - //to flood. As we do this, we try to queue a minimum of coordinates to our - //left and right to use for future cycles, moving horizontally in one direction until there are no - //more queued coordinates for that dir. Then we turn around and repeat - //until both left and right queues are completely empty. - while(coords) - //The current vertical line, the right and the left ones. - var/list/curr_line = grid[x] - var/list/right_line = x < width ? grid[x+1] : null - var/list/left_line = x > 1 ? grid[x-1] : null - //the queue we're on, depending on direction - var/list/curr_queue = go_right ? queue_right : queue_left - //Instead of queueing every point to our left and right that shares our prevous color, - //Causing a lot of empty cycles, we only queue an extremity of a vertical segment - //delimited by pixels of other colors or the y boundaries of the raster. To do this, - //we need to track where the segment (called line for simplicity) starts (or ends). - var/r_line_start - var/l_line_start - - //go up first (y = 1 is the upper border is) - while(y >= 1 && curr_line[y] == prev_color) - var/return_flags = canvas_scan_step(x, y, queue_left, queue_right, left_line, right_line, l_line_start, r_line_start, prev_color) - if(return_flags & CANVAS_FILL_R_MATCH) - r_line_start = y - else - r_line_start = null - if(return_flags & CANVAS_FILL_L_MATCH) - l_line_start = y - else - l_line_start = null - curr_line[y] = new_color - curr_queue -= CANVAS_COORD(x, y) //remove it from the queue if possible. - y-- - - //Any unqueued coordinate is queued and cleared before the next half of the cycle - QUEUE_CANVAS_COORD(x + 1, r_line_start, queue_right) - QUEUE_CANVAS_COORD(x - 1, l_line_start, queue_left) - r_line_start = l_line_start = null - - //set y to the pixel immediately below the starting y - y = coords[2] + 1 - - //then go down (y = height is the bottom border) - while(y <= height && curr_line[y] == prev_color) - var/return_flags = canvas_scan_step(x, y, queue_left, queue_right, left_line, right_line, l_line_start, r_line_start, prev_color) - if(!(return_flags & CANVAS_FILL_R_MATCH)) - r_line_start = null - else if(!r_line_start) - r_line_start = y - if(!(return_flags & CANVAS_FILL_L_MATCH)) - l_line_start = null - else if(!l_line_start) - l_line_start = y - curr_line[y] = new_color - curr_queue -= CANVAS_COORD(x, y) - y++ - - QUEUE_CANVAS_COORD(x + 1, r_line_start, queue_right) - QUEUE_CANVAS_COORD(x - 1, l_line_start, queue_left) - - //Pick the next set of coords from the queue (and change direction if necessary) - if(!length(curr_queue)) - var/list/other_queue = go_right ? queue_left : queue_right - coords = other_queue[other_queue[1]] - other_queue.Cut(1, 2) - go_right = !go_right - else - coords = curr_queue[curr_queue[1]] - curr_queue.Cut(1, 2) - - x = coords?[1] - y = coords?[2] - - return TRUE - -/** - * The step of canvas_fill() that scans the pixels to the immediate right and left of our coord and see if they need to be queue'd or not. - * Kept as a separate proc to reduce copypasted code. - */ -/proc/canvas_scan_step(x, y, list/queue_left, list/queue_right, list/left_line, list/right_line, left_pos, right_pos, prev_color) - if(left_line) - if(left_line[y] == prev_color) - . += CANVAS_FILL_L_MATCH - else - QUEUE_CANVAS_COORD(x - 1, left_pos, queue_left) - - if(!right_line) - return - - if(right_line[y] == prev_color) - . += CANVAS_FILL_R_MATCH - else - QUEUE_CANVAS_COORD(x + 1, right_pos, queue_right) - -#undef CANVAS_FILL_R_MATCH -#undef CANVAS_FILL_L_MATCH -#undef CANVAS_COORD -#undef QUEUE_CANVAS_COORD - /obj/item/canvas/nineteen_nineteen name = "canvas (19x19)" icon_state = "19x19" @@ -725,14 +584,14 @@ . = ..() SSpersistent_paintings.painting_frames -= src -/obj/structure/sign/painting/attackby(obj/item/I, mob/user, list/modifiers, list/attack_modifiers) - if(!current_canvas && istype(I, /obj/item/canvas)) - frame_canvas(user,I) - else if(current_canvas && current_canvas.painting_metadata.title == initial(current_canvas.painting_metadata.title) && istype(I,/obj/item/pen)) +/obj/structure/sign/painting/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(!current_canvas && istype(tool, /obj/item/canvas)) + frame_canvas(user, tool) + return ITEM_INTERACT_SUCCESS + if(current_canvas && current_canvas.painting_metadata.title == initial(current_canvas.painting_metadata.title) && istype(tool, /obj/item/pen)) if(try_rename(user)) SStgui.update_uis(src) - else - return ..() + return ITEM_INTERACT_SUCCESS /obj/structure/sign/painting/atom_deconstruct(disassembled) var/turf/drop_turf = drop_location() @@ -849,7 +708,7 @@ if(!istype(new_canvas)) CRASH("Found painting size with no matching canvas type") new_canvas.painting_metadata = painting - new_canvas.fill_grid_from_icon(I) + fill_grid_from_icon(new_canvas.workspace.get_first_layer_pixel_data(), I) new_canvas.generated_icon = I new_canvas.icon_generated = TRUE new_canvas.finalized = TRUE @@ -907,10 +766,11 @@ SSpersistent_paintings.paintings += current_canvas.painting_metadata /obj/item/canvas/proc/fill_grid_from_icon(icon/I) + var/list/grid = workspace.layers[1]["data"]["[SOUTH]"] var/h = I.Height() + 1 for(var/x in 1 to width) for(var/y in 1 to height) - grid[x][y] = I.GetPixel(x,h-y) + grid[y][x] = I.GetPixel(x,h-y) /obj/item/wallframe/painting/large name = "large painting frame" @@ -1073,4 +933,3 @@ current_color = chosen_color #undef AVAILABLE_PALETTE_SPACE -#undef MAX_PAINTING_ZOOM_OUT diff --git a/code/modules/sprite_editing/flood_fill.dm b/code/modules/sprite_editing/flood_fill.dm new file mode 100644 index 00000000000..5e1b295dd32 --- /dev/null +++ b/code/modules/sprite_editing/flood_fill.dm @@ -0,0 +1,53 @@ +//a macro for the stringized key for coordinates to check later +#define CANVAS_COORD(x, y) "[x]:[y]" +#define IS_IN_BOUNDS(x, y) (x > 0 && x <= width && y > 0 && y <= height) +#define COLORS_ARE_EQUAL(a, b) ((a == b) || (endswith(a, "00") && endswith(b, "00"))) + +#define SHOULD_ADD_POINT(x, y) (!coord_cache[CANVAS_COORD(x, y)] && IS_IN_BOUNDS(x, y) && COLORS_ARE_EQUAL(grid[y][x], target_color)) + +#define ADD_POINT(x, y) \ + points += list(list((x) - 1, (y) - 1, target_color));\ + coord_cache[CANVAS_COORD(x, y)] = TRUE + +/proc/flood_fill(list/grid, x, y, width, height) + var/target_color = grid[y][x] + var/list/coord_cache = list() + var/list/points = list() + var/list/coord_queue = list(x, x, y, 1, x, x, y-1, -1) + var/span_start + var/column + var/span_end + var/row + var/row_shift + while(length(coord_queue)) + span_start = coord_queue[1] + column = span_start + span_end = coord_queue[2] + row = coord_queue[3] + row_shift = coord_queue[4] + coord_queue.Cut(1, 5) + if(SHOULD_ADD_POINT(column, row)) + while(SHOULD_ADD_POINT(column - 1, row)) + ADD_POINT(column - 1, row) + column-- + if(column < span_start) + coord_queue += list(column, span_start - 1, row - row_shift, -row_shift) + while(span_start <= span_end) + while(SHOULD_ADD_POINT(span_start, row)) + ADD_POINT(span_start, row) + span_start++ + if(span_start > column) + coord_queue += list(column, span_start - 1, row + row_shift, row_shift) + if(span_start - 1 > span_end) + coord_queue += list(span_end + 1, span_start - 1, row - row_shift, -row_shift) + span_start++ + while(span_start < span_end && !SHOULD_ADD_POINT(span_start, row)) + span_start++ + column = span_start + return points + +#undef ADD_POINT +#undef SHOULD_ADD_POINT +#undef COLORS_ARE_EQUAL +#undef IS_IN_BOUNDS +#undef CANVAS_COORD diff --git a/code/modules/sprite_editing/workspace.dm b/code/modules/sprite_editing/workspace.dm new file mode 100644 index 00000000000..9871c342fd1 --- /dev/null +++ b/code/modules/sprite_editing/workspace.dm @@ -0,0 +1,302 @@ +/datum/sprite_editor_workspace + var/width + var/height + var/dirs + var/backdrop + + var/color_mode = SPRITE_EDITOR_COLOR_MODE_RGBA + /// A bitfield specifying whether certain functions of the sprite editor should be performed if the corresponding ui actions are received - used to prevent href exploitation + var/config_flags = ALL + /// A bitfield specifying what tools we are allowed to use in the sprite editor + var/tool_flags = ALL + + var/list/layers + var/list/undo_stack = list() + var/list/undo_names = list() + var/list/redo_stack = list() + var/list/redo_names = list() + +/datum/sprite_editor_workspace/New( + width = 32, + height = 32, + dirs = 1, + backdrop = null, + color_mode = SPRITE_EDITOR_COLOR_MODE_RGBA, + config_flags = ALL, + tool_flags = ALL, + initial_layer_color = null) + . = ..() + src.width = width + src.height = height + src.dirs = dirs + src.color_mode = color_mode + src.config_flags = config_flags + src.tool_flags = tool_flags + src.backdrop = backdrop + layers = list(list("name" = "Background", visible = TRUE, "data" = create_layer_data(initial_layer_color))) + +/datum/sprite_editor_workspace/proc/copy(preserve_history = FALSE) + var/datum/sprite_editor_workspace/new_workspace = new(width, height, dirs, color_mode, config_flags, tool_flags) + new_workspace.layers = deep_copy_list_alt(layers) + if(preserve_history) + new_workspace.undo_names = undo_names.Copy() + new_workspace.undo_stack = deep_copy_list_alt(undo_stack) + new_workspace.redo_names = redo_names.Copy() + new_workspace.redo_stack = deep_copy_list_alt(redo_stack) + return new_workspace + +/datum/sprite_editor_workspace/proc/create_layer_data(color = "#00000000") + var/list/out = list() + for(var/i in 1 to dirs) + var/list/layer = list() + for(var/y in 1 to height) + var/list/row = list() + for(var/x in 1 to width) + row += color + layer += list(row) + out["[GLOB.alldirs_dmi_order[i]]"] = layer + return out + +/** + * Take a new transaction, perform it, and optionally add it to the undo history. + * Returns TRUE if the transaction was valid. + */ +/datum/sprite_editor_workspace/proc/new_transaction(transaction) + if(!can_transact(transaction)) + return + preprocess_new_transaction(transaction) + transact(transaction) + if(!(config_flags & SPRITE_EDITOR_ALLOW_UNDO)) + return TRUE + redo_stack.Cut() + redo_names.Cut() + undo_stack += list(transaction) + undo_names += transaction["name"] + return TRUE + +/datum/sprite_editor_workspace/proc/undo() + if(!(config_flags & SPRITE_EDITOR_ALLOW_UNDO)) + return + if(length(undo_stack)) + pop(undo_names) + var/transaction = pop(undo_stack) + reverse_transact(transaction) + redo_stack += list(transaction) + redo_names += transaction["name"] + +/datum/sprite_editor_workspace/proc/redo() + if(!(config_flags & SPRITE_EDITOR_ALLOW_UNDO)) + return + if(length(redo_stack)) + pop(redo_names) + var/transaction = pop(redo_stack) + transact(transaction) + undo_stack += list(transaction) + undo_names += transaction["name"] + +/datum/sprite_editor_workspace/proc/toggle_layer_visible(layer) + if(!(config_flags & SPRITE_EDITOR_ALLOW_LAYERS)) + return + if(!isnum(layer)) + return + if(layer < 1 || layer > length(layers)) + return + layers[layer]["visible"] = !layers[layer]["visible"] + +/datum/sprite_editor_workspace/proc/is_valid_color(color) + if(SEND_SIGNAL(src, COMSIG_SPRITE_EDITOR_VALIDATE_COLOR, color)) + return FALSE + var/list/rgb_color = split_color(color) + switch(color_mode) + if(SPRITE_EDITOR_COLOR_MODE_RGBA) + return TRUE + if(SPRITE_EDITOR_COLOR_MODE_RGB) + return rgb_color[4] == 255 + if(SPRITE_EDITOR_COLOR_MODE_GREYSCALE) + return rgb_color[1] == rgb_color[2] && rgb_color[2] == rgb_color[3] + else + return TRUE + +/datum/sprite_editor_workspace/proc/can_transact(list/transaction) + switch(transaction["type"]) + if("pencil") + return tool_flags & SPRITE_EDITOR_TOOL_PENCIL && is_valid_color(transaction["color"]) + if("eraser") + return tool_flags & SPRITE_EDITOR_TOOL_ERASER + if("bucket") + return tool_flags & SPRITE_EDITOR_TOOL_BUCKET && is_valid_color(transaction["color"]) + if("renameLayer", "moveLayerUp", "moveLayerDown", "flattenLayer", "addLayer", "deleteLayer") + return config_flags & SPRITE_EDITOR_ALLOW_LAYERS + else // Invalid transaction type, probably from href exploitation + return FALSE + +/datum/sprite_editor_workspace/proc/preprocess_new_transaction(list/transaction) + switch(transaction["type"]) + if("pencil", "eraser") + var/layer = transaction["layer"] + var/dir = transaction["dir"] + var/list/points = transaction["points"] + var/list/affected_frame = layers[layer]["data"][dir] + for(var/point in points) + var/x = point[1]+1 + var/y = point[2]+1 + point += affected_frame[y][x] + if("bucket") + var/layer = transaction["layer"] + var/dir = transaction["dir"] + var/list/affected_frame = layers[layer]["data"][dir] + var/list/point = transaction["point"] + var/x = point[1]+1 + var/y = point[2]+1 + transaction["points"] = flood_fill(affected_frame, x, y, width, height) + transaction -= "point" + if("flattenLayer") + var/layer = transaction["layer"] + var/list/top_layer = layers[layer] + var/list/bottom_layer = layers[layer-1] + transaction["oldTop"] = top_layer + transaction["oldBottom"] = deep_copy_list(bottom_layer) + if("deleteLayer") + var/layer = transaction["layer"] + var/list/old_layer = layers[layer] + transaction["oldLayer"] = old_layer + +/datum/sprite_editor_workspace/proc/transact(list/transaction) + switch(transaction["type"]) + if("pencil", "bucket") + var/layer = transaction["layer"] + var/dir = transaction["dir"] + var/color = transaction["color"] + var/list/points = transaction["points"] + var/list/affected_frame = layers[layer]["data"][dir] + for(var/list/point in points) + var/x = point[1]+1 + var/y = point[2]+1 + affected_frame[y][x] = blend_color(affected_frame[y][x], color) + if("eraser") + var/layer = transaction["layer"] + var/dir = transaction["dir"] + var/list/points = transaction["points"] + var/list/affected_frame = layers[layer]["data"][dir] + for(var/list/point in points) + var/x = point[1]+1 + var/y = point[2]+1 + affected_frame[y][x] = "#00000000" + if("renameLayer") + var/layer = transaction["layer"] + var/new_name = transaction["newName"] + layers[layer]["name"] = new_name + if("moveLayerUp") + var/layer = transaction["layer"] + layers.Swap(layer, layer+1) + if("moveLayerDown") + var/layer = transaction["layer"] + layers.Swap(layer, layer-1) + if("flattenLayer") + var/layer = transaction["layer"] + var/list/top_layer = layers[layer] + var/list/bottom_layer = layers[layer-1] + for(var/dir in 1 to dirs) + for(var/y in 1 to height) + for(var/x in 1 to width) + bottom_layer["[dir]"][y][x] = blend_color(bottom_layer["[dir]"][y][x], top_layer["[dir]"][y][x]) + layers.Cut(layer, layer+1) + if("addLayer") + layers += list(list("name" = "New Layer", "visible" = TRUE, "data" = create_layer_data())) + if("deleteLayer") + var/layer = transaction["layer"] + layers.Cut(layer, layer+1) + +/datum/sprite_editor_workspace/proc/reverse_transact(list/transaction) + switch(transaction["type"]) + if("pencil", "eraser", "bucket") + var/layer = transaction["layer"] + var/dir = transaction["dir"] + var/list/points = transaction["points"] + var/list/affected_frame = layers[layer]["data"][dir] + for(var/list/point in points) + var/x = point[1]+1 + var/y = point[2]+1 + affected_frame[y][x] = point[3] + if("renameLayer") + var/layer = transaction["layer"] + var/old_name = transaction["oldName"] + layers[layer]["name"] = old_name + if("moveLayerUp") + var/layer = transaction["layer"] + layers.Swap(layer, layer+1) + if("moveLayerDown") + var/layer = transaction["layer"] + layers.Swap(layer, layer-1) + if("flattenLayer") + var/layer = transaction["layer"] + var/top_layer = transaction["oldTop"] + var/bottom_layer = transaction["oldBottom"] + var/bottom_layer_index = layer-1 + var/old_visibility = layers[bottom_layer_index]["visible"] + layers[bottom_layer_index] = bottom_layer + layers[bottom_layer_index]["visible"] = old_visibility + layers.Insert(layer, top_layer) + if("addLayer") + pop(layers) + if("deleteLayer") + var/layer = transaction["layer"] + var/old_layer = transaction["oldLayer"] + layers.Insert(layer, old_layer) + +/datum/sprite_editor_workspace/proc/sprite_editor_ui_data() + return list( + "colorMode" = color_mode, + "toolFlags" = tool_flags, + "undoStack" = undo_names, + "redoStack" = redo_names, + "sprite" = list( + "width" = width, + "height" = height, + "dirs" = dirs, + "backdrop" = backdrop, + "layers" = layers, + ) + ) + +/// Get a reference to the pixel data for the first layer of the given dir +/datum/sprite_editor_workspace/proc/get_first_layer_pixel_data(dir = SOUTH) + return layers[1]["data"]["[dir]"] + +/datum/sprite_editor_workspace/proc/to_icon() + var/metadata = json_encode(list( + "width" = width, + "height" = height, + "states" = list(list( + "name" = "", + "dirs" = dirs, + )), + )) + var/list/ideal_dims = calculate_optimal_icon_grid_dimensions(width, height, dirs) + var/grid_width = ideal_dims[1] + var/grid_height = ideal_dims[2] + var/file_width = width * grid_width + var/file_height = height * grid_height + var/layer_count = length(layers) + var/temp_file_prefix = copytext(REF(src), 2, -1) + for(var/i in 1 to layer_count) + var/list/layer_frames = list() + for(var/dir_index in 1 to dirs) + layer_frames += list(layers[i]["data"]["[GLOB.alldirs_dmi_order[dir_index]]"]) + var/pixels = reorder_pixels(width, height, grid_width, grid_height, layer_frames) + var/temp_path = "tmp/[temp_file_prefix]_layer[i].dmi" + var/result = rustg_dmi_create_png(temp_path, "[file_width]", "[file_height]", pixels) + if(result) + stack_trace(result) + return TRUE + result = rustg_dmi_inject_metadata(temp_path, metadata) + if(result) + stack_trace(result) + return TRUE + var/datum/universal_icon/out_icon = uni_icon("tmp/[temp_file_prefix]_layer1.dmi", "") + for(var/i in 2 to layer_count) + out_icon.blend_icon(uni_icon("tmp/[temp_file_prefix]_layer[i].dmi", ""), ICON_OVERLAY) + var/icon/final_icon = out_icon.to_icon() + for(var/i in 1 to layer_count) + fdel("tmp/[temp_file_prefix]_layer[i].dmi") + return final_icon diff --git a/code/modules/tgui/tgui.dm b/code/modules/tgui/tgui.dm index 89d25e98516..60b45bf0eb4 100644 --- a/code/modules/tgui/tgui.dm +++ b/code/modules/tgui/tgui.dm @@ -287,6 +287,7 @@ ), ) var/data = custom_data || with_data && src_object.ui_data(user) + SEND_SIGNAL(src_object, COMSIG_UI_DATA, user, data) if(data) json_data["data"] = data var/static_data = with_static_data && src_object.ui_static_data(user) diff --git a/tgstation.dme b/tgstation.dme index 88350149c0e..6114fda8931 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -238,6 +238,7 @@ #include "code\__DEFINES\species_clothing_paths.dm" #include "code\__DEFINES\speech_channels.dm" #include "code\__DEFINES\sprite_accessories.dm" +#include "code\__DEFINES\sprite_editor.dm" #include "code\__DEFINES\stack.dm" #include "code\__DEFINES\stack_trace.dm" #include "code\__DEFINES\stat.dm" @@ -6389,6 +6390,8 @@ #include "code\modules\spells\spell_types\touch\flesh_to_stone.dm" #include "code\modules\spells\spell_types\touch\scream_for_me.dm" #include "code\modules\spells\spell_types\touch\smite.dm" +#include "code\modules\sprite_editing\flood_fill.dm" +#include "code\modules\sprite_editing\workspace.dm" #include "code\modules\station_goals\bsa.dm" #include "code\modules\station_goals\dna_vault.dm" #include "code\modules\station_goals\generate_goals.dm" diff --git a/tgui/bun.lock b/tgui/bun.lock index 7308d97a26b..6dd4971b730 100644 --- a/tgui/bun.lock +++ b/tgui/bun.lock @@ -40,6 +40,7 @@ "name": "tgui", "version": "6.0.0", "dependencies": { + "color-blend": "4.0.0", "common": "workspace:*", "dateformat": "^5.0.3", "dompurify": "^3.2.5", @@ -413,6 +414,8 @@ "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + "color-blend": ["color-blend@4.0.0", "", {}, "sha512-fYODTHhI/NG+B5GnzvuL3kiFrK/UnkUezWFTgEPBTY5V+kpyfAn95Vn9sJeeCX6omrCOdxnqCL3CvH+6sXtIbw=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], diff --git a/tgui/packages/tgui/assets/transparency_checkerboard.svg b/tgui/packages/tgui/assets/transparency_checkerboard.svg new file mode 100644 index 00000000000..ecfd3a467d5 --- /dev/null +++ b/tgui/packages/tgui/assets/transparency_checkerboard.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/tgui/packages/tgui/interfaces/Canvas.tsx b/tgui/packages/tgui/interfaces/Canvas.tsx index dadd0cc202a..5ce942e8163 100644 --- a/tgui/packages/tgui/interfaces/Canvas.tsx +++ b/tgui/packages/tgui/interfaces/Canvas.tsx @@ -1,471 +1,342 @@ -import { Component, createRef, type RefObject, useState } from 'react'; -import { Color } from 'tgui-core/color'; -import { - Box, - Button, - Flex, - Icon, - KeyListener, - Tooltip, -} from 'tgui-core/components'; -import { KEY_F, KEY_G } from 'tgui-core/keycodes'; +import { type PropsWithChildren, type ReactNode, useState } from 'react'; +import { Box, Button, Stack } from 'tgui-core/components'; +import { clamp } from 'tgui-core/math'; +import type { BooleanLike } from 'tgui-core/react'; import { decodeHtmlEntities } from 'tgui-core/string'; import { useBackend } from '../backend'; import { Window } from '../layouts'; +import { SpriteEditor } from './common/SpriteEditor'; +import { + AdvancedCanvas, + type AdvancedCanvasPropsBase, +} from './common/SpriteEditor/Components/AdvancedCanvas'; +import { hasServerColorData } from './common/SpriteEditor/helpers'; +import { Dir, type SpriteEditorData } from './common/SpriteEditor/Types/types'; -const LEFT_CLICK = 0; - -type PaintCanvasProps = Partial<{ - onCanvasModifiedHandler: (data: PointData[]) => void; - onCanvasDropperHandler: (x: number, y: number) => void; - onCanvasFillHandler: (x: number, y: number) => void; - value: string[][]; - width: number; - height: number; - imageWidth: number; - imageHeight: number; - editable: boolean; - drawing_color: string | null; - has_palette: boolean; - show_grid: boolean; - zoom: number; - fillmode: boolean; -}>; - -type PointData = { - x: number; - y: number; -}; - -const fromDM = (data: string[][]) => { - return data.map((inner) => inner.map((v) => Color.fromHex(v))); -}; - -const toMassPaintFormat = (data: PointData[]) => { - return data.map((p) => ({ x: p.x + 1, y: p.y + 1 })); // 1-based index dm side -}; - -const checkPointCoords = (x: number, y: number, p: PointData) => { - return p.x === x && p.y === y; -}; - -class PaintCanvas extends Component { - canvasRef: RefObject; - baseImageData: Color[][]; - is_grid_shown: boolean; - modifiedElements: PointData[]; - onCanvasModified: (data: PointData[]) => void; - onCanvasDropper: (x: number, y: number) => void; - onCanvasFill: (x: number, y: number) => void; - drawing: boolean; - drawing_color: string; - zoom: number; - - constructor(props) { - super(props); - this.canvasRef = createRef(); - this.modifiedElements = []; - this.is_grid_shown = false; - this.drawing = false; - this.zoom = props.zoom; - - this.onCanvasModified = props.onCanvasModifiedHandler; - this.onCanvasDropper = props.onCanvasDropperHandler; - this.onCanvasFill = props.onCanvasFillHandler; - - this.handleStartDrawing = this.handleStartDrawing.bind(this); - this.handleDrawing = this.handleDrawing.bind(this); - this.handleEndDrawing = this.handleEndDrawing.bind(this); - this.handleDropper = this.handleDropper.bind(this); - } - - componentDidMount() { - this.prepareCanvas(); - this.syncCanvas(); - } - - componentDidUpdate() { - if (this.zoom !== this.props.zoom) { - this.prepareCanvas(); - this.syncCanvas(); - } else if ( - (this.props.value !== undefined && - JSON.stringify(this.baseImageData) !== - JSON.stringify(fromDM(this.props.value))) || - this.is_grid_shown !== this.props.show_grid - ) { - this.syncCanvas(); - } - } - - prepareCanvas() { - this.zoom = this.props.zoom as number; - const canvas = this.canvasRef.current!; - const ctx = canvas.getContext('2d'); - const width = this.props.width || canvas.width || 360; - const height = this.props.height || canvas.height || 360; - const x_resolution = this.props.imageWidth || 36; - const y_resolution = this.props.imageHeight || 36; - const x_scale = Math.round(width / x_resolution); - const y_scale = Math.round(height / y_resolution); - ctx?.setTransform(1, 0, 0, 1, 0, 0); - ctx?.scale(x_scale, y_scale); // This clears the canvas. - } - - syncCanvas() { - if (this.props.value === undefined) { - return; - } - this.baseImageData = fromDM(this.props.value); - this.is_grid_shown = !!this.props.show_grid; - this.modifiedElements = []; - - const canvas = this.canvasRef.current!; - const ctx = canvas.getContext('2d')!; - for (let x = 0; x < this.baseImageData.length; x++) { - const element = this.baseImageData[x]; - for (let y = 0; y < element.length; y++) { - const color = element[y]; - ctx.fillStyle = color.toString(); - ctx.fillRect(x, y, 1, 1); - if (this.is_grid_shown) { - ctx.strokeStyle = '#888888'; - ctx.lineWidth = 0.05; - ctx.strokeRect(x, y, 1, 1); - } - } - } - } - - eventToCoords(event: MouseEvent) { - const canvas = this.canvasRef.current!; - const width = this.props.width || canvas.width || 360; - const height = this.props.height || canvas.height || 360; - const x_resolution = this.props.imageWidth || 36; - const y_resolution = this.props.imageHeight || 36; - const x_scale = Math.round(width / x_resolution); - const y_scale = Math.round(height / y_resolution); - - const rect = canvas.getBoundingClientRect(); - const x = Math.floor((event.clientX - rect.left) / x_scale); - const y = Math.floor((event.clientY - rect.top) / y_scale); - return { x, y }; - } - - handleStartDrawing(event: MouseEvent) { - if ( - !this.props.editable || - this.props.drawing_color === undefined || - this.props.drawing_color === null || - event.button !== LEFT_CLICK - ) { - return; - } - const coords = this.eventToCoords(event); - if (this.props.fillmode) { - this.onCanvasFill(coords.x + 1, coords.y + 1); // 1-based index dm side - return; - } - this.modifiedElements = []; - this.drawing = true; - this.drawing_color = this.props.drawing_color; - this.drawPoint(coords.x, coords.y, this.drawing_color); - } - - drawPoint(x: number, y: number, color: any) { - // check if modifiedElements already contains a point with same x and y - if (this.modifiedElements.some(checkPointCoords.bind(null, x, y))) { - return; - } - const p: PointData = { x, y }; - this.modifiedElements.push(p); - const canvas = this.canvasRef.current!; - const ctx = canvas.getContext('2d')!; - ctx.fillStyle = color; - ctx.fillRect(x, y, 1, 1); - if (this.is_grid_shown) { - ctx.strokeStyle = '#888888'; - ctx.lineWidth = 0.05; - ctx.strokeRect(x, y, 1, 1); - } - } - - handleDrawing(event: MouseEvent) { - if (!this.drawing) { - return; - } - const coords = this.eventToCoords(event); - this.drawPoint(coords.x, coords.y, this.drawing_color); - } - - handleEndDrawing(event: MouseEvent) { - if (!this.drawing) { - return; - } - this.drawing = false; - if (this.onCanvasModified !== undefined) { - this.onCanvasModified(this.modifiedElements); - } - } - - handleDropper(event: MouseEvent) { - event.preventDefault(); - if (!this.props.has_palette) { - return; - } - const coords = this.eventToCoords(event); - this.onCanvasDropper(coords.x + 1, coords.y + 1); // 1-based index dm side - } - - render() { - const { - value, - width = 300, - height = 300, - imageWidth = 36, - imageHeight = 36, - ...rest - } = this.props; - return ( - - Canvas failed to render. - - ); - } -} - -const getImageSize = (value) => { - const width = value.length; - const height = width !== 0 ? value[0].length : 0; - return [width, height]; -}; - -type PaletteColor = { - color: string; - is_selected: boolean; +type CanvasMetadata = { + title: string; + author: string; + patron?: string; + medium: string; + date?: string; }; type CanvasData = { - grid: string[][]; - px_per_unit: number; - finalized: boolean; - name: string; - editable: boolean; - paint_tool_color: string | null; - paint_tool_palette: PaletteColor[] | null; - author: string | null; - medium: string | null; - patron: string | null; - date: string | null; - show_plaque: boolean; - show_grid: boolean; - zoom: number; - max_zoom: number; + metadata: CanvasMetadata; + editorData: SpriteEditorData; + pixelsPerUnit: number; + finalized: BooleanLike; + editable: BooleanLike; + allowColorPicker: BooleanLike; + showPlaque: BooleanLike; }; -export const Canvas = (props) => { - const { act, data } = useBackend(); - const [width, height] = getImageSize(data.grid); - const scaled_width = width * data.px_per_unit * data.zoom; - const scaled_height = height * data.px_per_unit * data.zoom; - const average_plaque_height = 90; - const palette_height = 38; - const griddy = !!data.show_grid && !!data.editable && !!data.paint_tool_color; - const [fillmode, setFillMode] = useState(false); +type ZoomProps = { + zoom: number; + setZoom: React.Dispatch>; + pixelsPerUnit: number; +}; + +type CanvasCommonProps = ZoomProps & { + width: number; + height: number; +}; + +const ZoomButtons = ({ zoom, setZoom, pixelsPerUnit }: ZoomProps) => ( + + + + + ))} + + + } + > +