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

🆑 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.
/🆑
This commit is contained in:
MrMelbert
2025-11-30 17:33:08 -07:00
committed by GitHub
parent 11e38ff10e
commit 6d20c4f20d
4 changed files with 188 additions and 27 deletions
+163 -27
View File
@@ -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