From 305ed6d5d95db3e940118e7c3e2ff3926ec337f9 Mon Sep 17 00:00:00 2001 From: TheOneAndOnlyCreeperJoe Date: Wed, 15 Jul 2026 12:32:23 +0200 Subject: [PATCH 1/7] Magboots now block summonable, you can no longer sneak on centcomm, and prevents cryopod abductions --- .../powers/resonant/aberrant/summonable.dm | 105 ++++++++++++++---- 1 file changed, 83 insertions(+), 22 deletions(-) diff --git a/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm b/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm index b8781c9a134131..213025f6709051 100644 --- a/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm +++ b/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm @@ -3,7 +3,7 @@ */ /datum/power/aberrant/summonable name = "Summonable" - desc = "By speaking a specific name or word, you appear next to the speaker after a short delay. The summoning takes time, you are stunned throughout, is entirely involuntary and can only be stopped by being silenced, buckled or dispelled.\ + desc = "By speaking a specific name or word, you appear next to the speaker after a short delay. The summoning takes time, you are stunned throughout, is entirely involuntary and can only be stopped by being silenced, buckled, wearing magboots or by being dispelled.\ \n After being succesfuly summoned, you are unable to be summoned again for 1 minute. \ \n The chosen word is a partial secret; the Security Records on your powers contain the word as well. It cannot contain any special characters, only standard letters and numbers." security_threat = POWER_THREAT_MAJOR @@ -16,18 +16,16 @@ // Lists the word in sec records. /datum/power/aberrant/summonable/get_security_record_text() - var/keyword = summon_component?.keyword - if(!keyword) - keyword = power_holder?.client?.prefs?.read_preference(/datum/preference/text/summonable_keyword) - if(!keyword) - var/datum/preference/text/summonable_keyword/pref_entry = GLOB.preference_entries[/datum/preference/text/summonable_keyword] - keyword = pref_entry?.create_default_value() || "Beetlejuice" - return "Subject is summonable via keyword \"[keyword]\"." - -// Adds the custom beetlejuice component and sets the beetlejuiec word. -/datum/power/aberrant/summonable/post_add() + var/resolved_keyword = summon_component?.keyword + if(!resolved_keyword) + var/datum/preference/text/summonable_keyword/preference_entry = GLOB.preference_entries[/datum/preference/text/summonable_keyword] + resolved_keyword = preference_entry?.create_default_value() || "Beetlejuice" + return "Subject is summonable via keyword \"[resolved_keyword]\"." + +// Gets and sets keywords +/datum/power/aberrant/summonable/add(client/client_source) if(!power_holder) - return + return ..() var/mob/living/holder = power_holder var/datum/component/beetlejuice/summonable/component = holder.GetComponent(/datum/component/beetlejuice/summonable) @@ -36,14 +34,13 @@ summon_component = component - var/keyword = holder.client?.prefs?.read_preference(/datum/preference/text/summonable_keyword) - if(!keyword) - var/datum/preference/text/summonable_keyword/pref_entry = GLOB.preference_entries[/datum/preference/text/summonable_keyword] - keyword = pref_entry?.create_default_value() || "Beetlejuice" + component.keyword = client_source?.prefs?.read_preference(/datum/preference/text/summonable_keyword) + if(!component.keyword) + var/datum/preference/text/summonable_keyword/preference_entry = GLOB.preference_entries[/datum/preference/text/summonable_keyword] + component.keyword = preference_entry?.create_default_value() || "Beetlejuice" - component.keyword = keyword + component.rune_color = client_source?.prefs?.read_preference(/datum/preference/color/summonable_rune_color) || component.rune_color component.update_regex() - component.rune_color = holder.client?.prefs?.read_preference(/datum/preference/color/summonable_rune_color) || component.rune_color . = ..() @@ -60,6 +57,10 @@ var/summon_delay = 1 SECONDS /// How long it takes for you to fully float up var/float_time = 3.5 SECONDS + /// How long active magboots hold you in place before the summon fizzles. + var/magboot_lock_time = 2 SECONDS + /// How long the failed magboot spotlight takes to fade out. + var/magboot_fade_time = 0.5 SECONDS /// Radius for orbiting runes var/rune_orbit_radius = 30 /// Rotation speed for orbiting runes @@ -87,9 +88,15 @@ var/mob/living/living_summoned = summoned if(living_summoned.buckled || HAS_TRAIT(living_summoned, TRAIT_RESONANCE_SILENCED)) return + // We don't block .loc for the sake of it being funny to be yoinked out of things, but cryopods are too integral to not. + if(istype(summoned.loc, /obj/machinery/cryopod)) + return var/turf/target_turf = get_adjacent_open_turf(target) if(QDELETED(summoned) || !target_turf) return + // Prevents being summoned to bad places. + if(!can_summon_to_turf(target_turf)) + return if(destination_is_visible_to_summoned(summoned, target_turf)) return active = FALSE @@ -102,16 +109,26 @@ if(!center) return null var/list/candidates = list() - for(var/turf/T in orange(1, center)) - if(T == center) + for(var/turf/candidate_turf in orange(1, center)) + if(candidate_turf == center) continue - if(T.is_blocked_turf(exclude_mobs = FALSE, ignore_atoms = list(/obj/structure/table), type_list = TRUE)) + if(!can_summon_to_turf(candidate_turf)) continue - candidates += T + if(candidate_turf.is_blocked_turf(exclude_mobs = FALSE, ignore_atoms = list(/obj/structure/table), type_list = TRUE)) + continue + candidates += candidate_turf if(!length(candidates)) return null return pick(candidates) +/// Keeps Summonable off forbidden z-levels. +/datum/component/beetlejuice/summonable/proc/can_summon_to_turf(turf/target_turf) + if(!target_turf) + return FALSE + if(is_centcom_level(target_turf.z)) // no more sneaking into centcomm because a medibot said "an apple a day keeps me away" + return FALSE + return TRUE + /// Prevents summoning to locations the summoned can already see. /datum/component/beetlejuice/summonable/proc/destination_is_visible_to_summoned(atom/movable/summoned, turf/target_turf) if(!ismob(summoned) || !target_turf) @@ -124,10 +141,16 @@ /datum/component/beetlejuice/summonable/proc/begin_summon(atom/movable/summoned, turf/target_turf) if(QDELETED(summoned) || QDELETED(target_turf)) return + if(!can_summon_to_turf(target_turf)) + return if(isliving(summoned)) var/mob/living/living_summoned = summoned if(HAS_TRAIT(living_summoned, TRAIT_RESONANCE_SILENCED)) return + // Magboots prevent summons by just sheer magnetism. YE SCIENCE BITCH- + if(has_active_magboots(living_summoned)) + handle_magboot_lock(living_summoned) + return summoning = TRUE beaming_up = TRUE // Start departure immediately while runes are appearing. @@ -152,6 +175,39 @@ current_runes = runes addtimer(CALLBACK(src, PROC_REF(spawn_rune_sequence), summoned, target_turf, runes, 1, old_alpha, old_pixel_y), 0) +/// Returns TRUE if the living mob has active magboots equipped. +/datum/component/beetlejuice/summonable/proc/has_active_magboots(mob/living/living_summoned) + var/obj/item/clothing/shoes/magboots/equipped_magboots = living_summoned.get_item_by_slot(ITEM_SLOT_FEET) + if(!istype(equipped_magboots)) + return FALSE + return equipped_magboots.magpulse + +/// Active magboots let you resist the summons. It being handed over to this proc means it has already failed and we're just being dramatic now. +/datum/component/beetlejuice/summonable/proc/handle_magboot_lock(mob/living/living_summoned) + var/turf/origin_turf = get_turf(living_summoned) + if(!origin_turf) + return + + var/obj/effect/temp_visual/spotlight/summonable/origin_spotlight = new(origin_turf, rune_color) + + // in my head people are doing the "wacky arm inflatable tube man" effect with their body + living_summoned.visible_message( + span_warning("[living_summoned] strains against an invisible pull upward, but their magboots hold fast!"), + span_warning("An invisible force tries to pull you away into the air, but your magboots lock you in place!") + ) + ADD_TRAIT(living_summoned, TRAIT_IMMOBILIZED, "summonable_apport") + living_summoned.Shake(pixelshiftx = 2, pixelshifty = 1, duration = magboot_lock_time, shake_interval = 0.04 SECONDS) + addtimer(CALLBACK(src, PROC_REF(finish_magboot_lock), living_summoned, origin_spotlight), magboot_lock_time) + +/// Clears the magboot fake-out without ever moving the summoned target. +/datum/component/beetlejuice/summonable/proc/finish_magboot_lock(mob/living/living_summoned, obj/effect/temp_visual/spotlight/summonable/origin_spotlight) + if(!QDELETED(living_summoned)) + REMOVE_TRAIT(living_summoned, TRAIT_IMMOBILIZED, "summonable_apport") + if(QDELETED(origin_spotlight)) + return + animate(origin_spotlight, alpha = 0, time = magboot_fade_time) + addtimer(CALLBACK(src, PROC_REF(clear_origin_spotlight), origin_spotlight), magboot_fade_time) + /// Removes the spotlight /datum/component/beetlejuice/summonable/proc/clear_origin_spotlight(obj/effect/temp_visual/spotlight/summonable/origin_spotlight) QDEL_NULL(origin_spotlight) @@ -182,6 +238,11 @@ if(QDELETED(summoned) || QDELETED(target_turf)) QDEL_LIST(runes) return + // We check one more time if the spot's valid before actually going htere. + if(!can_summon_to_turf(target_turf)) + cancel_summon(summoned) + QDEL_LIST(runes) + return beaming_up = FALSE var/obj/effect/temp_visual/spotlight/summonable/spotlight = new(target_turf, rune_color) From d334c5be95a2fac0c3b6f174976a0e79e8ab8966 Mon Sep 17 00:00:00 2001 From: TheOneAndOnlyCreeperJoe Date: Wed, 15 Jul 2026 13:31:04 +0200 Subject: [PATCH 2/7] Cuts out beeltejuice and makes summonable standalone. Adds language support. Includes MOD magboots in the magboot feature. --- .../powers/resonant/aberrant/summonable.dm | 216 +++++++++++++++--- .../powers/summonable_language.tsx | 10 + 2 files changed, 197 insertions(+), 29 deletions(-) create mode 100644 tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/dopplershift_preferences/powers/summonable_language.tsx diff --git a/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm b/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm index 213025f6709051..b96f862ee88717 100644 --- a/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm +++ b/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm @@ -1,6 +1,8 @@ /* You can be summoned by speaking a specific keywords. */ +#define SUMMONABLE_LANGUAGE_ANY "Any" + /datum/power/aberrant/summonable name = "Summonable" desc = "By speaking a specific name or word, you appear next to the speaker after a short delay. The summoning takes time, you are stunned throughout, is entirely involuntary and can only be stopped by being silenced, buckled, wearing magboots or by being dispelled.\ @@ -11,16 +13,22 @@ required_powers = list(/datum/power/aberrant_root/anomalous) - /// Reference to the beetlejuice component - var/datum/component/beetlejuice/summonable/summon_component + /// Reference to the summonable component + var/datum/component/summonable/summon_component // Lists the word in sec records. /datum/power/aberrant/summonable/get_security_record_text() var/resolved_keyword = summon_component?.keyword + var/resolved_language_name = summon_component?.language_name if(!resolved_keyword) var/datum/preference/text/summonable_keyword/preference_entry = GLOB.preference_entries[/datum/preference/text/summonable_keyword] resolved_keyword = preference_entry?.create_default_value() || "Beetlejuice" - return "Subject is summonable via keyword \"[resolved_keyword]\"." + if(!resolved_language_name) + var/datum/preference/choiced/summonable_language/language_preference = GLOB.preference_entries[/datum/preference/choiced/summonable_language] + resolved_language_name = language_preference?.create_default_value() || SUMMONABLE_LANGUAGE_ANY + if(resolved_language_name == SUMMONABLE_LANGUAGE_ANY) + return "Subject is summonable via keyword \"[resolved_keyword]\"." + return "Subject is summonable via keyword \"[resolved_keyword]\" when spoken in [resolved_language_name]." // Gets and sets keywords /datum/power/aberrant/summonable/add(client/client_source) @@ -28,9 +36,9 @@ return ..() var/mob/living/holder = power_holder - var/datum/component/beetlejuice/summonable/component = holder.GetComponent(/datum/component/beetlejuice/summonable) + var/datum/component/summonable/component = holder.GetComponent(/datum/component/summonable) if(!component) - component = holder.AddComponent(/datum/component/beetlejuice/summonable) + component = holder.AddComponent(/datum/component/summonable) summon_component = component @@ -39,6 +47,11 @@ var/datum/preference/text/summonable_keyword/preference_entry = GLOB.preference_entries[/datum/preference/text/summonable_keyword] component.keyword = preference_entry?.create_default_value() || "Beetlejuice" + component.language_name = client_source?.prefs?.read_preference(/datum/preference/choiced/summonable_language) + if(!component.language_name) + var/datum/preference/choiced/summonable_language/language_preference = GLOB.preference_entries[/datum/preference/choiced/summonable_language] + component.language_name = language_preference?.create_default_value() || SUMMONABLE_LANGUAGE_ANY + component.rune_color = client_source?.prefs?.read_preference(/datum/preference/color/summonable_rune_color) || component.rune_color component.update_regex() @@ -49,10 +62,20 @@ if(summon_component) QDEL_NULL(summon_component) -// Custom beetlejuice component for Summonable. -/datum/component/beetlejuice/summonable - min_count = 1 - cooldown = 60 SECONDS // for the love of god don't make this shorter than 10 seconds you will break things. +// Standalone summonable component. +/datum/component/summonable + /// Keyword used to trigger the summon. + var/keyword + /// Delay between summons. + var/cooldown = 60 SECONDS // for the love of god don't make this shorter than 10 seconds you will break things. + /// Whether the summon can currently trigger. + var/active = TRUE + /// Whether the keyword match is case sensitive. + var/case_sensitive = FALSE + /// Spoken language name required to trigger the summon, or "Any" for no restriction. + var/language_name = SUMMONABLE_LANGUAGE_ANY + /// Compiled regex for matching the keyword in speech. + var/regex/keyword_regex /// Delay after your name being mentioned before the summoning begins var/summon_delay = 1 SECONDS /// How long it takes for you to fully float up @@ -81,8 +104,84 @@ /// List of currnet active runes orbiting the mob. var/list/obj/effect/summonable_rune_orbiter/current_runes -// Custom apport because frankly put its cooler this way. -/datum/component/beetlejuice/summonable/apport(atom/target) +/* + The beetlejuice component cannot satisfy our wants no more. + Because we want language integration, we need to move off of COMSIG_MOB_SAY_SPECIAL and onto COMSIG_MOB_SAY. Paired with how we are full of redundancy in the beetlejuice component, we have now moved summonable to its own standalone component. +*/ +/// Sets up summon tracking and registers speech listeners for all living mobs. +/datum/component/summonable/Initialize() + if(!ismovable(parent)) + return COMPONENT_INCOMPATIBLE + + var/atom/movable/owner = parent + keyword = owner.name + if(ismob(owner)) + var/mob/owner_mob = owner + keyword = owner_mob.real_name + update_regex() + + RegisterSignal(SSdcs, COMSIG_GLOB_MOB_CREATED, PROC_REF(register_speaker)) + for(var/mob/living/living_mob as anything in GLOB.mob_living_list) + register_speaker(null, living_mob) + +/// Cleans up global and per-mob speech listeners. +/datum/component/summonable/Destroy(force) + UnregisterSignal(SSdcs, COMSIG_GLOB_MOB_CREATED) + for(var/mob/living/living_mob as anything in GLOB.mob_living_list) + UnregisterSignal(living_mob, COMSIG_MOB_SAY) + return ..() + +/// Hooks newly created living mobs into the summon keyword listener. +/datum/component/summonable/proc/register_speaker(datum/source, mob/new_mob) + SIGNAL_HANDLER + + if(!isliving(new_mob)) + return + RegisterSignal(new_mob, COMSIG_MOB_SAY, PROC_REF(say_react)) + +/// Rebuilds the keyword regex after config changes. +/datum/component/summonable/proc/update_regex() + keyword_regex = regex("[REGEX_QUOTE(keyword)]", "g[case_sensitive ? "" : "i"]") + +/// Keeps the regex synchronized with VV edits to keyword-related vars. +/datum/component/summonable/vv_edit_var(var_name, var_value) + . = ..() + if(var_name == NAMEOF(src, keyword) || var_name == NAMEOF(src, case_sensitive)) + update_regex() + +/// Watches spoken messages for the summon keyword and starts the summon once the threshold is met. +/datum/component/summonable/proc/say_react(mob/speaker, list/say_args) + SIGNAL_HANDLER + + if(!speaker || speaker == parent || !active) + return + + var/message = say_args[SPEECH_MESSAGE] + if(!message) + return + if(!language_matches_speech(say_args[SPEECH_LANGUAGE])) + return + + var/found = keyword_regex.Find(message) + if(!found) + return + keyword_regex.next = 1 + apport(speaker) + +/// Returns TRUE when the spoken language satisfies the summon restriction. +/datum/component/summonable/proc/language_matches_speech(spoken_language) + if(language_name == SUMMONABLE_LANGUAGE_ANY) + return TRUE + if(!spoken_language) + return FALSE + + var/datum/language/required_language = GLOB.language_types_by_name[language_name] + if(!required_language) + return FALSE + return (spoken_language == required_language || ispath(spoken_language, required_language)) + +/// Proc that validates and then starts the summoning sequence. +/datum/component/summonable/proc/apport(atom/target) var/atom/movable/summoned = parent if(ismob(summoned)) var/mob/living/living_summoned = summoned @@ -104,7 +203,7 @@ addtimer(CALLBACK(src, PROC_REF(begin_summon), summoned, target_turf), summon_delay) /// Gets a valid nearby turf within the mob's area. -/datum/component/beetlejuice/summonable/proc/get_adjacent_open_turf(atom/target) +/datum/component/summonable/proc/get_adjacent_open_turf(atom/target) var/turf/center = get_turf(target) if(!center) return null @@ -122,7 +221,7 @@ return pick(candidates) /// Keeps Summonable off forbidden z-levels. -/datum/component/beetlejuice/summonable/proc/can_summon_to_turf(turf/target_turf) +/datum/component/summonable/proc/can_summon_to_turf(turf/target_turf) if(!target_turf) return FALSE if(is_centcom_level(target_turf.z)) // no more sneaking into centcomm because a medibot said "an apple a day keeps me away" @@ -130,7 +229,7 @@ return TRUE /// Prevents summoning to locations the summoned can already see. -/datum/component/beetlejuice/summonable/proc/destination_is_visible_to_summoned(atom/movable/summoned, turf/target_turf) +/datum/component/summonable/proc/destination_is_visible_to_summoned(atom/movable/summoned, turf/target_turf) if(!ismob(summoned) || !target_turf) return FALSE @@ -138,7 +237,7 @@ return (target_turf in view(summoned_mob)) /// Starts the timers and starts manifesting effects. -/datum/component/beetlejuice/summonable/proc/begin_summon(atom/movable/summoned, turf/target_turf) +/datum/component/summonable/proc/begin_summon(atom/movable/summoned, turf/target_turf) if(QDELETED(summoned) || QDELETED(target_turf)) return if(!can_summon_to_turf(target_turf)) @@ -176,14 +275,24 @@ addtimer(CALLBACK(src, PROC_REF(spawn_rune_sequence), summoned, target_turf, runes, 1, old_alpha, old_pixel_y), 0) /// Returns TRUE if the living mob has active magboots equipped. -/datum/component/beetlejuice/summonable/proc/has_active_magboots(mob/living/living_summoned) +/datum/component/summonable/proc/has_active_magboots(mob/living/living_summoned) var/obj/item/clothing/shoes/magboots/equipped_magboots = living_summoned.get_item_by_slot(ITEM_SLOT_FEET) - if(!istype(equipped_magboots)) + // worn magboots + if(istype(equipped_magboots) && equipped_magboots.magpulse) + return TRUE + + // magboots for mod modules support + var/obj/item/mod/control/worn_modsuit = living_summoned.get_item_by_slot(ITEM_SLOT_OCLOTHING) + if(!istype(worn_modsuit)) return FALSE - return equipped_magboots.magpulse + for(var/obj/item/mod/module/magboot/magboot_module as anything in worn_modsuit.modules) + if(magboot_module.active) + return TRUE + + return FALSE /// Active magboots let you resist the summons. It being handed over to this proc means it has already failed and we're just being dramatic now. -/datum/component/beetlejuice/summonable/proc/handle_magboot_lock(mob/living/living_summoned) +/datum/component/summonable/proc/handle_magboot_lock(mob/living/living_summoned) var/turf/origin_turf = get_turf(living_summoned) if(!origin_turf) return @@ -200,7 +309,7 @@ addtimer(CALLBACK(src, PROC_REF(finish_magboot_lock), living_summoned, origin_spotlight), magboot_lock_time) /// Clears the magboot fake-out without ever moving the summoned target. -/datum/component/beetlejuice/summonable/proc/finish_magboot_lock(mob/living/living_summoned, obj/effect/temp_visual/spotlight/summonable/origin_spotlight) +/datum/component/summonable/proc/finish_magboot_lock(mob/living/living_summoned, obj/effect/temp_visual/spotlight/summonable/origin_spotlight) if(!QDELETED(living_summoned)) REMOVE_TRAIT(living_summoned, TRAIT_IMMOBILIZED, "summonable_apport") if(QDELETED(origin_spotlight)) @@ -209,11 +318,11 @@ addtimer(CALLBACK(src, PROC_REF(clear_origin_spotlight), origin_spotlight), magboot_fade_time) /// Removes the spotlight -/datum/component/beetlejuice/summonable/proc/clear_origin_spotlight(obj/effect/temp_visual/spotlight/summonable/origin_spotlight) +/datum/component/summonable/proc/clear_origin_spotlight(obj/effect/temp_visual/spotlight/summonable/origin_spotlight) QDEL_NULL(origin_spotlight) /// Creates the cool floaty runes -/datum/component/beetlejuice/summonable/proc/spawn_rune_sequence(atom/movable/summoned, turf/target_turf, list/obj/effect/summonable_rune_orbiter/runes, rune_index, old_alpha, old_pixel_y) +/datum/component/summonable/proc/spawn_rune_sequence(atom/movable/summoned, turf/target_turf, list/obj/effect/summonable_rune_orbiter/runes, rune_index, old_alpha, old_pixel_y) if(!summoning) QDEL_LIST(runes) return @@ -231,7 +340,7 @@ addtimer(CALLBACK(src, PROC_REF(spawn_rune_sequence), summoned, target_turf, runes, rune_index + 1, old_alpha, old_pixel_y), rune_spawn_interval) /// BEGINS THE RAPTURE -/datum/component/beetlejuice/summonable/proc/begin_arrival(atom/movable/summoned, turf/target_turf, list/obj/effect/summonable_rune_orbiter/runes, old_alpha, old_pixel_y) +/datum/component/summonable/proc/begin_arrival(atom/movable/summoned, turf/target_turf, list/obj/effect/summonable_rune_orbiter/runes, old_alpha, old_pixel_y) if(!summoning) QDEL_LIST(runes) return @@ -259,17 +368,17 @@ addtimer(CALLBACK(src, PROC_REF(finish_summon), summoned, target_turf, old_alpha, old_pixel_y, spotlight), float_time) /// Fade and clear the runes. -/datum/component/beetlejuice/summonable/proc/fade_and_clear_runes(list/obj/effect/summonable_rune_orbiter/runes) +/datum/component/summonable/proc/fade_and_clear_runes(list/obj/effect/summonable_rune_orbiter/runes) for(var/obj/effect/summonable_rune_orbiter/rune in runes) animate(rune, alpha = 0, time = rune_fade_time) addtimer(CALLBACK(src, PROC_REF(clear_runes), runes), rune_fade_time) /// Removes all active runes. -/datum/component/beetlejuice/summonable/proc/clear_runes(list/obj/effect/summonable_rune_orbiter/runes) +/datum/component/summonable/proc/clear_runes(list/obj/effect/summonable_rune_orbiter/runes) QDEL_LIST(runes) /// Alright, shows over, he's here now. Time to pack up and go. -/datum/component/beetlejuice/summonable/proc/finish_summon(atom/movable/summoned, turf/target_turf, old_alpha, old_pixel_y, obj/effect/temp_visual/spotlight/summonable/spotlight) +/datum/component/summonable/proc/finish_summon(atom/movable/summoned, turf/target_turf, old_alpha, old_pixel_y, obj/effect/temp_visual/spotlight/summonable/spotlight) if(QDELETED(summoned)) QDEL_NULL(spotlight) return @@ -297,7 +406,7 @@ addtimer(VARSET_CALLBACK(src, active, TRUE), cooldown) /// Ends summon at certain stages. -/datum/component/beetlejuice/summonable/proc/on_dispel(atom/movable/target, atom/dispeller) +/datum/component/summonable/proc/on_dispel(atom/movable/target, atom/dispeller) SIGNAL_HANDLER // Only cancel if they're currently being beamed up. if(!beaming_up || !summoning) @@ -317,7 +426,7 @@ return DISPEL_RESULT_DISPELLED /// Ends the summoning right there and now. -/datum/component/beetlejuice/summonable/proc/cancel_summon(atom/movable/summoned) +/datum/component/summonable/proc/cancel_summon(atom/movable/summoned) if(summoned) summoned.alpha = initial(summoned.alpha) summoned.pixel_y = initial(summoned.pixel_y) @@ -379,9 +488,56 @@ /datum/preference/color/summonable_rune_color/apply_to_human(mob/living/carbon/human/target, value) return +/// Preference options for which language can trigger your summon. +/datum/preference/choiced/summonable_language + category = PREFERENCE_CATEGORY_MANUALLY_RENDERED + savefile_key = "summonable_language" + savefile_identifier = PREFERENCE_CHARACTER + should_generate_icons = TRUE + +/datum/preference/choiced/summonable_language/create_default_value() + return SUMMONABLE_LANGUAGE_ANY + +/// Gets an icon for the chosen summonable language. +/datum/preference/choiced/summonable_language/icon_for(value) + if(value == SUMMONABLE_LANGUAGE_ANY) + var/datum/universal_icon/any_icon = uni_icon('icons/ui/chat/language.dmi', "unknown") + any_icon.scale(32, 32) + return any_icon + + var/datum/language/language_type = GLOB.language_types_by_name[value] + if(language_type) + var/datum/universal_icon/language_icon = uni_icon(language_type.icon, language_type.icon_state) + language_icon.scale(32, 32) + return language_icon + + var/datum/universal_icon/unknown_icon = uni_icon('icons/ui/chat/language.dmi', "unknown") + unknown_icon.scale(32, 32) + return unknown_icon + +/// Uses the same language pool as the bilingual preference, plus an unrestricted option. +/datum/preference/choiced/summonable_language/init_possible_values() + var/list/values = list() + + if(!GLOB.uncommon_roundstart_languages.len) + generate_selectable_species_and_languages() + + values += SUMMONABLE_LANGUAGE_ANY + values += /datum/language/uncommon::name + + for(var/datum/language/language_type as anything in GLOB.uncommon_roundstart_languages) + if(initial(language_type.name) in values) + continue + values += initial(language_type.name) + + return values + +/datum/preference/choiced/summonable_language/apply_to_human(mob/living/carbon/human/target, value) + return + /datum/power_constant_data/summonable associated_typepath = /datum/power/aberrant/summonable - customization_options = list(/datum/preference/text/summonable_keyword, /datum/preference/color/summonable_rune_color) + customization_options = list(/datum/preference/text/summonable_keyword, /datum/preference/color/summonable_rune_color, /datum/preference/choiced/summonable_language) // Orbiting rune for Summonable arrival. /obj/effect/summonable_rune_orbiter @@ -413,3 +569,5 @@ /obj/effect/temp_visual/spotlight/summonable/Initialize(mapload, spotlight_color = COLOR_RED) color = spotlight_color return ..() + +#undef SUMMONABLE_LANGUAGE_ANY diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/dopplershift_preferences/powers/summonable_language.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/dopplershift_preferences/powers/summonable_language.tsx new file mode 100644 index 00000000000000..280881a3e9cf0c --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/dopplershift_preferences/powers/summonable_language.tsx @@ -0,0 +1,10 @@ +import { + FeatureIconnedDropdownInput, + type FeatureWithIcons, +} from '../../dropdowns'; + +export const summonable_language: FeatureWithIcons = { + name: 'Summonable Language', + description: 'Restricts which spoken language can trigger your summon.', + component: FeatureIconnedDropdownInput, +}; From 9fabb8832795efd4e94e0a0ee2b18dea0c21538b Mon Sep 17 00:00:00 2001 From: TheOneAndOnlyCreeperJoe Date: Wed, 15 Jul 2026 14:35:18 +0200 Subject: [PATCH 3/7] Various clarifying comments. Buckles now also does the funny shakes. --- .../powers/resonant/aberrant/summonable.dm | 65 ++++++++++++------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm b/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm index b96f862ee88717..fec89e74c48de9 100644 --- a/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm +++ b/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm @@ -1,8 +1,10 @@ /* - You can be summoned by speaking a specific keywords. + You can be summoned by speaking a specific keyword, in a certain language, with a ton of caveats. */ #define SUMMONABLE_LANGUAGE_ANY "Any" +/// BIG TODO: Once Powers PR2 merges adjust this to Imbued and not Aberrant + /datum/power/aberrant/summonable name = "Summonable" desc = "By speaking a specific name or word, you appear next to the speaker after a short delay. The summoning takes time, you are stunned throughout, is entirely involuntary and can only be stopped by being silenced, buckled, wearing magboots or by being dispelled.\ @@ -26,32 +28,35 @@ if(!resolved_language_name) var/datum/preference/choiced/summonable_language/language_preference = GLOB.preference_entries[/datum/preference/choiced/summonable_language] resolved_language_name = language_preference?.create_default_value() || SUMMONABLE_LANGUAGE_ANY - if(resolved_language_name == SUMMONABLE_LANGUAGE_ANY) + if(resolved_language_name == SUMMONABLE_LANGUAGE_ANY) // if no language return "Subject is summonable via keyword \"[resolved_keyword]\"." return "Subject is summonable via keyword \"[resolved_keyword]\" when spoken in [resolved_language_name]." -// Gets and sets keywords +// Gets and sets various prefs + the component /datum/power/aberrant/summonable/add(client/client_source) if(!power_holder) return ..() + // Gets the component on the mob var/mob/living/holder = power_holder var/datum/component/summonable/component = holder.GetComponent(/datum/component/summonable) if(!component) component = holder.AddComponent(/datum/component/summonable) - summon_component = component + // Gets the keywords from prefs component.keyword = client_source?.prefs?.read_preference(/datum/preference/text/summonable_keyword) if(!component.keyword) var/datum/preference/text/summonable_keyword/preference_entry = GLOB.preference_entries[/datum/preference/text/summonable_keyword] component.keyword = preference_entry?.create_default_value() || "Beetlejuice" + // Gets the language from prefs component.language_name = client_source?.prefs?.read_preference(/datum/preference/choiced/summonable_language) if(!component.language_name) var/datum/preference/choiced/summonable_language/language_preference = GLOB.preference_entries[/datum/preference/choiced/summonable_language] component.language_name = language_preference?.create_default_value() || SUMMONABLE_LANGUAGE_ANY + // Gets the color from prefs component.rune_color = client_source?.prefs?.read_preference(/datum/preference/color/summonable_rune_color) || component.rune_color component.update_regex() @@ -80,10 +85,10 @@ var/summon_delay = 1 SECONDS /// How long it takes for you to fully float up var/float_time = 3.5 SECONDS - /// How long active magboots hold you in place before the summon fizzles. - var/magboot_lock_time = 2 SECONDS - /// How long the failed magboot spotlight takes to fade out. - var/magboot_fade_time = 0.5 SECONDS + /// How long a failed summon resistance holds you in place before the summon fizzles. + var/resist_lock_time = 2 SECONDS + /// How long the failed summon spotlight takes to fade out. + var/resist_fade_time = 0.5 SECONDS /// Radius for orbiting runes var/rune_orbit_radius = 30 /// Rotation speed for orbiting runes @@ -108,7 +113,7 @@ The beetlejuice component cannot satisfy our wants no more. Because we want language integration, we need to move off of COMSIG_MOB_SAY_SPECIAL and onto COMSIG_MOB_SAY. Paired with how we are full of redundancy in the beetlejuice component, we have now moved summonable to its own standalone component. */ -/// Sets up summon tracking and registers speech listeners for all living mobs. +/// Sets up summon tracking and registers speech listeners for all living mobs. This is how beetlejuice did it, and admittedly there's no easier way to do it. /datum/component/summonable/Initialize() if(!ismovable(parent)) return COMPONENT_INCOMPATIBLE @@ -149,7 +154,7 @@ if(var_name == NAMEOF(src, keyword) || var_name == NAMEOF(src, case_sensitive)) update_regex() -/// Watches spoken messages for the summon keyword and starts the summon once the threshold is met. +/// Watches spoken messages for the summon keyword and starts the summon once it matches. /datum/component/summonable/proc/say_react(mob/speaker, list/say_args) SIGNAL_HANDLER @@ -185,7 +190,7 @@ var/atom/movable/summoned = parent if(ismob(summoned)) var/mob/living/living_summoned = summoned - if(living_summoned.buckled || HAS_TRAIT(living_summoned, TRAIT_RESONANCE_SILENCED)) + if(HAS_TRAIT(living_summoned, TRAIT_RESONANCE_SILENCED)) return // We don't block .loc for the sake of it being funny to be yoinked out of things, but cryopods are too integral to not. if(istype(summoned.loc, /obj/machinery/cryopod)) @@ -200,6 +205,15 @@ return active = FALSE addtimer(VARSET_CALLBACK(src, active, TRUE), cooldown) + if(isliving(summoned)) + var/mob/living/living_summoned = summoned + if(living_summoned.buckled) + handle_resisted_summon( + living_summoned, + span_warning("[living_summoned] strains against an invisible pull upward, but remains held fast!"), + span_warning("An invisible force tries to pull you away into the air, but whatever has you buckled keeps you in place!") + ) + return addtimer(CALLBACK(src, PROC_REF(begin_summon), summoned, target_turf), summon_delay) /// Gets a valid nearby turf within the mob's area. @@ -248,7 +262,11 @@ return // Magboots prevent summons by just sheer magnetism. YE SCIENCE BITCH- if(has_active_magboots(living_summoned)) - handle_magboot_lock(living_summoned) + handle_resisted_summon( + living_summoned, + span_warning("[living_summoned] strains against an invisible pull upward, but their magboots hold fast!"), + span_warning("An invisible force tries to pull you away into the air, but your magboots lock you in place!") + ) return summoning = TRUE beaming_up = TRUE @@ -282,7 +300,7 @@ return TRUE // magboots for mod modules support - var/obj/item/mod/control/worn_modsuit = living_summoned.get_item_by_slot(ITEM_SLOT_OCLOTHING) + var/obj/item/mod/control/worn_modsuit = living_summoned.get_item_by_slot(ITEM_SLOT_BACK) if(!istype(worn_modsuit)) return FALSE for(var/obj/item/mod/module/magboot/magboot_module as anything in worn_modsuit.modules) @@ -291,31 +309,30 @@ return FALSE -/// Active magboots let you resist the summons. It being handed over to this proc means it has already failed and we're just being dramatic now. -/datum/component/summonable/proc/handle_magboot_lock(mob/living/living_summoned) +/// Plays the failed-summon fakeout when something holds the target in place. +/datum/component/summonable/proc/handle_resisted_summon(mob/living/living_summoned, visible_message_text, self_message_text) var/turf/origin_turf = get_turf(living_summoned) if(!origin_turf) return var/obj/effect/temp_visual/spotlight/summonable/origin_spotlight = new(origin_turf, rune_color) - // in my head people are doing the "wacky arm inflatable tube man" effect with their body living_summoned.visible_message( - span_warning("[living_summoned] strains against an invisible pull upward, but their magboots hold fast!"), - span_warning("An invisible force tries to pull you away into the air, but your magboots lock you in place!") + visible_message_text, + self_message_text ) ADD_TRAIT(living_summoned, TRAIT_IMMOBILIZED, "summonable_apport") - living_summoned.Shake(pixelshiftx = 2, pixelshifty = 1, duration = magboot_lock_time, shake_interval = 0.04 SECONDS) - addtimer(CALLBACK(src, PROC_REF(finish_magboot_lock), living_summoned, origin_spotlight), magboot_lock_time) + living_summoned.Shake(pixelshiftx = 2, pixelshifty = 1, duration = resist_lock_time, shake_interval = 0.04 SECONDS) + addtimer(CALLBACK(src, PROC_REF(finish_resisted_summon), living_summoned, origin_spotlight), resist_lock_time) -/// Clears the magboot fake-out without ever moving the summoned target. -/datum/component/summonable/proc/finish_magboot_lock(mob/living/living_summoned, obj/effect/temp_visual/spotlight/summonable/origin_spotlight) +/// Clears the failed-summon fakeout without ever moving the summoned target. +/datum/component/summonable/proc/finish_resisted_summon(mob/living/living_summoned, obj/effect/temp_visual/spotlight/summonable/origin_spotlight) if(!QDELETED(living_summoned)) REMOVE_TRAIT(living_summoned, TRAIT_IMMOBILIZED, "summonable_apport") if(QDELETED(origin_spotlight)) return - animate(origin_spotlight, alpha = 0, time = magboot_fade_time) - addtimer(CALLBACK(src, PROC_REF(clear_origin_spotlight), origin_spotlight), magboot_fade_time) + animate(origin_spotlight, alpha = 0, time = resist_fade_time) + addtimer(CALLBACK(src, PROC_REF(clear_origin_spotlight), origin_spotlight), resist_fade_time) /// Removes the spotlight /datum/component/summonable/proc/clear_origin_spotlight(obj/effect/temp_visual/spotlight/summonable/origin_spotlight) From daf9732ac874ddcd14a11f9f2d8c47eeb60e48e2 Mon Sep 17 00:00:00 2001 From: TheOneAndOnlyCreeperJoe Date: Wed, 15 Jul 2026 17:10:02 +0200 Subject: [PATCH 4/7] Removes secret languages and inaccessible languages --- .../code/powers/resonant/aberrant/summonable.dm | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm b/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm index 4cf2df644bbba6..9c8d7e5b6f2d2a 100644 --- a/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm +++ b/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm @@ -535,18 +535,31 @@ /// Uses the same language pool as the bilingual preference, plus an unrestricted option. /datum/preference/choiced/summonable_language/init_possible_values() var/list/values = list() + var/datum/species/species = GLOB.species_prototypes[/datum/species/human] // we can't read species choice in choiced components since they're generic, so we default to human + var/datum/language_holder/lang_holder = null if(!GLOB.uncommon_roundstart_languages.len) generate_selectable_species_and_languages() + if(species) + lang_holder = new species.species_language_holder() + values += SUMMONABLE_LANGUAGE_ANY - values += /datum/language/uncommon::name + // Iterates all languages, curbing any secret languages. for(var/datum/language/language_type as anything in GLOB.uncommon_roundstart_languages) + var/datum/language/language = GLOB.language_datum_instances[language_type] + if(language?.secret) + continue + if(species?.always_customizable && lang_holder && !(language.type in lang_holder.spoken_languages)) + continue if(initial(language_type.name) in values) continue values += initial(language_type.name) + if(lang_holder) + qdel(lang_holder) + return values /datum/preference/choiced/summonable_language/apply_to_human(mob/living/carbon/human/target, value) From 84756db8e13aa927dee1a5fc5744af78657244ca Mon Sep 17 00:00:00 2001 From: TheOneAndOnlyCreeperJoe Date: Tue, 28 Jul 2026 18:37:02 +0200 Subject: [PATCH 5/7] typo --- .../modular_powers/code/powers/resonant/aberrant/summonable.dm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm b/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm index 9c8d7e5b6f2d2a..0831647cfbb225 100644 --- a/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm +++ b/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm @@ -8,7 +8,7 @@ /datum/power/aberrant/summonable name = "Summonable" desc = "By speaking a specific name or word (and depending on your choice: in a specific language), you appear next to the speaker after a short delay. The summoning takes time, you are stunned throughout, is entirely involuntary and can only be stopped by being silenced, buckled, wearing magboots or by being dispelled.\ - \n After being succesfuly summoned, you are unable to be summoned again for 1 minute. \ + \n After being successfully summoned, you are unable to be summoned again for 1 minute. \ \n The chosen word is a partial secret; the Security Records on your powers contain the word as well. It cannot contain any special characters, only standard letters and numbers." security_threat = POWER_THREAT_MAJOR value = 7 From d450c798c349de8ed34a98c646378279ddd1db05 Mon Sep 17 00:00:00 2001 From: TheOneAndOnlyCreeperJoe Date: Sun, 2 Aug 2026 11:10:10 +0200 Subject: [PATCH 6/7] I can't believe I have to fix this by hand --- .../{aberrant => imbued}/summonable.dm | 20 +++++++++---------- tgstation.dme | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) rename modular_doppler/modular_powers/code/powers/resonant/{aberrant => imbued}/summonable.dm (98%) diff --git a/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm b/modular_doppler/modular_powers/code/powers/resonant/imbued/summonable.dm similarity index 98% rename from modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm rename to modular_doppler/modular_powers/code/powers/resonant/imbued/summonable.dm index 0831647cfbb225..cf72c86ca7ee9c 100644 --- a/modular_doppler/modular_powers/code/powers/resonant/aberrant/summonable.dm +++ b/modular_doppler/modular_powers/code/powers/resonant/imbued/summonable.dm @@ -2,24 +2,24 @@ You can be summoned by speaking a specific keyword, in a certain language, with a ton of caveats. */ #define SUMMONABLE_LANGUAGE_ANY "Any" - -/// BIG TODO: Once Powers PR2 merges adjust this to Imbued and not Aberrant - -/datum/power/aberrant/summonable +/datum/power/imbued/summonable name = "Summonable" desc = "By speaking a specific name or word (and depending on your choice: in a specific language), you appear next to the speaker after a short delay. The summoning takes time, you are stunned throughout, is entirely involuntary and can only be stopped by being silenced, buckled, wearing magboots or by being dispelled.\ \n After being successfully summoned, you are unable to be summoned again for 1 minute. \ \n The chosen word is a partial secret; the Security Records on your powers contain the word as well. It cannot contain any special characters, only standard letters and numbers." security_threat = POWER_THREAT_MAJOR value = 7 + magic_flags = POWER_MAGIC_STANDARD + required_powers = list(/datum/power/imbued_root/enchanted) - required_powers = list(/datum/power/aberrant_root/anomalous) + menu_icon = 'icons/effects/eldritch.dmi' + menu_icon_state = "realitycrack" /// Reference to the summonable component var/datum/component/summonable/summon_component // Lists the word in sec records. -/datum/power/aberrant/summonable/get_security_record_text() +/datum/power/imbued/summonable/get_security_record_text() var/resolved_keyword = summon_component?.keyword var/resolved_language_name = summon_component?.language_name if(!resolved_keyword) @@ -32,8 +32,8 @@ return "Subject is summonable via keyword \"[resolved_keyword]\"." return "Subject is summonable via keyword \"[resolved_keyword]\" when spoken in [resolved_language_name]." -// Gets and sets various prefs + the component -/datum/power/aberrant/summonable/add(client/client_source) +// Gets and sets keywords +/datum/power/imbued/summonable/add(client/client_source) if(!power_holder) return ..() @@ -62,7 +62,7 @@ . = ..() -/datum/power/aberrant/summonable/remove() +/datum/power/imbued/summonable/remove() . = ..() if(summon_component) QDEL_NULL(summon_component) @@ -566,7 +566,7 @@ return /datum/power_constant_data/summonable - associated_typepath = /datum/power/aberrant/summonable + associated_typepath = /datum/power/imbued/summonable customization_options = list(/datum/preference/text/summonable_keyword, /datum/preference/color/summonable_rune_color, /datum/preference/choiced/summonable_language) // Orbiting rune for Summonable arrival. diff --git a/tgstation.dme b/tgstation.dme index 212253e407290b..ef6c30abdec79d 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7578,7 +7578,6 @@ #include "modular_doppler\modular_powers\code\powers\resonant\aberrant\shapechange.dm" #include "modular_doppler\modular_powers\code\powers\resonant\aberrant\shapechange_spider.dm" #include "modular_doppler\modular_powers\code\powers\resonant\aberrant\shapechange_wolf.dm" -#include "modular_doppler\modular_powers\code\powers\resonant\aberrant\summonable.dm" #include "modular_doppler\modular_powers\code\powers\resonant\aberrant\tail_sweep.dm" #include "modular_doppler\modular_powers\code\powers\resonant\aberrant\vent_crawl.dm" #include "modular_doppler\modular_powers\code\powers\resonant\aberrant\riftwalker\_riftwalker_datum.dm" @@ -7604,6 +7603,7 @@ #include "modular_doppler\modular_powers\code\powers\resonant\cultivator\shadowwalker_root.dm" #include "modular_doppler\modular_powers\code\powers\resonant\cultivator\travel_under_the_veil_of_night.dm" #include "modular_doppler\modular_powers\code\powers\resonant\cultivator\vanish_unseen_into_shadow.dm" +#include "modular_doppler\modular_powers\code\powers\resonant\imbued\summonable.dm" #include "modular_doppler\modular_powers\code\powers\resonant\psyker\_psyker_action.dm" #include "modular_doppler\modular_powers\code\powers\resonant\psyker\_psyker_power.dm" #include "modular_doppler\modular_powers\code\powers\resonant\psyker\_psyker_root.dm" From 522dab6b9e78bb0a8b62ac67250a30cd3cd70f33 Mon Sep 17 00:00:00 2001 From: TheOneAndOnlyCreeperJoe Date: Sun, 2 Aug 2026 11:29:28 +0200 Subject: [PATCH 7/7] Disables randomization Summonable so it goes back to the proper defualt. --- .../modular_powers/code/powers/resonant/imbued/summonable.dm | 1 + 1 file changed, 1 insertion(+) diff --git a/modular_doppler/modular_powers/code/powers/resonant/imbued/summonable.dm b/modular_doppler/modular_powers/code/powers/resonant/imbued/summonable.dm index cf72c86ca7ee9c..14ea876473b719 100644 --- a/modular_doppler/modular_powers/code/powers/resonant/imbued/summonable.dm +++ b/modular_doppler/modular_powers/code/powers/resonant/imbued/summonable.dm @@ -510,6 +510,7 @@ category = PREFERENCE_CATEGORY_MANUALLY_RENDERED savefile_key = "summonable_language" savefile_identifier = PREFERENCE_CHARACTER + can_randomize = FALSE should_generate_icons = TRUE /datum/preference/choiced/summonable_language/create_default_value()