Adds a subtle ghost poll (#79105)

## About The Pull Request
This makes a new ghost poll system which doesn't give TGUI popups -
instead, users are prompted to follow the POI and one of the orbiters is
chosen. The old system remains in place, so you can still prompt if you
want to.

This gives two things:
1. A deadchat notification:

![image](https://github.com/tgstation/tgstation/assets/42397676/073fcfff-b1ed-47c3-bae0-4abf9c599144)
2. A screen alert:

![image](https://github.com/tgstation/tgstation/assets/42397676/92a4e566-614a-43ca-8680-3cb4ff86ced9)

## Why It's Good For The Game
As stated in #76507, popups are pretty annoying. This is halfway between
a screen alert with no time limit and an event with more important
pings. This is better because:
1. Less popup fatigue
2. You can SEE how many you're competing with
4. DRY
## Changelog
🆑
add: Adds a subtle ghost poll. This pings in dead chat and gives a
screen alert, but no TGUI popup. Orbit the point of interest to be
selected for the role.
refactor: A number of ghost spawns now feature this alert. Write an
issue report if anything breaks.
/🆑

---------

Co-authored-by: MrMelbert <51863163+MrMelbert@users.noreply.github.com>
This commit is contained in:
Jeremiah
2023-10-21 20:21:36 +00:00
committed by GitHub
co-authored by MrMelbert
parent 39a4440e6d
commit be5e7ccb02
11 changed files with 261 additions and 91 deletions
+18 -7
View File
@@ -45,15 +45,26 @@
/datum/brain_trauma/special/imaginary_friend/proc/make_friend()
friend = new(get_turf(owner), owner)
/// Tries an orbit poll for the imaginary friend
/datum/brain_trauma/special/imaginary_friend/proc/get_ghost()
set waitfor = FALSE
var/list/mob/dead/observer/candidates = poll_candidates_for_mob("Do you want to play as [owner.real_name]'s imaginary friend?", ROLE_PAI, null, 7.5 SECONDS, friend, POLL_IGNORE_IMAGINARYFRIEND)
if(LAZYLEN(candidates))
var/mob/dead/observer/C = pick(candidates)
friend.key = C.key
friend_initialized = TRUE
else
var/datum/callback/to_call = CALLBACK(src, PROC_REF(add_friend))
owner.AddComponent(/datum/component/orbit_poll, \
ignore_key = POLL_IGNORE_IMAGINARYFRIEND, \
job_bans = ROLE_PAI, \
title = "[owner.real_name]'s imaginary friend", \
to_call = to_call, \
)
/// Yay more friends!
/datum/brain_trauma/special/imaginary_friend/proc/add_friend(mob/dead/observer/ghost)
if(isnull(ghost))
qdel(src)
return
friend.key = ghost.key
friend_initialized = TRUE
friend.log_message("became [key_name(owner)]'s split personality.", LOG_GAME)
message_admins("[ADMIN_LOOKUPFLW(friend)] became [ADMIN_LOOKUPFLW(owner)]'s split personality.")
/mob/camera/imaginary_friend
name = "imaginary friend"
+18 -9
View File
@@ -32,17 +32,26 @@
var/datum/action/cooldown/spell/personality_commune/owner_spell = new(src)
owner_spell.Grant(owner_backseat)
/// Attempts to get a ghost to play the personality
/datum/brain_trauma/severe/split_personality/proc/get_ghost()
set waitfor = FALSE
var/list/mob/dead/observer/candidates = poll_candidates_for_mob("Do you want to play as [owner.real_name]'s [poll_role]?", ROLE_PAI, null, 7.5 SECONDS, stranger_backseat, POLL_IGNORE_SPLITPERSONALITY)
if(LAZYLEN(candidates))
var/mob/dead/observer/C = pick(candidates)
stranger_backseat.key = C.key
stranger_backseat.log_message("became [key_name(owner)]'s split personality.", LOG_GAME)
message_admins("[ADMIN_LOOKUPFLW(stranger_backseat)] became [ADMIN_LOOKUPFLW(owner)]'s split personality.")
else
var/datum/callback/to_call = CALLBACK(src, PROC_REF(schism))
owner.AddComponent(/datum/component/orbit_poll, \
ignore_key = POLL_IGNORE_SPLITPERSONALITY, \
job_bans = ROLE_PAI, \
title = "[owner.real_name]'s [poll_role]", \
to_call = to_call, \
)
/// Ghost poll has concluded
/datum/brain_trauma/severe/split_personality/proc/schism(mob/dead/observer/ghost)
if(isnull(ghost))
qdel(src)
return
stranger_backseat.key = ghost.key
stranger_backseat.log_message("became [key_name(owner)]'s split personality.", LOG_GAME)
message_admins("[ADMIN_LOOKUPFLW(stranger_backseat)] became [ADMIN_LOOKUPFLW(owner)]'s split personality.")
/datum/brain_trauma/severe/split_personality/on_life(seconds_per_tick, times_fired)
if(owner.stat == DEAD)
+108
View File
@@ -0,0 +1,108 @@
/**
* A replacement for the standard poll_ghost_candidate.
* Use this to subtly ask players to join - it picks from orbiters.
* Please use named arguments for this.
*
* @params ignore_key - Required so it doesn't spam
* @params job_bans - You can insert a list or single items here.
* @params cb - Invokes this proc and appends the poll winner as the last argument, mob/dead/observer/ghost
* @params title - Optional. Useful if the role name does not match the parent.
*
* @usage
* ```
* var/datum/callback/cb = CALLBACK(src, PROC_REF(do_stuff), arg1, arg2)
* AddComponent(/datum/component/orbit_poll, \
* ignore_key = POLL_IGNORE_EXAMPLE, \
* job_bans = ROLE_EXAMPLE or list(ROLE_EXAMPLE, ROLE_EXAMPLE2), \
* title = "Use this if you want something other than the parent name", \
* to_call = cb, \
* )
*/
/datum/component/orbit_poll
/// Prevent players with this ban from being selected
var/list/job_bans = list()
/// Title of the role to announce after it's done
var/title
/// Proc to invoke whenever the poll is complete
var/datum/callback/to_call
/datum/component/orbit_poll/Initialize( \
ignore_key, \
list/job_bans, \
datum/callback/to_call, \
title, \
header = "Ghost Poll", \
custom_message, \
timeout = 20 SECONDS \
)
. = ..()
if (!isatom(parent))
return COMPONENT_INCOMPATIBLE
var/atom/owner = parent
src.job_bans |= job_bans
src.title = title || owner.name
src.to_call = to_call
var/message = custom_message || "[capitalize(src.title)] is looking for volunteers"
notify_ghosts("[message]. An orbiter will be chosen in [DisplayTimeText(timeout)].\n", \
action = NOTIFY_ORBIT, \
enter_link = "<a href='?src=[REF(src)];ignore=[ignore_key]'>(Ignore)</a>", \
flashwindow = FALSE, \
header = "Volunteers requested", \
ignore_key = ignore_key, \
source = parent \
)
addtimer(CALLBACK(src, PROC_REF(end_poll)), timeout, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_STOPPABLE|TIMER_DELETE_ME)
/datum/component/orbit_poll/Topic(href, list/href_list)
if(!href_list["ignore"])
return
var/mob/user = usr
var/ignore_key = href_list["ignore"]
if(tgui_alert(user, "Ignore further [title] alerts?", "Ignore Alert", list("Yes", "No"), 20 SECONDS, TRUE) != "Yes")
return
GLOB.poll_ignore[ignore_key] |= user.ckey
/// Concludes the poll, picking one of the orbiters
/datum/component/orbit_poll/proc/end_poll()
if(QDELETED(parent))
return
var/list/candidates = list()
var/atom/owner = parent
var/datum/component/orbiter/orbiter_comp = owner.GetComponent(/datum/component/orbiter)
if(isnull(orbiter_comp))
phone_home()
return
for(var/mob/dead/observer/ghost as anything in orbiter_comp.orbiter_list)
if(QDELETED(ghost) || isnull(ghost.client))
continue
if(is_banned_from(ghost.ckey, job_bans))
continue
candidates += ghost
if(!length(candidates))
phone_home()
return
var/mob/dead/observer/chosen = pick(candidates)
if(chosen)
deadchat_broadcast("[key_name(chosen, include_name = FALSE)] was selected for the role ([title]).", "Ghost Poll: ", parent)
phone_home(chosen)
/// Make sure to call your parents my dude
/datum/component/orbit_poll/proc/phone_home(mob/dead/observer/chosen)
to_call.Invoke(chosen)
qdel(src)
+18 -9
View File
@@ -57,21 +57,30 @@
attempting_awakening = TRUE
to_chat(awakener, span_notice("You attempt to wake the spirit of [parent]..."))
var/mob/dead/observer/candidates = poll_ghost_candidates("Do you want to play as the spirit of [awakener.real_name]'s blade?", ROLE_PAI, FALSE, 100, POLL_IGNORE_POSSESSED_BLADE)
if(!LAZYLEN(candidates))
to_chat(awakener, span_warning("[parent] is dormant. Maybe you can try again later."))
attempting_awakening = FALSE
return
var/datum/callback/to_call = CALLBACK(src, PROC_REF(affix_spirit), awakener)
parent.AddComponent(/datum/component/orbit_poll, \
ignore_key = POLL_IGNORE_POSSESSED_BLADE, \
job_bans = ROLE_PAI, \
to_call = to_call, \
title = "Spirit of [awakener.real_name]'s blade", \
)
//Immediately unregister to prevent making a new spirit
UnregisterSignal(parent, COMSIG_ITEM_ATTACK_SELF)
var/mob/dead/observer/chosen_spirit = pick(candidates)
if(QDELETED(parent)) //if the thing that we're conjuring a spirit in has been destroyed, don't create a spirit
to_chat(chosen_spirit, span_userdanger("The new vessel for your spirit has been destroyed! You remain an unbound ghost."))
/// On conclusion of the ghost poll
/datum/component/spirit_holding/proc/affix_spirit(mob/awakener, mob/dead/observer/ghost)
if(isnull(ghost))
to_chat(awakener, span_warning("[parent] is dormant. Maybe you can try again later."))
attempting_awakening = FALSE
return
if(QDELETED(parent)) //if the thing that we're conjuring a spirit in has been destroyed, don't create a spirit
to_chat(ghost, span_userdanger("The new vessel for your spirit has been destroyed! You remain an unbound ghost."))
return
bound_spirit = new(parent)
bound_spirit.ckey = chosen_spirit.ckey
bound_spirit.ckey = ghost.ckey
bound_spirit.fully_replace_character_name(null, "The spirit of [parent]")
bound_spirit.status_flags |= GODMODE
bound_spirit.copy_languages(awakener, LANGUAGE_MASTER) //Make sure the sword can understand and communicate with the awakener.
+14 -11
View File
@@ -28,26 +28,29 @@
finding_candidate = FALSE
return TRUE
/// Sets up the ghost poll
/obj/item/aicard/syndie/loaded/proc/procure_ai(mob/user)
var/datum/antagonist/nukeop/op_datum = user.mind?.has_antag_datum(/datum/antagonist/nukeop,TRUE)
if(isnull(op_datum))
balloon_alert(user, "invalid access!")
return
var/list/nuke_candidates = poll_ghost_candidates(
question = "Do you want to play as a nuclear operative MODsuit AI?",
jobban_type = ROLE_OPERATIVE,
be_special_flag = ROLE_OPERATIVE_MIDROUND,
poll_time = 15 SECONDS,
ignore_category = POLL_IGNORE_SYNDICATE,
var/datum/callback/to_call = CALLBACK(src, PROC_REF(on_poll_concluded), user, op_datum)
AddComponent(/datum/component/orbit_poll, \
ignore_key = POLL_IGNORE_SYNDICATE, \
job_bans = ROLE_OPERATIVE, \
to_call = to_call, \
title = "Nuclear Operative Modsuit AI" \
)
if(QDELETED(src))
return
if(!LAZYLEN(nuke_candidates))
/// Poll has concluded with a ghost, create the AI
/obj/item/aicard/syndie/loaded/proc/on_poll_concluded(mob/user, datum/antagonist/nukeop/op_datum, mob/dead/observer/ghost)
if(isnull(ghost))
to_chat(user, span_warning("Unable to connect to S.E.L.F. dispatch. Please wait and try again later or use the intelliCard on your uplink to get your points refunded."))
return
// pick ghost, create AI and transfer
var/mob/dead/observer/ghos = pick(nuke_candidates)
var/mob/living/silicon/ai/weak_syndie/new_ai = new /mob/living/silicon/ai/weak_syndie(get_turf(src), new /datum/ai_laws/syndicate_override, ghos)
var/mob/living/silicon/ai/weak_syndie/new_ai = new /mob/living/silicon/ai/weak_syndie(get_turf(src), new /datum/ai_laws/syndicate_override, ghost)
// create and apply syndie datum
var/datum/antagonist/nukeop/nuke_datum = new()
nuke_datum.send_to_spawnpoint = FALSE
+13 -6
View File
@@ -189,14 +189,22 @@
to_chat(src, span_notice("You attempt to produce a blobbernaut."))
pick_blobbernaut_candidate(factory)
/** Polls ghosts to get a blobbernaut candidate. */
/// Polls ghosts to get a blobbernaut candidate.
/mob/camera/blob/proc/pick_blobbernaut_candidate(obj/structure/blob/special/factory/factory)
if(!factory)
if(isnull(factory))
return
var/list/mob/dead/observer/candidates = poll_ghost_candidates("Do you want to play as a [blobstrain.name] blobbernaut?", ROLE_BLOB, ROLE_BLOB, 50)
var/datum/callback/to_call = CALLBACK(src, PROC_REF(on_poll_concluded), factory)
factory.AddComponent(/datum/component/orbit_poll, \
ignore_key = POLL_IGNORE_BLOB, \
job_bans = ROLE_BLOB, \
to_call = to_call, \
title = "Blobbernaut", \
)
if(!length(candidates))
/// Called when the ghost poll concludes
/mob/camera/blob/proc/on_poll_concluded(obj/structure/blob/special/factory/factory, mob/dead/observer/ghost)
if(isnull(ghost))
to_chat(src, span_warning("You could not conjure a sentience for your blobbernaut. Your points have been refunded. Try again later."))
add_points(BLOBMOB_BLOBBERNAUT_RESOURCE_COST)
factory.assign_blobbernaut(null)
@@ -205,8 +213,7 @@
var/mob/living/basic/blob_minion/blobbernaut/minion/blobber = new(get_turf(factory))
assume_direct_control(blobber)
factory.assign_blobbernaut(blobber)
var/mob/dead/observer/player = pick(candidates)
blobber.assign_key(player.key, blobstrain)
blobber.assign_key(ghost.key, blobstrain)
RegisterSignal(blobber, COMSIG_HOSTILE_POST_ATTACKINGTARGET, PROC_REF(on_blobbernaut_attacked))
/// When one of our boys attacked something, we sometimes want to perform extra effects
@@ -318,7 +318,15 @@
return TRUE
to_chat(user, "[span_userdanger("Capture failed!")]: The soul has already fled its mortal frame. You attempt to bring it back...")
INVOKE_ASYNC(src, PROC_REF(get_ghost_to_replace_shade), victim, user)
var/datum/callback/to_call = CALLBACK(src, PROC_REF(on_poll_concluded), user, victim)
AddComponent(/datum/component/orbit_poll, \
ignore_key = POLL_IGNORE_SHADE, \
job_bans = ROLE_CULTIST, \
to_call = to_call, \
title = "A shade" \
)
return TRUE //it'll probably get someone ;)
///captures a shade that was previously released from a soulstone.
@@ -430,33 +438,19 @@
shade_datum = shade.mind.add_antag_datum(/datum/antagonist/shade_minion)
shade_datum.update_master(user.real_name)
/**
* Gets a ghost from dead chat to replace a missing player when a shade is created.
*
* Gets ran if a soulstone is used on a body that has no client to take over the shade.
*
* victim - the body that's being shaded
* user - the mob shading the body
*
* Returns FALSE if no ghosts are available or the replacement fails.
* Returns TRUE otherwise.
*/
/obj/item/soulstone/proc/get_ghost_to_replace_shade(mob/living/carbon/victim, mob/user)
var/mob/dead/observer/chosen_ghost
var/list/consenting_candidates = poll_ghost_candidates("Would you like to play as a Shade?", "Cultist", ROLE_CULTIST, 5 SECONDS, POLL_IGNORE_SHADE)
if(length(consenting_candidates))
chosen_ghost = pick(consenting_candidates)
if(!victim || user.incapacitated() || !user.is_holding(src) || !user.CanReach(victim, src))
/// Called when a ghost is chosen to become a shade.
/obj/item/soulstone/proc/on_poll_concluded(mob/living/master, mob/living/victim, mob/dead/observer/ghost)
if(isnull(victim) || master.incapacitated() || !master.is_holding(src) || !master.CanReach(victim, src))
return FALSE
if(!chosen_ghost || !chosen_ghost.client)
to_chat(user, span_danger("There were no spirits willing to become a shade."))
if(isnull(ghost?.client))
to_chat(master, span_danger("There were no spirits willing to become a shade."))
return FALSE
if(contents.len) //If they used the soulstone on someone else in the meantime
if(length(contents)) //If they used the soulstone on someone else in the meantime
return FALSE
to_chat(user, "[span_info("<b>Capture successful!</b>:")] A spirit has entered [src], \
to_chat(master, "[span_info("<b>Capture successful!</b>:")] A spirit has entered [src], \
taking upon the identity of [victim].")
init_shade(victim, user, shade_controller = chosen_ghost)
init_shade(victim, master, shade_controller = ghost)
return TRUE
/proc/make_new_construct_from_class(construct_class, theme, mob/target, mob/creator, cultoverride, loc_override)
+25 -13
View File
@@ -423,19 +423,31 @@
using = TRUE
balloon_alert(user, "you hold the scythe up...")
ADD_TRAIT(src, TRAIT_NODROP, type)
var/list/mob/dead/observer/candidates = poll_ghost_candidates("Do you want to play as [user.real_name]'s soulscythe?", ROLE_PAI, FALSE, 100, POLL_IGNORE_POSSESSED_BLADE)
if(LAZYLEN(candidates))
var/mob/dead/observer/picked_ghost = pick(candidates)
soul.ckey = picked_ghost.ckey
soul.copy_languages(user, LANGUAGE_MASTER) //Make sure the sword can understand and communicate with the user.
soul.faction = list("[REF(user)]")
balloon_alert(user, "the scythe glows up")
add_overlay("soulscythe_gem")
density = TRUE
if(!ismob(loc))
reset_spin()
else
balloon_alert(user, "the scythe is dormant!")
var/datum/callback/to_call = CALLBACK(src, PROC_REF(on_poll_concluded), user)
AddComponent(/datum/component/orbit_poll, \
ignore_key = POLL_IGNORE_POSSESSED_BLADE, \
job_bans = ROLE_PAI, \
to_call = to_call, \
)
/// Ghost poll has concluded and a candidate has been chosen.
/obj/item/soulscythe/proc/on_poll_concluded(mob/living/master, mob/dead/observer/ghost)
if(isnull(ghost))
balloon_alert(master, "the scythe is dormant!")
REMOVE_TRAIT(src, TRAIT_NODROP, type)
using = FALSE
return
soul.ckey = ghost.ckey
soul.copy_languages(master, LANGUAGE_MASTER) //Make sure the sword can understand and communicate with the master.
soul.faction = list("[REF(master)]")
balloon_alert(master, "the scythe glows")
add_overlay("soulscythe_gem")
density = TRUE
if(!ismob(loc))
reset_spin()
REMOVE_TRAIT(src, TRAIT_NODROP, type)
using = FALSE
@@ -84,26 +84,33 @@
return
attempt_grow()
///Attempt to burst an alien outside of the host, getting a ghost to play as the xeno.
/// Attempt to burst an alien outside of the host, getting a ghost to play as the xeno.
/obj/item/organ/internal/body_egg/alien_embryo/proc/attempt_grow(gib_on_success = TRUE)
if(!owner || bursting)
if(QDELETED(owner) || bursting)
return
bursting = TRUE
var/list/candidates = poll_ghost_candidates("Do you want to play as an alien larva that will burst out of [owner.real_name]?", ROLE_ALIEN, ROLE_ALIEN, 100, POLL_IGNORE_ALIEN_LARVA)
var/datum/callback/to_call = CALLBACK(src, PROC_REF(on_poll_concluded), gib_on_success)
owner.AddComponent(/datum/component/orbit_poll, \
ignore_key = POLL_IGNORE_ALIEN_LARVA, \
job_bans = ROLE_ALIEN, \
to_call = to_call, \
custom_message = "An alien is bursting out of [owner.real_name]", \
title = "alien larva" \
)
if(QDELETED(src) || QDELETED(owner))
/// Poll has concluded with a suitor
/obj/item/organ/internal/body_egg/alien_embryo/proc/on_poll_concluded(gib_on_success, mob/dead/observer/ghost)
if(QDELETED(owner))
return
if(!candidates.len || !owner)
if(isnull(ghost))
bursting = FALSE
stage = 5 // If no ghosts sign up for the Larva, let's regress our growth by one minute, we will try again!
addtimer(CALLBACK(src, PROC_REF(advance_embryo_stage)), growth_time)
return
var/mob/dead/observer/ghost = pick(candidates)
var/mutable_appearance/overlay = mutable_appearance('icons/mob/nonhuman-player/alien.dmi', "burst_lie")
owner.add_overlay(overlay)
@@ -705,19 +705,28 @@
balloon_alert(user, "offering...")
being_used = TRUE
var/list/candidates = poll_candidates_for_mob("Do you want to play as [dumb_mob.name]?", ROLE_SENTIENCE, ROLE_SENTIENCE, 5 SECONDS, dumb_mob, POLL_IGNORE_SENTIENCE_POTION) // see poll_ignore.dm
if(!LAZYLEN(candidates))
var/datum/callback/to_call = CALLBACK(src, PROC_REF(on_poll_concluded), user, dumb_mob)
dumb_mob.AddComponent(/datum/component/orbit_poll, \
ignore_key = POLL_IGNORE_SENTIENCE_POTION, \
job_bans = ROLE_SENTIENCE, \
to_call = to_call, \
)
/// Assign the chosen ghost to the mob
/obj/item/slimepotion/slime/sentience/proc/on_poll_concluded(mob/user, mob/living/dumb_mob, mob/dead/observer/ghost)
if(isnull(ghost))
balloon_alert(user, "try again later!")
being_used = FALSE
return ..()
return
var/mob/dead/observer/C = pick(candidates)
dumb_mob.key = C.key
dumb_mob.key = ghost.key
dumb_mob.mind.enslave_mind_to_creator(user)
SEND_SIGNAL(dumb_mob, COMSIG_SIMPLEMOB_SENTIENCEPOTION, user)
if(isanimal(dumb_mob))
var/mob/living/simple_animal/smart_animal = dumb_mob
smart_animal.sentience_act()
dumb_mob.mind.add_antag_datum(/datum/antagonist/sentient_creature)
balloon_alert(user, "success")
after_success(user, dumb_mob)
+1
View File
@@ -1058,6 +1058,7 @@
#include "code\datums\components\omen.dm"
#include "code\datums\components\on_hit_effect.dm"
#include "code\datums\components\onwear_mood.dm"
#include "code\datums\components\orbit_poll.dm"
#include "code\datums\components\orbiter.dm"
#include "code\datums\components\overlay_lighting.dm"
#include "code\datums\components\palette.dm"