diff --git a/code/__HELPERS/unsorted.dm b/code/__HELPERS/unsorted.dm
index a022851e45f..220abd17f2c 100644
--- a/code/__HELPERS/unsorted.dm
+++ b/code/__HELPERS/unsorted.dm
@@ -1867,6 +1867,8 @@ GLOBAL_DATUM_INIT(dview_mob, /mob/dview, new)
#undef DELTA_CALC
+#define UNTIL(X) while(!(X)) stoplag()
+
/*
* This proc gets a list of all "points of interest" (poi's) that can be used by admins to track valuable mobs or atoms (such as the nuke disk).
* @param mobs_only if set to TRUE it won't include locations to the returned list
diff --git a/code/_onclick/hud/alert.dm b/code/_onclick/hud/alert.dm
index a93e65c762b..f6bf7f7b5eb 100644
--- a/code/_onclick/hud/alert.dm
+++ b/code/_onclick/hud/alert.dm
@@ -3,7 +3,7 @@
//PUBLIC - call these wherever you want
-/mob/proc/throw_alert(category, type, severity, obj/new_master, override = FALSE)
+/mob/proc/throw_alert(category, type, severity, obj/new_master, override = FALSE, timeout_override, no_anim)
/*
Proc to create or update an alert. Returns the alert if the alert is new or updated, 0 if it was thrown already
@@ -59,14 +59,18 @@
LAZYSET(alerts, category, alert) // This also creates the list if it doesn't exist
if(client && hud_used)
hud_used.reorganize_alerts()
- alert.transform = matrix(32, 6, MATRIX_TRANSLATE)
- animate(alert, transform = matrix(), time = 2.5, easing = CUBIC_EASING)
- if(alert.timeout)
- spawn(alert.timeout)
+ if(!no_anim)
+ alert.transform = matrix(32, 6, MATRIX_TRANSLATE)
+ animate(alert, transform = matrix(), time = 2.5, easing = CUBIC_EASING)
+
+ var/timeout = timeout_override || alert.timeout
+ if(timeout)
+ spawn(timeout)
if(alert.timeout && alerts[category] == alert && world.time >= alert.timeout)
clear_alert(category)
- alert.timeout = world.time + alert.timeout - world.tick_lag
+ alert.timeout = world.time + timeout - world.tick_lag
+
return alert
// Proc to clear an existing alert.
@@ -507,6 +511,35 @@ so as to remain in compliance with the most up-to-date laws."
timeout = 300
var/atom/target = null
var/action = NOTIFY_JUMP
+ var/show_time_left = FALSE // If true you need to call START_PROCESSING manually
+ var/image/time_left_overlay // The last image showing the time left
+ var/datum/candidate_poll/poll // If set, on Click() it'll register the player as a candidate
+
+/obj/screen/alert/notify_action/process()
+ if(show_time_left)
+ var/timeleft = timeout - world.time
+ if(timeleft <= 0)
+ return PROCESS_KILL
+
+ if(time_left_overlay)
+ overlays -= time_left_overlay
+
+ var/obj/O = new
+ O.maptext = "[CEILING(timeleft / 10, 1)]"
+ O.maptext_width = O.maptext_height = 128
+ var/matrix/M = new
+ M.Scale(0.25, 0.25)
+ M.Translate(-8, 4)
+ O.transform = M
+
+ var/image/I = image(O)
+ I.layer = FLOAT_LAYER
+ I.plane = FLOAT_PLANE + 1
+ overlays += I
+
+ time_left_overlay = I
+ qdel(O)
+ ..()
/obj/screen/alert/notify_action/Destroy()
target = null
@@ -515,20 +548,47 @@ so as to remain in compliance with the most up-to-date laws."
/obj/screen/alert/notify_action/Click()
if(!usr || !usr.client)
return
- if(!target)
- return
var/mob/dead/observer/G = usr
if(!istype(G))
return
- switch(action)
- if(NOTIFY_ATTACK)
- target.attack_ghost(G)
- if(NOTIFY_JUMP)
- var/turf/T = get_turf(target)
- if(T && isturf(T))
- G.loc = T
- if(NOTIFY_FOLLOW)
- G.ManualFollow(target)
+
+ if(poll)
+ if(poll.sign_up(G))
+ // Add a small overlay to indicate we've signed up
+ display_signed_up()
+ else if(target)
+ switch(action)
+ if(NOTIFY_ATTACK)
+ target.attack_ghost(G)
+ if(NOTIFY_JUMP)
+ var/turf/T = get_turf(target)
+ if(T && isturf(T))
+ G.loc = T
+ if(NOTIFY_FOLLOW)
+ G.ManualFollow(target)
+
+/obj/screen/alert/notify_action/proc/display_signed_up()
+ var/image/I = image('icons/mob/screen_gen.dmi', icon_state = "selector")
+ I.layer = FLOAT_LAYER
+ I.plane = FLOAT_PLANE + 2
+ overlays += I
+
+/obj/screen/alert/notify_action/proc/display_stacks(stacks = 1)
+ if(stacks <= 1)
+ return
+
+ var/obj/O = new
+ O.maptext = "[stacks]x"
+ O.maptext_width = O.maptext_height = 128
+ var/matrix/M = new
+ M.Scale(0.25, 0.25)
+ M.Translate(4, -10)
+ O.transform = M
+
+ var/image/I = image(O)
+ I.layer = FLOAT_LAYER
+ I.plane = FLOAT_PLANE + 1
+ overlays += I
/obj/screen/alert/notify_soulstone
name = "Soul Stone"
diff --git a/code/controllers/subsystem/ghost_spawns.dm b/code/controllers/subsystem/ghost_spawns.dm
new file mode 100644
index 00000000000..a2fbc36fe35
--- /dev/null
+++ b/code/controllers/subsystem/ghost_spawns.dm
@@ -0,0 +1,216 @@
+SUBSYSTEM_DEF(ghost_spawns)
+ name = "Ghost Spawns"
+ init_order = INIT_ORDER_EVENTS
+ flags = SS_BACKGROUND
+ wait = 1 SECONDS
+ runlevels = RUNLEVEL_GAME
+ offline_implications = "Ghosts will no longer be able to respawn as event mobs (Terror, Blob, etc..). Shuttle call recommended."
+
+ var/list/datum/candidate_poll/currently_polling // List of polls currently ongoing, to be checked on next fire()
+ var/polls_active = FALSE // Whether there are active polls or not
+ var/total_polls = 0 // Number of polls performed since the start
+ var/datum/candidate_poll/next_poll_to_finish // The poll that's closest to finishing
+
+/datum/controller/subsystem/ghost_spawns/fire()
+ if(!polls_active)
+ return
+
+ for(var/poll in currently_polling)
+ var/datum/candidate_poll/P = poll
+ if(P.time_left() <= 0)
+ polling_finished(P)
+
+/datum/controller/subsystem/ghost_spawns/Initialize(start_timeofday)
+ currently_polling = list()
+ return ..()
+
+// Use this proc (SSghost_spawns.poll_candidates) instead of /proc/pollCandidates to poll for candidates!
+/datum/controller/subsystem/ghost_spawns/proc/poll_candidates(question = "Would you like to play a special role?", role, antag_age_check = FALSE, poll_time = 30 SECONDS, ignore_respawnability = FALSE, min_hours = 0, flash_window = TRUE, check_antaghud = TRUE, source)
+ log_debug("Polling candidates [role ? "for [get_roletext(role)]" : "\"[question]\""] for [poll_time / 10] seconds")
+
+ polls_active = TRUE
+ total_polls++
+
+ var/datum/candidate_poll/P = new(role, question, poll_time)
+ currently_polling += P
+
+ if(!next_poll_to_finish || poll_time < next_poll_to_finish.time_left())
+ next_poll_to_finish = P
+
+ var/category = "[P.hash]_notify_action"
+
+ for(var/mob/dead/observer/M in (ignore_respawnability ? GLOB.player_list : GLOB.respawnable_list))
+ if(!is_eligible(M))
+ continue
+
+ M << 'sound/misc/notice2.ogg'
+ if(flash_window)
+ window_flash(M.client)
+
+ // If we somehow send two polls for the same mob type, but with a duration on the second one shorter than the time left on the first one,
+ // we need to keep the first one's timeout rather than use the shorter one
+ var/obj/screen/alert/notify_action/current_alert = LAZYACCESS(M.alerts, category)
+ var/alert_time = poll_time
+ var/alert_poll = P
+ if(current_alert && current_alert.timeout > (world.time + poll_time - world.tick_lag))
+ alert_time = current_alert.timeout - world.time + world.tick_lag
+ alert_poll = current_alert.poll
+
+ // Send them an on-screen alert
+ var/obj/screen/alert/notify_action/A = M.throw_alert(category, /obj/screen/alert/notify_action, timeout_override = alert_time, no_anim = TRUE)
+ if(!A)
+ continue
+
+ A.icon = ui_style2icon(M.client?.prefs.UI_style)
+ A.name = "Looking for candidates"
+ A.desc = "[question]\n\n(expires in [poll_time / 10] seconds)"
+ A.show_time_left = TRUE
+ A.poll = alert_poll
+
+ var/inherited_sign_up = FALSE
+ var/num_stack = 1
+ for(var/existing_poll in currently_polling)
+ var/datum/candidate_poll/P2 = existing_poll
+ if(P != P2 && P.hash == P2.hash)
+ // If there's already a poll for an identical mob type ongoing and the client is signed up for it, sign them up for this one
+ if(!inherited_sign_up && (M in P2.signed_up) && P.sign_up(M, TRUE))
+ A.display_signed_up()
+ inherited_sign_up = TRUE
+ // This number is used to display the number of polls the alert regroups
+ num_stack++
+ if(num_stack > 1)
+ A.display_stacks(num_stack)
+
+ // Image to display
+ var/image/I
+ if(source)
+ if(!ispath(source))
+ var/atom/S = source
+ var/old_layer = S.layer
+ var/old_plane = S.plane
+
+ S.layer = FLOAT_LAYER
+ S.plane = FLOAT_PLANE
+ A.overlays += S
+ S.layer = old_layer
+ S.plane = old_plane
+ else
+ I = image(source, layer = FLOAT_LAYER, dir = SOUTH)
+ else
+ // Just use a generic image
+ I = image('icons/effects/effects.dmi', icon_state = "static", layer = FLOAT_LAYER, dir = SOUTH)
+
+ if(I)
+ I.layer = FLOAT_LAYER
+ I.plane = FLOAT_PLANE
+ A.overlays += I
+
+ // Start processing it so it updates visually the timer
+ START_PROCESSING(SSprocessing, A)
+ A.process()
+
+ // Sleep until the time is up
+ UNTIL(P.finished)
+ return P.signed_up
+
+// Whether an observer is eligible to be an event mob
+/datum/controller/subsystem/ghost_spawns/proc/is_eligible(mob/M, role, antag_age_check, role_text, min_hours, check_antaghud)
+ . = FALSE
+ if(!M.key || !M.client)
+ return
+ if(role)
+ if(!(role in M.client.prefs.be_special))
+ return
+ if(antag_age_check)
+ if(!player_old_enough_antag(M.client, role))
+ return
+ if(role_text)
+ if(jobban_isbanned(M, role_text) || jobban_isbanned(M, "Syndicate"))
+ return
+ if(config.use_exp_restrictions && min_hours)
+ if(M.client.get_exp_type_num(EXP_TYPE_LIVING) < min_hours * 60)
+ return
+ if(check_antaghud && cannotPossess(M))
+ return
+
+ return TRUE
+
+// Called when polling is finished for a /datum/candidate_poll
+/datum/controller/subsystem/ghost_spawns/proc/polling_finished(datum/candidate_poll/P)
+ // Trim players who aren't eligible anymore
+ var/len_pre_trim = P.signed_up.len
+ P.trim_candidates()
+ log_debug("Candidate poll [P.role ? "for [get_roletext(P.role)]" : "\"[P.question]\""] finished. [len_pre_trim] players signed up, [P.signed_up.len] after trimming")
+
+ P.finished = TRUE
+ currently_polling -= P
+ if(!currently_polling.len)
+ polls_active = FALSE
+ next_poll_to_finish = null
+ else if(P == next_poll_to_finish)
+ next_poll_to_finish = null
+ for(var/poll in currently_polling)
+ var/datum/candidate_poll/P2 = poll
+ if(!next_poll_to_finish || P2.time_left() < next_poll_to_finish.time_left())
+ next_poll_to_finish = P2
+
+/datum/controller/subsystem/ghost_spawns/stat_entry(msg)
+ msg += "Active: [currently_polling.len] | Total: [total_polls]"
+ if(next_poll_to_finish)
+ msg += " | Next: [DisplayTimeText(next_poll_to_finish.time_left())] ([next_poll_to_finish.signed_up.len] candidates)"
+ ..(msg)
+
+// The datum that describes one instance of candidate polling
+/datum/candidate_poll
+ var/role // The role the poll is for
+ var/question // The question asked to observers
+ var/duration // The duration of the poll
+ var/list/mob/dead/observer/signed_up // The players who signed up to this poll
+ var/time_started // The world.time at which the poll was created
+ var/finished = FALSE // Whether the polling is finished
+ var/hash // Used to categorize in the alerts system
+
+/datum/candidate_poll/New(polled_role, polled_question, poll_duration)
+ role = polled_role
+ question = polled_question
+ duration = poll_duration
+ signed_up = list()
+ time_started = world.time
+ hash = copytext(md5("[question]_[role ? role : "0"]"), 1, 7)
+ return ..()
+
+/datum/candidate_poll/proc/sign_up(mob/dead/observer/M, silent = FALSE)
+ . = FALSE
+ if(!istype(M) || !M.key || !M.client)
+ return
+ if(M in signed_up)
+ if(!silent)
+ to_chat(M, "You have already signed up for this!")
+ return
+ if(time_left() <= 0)
+ if(!silent)
+ to_chat(M, "Sorry, you were too late for the consideration!")
+ M << 'sound/machines/buzz-sigh.ogg'
+ return
+
+ signed_up += M
+ if(!silent)
+ to_chat(M, "You have signed up for this role! A candidate will be picked randomly soon..")
+ // Sign them up for any other polls with the same mob type
+ for(var/existing_poll in SSghost_spawns.currently_polling)
+ var/datum/candidate_poll/P = existing_poll
+ if(src != P && hash == P.hash && !(M in P.signed_up))
+ P.sign_up(M, TRUE)
+
+ return TRUE
+
+// Deletes any candidates who may have disconnected from the list
+/datum/candidate_poll/proc/trim_candidates()
+ listclearnulls(signed_up)
+ for(var/mob in signed_up)
+ var/mob/M = mob
+ if(!M.key || !M.client)
+ signed_up -= M
+
+/datum/candidate_poll/proc/time_left()
+ return duration - (world.time - time_started)
diff --git a/code/game/gamemodes/game_mode.dm b/code/game/gamemodes/game_mode.dm
index 2d71df8c979..fc3689a823d 100644
--- a/code/game/gamemodes/game_mode.dm
+++ b/code/game/gamemodes/game_mode.dm
@@ -421,7 +421,7 @@ proc/display_roundstart_logout_report()
return nukecode
/datum/game_mode/proc/replace_jobbanned_player(mob/living/M, role_type)
- var/list/mob/dead/observer/candidates = pollCandidates("Do you want to play as a [role_type]?", role_type, 0, 100)
+ var/list/mob/dead/observer/candidates = SSghost_spawns.poll_candidates("Do you want to play as a [role_type]?", role_type, FALSE, 10 SECONDS)
var/mob/dead/observer/theghost = null
if(candidates.len)
theghost = pick(candidates)
diff --git a/code/modules/events/blob.dm b/code/modules/events/blob.dm
index caec11de9f2..75aeefa5d1a 100644
--- a/code/modules/events/blob.dm
+++ b/code/modules/events/blob.dm
@@ -12,7 +12,7 @@
if(!T)
return kill()
- var/list/candidates = pollCandidates("Do you want to play as a blob infested mouse?", ROLE_BLOB, 1)
+ var/list/candidates = SSghost_spawns.poll_candidates("Do you want to play as a blob infested mouse?", ROLE_BLOB, TRUE, source = /mob/living/simple_animal/mouse/blobinfected)
if(!candidates.len)
return kill()
diff --git a/paradise.dme b/paradise.dme
index 8bdc190652f..f7741955994 100644
--- a/paradise.dme
+++ b/paradise.dme
@@ -224,6 +224,7 @@
#include "code\controllers\subsystem\events.dm"
#include "code\controllers\subsystem\fires.dm"
#include "code\controllers\subsystem\garbage.dm"
+#include "code\controllers\subsystem\ghost_spawns.dm"
#include "code\controllers\subsystem\holiday.dm"
#include "code\controllers\subsystem\icon_smooth.dm"
#include "code\controllers\subsystem\idlenpcpool.dm"