Handcuffs can now be used to bind certain items (briefcases, toolboxes, etc.) to your hand. (#93305)

## About The Pull Request
Technically, this PR introduces the cuffable_item element and the
cuffed_item status effect and their relative code.

In more player-friendly terms, this allows the ability to use handcuffs
to bind certain items to your hands by right-clicking it with a pair of
handcuffs in your active hand. This makes the item unable to be dropped,
for better or worse, until you or someone else remove said cuffs. And
no, this doesn't conflict with the ability to be handcuffed if you're
silly enough to think that.

There are more than one way to remove the cuffs. For the player with the
item cuffed to their hand, to remove the cuffs they can either click the
status alert, or examine the item and click the relative hyperlink. The
second option is good to have if for some reason the status alert
doesn't show up (too many alerts etc.).

For other people, they can remove the cuffs by opening the strip
inventory menu (the one you open by click-dragging the sprite of person
with the item onto yours). It's an alternative action specific to this
status effect (therefore only held items). Until the cuffs are removed,
trying to remove the item **directly** will bring you nowhere **because
the item is stuck to their hands**, duh. Alternatively you can just chop
their arm off. You do what you do.

For a list of items that can be bound with cuffs (suggestions welcome):
- briefcases
- toolboxes
- lockboxes
- first aid kits
- shields (they generally have handles and all. gameplay-wise they
already take away one hand slot to use. Using cuffs seals the deal: no
swapping items on the go, so no two-handed weapons, but you won't drop
the shield until it's broken)
- jerrycans (Kryson's suggestion)
- soup pots (ditto, kinda weird)
- coffee mugs, and the mauna mug (ditto)
- buckets
- plushes (silly stuff, if you ever want to arrest a plush or test the
feature)
- pet carriers
- mining drills
- swords with closed guards (ERT chainsaw-sword, cap's sabre, parsnip
sabre, cutlass, e-cutlass...)
- crutches and the white cane
- baskets
- flashlights and lamps (not subtypes like flares, glowsticks and
torches)
- TTVs
- chairs

## Why It's Good For The Game
This opens up for some emergent use for handcuffs beside people (or
prisoner shoes). Inspired by a scene of some 1998 action movie, where
one of the bad guys had the mc guffin briefcase latched to his wrist
with a pair of handcuffs.

Codewise, it was also a reason to refactor bits of code like handcuffs
and screen alerts slightly. On a sidenote, actual sprites for
cult/heretic shackles.

## Changelog

🆑
add: You can now bind certain items like briefcases, toolboxes, medkits,
shields, jerrycans etc. to your hand with a pair of handcuffs,
preventing them from being dropped. You can remove said binds at any
time unless incapacitated, and so can others through the strip inventory
menu.
qol: The appearance of a screen alert now updates if the object it
represents (like, an item offered by another player) changes appearance.
imageadd: The shadow shackles item (from cult magic and heretic
sacrifices) now has its own icon.
/🆑
This commit is contained in:
Ghom
2025-10-07 05:24:20 -06:00
committed by GitHub
parent 76129b9fb3
commit f72eb75a6f
40 changed files with 591 additions and 193 deletions
+69
View File
@@ -0,0 +1,69 @@
///This element allows the item it's attached to be bound to oneself's arm with a pair of handcuffs (sold separately). Borgs need not to apply
/datum/element/cuffable_item
/datum/element/cuffable_item/Attach(datum/target)
. = ..()
if(!isitem(target))
return ELEMENT_INCOMPATIBLE
RegisterSignal(target, COMSIG_ATOM_EXAMINE_MORE, PROC_REF(on_examine_more))
RegisterSignal(target, COMSIG_ATOM_ITEM_INTERACTION_SECONDARY, PROC_REF(item_interaction))
var/atom/atom_target = target
atom_target.flags_1 |= HAS_CONTEXTUAL_SCREENTIPS_1
RegisterSignal(atom_target, COMSIG_ATOM_REQUESTING_CONTEXT_FROM_ITEM, PROC_REF(on_requesting_context_from_item))
///Tell the player about the interaction if they examine the item twice.
/datum/element/cuffable_item/proc/on_examine_more(obj/item/source, mob/user, list/examine_list)
SIGNAL_HANDLER
if(length(user.held_items) < 0 || iscyborg(user) || source.anchored)
return
examine_list += span_smallnotice("You could bind [source.p_they()] to your wrist with a pair of handcuffs...")
///Give context to players holding a pair of handcuffs when hovering the item
/datum/element/cuffable_item/proc/on_requesting_context_from_item(datum/source, list/context, obj/item/held_item, mob/user)
SIGNAL_HANDLER
if (!istype(held_item, /obj/item/restraints/handcuffs))
return NONE
var/obj/item/restraints/handcuffs/cuffs = held_item
if(!cuffs.used)
context[SCREENTIP_CONTEXT_RMB] = "Cuff to your wrist"
return CONTEXTUAL_SCREENTIP_SET
/datum/element/cuffable_item/proc/item_interaction(obj/item/source, mob/living/user, obj/item/tool, modifiers)
SIGNAL_HANDLER
if(!istype(tool, /obj/item/restraints/handcuffs) || iscyborg(user) || source.anchored || !user.CanReach(source))
return NONE
INVOKE_ASYNC(src, PROC_REF(apply_cuffs), source, user, tool)
return ITEM_INTERACT_SUCCESS
///The proc responsible for adding the status effect to the player and all...
/datum/element/cuffable_item/proc/apply_cuffs(obj/item/source, mob/living/user, obj/item/restraints/handcuffs/cuffs)
if(cuffs.used || DOING_INTERACTION_WITH_TARGET(user, source))
return
if(HAS_TRAIT_FROM(source, TRAIT_NODROP, CUFFED_ITEM_TRAIT))
to_chat(user, span_warning("[source] is already cuffed to your wrist!"))
return
if(cuffs.handcuffs_clumsiness_check(user))
return
source.balloon_alert(user, "cuffing item...")
playsound(source, cuffs.cuffsound, 30, TRUE, -2)
if(!do_after(user, cuffs.get_handcuff_time(user), source))
return
playsound(source, cuffs.cuffsuccesssound, 30, TRUE, -2)
if(user.apply_status_effect(/datum/status_effect/cuffed_item, source, cuffs))
source.balloon_alert(user, "item cuffed to wrist")
return
source.balloon_alert(user, "couldn't cuff to wrist!")
return
+71 -41
View File
@@ -49,12 +49,16 @@
UnregisterSignal(target, list(COMSIG_ITEM_ATTACK_SECONDARY, COMSIG_ATOM_EXAMINE, COMSIG_ITEM_REQUESTING_CONTEXT_FOR_TARGET))
return ..()
/datum/element/cuffsnapping/proc/add_item_context(obj/item/source, list/context, mob/living/carbon/target, mob/living/user)
/datum/element/cuffsnapping/proc/add_item_context(obj/item/source, list/context, mob/living/target, mob/living/user)
SIGNAL_HANDLER
if(!iscarbon(target) || !target.handcuffed)
return NONE
context[SCREENTIP_CONTEXT_RMB] = "Cut Restraints"
return CONTEXTUAL_SCREENTIP_SET
if(iscarbon(target)) //Removing restraints takes precedence
var/mob/living/carbon/carbon_target = target
if(carbon_target.handcuffed)
context[SCREENTIP_CONTEXT_RMB] = "Cut Restraints"
return CONTEXTUAL_SCREENTIP_SET
if(target.has_status_effect(/datum/status_effect/cuffed_item))
context[SCREENTIP_CONTEXT_RMB] = "Remove Binds From Item"
return CONTEXTUAL_SCREENTIP_SET
///signal called on parent being examined
/datum/element/cuffsnapping/proc/on_examine(datum/target, mob/user, list/examine_list)
@@ -72,48 +76,74 @@
examine_list += span_notice(examine_string)
/datum/element/cuffsnapping/proc/try_cuffsnap_target(obj/item/cutter, mob/living/carbon/target, mob/living/cutter_user, list/modifiers)
///Signal called on parent when it right-clicks another mob.
/datum/element/cuffsnapping/proc/try_cuffsnap_target(obj/item/cutter, mob/living/target, mob/living/cutter_user, list/modifiers)
SIGNAL_HANDLER
if(!istype(target)) //we aren't the kind of mob that can even have cuffs, so we skip.
return
if(!target.handcuffed)
return
var/obj/item/restraints/handcuffs/cuffs = target.handcuffed
if(!istype(cuffs))
return
if(cuffs.restraint_strength && isnull(src.snap_time_strong))
cutter_user.visible_message(span_notice("[cutter_user] tries to cut through [target]'s restraints with [cutter], but fails!"))
playsound(source = get_turf(cutter), soundin = cutter.usesound ? cutter.usesound : cutter.hitsound, vol = cutter.get_clamped_volume(), vary = TRUE)
return COMPONENT_SKIP_ATTACK
else if(isnull(src.snap_time_weak))
cutter_user.visible_message(span_notice("[cutter_user] tries to cut through [target]'s restraints with [cutter], but fails!"))
playsound(source = get_turf(cutter), soundin = cutter.usesound ? cutter.usesound : cutter.hitsound, vol = cutter.get_clamped_volume(), vary = TRUE)
return COMPONENT_SKIP_ATTACK
. = COMPONENT_SKIP_ATTACK
INVOKE_ASYNC(src, PROC_REF(do_cuffsnap_target), cutter, target, cutter_user, cuffs)
/datum/element/cuffsnapping/proc/do_cuffsnap_target(obj/item/cutter, mob/living/carbon/target, mob/cutter_user, obj/item/restraints/handcuffs/cuffs)
if(LAZYACCESS(cutter_user.do_afters, cutter))
return
var/mob/living/carbon/carbon_target = target
if(!istype(carbon_target) || !carbon_target.handcuffed)
var/datum/status_effect/cuffed_item/cuffed_status = target.has_status_effect(/datum/status_effect/cuffed_item)
if(!cuffed_status)
return NONE
INVOKE_ASYNC(src, PROC_REF(try_cuffsnap_item), cutter, target, cutter_user, cuffed_status.cuffed, cuffed_status.cuffs)
return COMPONENT_SKIP_ATTACK
var/obj/item/restraints/handcuffs/cuffs = carbon_target.handcuffed
if(!istype(cuffs))
return NONE
if(check_cuffs_strength(carbon_target, target, cutter_user, cuffs, span_notice("[cutter_user] tries to cut through [target]'s restraints with [cutter], but fails!")))
INVOKE_ASYNC(src, PROC_REF(do_cuffsnap_target), cutter, target, cutter_user, cuffs)
return COMPONENT_SKIP_ATTACK
///Check that the type of restraints can be cut by this element.
/datum/element/cuffsnapping/proc/check_cuffs_strength(obj/item/cutter, mob/living/target, mob/living/cutter_user, obj/item/restraints/handcuffs/cuffs, message)
if(cuffs.restraint_strength ? snap_time_strong : snap_time_weak)
return TRUE
cutter_user.visible_message(message)
playsound(source = get_turf(cutter), soundin = cutter.usesound || cutter.hitsound, vol = cutter.get_clamped_volume(), vary = TRUE)
return FALSE
///Called when a player tries to remove the cuffs restraining another mob.
/datum/element/cuffsnapping/proc/do_cuffsnap_target(obj/item/cutter, mob/living/carbon/target, mob/cutter_user, obj/item/restraints/handcuffs/cuffs)
if(LAZYACCESS(cutter_user.do_afters, cutter))
return
log_combat(cutter_user, target, "cut or tried to cut [target]'s cuffs", cutter)
var/snap_time = src.snap_time_weak
if(cuffs.restraint_strength)
snap_time = src.snap_time_strong
do_snip_snap(cutter, target, cutter_user, cuffs, span_notice("[cutter_user] cuts [target]'s restraints with [cutter]!"))
if(snap_time == 0 || do_after(cutter_user, snap_time, target, interaction_key = cutter)) // If 0 just do it. This to bypass the do_after() creating a needless progress bar.
cutter_user.do_attack_animation(target, used_item = cutter)
cutter_user.visible_message(span_notice("[cutter_user] cuts [target]'s restraints with [cutter]!"))
qdel(target.handcuffed)
playsound(source = get_turf(cutter), soundin = cutter.usesound ? cutter.usesound : cutter.hitsound, vol = cutter.get_clamped_volume(), vary = TRUE)
///Called when a player tries to remove the cuffs binding an item to their owner
/datum/element/cuffsnapping/proc/try_cuffsnap_item(obj/item/cutter, mob/living/target, mob/living/cutter_user, obj/item/cuffed, obj/item/restraints/handcuffs/cuffs)
if(check_cuffs_strength(cutter, target, cutter_user, cuffs, span_notice("[cutter_user] tries to cut through the restraints binding [cuffed] to [target], but fails!")))
return
return
log_combat(cutter_user, target, "cut or tried to cut restraints binding [cuffed] to")
do_snip_snap(cutter, target, cutter_user, cuffs, span_notice("[cutter_user] cuts the restraints binding [src] to [target] with [cutter]!"))
///The proc responsible for the very timed action that deletes the cuffs
/datum/element/cuffsnapping/proc/do_snip_snap(obj/item/cutter, mob/living/target, mob/cutter_user, obj/item/restraints/handcuffs/cuffs, message)
var/snap_time = cuffs.restraint_strength ? snap_time_strong : snap_time_weak
var/target_was_restrained = FALSE
if(iscarbon(target))
var/mob/living/carbon/carbon_target = target
target_was_restrained = carbon_target.handcuffed
if(snap_time)
if(!do_after(cutter_user, snap_time, target, interaction_key = cutter)) // If 0 just do it. This to bypass the do_after() creating a needless progress bar.
return
if(target_was_restrained) //Removing restraints takes priority over cuffed items. This only applies for carbon mobs, but we need to make sure the restraints are still the same.
var/mob/living/carbon/carbon_target = target
if(carbon_target.handcuffed != cuffs)
return
cutter_user.do_attack_animation(target, used_item = cutter)
cutter_user.visible_message(message)
qdel(cuffs)
playsound(source = get_turf(cutter), soundin = cutter.usesound || cutter.hitsound, vol = cutter.get_clamped_volume(), vary = TRUE)
+13 -9
View File
@@ -182,9 +182,14 @@
* All string keys in the list must be inside tgui\packages\tgui\interfaces\StripMenu.tsx
* You can also return null if there are no alternate actions.
*/
/datum/strippable_item/proc/get_alternate_actions(atom/source, mob/user)
/datum/strippable_item/proc/get_alternate_actions(atom/source, mob/user, obj/item/item)
RETURN_TYPE(/list)
return null
SHOULD_CALL_PARENT(TRUE)
var/list/alt_actions = list()
if(item)
SEND_SIGNAL(item, COMSIG_ITEM_GET_STRIPPABLE_ALT_ACTIONS, source, user, alt_actions)
return alt_actions
/**
* Performs an alternate action on this strippable_item.
@@ -193,9 +198,9 @@
* - action_key: The key of the alternate action to perform.
* Returns FALSE if unable to perform the action; whether it be due to the signal or some other factor.
*/
/datum/strippable_item/proc/perform_alternate_action(atom/source, mob/user, action_key)
/datum/strippable_item/proc/perform_alternate_action(atom/source, mob/user, action_key, obj/item/item)
SHOULD_CALL_PARENT(TRUE)
if(SEND_SIGNAL(user, COMSIG_TRY_ALT_ACTION, source, action_key) & COMPONENT_CANT_ALT_ACTION)
if(item && SEND_SIGNAL(item, COMSIG_ITEM_STRIPPABLE_ALT_ACTION, source, user, action_key) & COMPONENT_ALT_ACTION_DONE)
return FALSE
return TRUE
@@ -375,7 +380,8 @@
result["icon"] = icon2base64(icon(item.icon, item.icon_state))
result["name"] = item.name
result["alternate"] = item_data.get_alternate_actions(owner, user)
var/list/alt_actions = item_data.get_alternate_actions(owner, user, item)
result["alternate"] = length(alt_actions) ? alt_actions : null
var/static/list/already_cried = list()
if(length(result["alternate"]) > 3 && !(type in already_cried))
stack_trace("Too many alternate actions for [type]! Only three are supported at the moment! This will look bad!")
@@ -486,16 +492,14 @@
return
var/item = strippable_item.get_item(owner)
if (isnull(item))
return
if (!(alt_action in strippable_item.get_alternate_actions(owner, user)))
if (!(alt_action in strippable_item.get_alternate_actions(owner, user, item)))
return
LAZYORASSOCLIST(interactions, user, key)
// Potentially yielding
strippable_item.perform_alternate_action(owner, user, alt_action)
strippable_item.perform_alternate_action(owner, user, alt_action, item)
LAZYREMOVEASSOC(interactions, user, key)