diff --git a/code/datums/sexcon/sex_actions/deviant/spanking.dm b/code/datums/sexcon/sex_actions/deviant/spanking.dm new file mode 100644 index 00000000000..154a7e5af3b --- /dev/null +++ b/code/datums/sexcon/sex_actions/deviant/spanking.dm @@ -0,0 +1,58 @@ +/datum/sex_action/spanking + name = "Spank them" + check_same_tile = FALSE + continous = TRUE + stamina_cost = 0 + do_time = 1 SECONDS + var/last_pain_sound = 0 + var/pain_sound_cooldown = 3 SECONDS + +/datum/sex_action/spanking/shows_on_menu(mob/living/carbon/human/user, mob/living/carbon/human/target) + if(user == target) + return FALSE + return TRUE + +/datum/sex_action/spanking/can_perform(mob/living/user, mob/living/target) + if(user == target) + return FALSE + return TRUE + +/datum/sex_action/spanking/on_start(mob/living/carbon/human/user, mob/living/carbon/human/target) + ..() + user.visible_message(span_warning("[user] starts spanking [target]...")) + +/datum/sex_action/spanking/on_perform(mob/living/carbon/human/user, mob/living/carbon/human/target) + . = ..() + playsound(target, 'sound/foley/slap.ogg', 50, TRUE, -1) + + // Flash screen red based on force level + switch(user.sexcon.force) + if(SEX_FORCE_LOW) + target.flash_fullscreen("redflash1") + if(SEX_FORCE_MID) + target.flash_fullscreen("redflash2") + if(SEX_FORCE_HIGH) + target.flash_fullscreen("redflash3") + if(SEX_FORCE_EXTREME) + target.flash_fullscreen("redflash3") + + addtimer(CALLBACK(target, TYPE_PROC_REF(/mob, clear_fullscreen), "redflash"), 0.5 SECONDS) + + // Occasional pain sounds with proper emotes + if(world.time > last_pain_sound + pain_sound_cooldown && prob(25)) + if(prob(50)) + target.emote("whimpers", intentional = FALSE) + else + target.emote("groans", intentional = FALSE) + last_pain_sound = world.time + + var/force_text = user.sexcon.get_generic_force_adjective() + user.visible_message(user.sexcon.spanify_force("[user] [force_text] spanks [target]!")) + + // Pain messages + to_chat(target, span_warning("It stings!")) + to_chat(target, span_danger("It hurts!")) + +/datum/sex_action/spanking/on_finish(mob/living/carbon/human/user, mob/living/carbon/human/target) + ..() + user.visible_message(span_warning("[user] stops spanking [target]...")) \ No newline at end of file diff --git a/code/modules/antagonists/collar_master/collar_master.dm b/code/modules/antagonists/collar_master/collar_master.dm new file mode 100644 index 00000000000..10392352810 --- /dev/null +++ b/code/modules/antagonists/collar_master/collar_master.dm @@ -0,0 +1,204 @@ +/datum/antagonist/collar_master + name = "Collar Master" + antagpanel_category = "Other" + show_in_antagpanel = FALSE + show_name_in_check_antagonists = FALSE + var/obj/item/clothing/neck/roguetown/cursed_collar/my_collar + var/static/list/animal_sounds = list( + "lets out a whimper!", + "whines softly.", + "makes a pitiful noise.", + "whimpers.", + "lets out a submissive bark.", + "mewls pathetically." + ) + +/datum/antagonist/collar_master/on_gain() + . = ..() + owner.current.verbs += list( + /mob/proc/collar_scry, + /mob/proc/collar_listen, + /mob/proc/collar_shock, + /mob/proc/collar_message, + /mob/proc/collar_force_surrender, + /mob/proc/collar_force_naked, + /mob/proc/collar_permit_clothing, + /mob/proc/collar_toggle_silence, + /mob/proc/collar_force_emote, + ) + to_chat(owner.current, span_notice("You can now control your pet through the Collar menu.")) + +/datum/antagonist/collar_master/on_removal() + owner.current.verbs -= list( + /mob/proc/collar_scry, + /mob/proc/collar_listen, + /mob/proc/collar_shock, + /mob/proc/collar_message, + /mob/proc/collar_force_surrender, + /mob/proc/collar_force_naked, + /mob/proc/collar_permit_clothing, + /mob/proc/collar_toggle_silence, + /mob/proc/collar_force_emote, + ) + . = ..() + +/mob/proc/collar_control_menu() + set name = "Collar Control" + set category = "Collar" + + var/datum/antagonist/collar_master/CM = mind?.has_antag_datum(/datum/antagonist/collar_master) + if(!CM || !CM.my_collar || !CM.my_collar.victim) + return + +/mob/proc/select_pet(var/action) + var/list/pets = list() + for(var/datum/antagonist/collar_master/CM in mind.antag_datums) + if(CM.my_collar && CM.my_collar.victim) + pets[CM.my_collar.victim.name] = CM.my_collar + + if(!length(pets)) + return null + + var/choice = input(src, "Choose a pet:", "Pet Selection") as null|anything in pets + if(!choice) + return null + return pets[choice] + +/mob/proc/collar_scry() + set name = "Scry on Pet" + set category = "Collar" + + var/obj/item/clothing/neck/roguetown/cursed_collar/collar = select_pet("scry") + if(!collar) + return + + var/mob/dead/observer/screye/S = scry_ghost() + if(S) + S.ManualFollow(collar.victim) + addtimer(CALLBACK(S, TYPE_PROC_REF(/mob/dead/observer, reenter_corpse)), 8 SECONDS) + +/mob/proc/collar_listen() + set name = "Listen to Pet" + set category = "Collar" + + var/datum/antagonist/collar_master/CM = mind?.has_antag_datum(/datum/antagonist/collar_master) + if(!CM || !CM.my_collar || !CM.my_collar.victim) + return + + CM.my_collar.listening = !CM.my_collar.listening + to_chat(src, span_notice("You [CM.my_collar.listening ? "attune your mind to" : "cease listening through"] the collar.")) + +/mob/proc/collar_shock() + set name = "Shock Pet" + set category = "Collar" + + var/datum/antagonist/collar_master/CM = mind?.has_antag_datum(/datum/antagonist/collar_master) + if(!CM || !CM.my_collar || !CM.my_collar.victim) + return + + to_chat(CM.my_collar.victim, span_danger("The collar sends painful shocks through your body!")) + CM.my_collar.victim.electrocute_act(15, CM.my_collar, flags = SHOCK_NOGLOVES) + CM.my_collar.victim.Knockdown(20) + playsound(CM.my_collar.victim, 'sound/blank.ogg', 50, TRUE) + +/mob/proc/collar_message() + set name = "Send Message" + set category = "Collar" + + var/datum/antagonist/collar_master/CM = mind?.has_antag_datum(/datum/antagonist/collar_master) + if(!CM || !CM.my_collar || !CM.my_collar.victim) + return + + var/msg = input(src, "Enter a message to send to your pet:", "Collar Message") as text|null + if(msg) + to_chat(CM.my_collar.victim, span_warning("Your collar tingles as you hear your master's voice: [msg]")) + +/mob/proc/collar_force_surrender() + set name = "Force Surrender" + set category = "Collar" + + var/datum/antagonist/collar_master/CM = mind?.has_antag_datum(/datum/antagonist/collar_master) + if(!CM || !CM.my_collar || !CM.my_collar.victim) + return + + to_chat(CM.my_collar.victim, span_userdanger("The collar forces you to your knees!")) + CM.my_collar.victim.Paralyze(600) // 1 minute stun + new /obj/effect/temp_visual/surrender(get_turf(CM.my_collar.victim)) + playsound(CM.my_collar.victim, 'sound/blank.ogg', 50, TRUE) + +/mob/proc/collar_force_naked() + set name = "Force Strip" + set category = "Collar" + + var/datum/antagonist/collar_master/CM = mind?.has_antag_datum(/datum/antagonist/collar_master) + if(!CM || !CM.my_collar || !CM.my_collar.victim) + return + + var/mob/living/victim = CM.my_collar.victim + to_chat(victim, span_userdanger("The collar's magic forces you to remove all your clothing!")) + if(ishuman(victim)) + var/mob/living/carbon/human/H = victim + for(var/obj/item/I in H.get_equipped_items()) + if(I == CM.my_collar) // Don't remove the collar itself + continue + if(H.dropItemToGround(I, TRUE)) + H.visible_message(span_warning("[H]'s [I.name] falls to the ground!")) + + ADD_TRAIT(victim, TRAIT_NUDIST, CURSED_ITEM_TRAIT) + playsound(victim, 'sound/blank.ogg', 50, TRUE) + +/mob/proc/collar_permit_clothing() + set name = "Permit Clothing" + set category = "Collar" + + var/datum/antagonist/collar_master/CM = mind?.has_antag_datum(/datum/antagonist/collar_master) + if(!CM || !CM.my_collar || !CM.my_collar.victim) + return + + var/mob/living/victim = CM.my_collar.victim + to_chat(victim, span_notice("The collar's magic allows you to wear clothing again.")) + REMOVE_TRAIT(victim, TRAIT_NUDIST, CURSED_ITEM_TRAIT) + playsound(victim, 'sound/blank.ogg', 50, TRUE) + +/mob/proc/collar_toggle_silence() + set name = "Toggle Pet Speech" + set category = "Collar" + + var/datum/antagonist/collar_master/CM = mind?.has_antag_datum(/datum/antagonist/collar_master) + if(!CM || !CM.my_collar || !CM.my_collar.victim) + return + + CM.my_collar.silenced = !CM.my_collar.silenced + to_chat(CM.my_collar.victim, span_userdanger("The collar [CM.my_collar.silenced ? "forces you to speak like an animal!" : "allows you to speak normally again."]")) + playsound(CM.my_collar.victim, 'sound/blank.ogg', 50, TRUE) + + if(CM.my_collar.silenced) + RegisterSignal(CM.my_collar.victim, COMSIG_MOB_SAY, PROC_REF(handle_silenced_speech)) + else + UnregisterSignal(CM.my_collar.victim, COMSIG_MOB_SAY) + +/mob/proc/handle_silenced_speech(datum/source, list/speech_args) + SIGNAL_HANDLER + + var/datum/antagonist/collar_master/CM = mind?.has_antag_datum(/datum/antagonist/collar_master) + if(!CM || !CM.my_collar || !CM.my_collar.silenced) + return + + speech_args[SPEECH_MESSAGE] = "" + emote("me", EMOTE_VISIBLE, pick(CM.animal_sounds)) + return TRUE // Just return TRUE to block speech + +/mob/proc/collar_force_emote() + set name = "Force Emote" + set category = "Collar" + + var/datum/antagonist/collar_master/CM = mind?.has_antag_datum(/datum/antagonist/collar_master) + if(!CM || !CM.my_collar || !CM.my_collar.victim) + return + + var/emote = input(src, "What emote should your pet perform?", "Force Emote") as text|null + if(!emote) + return + + CM.my_collar.victim.say(emote, forced = TRUE) + playsound(CM.my_collar.victim, 'sound/blank.ogg', 50, TRUE) diff --git a/code/modules/clothing/neck/_neck.dm b/code/modules/clothing/neck/_neck.dm index 739d2f08058..d23d492056e 100644 --- a/code/modules/clothing/neck/_neck.dm +++ b/code/modules/clothing/neck/_neck.dm @@ -1,3 +1,13 @@ +#include "../../antagonists/collar_master/collar_master.dm" + +#define COMSIG_MOB_ATTACK "mob_attack" +#define COMSIG_MOB_SAY "mob_say" +#define COMSIG_MOB_CLICKON "mob_clickon" +#define COMSIG_ITEM_PRE_UNEQUIP "item_pre_unequip" +#define COMPONENT_CANCEL_ATTACK "cancel_attack" +#define COMPONENT_CANCEL_SAY "cancel_say" +#define COMPONENT_ITEM_BLOCK_UNEQUIP (1<<0) + /obj/item/clothing/neck name = "necklace" icon = 'icons/obj/clothing/neck.dmi' @@ -223,3 +233,96 @@ user.visible_message(span_notice("I untie [oldName] back into a [newBand.name]."), span_notice("[user] unties [oldName] back into a [newBand.name].")) else to_chat(user, span_warning("I must be holding [src] in order to untie it!")) + +/obj/item/clothing/neck/roguetown/cursed_collar + name = "cursed collar" + desc = "A sinister looking collar with emerald studs. It seems to radiate a dark energy." + icon_state = "listenstone" + item_state = "listenstone" + w_class = WEIGHT_CLASS_SMALL + slot_flags = ITEM_SLOT_NECK + body_parts_covered = NECK + var/mob/living/carbon/human/victim = null + var/mob/living/carbon/human/collar_master = null + var/listening = FALSE + var/silenced = FALSE + resistance_flags = INDESTRUCTIBLE + armor = list("blunt" = 0, "slash" = 0, "stab" = 0, "bullet" = 0, "laser" = 0, "energy" = 0, "bomb" = 0, "bio" = 0, "rad" = 0, "fire" = 0, "acid" = 0) + +/obj/item/clothing/neck/roguetown/cursed_collar/proc/handle_speech(datum/source, list/speech_args) + SIGNAL_HANDLER + if(silenced) + speech_args[SPEECH_MESSAGE] = "" + var/mob/living/carbon/human/H = source + if(istype(H)) + H.say("*[pick(list( + "whines softly.", + "makes a pitiful noise.", + "whimpers.", + "lets out a submissive bark.", + "mewls pathetically." + ))]") + return COMPONENT_CANCEL_SAY + return NONE + +/obj/item/clothing/neck/roguetown/cursed_collar/proc/check_attack(datum/source, atom/target) + SIGNAL_HANDLER + if(!istype(target, /mob/living/carbon/human)) + return NONE + + if(target == collar_master) + to_chat(source, span_warning("The collar sends painful shocks through your body as you try to attack your master!")) + var/mob/living/carbon/human/H = source + H.electrocute_act(25, src, flags = SHOCK_NOGLOVES) + H.Paralyze(600) // 1 minute stun + playsound(H, 'sound/blank.ogg', 50, TRUE) + return COMPONENT_CANCEL_ATTACK + return NONE + +/obj/item/clothing/neck/roguetown/cursed_collar/attack(mob/living/carbon/human/M, mob/living/carbon/human/user) + if(!istype(M) || !istype(user)) + return ..() + + if(M.get_item_by_slot(SLOT_NECK)) + to_chat(user, span_warning("[M] is already wearing something around their neck!")) + return + + if(!do_mob(user, M, 50)) + return + + victim = M + collar_master = user + if(!M.equip_to_slot_if_possible(src, SLOT_NECK, 0, 0, 1)) + to_chat(user, span_warning("You fail to collar [M]!")) + victim = null + collar_master = null + return + + ADD_TRAIT(src, TRAIT_NODROP, CURSED_ITEM_TRAIT) + to_chat(M, span_userdanger("The collar snaps shut around your neck!")) + to_chat(user, span_notice("You successfully collar [M].")) + + if(user.mind) + var/datum/antagonist/collar_master/CM = new() + CM.my_collar = src + user.mind.add_antag_datum(CM) + +/obj/item/clothing/neck/roguetown/cursed_collar/Destroy() + victim = null + collar_master = null + return ..() + +/obj/item/clothing/neck/roguetown/cursed_collar/equipped(mob/user, slot) + . = ..() + if(slot == SLOT_NECK && user == victim) + RegisterSignal(src, COMSIG_ITEM_PRE_UNEQUIP, PROC_REF(prevent_removal)) + RegisterSignal(user, COMSIG_MOB_SAY, PROC_REF(handle_speech)) + RegisterSignal(user, COMSIG_MOB_CLICKON, PROC_REF(check_attack)) + +/obj/item/clothing/neck/roguetown/cursed_collar/proc/prevent_removal(datum/source, mob/living/carbon/human/user) + SIGNAL_HANDLER + if(user == victim) + to_chat(user, span_userdanger("The collar's magic holds it firmly in place! You can't remove it!")) + playsound(user, 'sound/blank.ogg', 50, TRUE) + return COMPONENT_ITEM_BLOCK_UNEQUIP + return NONE diff --git a/code/modules/roguetown/roguejobs/blacksmith/anvil_recipes/armor.dm b/code/modules/roguetown/roguejobs/blacksmith/anvil_recipes/armor.dm index 4144247229b..1e51a12eff3 100644 --- a/code/modules/roguetown/roguejobs/blacksmith/anvil_recipes/armor.dm +++ b/code/modules/roguetown/roguejobs/blacksmith/anvil_recipes/armor.dm @@ -881,3 +881,9 @@ additional_items = list(/obj/item/clothing/wrists/roguetown/bracers/leather, /obj/item/roguegear) created_item = /obj/item/clothing/wrists/roguetown/hiddenblade i_type = "Armor" + +/datum/anvil_recipe/armor/cursed_collar + name = "Cursed Collar" + req_bar = /obj/item/ingot/steel + created_item = /obj/item/clothing/neck/roguetown/cursed_collar + craftdiff = 3 diff --git a/modular_hearthstone/code/datums/loadout.dm b/modular_hearthstone/code/datums/loadout.dm index 7f5d421b8d1..574fa6e5ba1 100644 --- a/modular_hearthstone/code/datums/loadout.dm +++ b/modular_hearthstone/code/datums/loadout.dm @@ -235,6 +235,10 @@ GLOBAL_LIST_EMPTY(loadout_items) name = "Iron Gorget" path = /obj/item/clothing/neck/roguetown/gorget +/datum/loadout_item/cursed_collar + name = "Cursed Collar" + path = /obj/item/clothing/neck/roguetown/cursed_collar + //ARMS /datum/loadout_item/lbracers name = "Leather Bracers" @@ -284,3 +288,4 @@ GLOBAL_LIST_EMPTY(loadout_items) name = "Iron Boots" path = /obj/item/clothing/shoes/roguetown/boots/armoriron + diff --git a/roguetown.dme b/roguetown.dme index 468e17721af..2b579fd32e7 100644 --- a/roguetown.dme +++ b/roguetown.dme @@ -715,6 +715,7 @@ #include "code\datums\sexcon\sex_actions\deviant\nipple_sex.dm" #include "code\datums\sexcon\sex_actions\deviant\rub_body.dm" #include "code\datums\sexcon\sex_actions\deviant\scissoring.dm" +#include "code\datums\sexcon\sex_actions\deviant\spanking.dm" #include "code\datums\sexcon\sex_actions\deviant\tailpegging_anal.dm" #include "code\datums\sexcon\sex_actions\deviant\tailpegging_vaginal.dm" #include "code\datums\sexcon\sex_actions\deviant\thighjob.dm" @@ -1668,6 +1669,7 @@ #include "code\modules\antagonists\changeling\powers\strained_muscles.dm" #include "code\modules\antagonists\changeling\powers\tiny_prick.dm" #include "code\modules\antagonists\changeling\powers\transform.dm" +#include "code\modules\antagonists\collar_master\collar_master.dm" #include "code\modules\antagonists\creep\creep.dm" #include "code\modules\antagonists\cult\blood_magic.dm" #include "code\modules\antagonists\cult\cult.dm" diff --git a/tgui/packages/tgui/interfaces/CursedCollar.js b/tgui/packages/tgui/interfaces/CursedCollar.js new file mode 100644 index 00000000000..0c4e7a0e8a6 --- /dev/null +++ b/tgui/packages/tgui/interfaces/CursedCollar.js @@ -0,0 +1,35 @@ +import { useBackend } from '../backend'; +import { Button, Section } from '../components'; +import { Window } from '../layouts'; + +export const CursedCollar = (props, context) => { + const { act, data } = useBackend(context); + const { + victim_name, + listening, + } = data; + return ( + + +
+
+
+
+ ); +};