From 67eef8efa7bcf338c5fa1f4785ed39ee19247dc1 Mon Sep 17 00:00:00 2001 From: Geeves <22774890+Geevies@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:04:35 +0000 Subject: [PATCH] Smooth Progress Bar (#23093) * Progress bars now use smooth, colour-changing fills and support visual themes. https://github.com/user-attachments/assets/ac493f6d-dce7-4b11-a105-985a076f4904 Ported from https://github.com/NebulaSS13/Nebula/pull/1574, which ported from the original PR: https://github.com/ParadiseSS13/Paradise/pull/15695 ### Asset Licenses The following assets that **have not** been created by myself are included in this PR: | Path | Original Author | License | | --- | --- | --- | | image | [dearmochi](https://github.com/dearmochi) (ParadiseSS13) | Creative Commons 3.0 BY-SA | AI usage disclosure: Ported using GPT 5.6 Sol. --- code/__HELPERS/unsorted.dm | 16 +- code/datums/progressbar.dm | 230 ++++++++++++++------ html/changelogs/geeves-progress_bar.yml | 6 + icons/effects/progress_bar/default.dmi | Bin 0 -> 312 bytes icons/effects/progress_bar/default_slim.dmi | Bin 0 -> 307 bytes icons/effects/progress_bar/warning.dmi | Bin 0 -> 315 bytes icons/effects/progress_bar/warning_slim.dmi | Bin 0 -> 304 bytes icons/effects/progressbar.dmi | Bin 1013 -> 0 bytes 8 files changed, 173 insertions(+), 79 deletions(-) create mode 100644 html/changelogs/geeves-progress_bar.yml create mode 100644 icons/effects/progress_bar/default.dmi create mode 100644 icons/effects/progress_bar/default_slim.dmi create mode 100644 icons/effects/progress_bar/warning.dmi create mode 100644 icons/effects/progress_bar/warning_slim.dmi delete mode 100644 icons/effects/progressbar.dmi diff --git a/code/__HELPERS/unsorted.dm b/code/__HELPERS/unsorted.dm index 847ea28a4e6..92fa0a10669 100644 --- a/code/__HELPERS/unsorted.dm +++ b/code/__HELPERS/unsorted.dm @@ -585,8 +585,9 @@ Turf and target are seperate in case you want to teleport some distance from a t * * display_progress - Boolean, if the progress bar is shown * * extra_checks - A `/datum/callback` that is invoked to perform extra checks and validate that the action can continue to be performed, * if it returns `FALSE` or an algebraic equivalent the action is aborted + * * progressbar_type - The progress bar theme to display */ -/proc/do_mob(mob/user, mob/target, delay = 30, needhand = TRUE, display_progress = TRUE, datum/callback/extra_checks) //This is quite an ugly solution but i refuse to use the old request system. +/proc/do_mob(mob/user, mob/target, delay = 30, needhand = TRUE, display_progress = TRUE, datum/callback/extra_checks, progressbar_type = /datum/progressbar/default) //This is quite an ugly solution but i refuse to use the old request system. if(!user || !target) stack_trace("do_mob called without either an user or a target!") return FALSE @@ -600,7 +601,7 @@ Turf and target are seperate in case you want to teleport some distance from a t var/atom/loc_check = target for(var/i = 0; !isturf(loc_check.loc) && i < 5; i++) loc_check = target.loc - progbar = new(user, delay, loc_check) + progbar = new progressbar_type(user, delay, loc_check) var/endtime = world.time + delay var/starttime = world.time @@ -621,7 +622,7 @@ Turf and target are seperate in case you want to teleport some distance from a t break if (progbar) - qdel(progbar) + progbar.end_progress() /** * Timed actions involving one mob user and (optionally) one target. @@ -635,17 +636,18 @@ Turf and target are seperate in case you want to teleport some distance from a t * * do_flags: Flags that determine what the user and target can and cannot do, defined in [mobs.dm]. Defaults to DO_DEFAULT. * * incapacitation_flags: Incapacitation flags that determines if the user can be incapacitated. Defaults to INCAPACITATION_DEFAULT. * * extra_checks: Optional extra checks, that uses a callback. See [datum/callback]. + * * progressbar_type: The progress bar theme to display. * */ -/proc/do_after(mob/user, delay, atom/target, do_flags = DO_DEFAULT, incapacitation_flags = INCAPACITATION_DEFAULT, datum/callback/extra_checks) - return !do_after_detailed(user, delay, target, do_flags, incapacitation_flags, extra_checks) +/proc/do_after(mob/user, delay, atom/target, do_flags = DO_DEFAULT, incapacitation_flags = INCAPACITATION_DEFAULT, datum/callback/extra_checks, progressbar_type = /datum/progressbar/default) + return !do_after_detailed(user, delay, target, do_flags, incapacitation_flags, extra_checks, progressbar_type) /** * See [/proc/do_after] * Returns the exact error, defined in [mobs.dm] for custom error messages. * Overlaps with do_flags, with some extra error messages available. */ -/proc/do_after_detailed(mob/user, delay, atom/target, do_flags = DO_DEFAULT, incapacitation_flags = INCAPACITATION_DEFAULT, datum/callback/extra_checks) +/proc/do_after_detailed(mob/user, delay, atom/target, do_flags = DO_DEFAULT, incapacitation_flags = INCAPACITATION_DEFAULT, datum/callback/extra_checks, progressbar_type = /datum/progressbar/default) if(!delay) return FALSE @@ -686,7 +688,7 @@ Turf and target are seperate in case you want to teleport some distance from a t var/datum/progressbar/progbar if ((do_flags & DO_SHOW_PROGRESS) && user.client && (user.client.prefs.toggles_secondary & PROGRESS_BARS)) var/progbar_pos = (do_flags & DO_PLACE_PROGRESSBAR_ON_USER) ? user : (target || user) - progbar = new(user, delay, progbar_pos) + progbar = new progressbar_type(user, delay, progbar_pos) SEND_SIGNAL(user, COMSIG_DO_AFTER_BEGAN) diff --git a/code/datums/progressbar.dm b/code/datums/progressbar.dm index 4ba6b03362d..70842f81dea 100644 --- a/code/datums/progressbar.dm +++ b/code/datums/progressbar.dm @@ -1,25 +1,45 @@ -#define PROGRESSBAR_HEIGHT 6 -#define PROGRESSBAR_ANIMATION_TIME 5 - /datum/progressbar - ///The progress bar visual element. + /// The icon file containing the backdrop, mask, and fill states for this theme. + var/icon = 'icons/effects/progress_bar/default.dmi' + /// The total height in pixels of the themed progress bar. + var/height = 7 + /// The width in pixels of the theme's fill sprite. + var/fill_width = 22 + /// The time taken for the progress bar to appear. + var/animation_time_appear = 0.5 SECONDS + /// The time taken for the progress bar to fade away. + var/animation_time_fade = 0.5 SECONDS + /// The time taken for stacked progress bars to shift down. + var/animation_time_shift = 0.5 SECONDS + + /// The visual element containing the backdrop and fill. var/image/bar - ///The target where this progress bar is applied and where it is shown. + /// The backdrop visual element. + var/image/backdrop + /// The fill visual element. + var/image/fill + /// The target where this progress bar is applied and where it is shown. var/atom/bar_loc - ///The mob whose client sees the progress bar. + /// The mob whose client sees the progress bar. var/mob/user - ///The client seeing the progress bar. + /// The client seeing the progress bar. var/client/user_client - ///Effectively the number of steps the progress bar will need to do before reaching completion. + /// The number of steps the progress bar needs to reach completion. var/goal = 1 - ///Control check to see if the progress was interrupted before reaching its goal. + /// The most recently displayed progress value. var/last_progress = 0 - ///Variable to ensure smooth visual stacking on multiple progress bars. + /// The progress bar's position in the stack over its target. var/listindex = 0 - ///The type of our last value for bar_loc, for debugging + /// The type of our last value for bar_loc, for debugging. var/location_type - ///Where to draw the progress bar above the icon + /// Horizontal offset needed to centre the bar over oversized icons. + var/offset_x + /// Vertical offset needed to place the bar over oversized icons. var/offset_y + /// Whether the bar has started its ending animation. + var/stopping = FALSE + /// Whether this bar has already been removed from the user's stack. + var/removed_from_stack = FALSE /datum/progressbar/New(mob/User, goal_number, atom/target) . = ..() @@ -27,33 +47,30 @@ stack_trace("Invalid target [target] passed in") qdel(src) return - if(QDELETED(User) || !istype(User)) + if (QDELETED(User) || !istype(User)) stack_trace("/datum/progressbar created with [isnull(User) ? "null" : "invalid"] user") qdel(src) return - if(!isnum(goal_number)) + if (!isnum(goal_number)) stack_trace("/datum/progressbar created with [isnull(User) ? "null" : "invalid"] goal_number") qdel(src) return + goal = goal_number bar_loc = target location_type = bar_loc.type + user = User var/list/icon_offsets = target.get_oversized_icon_offsets() - var/offset_x = icon_offsets["x"] + offset_x = icon_offsets["x"] offset_y = icon_offsets["y"] - - bar = image('icons/effects/progressbar.dmi', bar_loc, "prog_bar_0", pixel_x = offset_x) - // SET_PLANE_EXPLICIT(bar, ABOVE_HUD_PLANE, User) - bar.plane = HUD_PLANE - bar.appearance_flags = APPEARANCE_UI_IGNORE_ALPHA - user = User + init_images() LAZYADDASSOCLIST(user.progressbars, bar_loc, src) var/list/bars = user.progressbars[bar_loc] - listindex = bars.len + listindex = length(bars) - if(user.client) + if (user.client) user_client = user.client add_prog_bar_image_to_client() @@ -62,111 +79,180 @@ RegisterSignal(user, COMSIG_MOB_LOGIN, PROC_REF(on_user_login)) RegisterSignal(bar_loc, COMSIG_QDELETING, PROC_REF(on_bar_loc_delete)) - /datum/progressbar/Destroy() - if(user) + if (user) UnregisterSignal(user, list(COMSIG_QDELETING, COMSIG_MOB_LOGOUT, COMSIG_MOB_LOGIN)) - if(isliving(user)) - var/mob/living/L = user - if(L.stamina_bar == src) - L.stamina_bar = null + if (isliving(user)) + var/mob/living/living_user = user + if (living_user.stamina_bar == src) + living_user.stamina_bar = null - for(var/pb in user.progressbars[bar_loc]) - var/datum/progressbar/progress_bar = pb - if(progress_bar == src || progress_bar.listindex <= listindex) - continue - progress_bar.listindex-- - - progress_bar.bar.pixel_y = world.icon_size + offset_y + (PROGRESSBAR_HEIGHT * (progress_bar.listindex - 1)) - var/dist_to_travel = world.icon_size + offset_y + (PROGRESSBAR_HEIGHT * (progress_bar.listindex - 1)) - PROGRESSBAR_HEIGHT - animate(progress_bar.bar, pixel_y = dist_to_travel, time = PROGRESSBAR_ANIMATION_TIME, easing = SINE_EASING) - - LAZYREMOVEASSOC(user.progressbars, bar_loc, src) + remove_from_stack() user = null - if(user_client) + if (user_client) clean_user_client() - if(bar_loc) + if (bar_loc) UnregisterSignal(bar_loc, COMSIG_QDELETING) bar_loc = null + bar = null + backdrop = null + fill = null return ..() +/// Initializes the holder, backdrop, and masked fill images used by the theme. +/datum/progressbar/proc/init_images() + bar = image('icons/effects/effects.dmi', bar_loc, "nothing", HUD_ABOVE_ITEM_LAYER, pixel_x = offset_x) + bar.mouse_opacity = MOUSE_OPACITY_TRANSPARENT + bar.alpha = 0 + bar.plane = HUD_PLANE + bar.appearance_flags = KEEP_TOGETHER | APPEARANCE_UI_IGNORE_ALPHA -///Called right before the user's Destroy() + backdrop = image(icon, bar_loc, "backdrop") + fill = image(icon, bar_loc, "fill") + fill.filters = filter(type = "alpha", icon = icon(icon, "mask"), x = -fill_width) + bar.overlays += list(backdrop, fill) + +/// Removes this bar from its stack and smoothly shifts any bars above it down. +/datum/progressbar/proc/remove_from_stack() + if (removed_from_stack || !user || !bar_loc) + return + + var/list/bars = user.progressbars?[bar_loc] + if (bars) + for (var/pb in bars) + var/datum/progressbar/progress_bar = pb + if (progress_bar == src || progress_bar.listindex <= listindex) + continue + progress_bar.listindex-- + progress_bar.shift_down() + LAZYREMOVEASSOC(user.progressbars, bar_loc, src) + + removed_from_stack = TRUE + +/// Shifts this progress bar down one position in its stack. +/datum/progressbar/proc/shift_down() + animate(bar, + pixel_y = world.icon_size + offset_y + (height * (listindex - 1)), + time = animation_time_shift, + easing = SINE_EASING | EASE_OUT, + flags = ANIMATION_PARALLEL + ) + +/// Called right before the user's Destroy(). /datum/progressbar/proc/on_user_delete(datum/source) SIGNAL_HANDLER - user.progressbars = null //We can simply nuke the list and stop worrying about updating other prog bars if the user itself is gone. + user.progressbars = null + removed_from_stack = TRUE user = null qdel(src) - -///Called right before the bar_loc's Destroy() +/// Called right before the bar_loc's Destroy(). /datum/progressbar/proc/on_bar_loc_delete(datum/source) SIGNAL_HANDLER qdel(src) -///Removes the progress bar image from the user_client and nulls the variable, if it exists. +/// Removes the progress bar image from the current client. /datum/progressbar/proc/clean_user_client(datum/source) SIGNAL_HANDLER - if(!user_client) //Disconnected, already gone. + if (!user_client) return user_client.images -= bar user_client = null - -///Called by user's Login(), it transfers the progress bar image to the new client. +/// Transfers the progress bar image to the user's new client after login. /datum/progressbar/proc/on_user_login(datum/source) SIGNAL_HANDLER - if(user_client) - if(user_client == user.client) //If this was not client handling I'd condemn this sanity check. But clients are fickle things. + if (user_client) + if (user_client == user.client) return clean_user_client() - if(!user.client) //Clients can vanish at any time, the bastards. + if (!user.client) return user_client = user.client add_prog_bar_image_to_client() - -///Adds a smoothly-appearing progress bar image to the player's screen. +/// Adds a smoothly appearing progress bar image to the player's client. /datum/progressbar/proc/add_prog_bar_image_to_client() bar.pixel_y = 0 bar.alpha = 0 user_client.images += bar - animate(bar, pixel_y = world.icon_size + offset_y + (PROGRESSBAR_HEIGHT * (listindex - 1)), alpha = 255, time = PROGRESSBAR_ANIMATION_TIME, easing = SINE_EASING) + animate(bar, + pixel_y = world.icon_size + offset_y + (height * (listindex - 1)), + alpha = 255, + time = animation_time_appear, + easing = SINE_EASING | EASE_OUT + ) - -///Updates the progress bar image visually. +/// Updates the progress bar's masked fill. /datum/progressbar/proc/update(progress) + if (QDELETED(bar_loc)) + qdel(src) + return + progress = clamp(progress, 0, goal) - if(progress == last_progress) + if (progress == last_progress) return last_progress = progress - bar.icon_state = "prog_bar_[round(((progress / goal) * 100), 5)]" + refresh_fill() +/// Removes and re-adds the fill so its filtered appearance updates immediately. +/datum/progressbar/proc/refresh_fill() + if (!fill || !bar) + return + bar.overlays -= fill + update_fill(clamp(last_progress / goal, 0, 1)) + bar.overlays += fill -///Called on progress end, be it successful or a failure. Wraps up things to delete the datum and bar. +/// Updates the fill image for the current theme. +/datum/progressbar/proc/update_fill(fraction) + UNLINT(fill?.filters[1]?.x = -fill_width * (1 - fraction)) + if (!stopping) + fill.color = rgb_gradient(fraction, 0, "#cc0033", 0.25, "#cc6633", 0.5, "#d1cc33", 0.75, "#00cc33") + else if (last_progress == goal) + fill.color = COLOR_YELLOW + +/// Ends the progress bar and schedules its deletion after the fade animation. /datum/progressbar/proc/end_progress() - if(last_progress != goal) - bar.icon_state = "[bar.icon_state]_fail" + if (stopping) + return + stopping = TRUE + refresh_fill() + remove_from_stack() + animate(bar, alpha = 0, time = animation_time_fade, flags = ANIMATION_PARALLEL) + QDEL_IN(src, animation_time_fade) - animate(bar, alpha = 0, time = PROGRESSBAR_ANIMATION_TIME) - - QDEL_IN(src, PROGRESSBAR_ANIMATION_TIME) - -///Progress bars are very generic, and what hangs a ref to them depends heavily on the context in which they're used -///So let's make hunting harddels easier yeah? +/// Progress bars are generic, so record the target type to make harddel debugging easier. /datum/progressbar/dump_harddel_info() - if(harddel_deets_dumped) + if (harddel_deets_dumped) return harddel_deets_dumped = TRUE return "Owner's type: [location_type]" -#undef PROGRESSBAR_ANIMATION_TIME -#undef PROGRESSBAR_HEIGHT +/datum/progressbar/default + +/datum/progressbar/default/slim + icon = 'icons/effects/progress_bar/default_slim.dmi' + height = 5 + +/// A warning theme that pulses between yellow and red after its halfway point. +/datum/progressbar/warning + icon = 'icons/effects/progress_bar/warning.dmi' + +/datum/progressbar/warning/update_fill(fraction) + . = ..() + if (fraction <= 0.5) + fill.color = COLOR_YELLOW + else + fill.color = rgb_gradient(fraction * 10, 0.5, COLOR_YELLOW, 0.5, COLOR_RED, "loop") + +/datum/progressbar/warning/slim + icon = 'icons/effects/progress_bar/warning_slim.dmi' + height = 5 diff --git a/html/changelogs/geeves-progress_bar.yml b/html/changelogs/geeves-progress_bar.yml new file mode 100644 index 00000000000..97ed45da856 --- /dev/null +++ b/html/changelogs/geeves-progress_bar.yml @@ -0,0 +1,6 @@ +author: Geeves + +delete-after: True + +changes: + - rscadd: "Progress bars now use smooth, colour-changing fills and support visual themes." diff --git a/icons/effects/progress_bar/default.dmi b/icons/effects/progress_bar/default.dmi new file mode 100644 index 0000000000000000000000000000000000000000..45d9865068034b895ad4a83e22f0ec337e6c60e3 GIT binary patch literal 312 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0J3?w7mbKU|ei2$Dv*8>L*2nmVk>6n9;N!wQN@ zzkdlX`1tjSmbb3fxija3H-s8oG=A_%=e&>RNrs}H-W?XkLB^Mjy%gp=nsj7Rh(fTY zrlFCy literal 0 HcmV?d00001 diff --git a/icons/effects/progress_bar/default_slim.dmi b/icons/effects/progress_bar/default_slim.dmi new file mode 100644 index 0000000000000000000000000000000000000000..9db0f7f7de85ac38b4dacba77d5cd3766c3d456f GIT binary patch literal 307 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0J3?w7mbKU|e(Ey(i*8>L*2nmVk>6rZg|KH5i z8pxeCZCcH@=l6jM7)yfuf*Bm1-ADs+s;WXFN?cNllZ!G7N;32F7#J$%1cwzAm45#c zT=4Ph6D@CDt#fD22X6>9xM=*~k!lj|9Xh*>RX4AY&`3FPbY|F;8MC<(4~sri6H6BknKEtWF%APdOcL<~hvc{K3eO#3X*aV!g@+pot8gu6{1-oD!ML*2nmVk=`j5N{~yQ( z3Ky{D0!aqOk|4ie28U-i(tw<*s*s2hm(=3qqRfJl%=|nChKf1CVFg8{-@gPGeEj-E z%Uf6L+?n&i8$t~(8b5fXbKXbuBtub8?+y#&Amhu%UJ7#_O*%3uL?KvH)6mj-sm6YX z&TeDX%_}4{QcfJ588&6cY_7z^qR-UC(uG5&Oq)5m*?Z!qrq%PgZ}c);w9tQGYbUP^ zbVZ=2i(`mKXL5oB>*55F3Ct`L8Nyhdyb2a@JTjI(+<0(7pu+`mpiB=_6vMTI%K?uA z3zopr E06Xn&r~m)} literal 0 HcmV?d00001 diff --git a/icons/effects/progress_bar/warning_slim.dmi b/icons/effects/progress_bar/warning_slim.dmi new file mode 100644 index 0000000000000000000000000000000000000000..e7fd0aa1bbf9f520a1adc23371a4d11c5a4baa25 GIT binary patch literal 304 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0J3?w7mbKU|ep#Yx{*8>L*2nmVk=`j5N{~yQ( z3Ky{D0!aqOk|4ie28U-i(tw<*s*s2hm(=3qqRfJl%=|nChKf1CVFg8{-@gPGeEj-E z%Uf6L+?n&i8$t~(8b5fXbKXbuBtub8?+y#&Amhu%UJ7#_O*%3uL?KvH)6mj-sm6YX z&TeDX%_}4{QcfJ588&6cY_7z^qR-UC(uG5&Oq)5m*?Z!qrq%PgZ}c);w9tQGYbUP^ zbcLIzi(`mKXL5oB>*53v9v%S~rU+3E&4Z0f9p`g+*x5pY94_zzWqO(pa?bE|?saZH t*uq%G8W5=<*bvI7eTO0V4c7-|hEI1yf2m#1w+5QX;OXk;vd$@?2>>DcYXJZN literal 0 HcmV?d00001 diff --git a/icons/effects/progressbar.dmi b/icons/effects/progressbar.dmi deleted file mode 100644 index f055a07ba1492a36912ed6b9fdbf1b8e278e4864..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1013 zcmV{hG&VIm%m6bYAt(O;Gt6c)|CuuY%rgN0Gc#uYW|{wws`2W@R{!XX_Jw>-00001 zbW%=J06^y0W&i*H`+8JZbVOxyV{&P5bZKvH004NLt=78=!Y~vD;JJBzP}a&<)8%MEhWm?j3)Zra)=6xBl?EerTz7 z2YY{}C!ZDnI3odCOJc@_05D+)6(Cd$p#p@8A=H3SGlUutYKG7NLc!i*uz0b$M%=72C~2n#@1FoXplEEvKP5S9#K2?$Hhi~pB6mdowp z_0xF(AJ~Na{wENT0007PNklv0t6x9nR}0$u{oB{)U_tBr^7Wp8-tS=Ve1xxX z-y8q{0001B9=)5H{`uJ5-8Y;M*c?^b)l|-F?d&Gl;Cg;4=6BBDl=2j8a8{3Rk1G9o zF6Xs=ej99XJwF%oJLhjpc@8#MJOQU0F!(#_e897F000000DyV)Zf5%DV|RB0{QrPW zP`^g)YbxY-&)>v(3N~n&zdH%r;k*0yZ6VJ=0slz9|8;|Kevb3n{QXIMK4A7IVDNX; z`G9BV00000008so-OTjQ$L{XF;e0>}>emO`*IksSV1q~J?<(_C^Zc{^J+JNG_fejM z4IZ7puguTQ^F0B*-@)Gb2w&m8IRF3v006)|dN(uu^Rc_TZ#W;Y3+mTm`9QR@@89>8{ynek_dho%&(F>C-!4?<=d1Gpvp)fYzoX6vJUa&f00000 zm`Cqsrhh(mcQ?TA3tYCZhf4dJR<^qz8{C|q*3Lh){l36u|Nd6#-}B0T|6_xj^YhyI zZ*9LX@Y|n&!QWBm1D>4&000000L)|d=H>a_+u)-1H8r)b{{qWTx&hbydv5CA{|A25 j4Ve82nEe|t^Iw5?ZsBHuRElq(00000NkvXXu0mjf6jA0r