diff --git a/code/__DEFINES/dcs/signals/signals_leash.dm b/code/__DEFINES/dcs/signals/signals_leash.dm new file mode 100644 index 00000000000..4f83d790903 --- /dev/null +++ b/code/__DEFINES/dcs/signals/signals_leash.dm @@ -0,0 +1,11 @@ +/// Called when a /datum/component/leash must forcibly teleport the parent to the owner. +/// Fired on the object with the leash component. +#define COMSIG_LEASH_FORCE_TELEPORT "leash_force_teleport" + +/// Called when a /datum/component/leash plans on pathfinding to the target, if out of range. +/// Fired on the object with the leash component. +#define COMSIG_LEASH_PATH_STARTED "leash_path_started" + +/// Called when a /datum/component/leash finishes its pathfinding to the target. +/// Fired on the object with the leash component. +#define COMSIG_LEASH_PATH_COMPLETE "leash_path_complete" diff --git a/code/__DEFINES/pai.dm b/code/__DEFINES/pai.dm index ed616a357a6..356cbc28843 100644 --- a/code/__DEFINES/pai.dm +++ b/code/__DEFINES/pai.dm @@ -15,6 +15,8 @@ #define HOLOFORM_MAX_RANGE 25 // SKYRAT EDIT CHANGE - ORIGINAL: #define HOLOFORM_MAX_RANGE 9 /// Minimum distance you can set the holoform leash #define HOLOFORM_MIN_RANGE 3 +/// Default holoform leash distance +#define HOLOFORM_DEFAULT_RANGE HOLOFORM_MAX_RANGE /// UI action to toggle huds #define PAI_TOGGLE_MEDICAL_HUD 0 diff --git a/code/datums/components/leash.dm b/code/datums/components/leash.dm new file mode 100644 index 00000000000..ac8bc271cfe --- /dev/null +++ b/code/datums/components/leash.dm @@ -0,0 +1,180 @@ +/// Keeps the parent within the distance of its owner as naturally as possible, +/// but teleporting if necessary. +/datum/component/leash + /// The owner of the leash. If this is qdeleted, the leash is as well. + var/atom/movable/owner + + /// The maximum distance you can move from your owner + var/distance + + /// The object type to create on the old turf when forcibly teleporting out + var/force_teleport_out_effect + + /// The object type to create on the new turf when forcibly teleporting out + var/force_teleport_in_effect + + VAR_PRIVATE + // Pathfinding can yield, so only move us closer if this is the best one + current_path_tick = 0 + last_completed_path_tick = 0 + + performing_path_move = FALSE + +/datum/component/leash/Initialize( + atom/movable/owner, + distance = 3, + force_teleport_out_effect, + force_teleport_in_effect, +) + . = ..() + + if (!ismovable(parent)) + stack_trace("Parent must be a movable") + return COMPONENT_INCOMPATIBLE + + if (!ismovable(owner)) + stack_trace("[owner] (owner) is not a movable") + return COMPONENT_INCOMPATIBLE + + if (!isnum(distance)) + stack_trace("[distance] (distance) must be a number") + return COMPONENT_INCOMPATIBLE + + if (!isnull(force_teleport_out_effect) && !ispath(force_teleport_out_effect)) + stack_trace("force_teleport_out_effect must be null or a path, not [force_teleport_out_effect]") + return COMPONENT_INCOMPATIBLE + + if (!isnull(force_teleport_in_effect) && !ispath(force_teleport_in_effect)) + stack_trace("force_teleport_in_effect must be null or a path, not [force_teleport_in_effect]") + return COMPONENT_INCOMPATIBLE + + src.owner = owner + src.distance = distance + src.force_teleport_out_effect = force_teleport_out_effect + src.force_teleport_in_effect = force_teleport_in_effect + + RegisterSignal(owner, COMSIG_QDELETING, PROC_REF(on_owner_qdel)) + + var/static/list/container_connections = list( + COMSIG_MOVABLE_MOVED = PROC_REF(on_owner_moved), + ) + + AddComponent(/datum/component/connect_containers, owner, container_connections) + RegisterSignal(owner, COMSIG_MOVABLE_MOVED, PROC_REF(on_owner_moved)) + RegisterSignal(parent, COMSIG_MOVABLE_PRE_MOVE, PROC_REF(on_parent_pre_move)) + + check_distance() + +/datum/component/leash/Destroy() + owner = null + return ..() + +/datum/component/leash/proc/set_distance(distance) + ASSERT(isnum(distance)) + src.distance = distance + check_distance() + +/datum/component/leash/proc/on_owner_qdel() + SIGNAL_HANDLER + PRIVATE_PROC(TRUE) + + qdel(src) + +/datum/component/leash/proc/on_owner_moved(atom/movable/source) + SIGNAL_HANDLER + PRIVATE_PROC(TRUE) + + check_distance() + +/datum/component/leash/proc/on_parent_pre_move(atom/movable/source, atom/new_location) + SIGNAL_HANDLER + PRIVATE_PROC(TRUE) + + if (performing_path_move) + return NONE + + var/turf/new_location_turf = get_turf(new_location) + if (get_dist(new_location_turf, owner) <= distance) + return NONE + + if (ismob(source)) + source.balloon_alert(source, "too far!") + + return COMPONENT_MOVABLE_BLOCK_PRE_MOVE + +/datum/component/leash/proc/check_distance() + set waitfor = FALSE + PRIVATE_PROC(TRUE) + + if (get_dist(parent, owner) <= distance) + return + + SEND_SIGNAL(parent, COMSIG_LEASH_PATH_STARTED) + + current_path_tick += 1 + var/our_path_tick = current_path_tick + + var/list/path = get_path_to(parent, owner, mintargetdist = distance) + + if (last_completed_path_tick > our_path_tick) + return + + last_completed_path_tick = our_path_tick + + commit_path(path) + +/datum/component/leash/proc/commit_path(list/turf/path) + SHOULD_NOT_SLEEP(TRUE) + PRIVATE_PROC(TRUE) + + performing_path_move = TRUE + + var/atom/movable/movable_parent = parent + + for (var/turf/to_move as anything in path) + // Could be an older path, don't make us teleport back + if (!to_move.Adjacent(parent)) + continue + + if (!movable_parent.Move(to_move)) + force_teleport_back("bad path step") + return + + if (get_dist(parent, owner) > distance) + force_teleport_back("incomplete path") + + performing_path_move = FALSE + SEND_SIGNAL(parent, COMSIG_LEASH_PATH_COMPLETE) + +/datum/component/leash/proc/force_teleport_back(reason) + PRIVATE_PROC(TRUE) + + var/atom/movable/movable_parent = parent + + SSblackbox.record_feedback("tally", "leash_force_teleport_back", 1, reason) + + if (force_teleport_out_effect) + new force_teleport_out_effect(movable_parent.loc) + + movable_parent.forceMove(get_turf(owner)) + + if (force_teleport_in_effect) + new force_teleport_in_effect(movable_parent.loc) + + if (ismob(movable_parent)) + movable_parent.balloon_alert(movable_parent, "moved out of range!") + + SEND_SIGNAL(parent, COMSIG_LEASH_FORCE_TELEPORT) + +/// A debug spawner that will create a corgi leashed to a bike horn, plus a beam +/obj/effect/spawner/debug_leash + +/obj/effect/spawner/debug_leash/Initialize(mapload) + . = ..() + + var/obj/item/bikehorn/bike_horn = new(loc) + var/mob/living/basic/pet/dog/corgi/corgi = new(loc) + + corgi.AddComponent(/datum/component/leash, bike_horn) + + corgi.Beam(bike_horn) diff --git a/code/modules/pai/card.dm b/code/modules/pai/card.dm index ec810f9d924..cc6f22e1ec5 100644 --- a/code/modules/pai/card.dm +++ b/code/modules/pai/card.dm @@ -67,20 +67,9 @@ /obj/item/pai_card/Initialize(mapload) . = ..() - var/static/list/containers_connections = list(COMSIG_MOVABLE_MOVED = PROC_REF(card_moved)) - AddComponent(/datum/component/connect_containers, tracked = src, connections = containers_connections) update_appearance() SSpai.pai_card_list += src -/obj/item/pai_card/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change) - . = ..() - card_moved() - -/// Called when we, our loc, or our loc's loc, or our loc's loc's loc, or etc has moved -/obj/item/pai_card/proc/card_moved() - SIGNAL_HANDLER - pai?.check_distance() - /obj/item/pai_card/suicide_act(mob/living/user) user.visible_message(span_suicide("[user] is staring sadly at [src]! [user.p_They()] can't keep living without real human intimacy!")) return OXYLOSS @@ -128,7 +117,7 @@ name = pai.name, transmit = pai.can_transmit, receive = pai.can_receive, - range = pai.leashed_distance, + range = pai.leash.distance, ) return data diff --git a/code/modules/pai/pai.dm b/code/modules/pai/pai.dm index 04f673ee9f9..5ae643db31f 100644 --- a/code/modules/pai/pai.dm +++ b/code/modules/pai/pai.dm @@ -37,8 +37,6 @@ var/can_transmit = TRUE /// The card we inhabit var/obj/item/pai_card/card - /// The maximum distance we can travel away from our pai card - var/leashed_distance = 5 /// The current chasis that will appear when in holoform var/chassis = "repairbot" /// Toggles whether the pAI can hold encryption keys or not @@ -67,6 +65,8 @@ var/ram = 100 /// Toggles whether the Security HUD is active or not var/secHUD = FALSE + /// The current leash to the owner + var/datum/component/leash/leash // Onboard Items /// Atmospheric analyzer @@ -221,6 +221,7 @@ pai_card.set_personality(src) card = pai_card forceMove(pai_card) + leash = AddComponent(/datum/component/leash, pai_card, HOLOFORM_DEFAULT_RANGE, force_teleport_out_effect = /obj/effect/temp_visual/guardian/phase/out) addtimer(VARSET_WEAK_CALLBACK(src, holochassis_ready, TRUE), HOLOCHASSIS_INIT_TIME) if(!holoform) add_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED), PAI_FOLDED) @@ -228,19 +229,6 @@ RegisterSignal(src, COMSIG_LIVING_CULT_SACRIFICED, PROC_REF(on_cult_sacrificed)) -/mob/living/silicon/pai/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change) - . = ..() - check_distance() - -/// Checks if we're in range of our pai card -/mob/living/silicon/pai/proc/check_distance() - SIGNAL_HANDLER - if (get_dist(get_turf(card), get_turf(src)) <= leashed_distance) - return - to_chat(src, span_warning("You moved out of range of your holotransmitter!")) - new /obj/effect/temp_visual/guardian/phase/out(loc) - forceMove(get_turf(card)) - /mob/living/silicon/pai/make_laws() laws = new /datum/ai_laws/pai() return TRUE @@ -453,9 +441,7 @@ /// Updates the distance we can be from our pai card /mob/living/silicon/pai/proc/increment_range(increment_amount) - var/new_distance = leashed_distance + increment_amount + var/new_distance = leash.distance + increment_amount if (new_distance < HOLOFORM_MIN_RANGE || new_distance > HOLOFORM_MAX_RANGE) return - leashed_distance = new_distance - if (increment_amount < 0) - check_distance() + leash.set_distance(new_distance) diff --git a/code/modules/unit_tests/_unit_tests.dm b/code/modules/unit_tests/_unit_tests.dm index a41e31f8043..508a37aa86b 100644 --- a/code/modules/unit_tests/_unit_tests.dm +++ b/code/modules/unit_tests/_unit_tests.dm @@ -156,6 +156,7 @@ #include "keybinding_init.dm" #include "knockoff_component.dm" #include "language_transfer.dm" +#include "leash.dm" #include "lesserform.dm" #include "limbsanity.dm" #include "liver.dm" diff --git a/code/modules/unit_tests/leash.dm b/code/modules/unit_tests/leash.dm new file mode 100644 index 00000000000..2372ebca7df --- /dev/null +++ b/code/modules/unit_tests/leash.dm @@ -0,0 +1,102 @@ +/datum/unit_test/leash + abstract_type = /datum/unit_test/leash + + var/atom/movable/owner + var/atom/movable/pet + + var/max_distance = 3 + + var/forcibly_teleported = FALSE + var/datum/leash_wait/leash_wait + +/datum/unit_test/leash/New() + . = ..() + + owner = allocate(/obj/item/pen) + pet = allocate(/obj/item/pen) + + pet.AddComponent(/datum/component/leash, owner, max_distance) + + RegisterSignal(pet, COMSIG_LEASH_FORCE_TELEPORT, PROC_REF(on_leash_force_teleport)) + RegisterSignal(pet, COMSIG_LEASH_PATH_STARTED, PROC_REF(on_leash_path_started)) + RegisterSignal(pet, COMSIG_LEASH_PATH_COMPLETE, PROC_REF(on_leash_path_complete)) + +/datum/unit_test/leash/Destroy() + QDEL_NULL(owner) + QDEL_NULL(pet) + + return ..() + +/datum/unit_test/leash/proc/on_leash_force_teleport() + SIGNAL_HANDLER + forcibly_teleported = TRUE + +/datum/unit_test/leash/proc/on_leash_path_complete() + SIGNAL_HANDLER + leash_wait?.completed() + +/datum/unit_test/leash/proc/on_leash_path_started() + SIGNAL_HANDLER + leash_wait?.started() + +/datum/unit_test/leash/proc/move_away(atom/movable/mover, distance) + RETURN_TYPE(/datum/leash_wait) + leash_wait = new + + for (var/_ in 1 to distance) + mover.Move(get_step(mover, EAST)) + + return leash_wait + +/datum/leash_wait + var/completed = FALSE + var/started = FALSE + + var/timed_out = FALSE + +/datum/leash_wait/New() + addtimer(VARSET_CALLBACK(src, timed_out, TRUE), 1 SECONDS) + +/datum/leash_wait/proc/completed() + completed = TRUE + +/datum/leash_wait/proc/started() + started = TRUE + +/datum/leash_wait/proc/assert_unmoved() + ASSERT(!started, "Leash started to move when it should not have") + +/datum/leash_wait/proc/wait() + ASSERT(started, "Leash doesn't plan on moving") + + UNTIL(completed || timed_out) + ASSERT(!timed_out, "Waiting for leash movement timed out, it didn't want to move") + +/// Validates the leash component will keep its parent within range without teleporting +/// when possible. +/datum/unit_test/leash/no_teleport + +/datum/unit_test/leash/no_teleport/Run() + move_away(owner, 1).assert_unmoved() + TEST_ASSERT_EQUAL(get_dist(owner, pet), 1, "Pet should not have moved") + + move_away(owner, max_distance).wait() // max_distance + 1 = we move closer, but don't teleport + TEST_ASSERT_EQUAL(get_dist(owner, pet), max_distance, "Pet should have stayed directly outside range of owner") + + TEST_ASSERT(!forcibly_teleported, "Pet should not have been forcibly teleported") + +/// Validates that the leash component will forcibly teleport when necessary +/datum/unit_test/leash/will_teleport + +/datum/unit_test/leash/will_teleport/Run() + leash_wait = new + owner.forceMove(locate(1, 1, 1)) + leash_wait.wait() + TEST_ASSERT(forcibly_teleported, "Pet should have been forcibly teleported, since they are too far away with no valid path") + +/// Validates that the leashed object cannot move outside of the max distance from owner +/datum/unit_test/leash/limit_range + +/datum/unit_test/leash/limit_range/Run() + move_away(pet, max_distance + 1) + TEST_ASSERT_EQUAL(get_dist(owner, pet), max_distance, "Pet should not have moved farther than max_distance") diff --git a/modular_skyrat/master_files/code/datums/components/leash.dm b/modular_skyrat/master_files/code/datums/components/leash.dm new file mode 100644 index 00000000000..8cbd4985e73 --- /dev/null +++ b/modular_skyrat/master_files/code/datums/components/leash.dm @@ -0,0 +1,30 @@ +/datum/component/leash + /// whether the leash is enabled or not + var/enabled = TRUE + +/// Stop listening for any signals +/datum/component/leash/proc/disable_leash() + if(!enabled) + return + + enabled = FALSE + UnregisterSignal(owner, COMSIG_MOVABLE_MOVED) + UnregisterSignal(parent, COMSIG_MOVABLE_PRE_MOVE) + +/// Start listening for signals again +/datum/component/leash/proc/enable_leash() + if(enabled) + return + + enabled = TRUE + check_distance() // yoink them back + RegisterSignal(owner, COMSIG_MOVABLE_MOVED, PROC_REF(on_owner_moved)) + RegisterSignal(parent, COMSIG_MOVABLE_PRE_MOVE, PROC_REF(on_parent_pre_move)) + +/// Enables or disables the leash, allowing or forbidding the PAI from leaving a specified range +/datum/component/leash/proc/toggle_leash() + to_chat(owner, span_warning("Your virtual leash has been [enabled ? "activated" : "deactivated"]!")) + if(enabled) + disable_leash() + else + enable_leash() diff --git a/modular_skyrat/master_files/code/modules/pai/card.dm b/modular_skyrat/master_files/code/modules/pai/card.dm index b804649d343..fff159f31df 100644 --- a/modular_skyrat/master_files/code/modules/pai/card.dm +++ b/modular_skyrat/master_files/code/modules/pai/card.dm @@ -1,9 +1,17 @@ +/obj/item/pai_card/download_candidate(mob/user, ckey) + . = ..() + + if(!.) + return + + pai.leash.disable_leash() // leash starts off disabled by default + /obj/item/pai_card/ui_data(mob/user) . = ..() if(!pai) return - .["pai"]["leash_enabled"] = pai.leashed + .["pai"]["leash_enabled"] = pai.leash.enabled /obj/item/pai_card/ui_act(action, list/params, datum/tgui/ui) . = ..() @@ -11,7 +19,7 @@ return TRUE if(pai && action == "toggle_leash") - pai.toggle_leash() + pai.leash.toggle_leash() return TRUE return FALSE diff --git a/modular_skyrat/master_files/code/modules/pai/pai.dm b/modular_skyrat/master_files/code/modules/pai/pai.dm deleted file mode 100644 index 33fc1cfeacc..00000000000 --- a/modular_skyrat/master_files/code/modules/pai/pai.dm +++ /dev/null @@ -1,16 +0,0 @@ -/mob/living/silicon/pai/ - /// Whether or not the PAI is subject to range limitations and able to roam freely, off by default - var/leashed = FALSE - -/// Enables or disables the leash, allowing or forbidding the PAI from leaving a specified range -/mob/living/silicon/pai/proc/toggle_leash() - leashed = !leashed - to_chat(src, span_warning("Your virtual leash has been [leashed ? "activated" : "deactivated"]!")) - if(leashed) - check_distance() // yoink them back - -/mob/living/silicon/pai/check_distance() - if(!leashed) - return - - return ..() diff --git a/tgstation.dme b/tgstation.dme index 85cd79770f9..bb0b7c1b9e4 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -289,6 +289,7 @@ #include "code\__DEFINES\dcs\signals\signals_janitor.dm" #include "code\__DEFINES\dcs\signals\signals_key.dm" #include "code\__DEFINES\dcs\signals\signals_ladder.dm" +#include "code\__DEFINES\dcs\signals\signals_leash.dm" #include "code\__DEFINES\dcs\signals\signals_lift.dm" #include "code\__DEFINES\dcs\signals\signals_light_eater.dm" #include "code\__DEFINES\dcs\signals\signals_material_container.dm" @@ -1064,6 +1065,7 @@ #include "code\datums\components\keep_me_secure.dm" #include "code\datums\components\knockoff.dm" #include "code\datums\components\label.dm" +#include "code\datums\components\leash.dm" #include "code\datums\components\light_eater.dm" #include "code\datums\components\lock_on_cursor.dm" #include "code\datums\components\manual_blinking.dm" @@ -5575,6 +5577,7 @@ #include "modular_skyrat\master_files\code\datums\bodypart_overlays\mutant_bodypart_overlay.dm" #include "modular_skyrat\master_files\code\datums\components\crafting.dm" #include "modular_skyrat\master_files\code\datums\components\fullauto.dm" +#include "modular_skyrat\master_files\code\datums\components\leash.dm" #include "modular_skyrat\master_files\code\datums\components\shielded_suit.dm" #include "modular_skyrat\master_files\code\datums\components\tippable.dm" #include "modular_skyrat\master_files\code\datums\greyscale\config_types\greyscale_configs.dm" @@ -5784,7 +5787,6 @@ #include "modular_skyrat\master_files\code\modules\mod\modules\modules_antag.dm" #include "modular_skyrat\master_files\code\modules\modular_computers\computers\item\laptop_presets.dm" #include "modular_skyrat\master_files\code\modules\pai\card.dm" -#include "modular_skyrat\master_files\code\modules\pai\pai.dm" #include "modular_skyrat\master_files\code\modules\paperwork\stamps.dm" #include "modular_skyrat\master_files\code\modules\power\cable.dm" #include "modular_skyrat\master_files\code\modules\power\powernet.dm"