Files
SmArtKarandGitHub 01f6864748 Refactors atom HUD positioning on mobs, fixes some inlay overlays on wide humans (#96682)
## About The Pull Request
This PR moves out hud offsets into a separate proc on atom level, which
lets mobs override it as to control their atom HUD positioning. This
fixes weird hud offsets on mobs who are located in files larger than
their own icon, or have a very offset but small/detached detail on their
sprite (blood-drunk miner, raptors, megacarps)
This also lets humans adjust their HUDs based on their height, which
should prevent hud clipping for spacers.
<img width="93" height="88" alt="dreamseeker_iBapCH9y5a"
src="https://github.com/user-attachments/assets/f429d4d9-6e1f-451e-9908-54c8a1373f1a"
/>

Main reason behind this change is to allow humans to override their
sprite width/height according to their bodypart overlays, which fixes
immerse and brimdust sac inlaid overlays being shorter/thinner than
expected.

Before & after:

<img width="172" height="188" alt="dreamseeker_5l78R30uLI"
src="https://github.com/user-attachments/assets/0b62ac27-71e6-4b3b-92d0-cf3e73eb458d"
/>
<img width="222" height="189" alt="dreamseeker_9hX6FXLvK9"
src="https://github.com/user-attachments/assets/ab36bc36-abd5-4bdf-965d-ee3951ce9b11"
/>

These values are cached and only updated on ``update_body_parts`` as to
save on a bit of performance.

## Why It's Good For The Game

Fixes jank visuals

## Changelog
🆑
fix: Brimdust sac and fluid immersion overlays no longer look weird on
humans with extra-wide or tall bodyparts or organs
fix: Some mobs now have more sensible health HUD positions
fix: Health and security HUD now scales with player height, no more huds
clipping into spacer hair
/🆑
2026-07-02 09:56:14 +02:00

325 lines
15 KiB
Plaintext

/// A list of movables that shouldn't be affected by the element, either because it'd look bad or barely perceptible
GLOBAL_LIST_INIT(immerse_ignored_movable, typecacheof(list(
/obj/effect,
/mob/dead,
/obj/projectile,
)))
/// A visual element that makes movables entering the attached turfs look immersed into that turf.
/// May the gods forgive me for the bullshit you're about to witness
/datum/element/immerse
element_flags = ELEMENT_DETACH_ON_HOST_DESTROY | ELEMENT_BESPOKE
argument_hash_start_idx = 2
/// An association list of turfs that have this element attached and their affected contents.
var/list/attached_turf_contents = list()
/// A list of generated immersion masks based on object width, height and whever they're fully immersed underwater
var/list/immersion_masks = list()
/// An assoc list of instances of /atom/movable/immerse_mask used as abstract effect relays, because god is dead
var/list/generated_visual_overlays = list()
/// icon_state used as a mask by our turf
var/mask_icon = "immerse"
/// Alpha of the mask, to make the liquid partially transparent
var/alpha = 180
/datum/element/immerse/Attach(turf/target, mask_icon = "immerse", alpha = 180)
. = ..()
if(!isturf(target) || !mask_icon)
return ELEMENT_INCOMPATIBLE
src.mask_icon = mask_icon
src.alpha = alpha
RegisterSignal(target, SIGNAL_ADDTRAIT(TRAIT_IMMERSE_STOPPED), PROC_REF(stop_immersion))
RegisterSignal(target, SIGNAL_REMOVETRAIT(TRAIT_IMMERSE_STOPPED), PROC_REF(start_immersion))
if(!HAS_TRAIT(target, TRAIT_IMMERSE_STOPPED))
start_immersion(target)
/datum/element/immerse/Detach(turf/source)
UnregisterSignal(source, list(SIGNAL_ADDTRAIT(TRAIT_IMMERSE_STOPPED), SIGNAL_REMOVETRAIT(TRAIT_IMMERSE_STOPPED)))
if(!HAS_TRAIT(source, TRAIT_IMMERSE_STOPPED))
stop_immersion(source)
return ..()
/// Makes the element start affecting the turf and its contents. Called on Attach() or when TRAIT_IMMERSE_STOPPED is removed.
/datum/element/immerse/proc/start_immersion(turf/source)
SIGNAL_HANDLER
RegisterSignals(source, list(COMSIG_ATOM_ABSTRACT_ENTERED, COMSIG_ATOM_AFTER_SUCCESSFUL_INITIALIZED_ON), PROC_REF(on_init_or_entered))
RegisterSignal(source, COMSIG_ATOM_ABSTRACT_EXITED, PROC_REF(on_atom_exited))
attached_turf_contents += source
for(var/atom/movable/movable as anything in source)
if(!(movable.flags_1 & INITIALIZED_1) || movable.invisibility >= INVISIBILITY_OBSERVER)
continue
on_init_or_entered(source, movable)
/// Stops the element from affecting on the turf and its contents. Called on Detach() or when TRAIT_IMMERSE_STOPPED is added.
/datum/element/immerse/proc/stop_immersion(turf/source)
SIGNAL_HANDLER
UnregisterSignal(source, list(COMSIG_ATOM_ABSTRACT_ENTERED, COMSIG_ATOM_AFTER_SUCCESSFUL_INITIALIZED_ON, COMSIG_ATOM_ABSTRACT_EXITED))
for(var/atom/movable/movable as anything in attached_turf_contents[source])
remove_from_element(source, movable)
attached_turf_contents -= source
/**
* If the movable is within the right layers and planes, not in the list of movable types to ignore,
* or already affected by the element for that matter, signals will be registered and,
* unless the movable is flying, it'll appear as if immersed in that water.
*/
/datum/element/immerse/proc/on_init_or_entered(turf/source, atom/movable/movable)
SIGNAL_HANDLER
if(QDELETED(movable))
return
if(HAS_TRAIT(movable, TRAIT_IMMERSED) || HAS_TRAIT(movable, TRAIT_WALLMOUNTED))
return
if(!ISINRANGE(PLANE_TO_TRUE(movable.plane), FLOOR_PLANE, GAME_PLANE))
return
// First, floor plane objects use TOPDOWN_LAYER, second this check shouldn't apply to them anyway.
var/layer_to_check = IS_TOPDOWN_PLANE(source.plane) ? TOPDOWN_ABOVE_WATER_LAYER : ABOVE_ALL_MOB_LAYER
if(movable.layer >= layer_to_check)
return
if(is_type_in_typecache(movable, GLOB.immerse_ignored_movable))
return
var/atom/movable/buckled = null
if(isliving(movable))
var/mob/living/living_mob = movable
buckled = living_mob.buckled
RegisterSignal(living_mob, COMSIG_LIVING_SET_BUCKLED, PROC_REF(on_set_buckled))
RegisterSignal(living_mob, COMSIG_LIVING_UPDATE_OFFSETS, PROC_REF(on_update_offsets))
RegisterSignal(movable, COMSIG_LIVING_POST_UPDATE_TRANSFORM, PROC_REF(on_update_transform))
RegisterSignal(movable, COMSIG_ATOM_SPIN_ANIMATION, PROC_REF(on_spin_animation))
RegisterSignal(movable, COMSIG_QDELETING, PROC_REF(on_movable_qdel))
try_immerse(movable, buckled)
LAZYADD(attached_turf_contents[source], movable)
ADD_TRAIT(movable, TRAIT_IMMERSED, ELEMENT_TRAIT(src))
/datum/element/immerse/proc/on_movable_qdel(atom/movable/source)
SIGNAL_HANDLER
remove_from_element(source.loc, source)
/**
* Called by init_or_entered() and on_set_buckled().
* This applies the overlay if neither the movable or whatever is buckled to (exclusive to living mobs) are flying
* as well as movetype signals when the movable isn't buckled.
*/
/datum/element/immerse/proc/try_immerse(atom/movable/movable, atom/movable/buckled)
var/atom/movable/to_check = buckled || movable
if(!(to_check.movement_type & MOVETYPES_NOT_TOUCHING_GROUND) && !movable.throwing)
add_immerse_overlay(movable)
if(buckled)
return
RegisterSignal(movable, COMSIG_MOVETYPE_FLAG_ENABLED, PROC_REF(on_move_flag_enabled))
RegisterSignal(movable, COMSIG_MOVETYPE_FLAG_DISABLED, PROC_REF(on_move_flag_disabled))
RegisterSignal(movable, COMSIG_MOVABLE_POST_THROW, PROC_REF(on_throw))
RegisterSignal(movable, COMSIG_MOVABLE_THROW_LANDED, PROC_REF(on_throw_landed))
/// Called by on_set_buckled() and remove_from_element().
/// This removes the filter and signals from the movable unless it doesn't have them.
/datum/element/immerse/proc/try_unimmerse(atom/movable/movable, atom/movable/buckled)
var/atom/movable/to_check = buckled || movable
if(!(to_check.movement_type & MOVETYPES_NOT_TOUCHING_GROUND) && !movable.throwing)
remove_immerse_overlay(movable)
if(buckled)
return
UnregisterSignal(movable, list(
COMSIG_MOVETYPE_FLAG_ENABLED,
COMSIG_MOVETYPE_FLAG_DISABLED,
COMSIG_MOVABLE_POST_THROW,
COMSIG_MOVABLE_THROW_LANDED
))
/datum/element/immerse/proc/on_set_buckled(mob/living/source, atom/movable/new_buckled)
SIGNAL_HANDLER
try_unimmerse(source, source.buckled)
try_immerse(source, new_buckled)
/// Removes the overlay from mob and bucklees is flying.
/datum/element/immerse/proc/on_move_flag_enabled(atom/movable/source, flag, old_movement_type)
SIGNAL_HANDLER
if(!(flag & MOVETYPES_NOT_TOUCHING_GROUND) || (old_movement_type & MOVETYPES_NOT_TOUCHING_GROUND) || source.throwing)
return
remove_immerse_overlay(source)
for(var/mob/living/buckled_mob as anything in source.buckled_mobs)
remove_immerse_overlay(buckled_mob)
/// Works just like on_move_flag_enabled, except it only has to check that movable isn't flying
/datum/element/immerse/proc/on_throw(atom/movable/source)
SIGNAL_HANDLER
if(source.movement_type & MOVETYPES_NOT_TOUCHING_GROUND)
return
remove_immerse_overlay(source)
for(var/mob/living/buckled_mob as anything in source.buckled_mobs)
remove_immerse_overlay(buckled_mob)
/// Readds the overlay to the mob and bucklees if no longer flying.
/datum/element/immerse/proc/on_move_flag_disabled(atom/movable/source, flag, old_movement_type)
SIGNAL_HANDLER
if(!(flag & MOVETYPES_NOT_TOUCHING_GROUND) || (source.movement_type & MOVETYPES_NOT_TOUCHING_GROUND) || source.throwing)
return
add_immerse_overlay(source)
for(var/mob/living/buckled_mob as anything in source.buckled_mobs)
add_immerse_overlay(buckled_mob)
/// Works just like on_move_flag_disabled, except it only has to check that movable isn't flying
/datum/element/immerse/proc/on_throw_landed(atom/movable/source)
SIGNAL_HANDLER
if(source.movement_type & MOVETYPES_NOT_TOUCHING_GROUND)
return
add_immerse_overlay(source)
for(var/mob/living/buckled_mob as anything in source.buckled_mobs)
add_immerse_overlay(buckled_mob)
/// Called when a movable exits the turf. If its new location is not in the list of turfs with this element,
/// remove the movable from the element.
/datum/element/immerse/proc/on_atom_exited(turf/source, atom/movable/exited, direction)
SIGNAL_HANDLER
if(!attached_turf_contents[exited.loc])
remove_from_element(source, exited)
return
LAZYREMOVE(attached_turf_contents[source], exited)
LAZYADD(attached_turf_contents[exited.loc], exited)
//// Remove any signal, overlay, trait given to the movable and reference to it within the element.
/datum/element/immerse/proc/remove_from_element(turf/source, atom/movable/movable)
var/atom/movable/buckled = null
if(isliving(movable))
var/mob/living/living_mob = movable
buckled = living_mob.buckled
try_unimmerse(movable, buckled)
LAZYREMOVE(attached_turf_contents[source], movable)
UnregisterSignal(movable, list(COMSIG_LIVING_SET_BUCKLED, COMSIG_QDELETING, COMSIG_LIVING_UPDATE_OFFSETS, COMSIG_ATOM_SPIN_ANIMATION, COMSIG_LIVING_POST_UPDATE_TRANSFORM))
REMOVE_TRAIT(movable, TRAIT_IMMERSED, ELEMENT_TRAIT(src))
/// Generate a mask filter mutable to use as render_source for the alpha filter based on provided width, height and immersion state
/datum/element/immerse/proc/generate_immerse_mask(width, height, is_below_water)
if (!width || !height)
return
var/clean_height = height
width = ceil(width / ICON_SIZE_X) * ICON_SIZE_X
height = ceil(height / ICON_SIZE_Y) * ICON_SIZE_Y
var/mask_key = "[width]-[height]-[is_below_water]"
var/mutable_appearance/target_mask = immersion_masks[mask_key]
if (target_mask)
return target_mask
if (width == ICON_SIZE_X && height == ICON_SIZE_Y)
target_mask = mutable_appearance('icons/effects/effects.dmi', mask_icon, alpha = alpha)
immersion_masks[mask_key] = target_mask
return target_mask
var/icon/column_icon = icon('icons/effects/effects.dmi', mask_icon)
var/y_tiles = 1
if (height != ICON_SIZE_Y)
column_icon.Crop(1, 1, ICON_SIZE_X, ICON_SIZE_Y) // Use base icon and crop it out so animation frames respect dmi's delays
y_tiles = ceil((height / ICON_SIZE_Y - 1) / 2) + 1
column_icon.Scale(ICON_SIZE_X, y_tiles * ICON_SIZE_Y)
var/icon/effect_icon = icon('icons/effects/effects.dmi', mask_icon)
var/icon/fill_icon = icon('icons/effects/alphacolors.dmi', "white")
for (var/y_tile in 1 to y_tiles - 1)
column_icon.Blend(fill_icon, ICON_OVERLAY, 1, 1 + (y_tile - 1) * ICON_SIZE_Y)
column_icon.Blend(effect_icon, ICON_OVERLAY, 1, 1 + (y_tiles - 1) * ICON_SIZE_Y)
var/icon/immerse_icon = null
if (width == ICON_SIZE_X)
immerse_icon = column_icon
else
immerse_icon = icon('icons/effects/effects.dmi', mask_icon) // Use base icon and crop it out so animation frames respect dmi's delays
immerse_icon.Crop(1, 1, ICON_SIZE_X, ICON_SIZE_Y)
immerse_icon.Scale(ceil(width / ICON_SIZE_X) * ICON_SIZE_X, ceil(height / ICON_SIZE_Y) * ICON_SIZE_Y)
for (var/x_tile in 1 to ceil(width / ICON_SIZE_X))
immerse_icon.Blend(column_icon, ICON_OVERLAY, 1 + (x_tile - 1) * ICON_SIZE_X, 1)
target_mask = mutable_appearance(immerse_icon)
target_mask.alpha = alpha
target_mask.pixel_y = -(y_tiles - 1) * ICON_SIZE_Y + floor((clean_height - ICON_SIZE_Y) / 2)
immersion_masks[mask_key] = target_mask
return target_mask
/datum/element/immerse/proc/add_immerse_overlay(atom/movable/movable)
// This determines if the overlay should cover the entire surface of the object or not
var/layer_to_check = IS_TOPDOWN_PLANE(movable.plane) ? TOPDOWN_WATER_LEVEL_LAYER : WATER_LEVEL_LAYER
var/is_below_water = (movable.layer < layer_to_check) ? "underwater-" : ""
var/x_offset = 0
var/y_offset = 0
var/movable_width = movable.get_cached_width()
if (ishuman(movable))
var/mob/living/carbon/human/as_human = movable
if (as_human.cached_body_min_x_offset && movable_width > ICON_SIZE_X)
x_offset = as_human.cached_body_min_x_offset
if (as_human.cached_body_min_y_offset && movable.get_cached_height() > ICON_SIZE_Y)
y_offset = as_human.cached_body_min_y_offset
// Tall mobs still only get covered to their feet, unless they're offset down
var/mutable_appearance/immerse_mask = generate_immerse_mask(movable_width, max(ICON_SIZE_Y - movable.pixel_z - y_offset, ICON_SIZE_Y), is_below_water)
if (!immerse_mask)
return
var/atom/movable/immerse_mask/effect_relay = generated_visual_overlays[movable]
if (!effect_relay)
effect_relay = new(movable)
movable.vis_contents += effect_relay
generated_visual_overlays[movable] = effect_relay
var/mutable_appearance/mask_copy = new(immerse_mask)
effect_relay.appearance = mask_copy
effect_relay.pixel_w += x_offset
effect_relay.pixel_z += y_offset
effect_relay.render_target = "*immerse_[REF(movable)]"
SEND_SIGNAL(movable, COMSIG_MOVABLE_EDIT_UNIQUE_IMMERSE_OVERLAY, effect_relay)
// Should always render above any other filters that could be adding visuals
movable.add_filter("immerse_mask", INFINITY, alpha_mask_filter(y = -floor((movable.get_cached_height() - ICON_SIZE_Y) / 2) - movable.pixel_z, render_source = effect_relay.render_target, flags = MASK_INVERSE))
/datum/element/immerse/proc/remove_immerse_overlay(atom/movable/movable, deleting = TRUE)
movable.remove_filter("immerse_mask")
if (!deleting)
return
var/atom/movable/immerse_mask/mask = generated_visual_overlays[movable]
movable.vis_contents -= mask
generated_visual_overlays -= movable
QDEL_NULL(mask)
/// A band-aid to keep the (unique) visual overlay from scaling and rotating along with its owner. I'm sorry.
/datum/element/immerse/proc/on_update_transform(mob/living/source, resize, new_lying_angle, is_opposite_angle)
SIGNAL_HANDLER
var/atom/movable/immerse_mask/effect_relay = generated_visual_overlays[source]
if (!effect_relay)
return
var/matrix/new_transform = matrix()
new_transform.Scale(1 / source.current_size)
new_transform.Turn(-new_lying_angle)
var/mutable_appearance/relay_appearance = new(effect_relay.appearance)
relay_appearance.transform = new_transform
effect_relay.appearance = relay_appearance
/// Spin the overlay in the opposite direction so it doesn't look like it's spinning at all.
/datum/element/immerse/proc/on_spin_animation(atom/source, speed, loops, segments, segment)
SIGNAL_HANDLER
var/atom/movable/immerse_mask/immerse_mask = generated_visual_overlays[source]
if (immerse_mask)
immerse_mask.do_spin_animation(speed, loops, segments, -segment)
/datum/element/immerse/proc/on_update_offsets(mob/living/source, new_x, new_y, new_w, new_z, animate)
SIGNAL_HANDLER
if (!generated_visual_overlays[source])
return
var/old_height = ceil(max(ICON_SIZE_Y - source.pixel_z, ICON_SIZE_Y) / ICON_SIZE_Y)
var/new_height = ceil(max(ICON_SIZE_Y - new_z, ICON_SIZE_Y) / ICON_SIZE_Y)
if (old_height != new_height)
remove_immerse_overlay(source, FALSE)
add_immerse_overlay(source)
if (source.pixel_z == new_z)
return
if (animate)
source.transition_filter("immerse_mask", list("y" = -floor((source.get_cached_height() - ICON_SIZE_Y) / 2) - new_z), time = UPDATE_TRANSFORM_ANIMATION_TIME)
else
source.modify_filter("immerse_mask", list("y" = -floor((source.get_cached_height() - ICON_SIZE_Y) / 2) - new_z))
/atom/movable/immerse_mask
appearance_flags = RESET_TRANSFORM|RESET_COLOR|RESET_ALPHA|KEEP_APART
vis_flags = VIS_HIDE