From 6d20c4f20d1a7d6f3d50d759e42d7033d9f178f0 Mon Sep 17 00:00:00 2001 From: MrMelbert <51863163+MrMelbert@users.noreply.github.com> Date: Sun, 30 Nov 2025 18:33:08 -0600 Subject: [PATCH] Stairs update (#94146) ## About The Pull Request 1. Stairs are now on the floor plane, meaning they don't have AO and blend in with turfs 2. Stairs now blend with adjacent stairs, forming a contiguous sprite 3. There is now a visual indicator that stairs will take you up in the form of an arrow - The arrow appears when within three tiles and only appears if the stairs can actually take you up. https://github.com/user-attachments/assets/d941a312-9470-4d2a-95f0-a1834adf0212 ## Why It's Good For The Game 1. The common method of making stairs (stair objects on stair turfs) looks ugly due to AO 2. Stair turfs have these sprites but not the objects themselves 3. Given stairs now blend in better, I figured there should be a proper indicator that stairs exist. Helps with fake stair confusion ## Changelog :cl: Melbert qol: Stairs now have an indicator that stepping beyond them will take you upwards. You can disable it in accessibility settings if desired. image: Stairs blend in and together better. /:cl: --- code/game/objects/structures/stairs.dm | 190 +++++++++++++++--- .../client/preferences/accessibility.dm | 15 ++ icons/obj/stairs.dmi | Bin 2226 -> 2848 bytes .../game_preferences/accessibility.tsx | 10 + 4 files changed, 188 insertions(+), 27 deletions(-) diff --git a/code/game/objects/structures/stairs.dm b/code/game/objects/structures/stairs.dm index 6cf82aef603..31c65be2ec3 100644 --- a/code/game/objects/structures/stairs.dm +++ b/code/game/objects/structures/stairs.dm @@ -2,6 +2,9 @@ #define STAIR_TERMINATOR_NO 1 #define STAIR_TERMINATOR_YES 2 +/// Range within which stair indicators will appear for approaching mobs +#define STAIR_INDICATOR_RANGE 3 + // dir determines the direction of travel to go upwards // stairs require /turf/open/openspace as the tile above them to work, unless your stairs have 'force_open_above' set to TRUE // multiple stair objects can be chained together; the Z level transition will happen on the final stair object in the chain @@ -10,12 +13,22 @@ name = "stairs" icon = 'icons/obj/stairs.dmi' icon_state = "stairs" + base_icon_state = "stairs" anchored = TRUE move_resist = INFINITY + plane = FLOOR_PLANE + layer = ABOVE_OPEN_TURF_LAYER - var/force_open_above = FALSE // replaces the turf above this stair obj with /turf/open/openspace - var/terminator_mode = STAIR_TERMINATOR_AUTOMATIC - var/turf/listeningTo + /// If TRUE replaces the turf above this stair obj with /turf/open/openspace + var/force_open_above = FALSE + /// Determines if this stair is the last in a "chain" of stairs, ie next step is upstairs + VAR_FINAL/terminator_mode = STAIR_TERMINATOR_AUTOMATIC + /// Upstairs turf. Is observed for changes if force_open_above is TRUE (to re-open if necessary) + VAR_FINAL/turf/directly_above + /// If TRUE, we have left/middle/right sprites. + var/has_merged_sprites = TRUE + /// Lazyassoc list of weakef to mob viewing stair indicators to their images + VAR_PRIVATE/list/mob_to_image /obj/structure/stairs/north dir = NORTH @@ -31,49 +44,94 @@ /obj/structure/stairs/wood icon_state = "stairs_wood" + has_merged_sprites = FALSE /obj/structure/stairs/stone icon_state = "stairs_stone" + has_merged_sprites = FALSE /obj/structure/stairs/material icon_state = "stairs_material" material_flags = MATERIAL_EFFECTS | MATERIAL_ADD_PREFIX | MATERIAL_COLOR | MATERIAL_AFFECT_STATISTICS + has_merged_sprites = FALSE /obj/structure/stairs/Initialize(mapload) + . = ..() + GLOB.stairs += src if(force_open_above) force_open_above() build_signal_listener() update_surrounding() - var/static/list/loc_connections = list( - COMSIG_ATOM_EXIT = PROC_REF(on_exit), + var/static/list/exit_connections = list( + COMSIG_ATOM_EXIT = PROC_REF(on_exit_stairs), ) - AddElement(/datum/element/connect_loc, loc_connections) + AddElement(/datum/element/connect_loc, exit_connections) + + var/static/list/range_connections = list( + COMSIG_ATOM_ENTERED = PROC_REF(on_enter_range), + COMSIG_ATOM_EXITED = PROC_REF(on_exit_range), + ) + AddComponent(/datum/component/connect_range, tracked = src, connections = range_connections, range = STAIR_INDICATOR_RANGE) - return ..() /obj/structure/stairs/Destroy() - listeningTo = null + if(directly_above) + UnregisterSignal(directly_above, COMSIG_TURF_MULTIZ_NEW) + directly_above = null + for(var/climber_ref in mob_to_image) + clear_climber_image(climber_ref, instant = TRUE) GLOB.stairs -= src return ..() -/obj/structure/stairs/Move() //Look this should never happen but... +/obj/structure/stairs/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change) //Look this should never happen but... . = ..() if(force_open_above) build_signal_listener() update_surrounding() +/// Updates the sprite and the sprites of neighboring stairs to reflect merged sprites /obj/structure/stairs/proc/update_surrounding() - update_appearance() - for(var/i in GLOB.cardinals) - var/turf/T = get_step(get_turf(src), i) - var/obj/structure/stairs/S = locate() in T - if(S) - S.update_appearance() + if(!has_merged_sprites) + return -/obj/structure/stairs/proc/on_exit(datum/source, atom/movable/leaving, direction) + update_appearance() + + for(var/obj/structure/stairs/stair in get_step(src, turn(dir, 90))) + stair.update_appearance() + + for(var/obj/structure/stairs/stair in get_step(src, turn(dir, -90))) + stair.update_appearance() + +/obj/structure/stairs/update_icon_state() + . = ..() + if(!has_merged_sprites) + return + + var/has_left_stairs = FALSE + var/has_right_stairs = FALSE + for(var/obj/structure/stairs/stair in get_step(src, turn(dir, 90))) + if(stair.dir == dir) + has_left_stairs = TRUE + break + + for(var/obj/structure/stairs/stair in get_step(src, turn(dir, -90))) + if(stair.dir == dir) + has_right_stairs = TRUE + break + + if(has_left_stairs && has_right_stairs) + icon_state = "[base_icon_state]-m" + else if(has_left_stairs) + icon_state = "[base_icon_state]-r" + else if(has_right_stairs) + icon_state = "[base_icon_state]-l" + else + icon_state = base_icon_state + +/obj/structure/stairs/proc/on_exit_stairs(datum/source, atom/movable/leaving, direction) SIGNAL_HANDLER if(leaving == src) @@ -85,19 +143,95 @@ leaving.Bump(src) return COMPONENT_ATOM_BLOCK_EXIT +#define POINT_X_COMPONENT(pdir) ((pdir & EAST) ? 2 : ((pdir & WEST) ? -2 : 0)) +#define POINT_Y_COMPONENT(pdir) ((pdir & SOUTH) ? 2 : ((pdir & NORTH) ? -2 : 0)) + +/obj/structure/stairs/proc/on_enter_range(datum/source, atom/movable/entered) + SIGNAL_HANDLER + + if(!isliving(entered)) + return + + var/mob/living/climber = entered + var/datum/weakref/climber_ref = WEAKREF(climber) + if(!climber.client || !climber.client.prefs.read_preference(/datum/preference/toggle/stair_indicator)) + return + if(climber.dir == REVERSE_DIR(dir)) + return // walking away + if(LAZYACCESS(mob_to_image, climber_ref)) + return // already see it + if(!(climber in viewers(STAIR_INDICATOR_RANGE + 1, src))) + return // can't see the staircase (+1 tile for some leeway) + if(!isopenturf(get_step_multiz(src, UP))) + return // no place to go up to + + var/image/pointing_image = get_pointing_image() + climber.client.images += pointing_image + pointing_image.alpha = 0 + animate(pointing_image, pixel_x = POINT_X_COMPONENT(dir), pixel_y = POINT_Y_COMPONENT(dir), time = 0.5 SECONDS, easing = SINE_EASING|EASE_OUT, loop = -1, tag = "point_xy") + animate(pixel_x = 0, pixel_y = 0, time = 0.5 SECONDS, easing = SINE_EASING|EASE_IN) + animate(pointing_image, alpha = 180, time = 0.75 SECONDS, tag = "point_fadein") + LAZYSET(mob_to_image, climber_ref, pointing_image) + +/obj/structure/stairs/proc/on_exit_range(datum/source, atom/movable/exited) + SIGNAL_HANDLER + + if(!isliving(exited)) + return + + var/datum/weakref/climber_ref = WEAKREF(exited) + if(!LAZYACCESS(mob_to_image, climber_ref)) + return // not seeing anything + if(exited in viewers(STAIR_INDICATOR_RANGE, src)) + return // still in range and can see the staircase + + clear_climber_image(climber_ref) + +/obj/structure/stairs/proc/clear_climber_image(datum/weakref/climber_ref, instant = FALSE) + var/image/pointing_image = LAZYACCESS(mob_to_image, climber_ref) + if(!pointing_image) + LAZYREMOVE(mob_to_image, climber_ref) // just in case + return + if(instant) + clear_climber_image_callback(climber_ref, pointing_image) + return + + animate(pointing_image, alpha = 0, time = 0.75 SECONDS, tag = "point_fadeout") + // note: the player won't see a new indicator until the image is fully a removed, so this timer also serves as a cooldown + addtimer(CALLBACK(src, PROC_REF(clear_climber_image_callback), climber_ref, pointing_image), 1.5 SECONDS, TIMER_UNIQUE) + +/obj/structure/stairs/proc/clear_climber_image_callback(datum/weakref/climber_ref, image/pointing_image) + PRIVATE_PROC(TRUE) + var/mob/living/climber = climber_ref?.resolve() + climber?.client?.images -= pointing_image + LAZYREMOVE(mob_to_image, climber_ref) + +/obj/structure/stairs/proc/get_pointing_image() + PROTECTED_PROC(TRUE) + var/image/point_image = image('icons/hud/screen_gen.dmi', src, "arrow_large_white_still") + point_image.color = COLOR_DARK_MODERATE_LIME_GREEN + point_image.appearance_flags |= KEEP_APART + point_image.transform = matrix().Turn(dir2angle(REVERSE_DIR(dir))) + point_image.layer = BELOW_MOB_LAYER + SET_PLANE(point_image, GAME_PLANE, src) + return point_image + +#undef POINT_X_COMPONENT +#undef POINT_Y_COMPONENT + /obj/structure/stairs/Cross(atom/movable/AM) if(isTerminator() && (get_dir(src, AM) == dir)) return FALSE return ..() /obj/structure/stairs/proc/stair_ascend(atom/movable/climber) - var/turf/checking = get_step_multiz(get_turf(src), UP) + var/turf/checking = get_step_multiz(src, UP) if(!istype(checking)) return // I'm only interested in if the pass is unobstructed, not if the mob will actually make it if(!climber.can_z_move(UP, get_turf(src), checking, z_move_flags = ZMOVE_ALLOW_BUCKLED)) return - var/turf/target = get_step_multiz(get_turf(src), (dir|UP)) + var/turf/target = get_step_multiz(src, dir|UP) if(istype(target) && !climber.can_z_move(DOWN, target, z_move_flags = ZMOVE_FALL_FLAGS)) //Don't throw them into a tile that will just dump them back down. climber.zMove(target = target, z_move_flags = ZMOVE_STAIRS_FLAGS) /// Moves anything that's being dragged by src or anything buckled to it to the stairs turf. @@ -113,22 +247,22 @@ if(var_name != NAMEOF(src, force_open_above)) return if(!var_value) - if(listeningTo) - UnregisterSignal(listeningTo, COMSIG_TURF_MULTIZ_NEW) - listeningTo = null + if(directly_above) + UnregisterSignal(directly_above, COMSIG_TURF_MULTIZ_NEW) + directly_above = null else build_signal_listener() force_open_above() /obj/structure/stairs/proc/build_signal_listener() - if(listeningTo) - UnregisterSignal(listeningTo, COMSIG_TURF_MULTIZ_NEW) - var/turf/open/openspace/T = get_step_multiz(get_turf(src), UP) + if(directly_above) + UnregisterSignal(directly_above, COMSIG_TURF_MULTIZ_NEW) + var/turf/open/openspace/T = get_step_multiz(src, UP) RegisterSignal(T, COMSIG_TURF_MULTIZ_NEW, PROC_REF(on_multiz_new)) - listeningTo = T + directly_above = T /obj/structure/stairs/proc/force_open_above() - var/turf/open/openspace/T = get_step_multiz(get_turf(src), UP) + var/turf/open/openspace/T = get_step_multiz(src, UP) if(T && !istype(T)) T.ChangeTurf(/turf/open/openspace, flags = CHANGETURF_INHERIT_AIR) @@ -136,7 +270,7 @@ SIGNAL_HANDLER if(dir == UP) - var/turf/open/openspace/T = get_step_multiz(get_turf(src), UP) + var/turf/open/openspace/T = get_step_multiz(src, UP) if(T && !istype(T)) T.ChangeTurf(/turf/open/openspace, flags = CHANGETURF_INHERIT_AIR) @@ -282,3 +416,5 @@ #undef STAIR_TERMINATOR_AUTOMATIC #undef STAIR_TERMINATOR_NO #undef STAIR_TERMINATOR_YES + +#undef STAIR_INDICATOR_RANGE diff --git a/code/modules/client/preferences/accessibility.dm b/code/modules/client/preferences/accessibility.dm index e7486719978..1cc413ffca5 100644 --- a/code/modules/client/preferences/accessibility.dm +++ b/code/modules/client/preferences/accessibility.dm @@ -18,3 +18,18 @@ default_value = FALSE savefile_key = "remove_double_click" savefile_identifier = PREFERENCE_PLAYER + +/// When toggled, enables staircase indicators +/datum/preference/toggle/stair_indicator + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + default_value = TRUE + savefile_key = "stair_indicator" + savefile_identifier = PREFERENCE_PLAYER + +/datum/preference/toggle/stair_indicator/apply_to_client_updated(client/client, value) + if(value || !isliving(client.mob)) // only hide, showing is more trouble than it's worth + return + + var/datum/weakref/climber_ref = WEAKREF(client.mob) + for(var/obj/structure/stairs/stair as anything in GLOB.stairs) + stair.clear_climber_image(climber_ref) diff --git a/icons/obj/stairs.dmi b/icons/obj/stairs.dmi index a59e8f1fc9f8c55b076172407e19673721224210..c541967a42519d21cc1d3594cdefb0ca76f390c9 100644 GIT binary patch delta 2753 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 aw+ugla<{7%49ufnL%^_M(n|LGa{dRPvhO|s delta 2126 zcmV-U2(kB|7P1jsiBL{Q4GJ0x0000DNk~Le0001>0001>2m=5B0K5^G9{>OVhfqvZ zMZmzof`NimR8-Z~)w{d9)YR0_(9kv*8oIi=($UgFAQ#fo(p@wkQY;;MQ!mrg)7IA3 zkvKCpIXO90S6sHf!ZR~Af{2fdl$l;H7>{B&z`(#j84ZF>C)MRo(f|Me0d!JMQvg8b z*k%9#0Em&uO@CJ6;!FWjAYELXX+?>-sUWr?y0ZB4{QMMR)fSiJ=cN*>I2Xt*%1q25 zRy|mXB*l2$uB_nd=K>Bt06jZl@dIXf^8f$`MM*?KRCt{2nhA5GAQ*;CV|wIhTD9Bz zOzmdJ{{P>%7y$*pg9vDMX20o31doUBg;#W%d@wO%;(z00Hc!)ZcG52wbvYB8(|S*o z^M3hp#zb<$L`ueaN&p_p*?F2?5P(Z25-vmH9yp(9IsncjkXbJ3Qv9W>1JF!@8Rf@v zO~x{rGeKuCr81}`1~R8JxVa=_lW+iw`9j2YyIrkTi>pH{wu`x_5cI2yQ(wT#Ycehq zPQnnAgnt60`L3_o404l*0Ng6Ttq&lVq1b@y4H;|JW;`Tz0%UE*&j-m_ymA?Xw(~Ffbi~226yiLm2 zO7Vs%D(ctG`NAWEPJmPTpcCLYJ_vvMSgB7R4IupKgZII6I)ffIbCIU!{x9xqhPc21 zPhBKteULlgY`NTsak5?_c?mL&C zQ4Q84i$HOJvajQU8!7=6`BG zx(}ibEcbu>dXO8U@r~LZ^~mHv0i&-6ZQrnbg$0bh9%M4e`1n)&^`NiB(U)5cAhVYE zzaISe*Ms&5u-_1(1L{$4r$*MZfU$j0`v&q}$pXgqL7hS5&D{2JyC2bgkOQsmJ;+3o6}R^ywZbqT>j$@3KsIGj+&qX5=DFBB$bRO+zViC{ z`PlA1Gx5U2^Xtpo`}_OLD^m6V3j{D12i@Nm8ZZ|!-3h{1Cf=C{4OrNKT!tnGd}CsA zKn{TGz~q3zCP`)iW`O{?N#qWg6@PMVCIRKX1k5bJ%yp841C$wSGnn5AhFtrf1l0SE zHWSbXO~66|DiEDPmC^BikOV9&z$tui7~qILI1G>j2mxy#=D|W&>+CLiMKH zHEe3LE3rN~NL->}W&>+CGN;hL8Z5Q>DzQGf0Pev2>uRputd-FN#QNj{2oGRg&9xuG z&{j4YfU-|LrN;s^v_$~{r9%KZN!-*i20V zMBUx4c!Quy z#~XuPZ5#L&(5N<*c7GUyM3|&-2LNrmM|97Ily(_|ZW_{n))Nl_1e6}{Nn(v$1hA*{ zM5`GTP?Al)nxO%wSZ) zQk(Az?UMuG9z07|bM0Vndw?EFS8%sNz|wmjfb zN_&n$KwBP=xb6VJZ%}2`j^EtcAcqD4q=o==S7bF43y_f*xNEzHF;+9f06lF69#~C| zrMh}PfJ(FBX*004?bJ}y1y$OC(9>ohZ98mogDUL=NPkv4j=`?B4SWk|R9i~30m-Yv zr2~Mr-6OhZgG#f3D`;v+16of!1Q1f%fiTRI#2UE>U|(rAqO4|6KuI?FYK8`!W;N3v zuo$3qiVGlF?KTF1i=lwzbwLtDV%^THX3Sdi2&)3PfCJzj(r@1alI>P=cSi!kjqMFs zK)?55OE2QeI>KnKhDnSZ*M3