diff --git a/code/modules/capture_the_flag/_defines.dm b/code/modules/capture_the_flag/_defines.dm
index 96079739950..8e8f5860255 100644
--- a/code/modules/capture_the_flag/_defines.dm
+++ b/code/modules/capture_the_flag/_defines.dm
@@ -1 +1,5 @@
+/// The number of voters required for CTF to enable
#define CTF_REQUIRED_PLAYERS 4
+
+/// The game ID for normal ghost CTF
+#define CTF_GHOST_CTF_GAME_ID "centcom"
diff --git a/code/modules/capture_the_flag/ctf_game.dm b/code/modules/capture_the_flag/ctf_game.dm
index 0efab65b65a..42d2494b411 100644
--- a/code/modules/capture_the_flag/ctf_game.dm
+++ b/code/modules/capture_the_flag/ctf_game.dm
@@ -174,7 +174,28 @@
desc = "This is where a yellow banner used to play capture the flag \
would go."
+#define CTF_LOADING_UNLOADED 0
+#define CTF_LOADING_LOADING 1
+#define CTF_LOADING_LOADED 2
+
/proc/toggle_id_ctf(user, activated_id, automated = FALSE)
+ var/static/loading = CTF_LOADING_UNLOADED
+ switch (loading)
+ if (CTF_LOADING_UNLOADED)
+ if (isnull(GLOB.ctf_spawner))
+ to_chat(user, span_boldwarning("Couldn't find a CTF spawner. Call a maintainer!"))
+ return
+
+ to_chat(user, span_notice("Loading CTF..."))
+
+ loading = CTF_LOADING_LOADING
+ GLOB.ctf_spawner.load_map()
+ loading = CTF_LOADING_LOADED
+ if (CTF_LOADING_LOADING)
+ to_chat(user, span_warning("CTF is loading!"))
+
+ return
+
var/ctf_enabled = FALSE
var/area/A
for(var/obj/machinery/capture_the_flag/CTF in GLOB.machines)
@@ -194,13 +215,17 @@
if(!automated)
notify_ghosts("CTF has been [ctf_enabled? "enabled" : "disabled"] in [A]!",'sound/effects/ghost2.ogg')
+#undef CTF_LOADING_UNLOADED
+#undef CTF_LOADING_LOADING
+#undef CTF_LOADING_LOADED
+
/obj/machinery/capture_the_flag
name = "CTF Controller"
desc = "Used for running friendly games of capture the flag."
icon = 'icons/obj/device.dmi'
icon_state = "syndbeacon"
resistance_flags = INDESTRUCTIBLE
- var/game_id = "centcom"
+ var/game_id = CTF_GHOST_CTF_GAME_ID
var/victory_rejoin_text = "Teams have been cleared. Click on the machines to vote to begin another round."
var/team = WHITE_TEAM
@@ -227,7 +252,6 @@
var/list/dead_barricades = list()
var/static/arena_reset = FALSE
- var/static/list/people_who_want_to_play = list()
var/game_area = /area/centcom/ctf
/// This variable is needed because of ctf shitcode + we need to make sure we're deleting the current ctf landmark that spawned us in and not a new one.
@@ -306,15 +330,7 @@
if(CTF.game_id != game_id && CTF.ctf_enabled)
to_chat(user, span_warning("There is already an ongoing game in the [get_area(CTF)]!"))
return
- people_who_want_to_play |= user.ckey
- var/num = people_who_want_to_play.len
- var/remaining = CTF_REQUIRED_PLAYERS - num
- if(remaining <= 0)
- people_who_want_to_play.Cut()
- toggle_id_ctf(null, game_id)
- else
- to_chat(user, span_notice("CTF has been requested. [num]/[CTF_REQUIRED_PLAYERS] have readied up."))
-
+ get_ctf_voting_controller(game_id).vote(user)
return
if(!SSticker.HasRoundStarted())
diff --git a/code/modules/capture_the_flag/ctf_map_loading.dm b/code/modules/capture_the_flag/ctf_map_loading.dm
index 60212cd8b44..0edd21aabac 100644
--- a/code/modules/capture_the_flag/ctf_map_loading.dm
+++ b/code/modules/capture_the_flag/ctf_map_loading.dm
@@ -9,7 +9,6 @@ GLOBAL_DATUM(ctf_spawner, /obj/effect/landmark/ctf)
if(GLOB.ctf_spawner)
qdel(GLOB.ctf_spawner)
GLOB.ctf_spawner = src
- INVOKE_ASYNC(src, .proc/load_map)
/obj/effect/landmark/ctf/Destroy()
if(map_bounds)
@@ -30,6 +29,8 @@ GLOBAL_DATUM(ctf_spawner, /obj/effect/landmark/ctf)
return ..()
/obj/effect/landmark/ctf/proc/load_map()
+ if (map_bounds)
+ return
var/list/map_options = subtypesof(/datum/map_template/ctf)
var/turf/spawn_area = get_turf(src)
diff --git a/code/modules/capture_the_flag/ctf_panel.dm b/code/modules/capture_the_flag/ctf_panel.dm
index a12eeaa08dc..ed836be432f 100644
--- a/code/modules/capture_the_flag/ctf_panel.dm
+++ b/code/modules/capture_the_flag/ctf_panel.dm
@@ -13,21 +13,30 @@ GLOBAL_DATUM_INIT(ctf_panel, /datum/ctf_panel, new())
/datum/ctf_panel/ui_data(mob/user)
var/list/data = list()
- data["teams"] = list()
- data["enabled"] = ""
+ var/list/teams = list()
+
for(var/obj/machinery/capture_the_flag/team in GLOB.machines)
+ if (!team.ctf_enabled)
+ continue
+
var/list/this = list()
this["name"] = team
this["color"] = team.team
this["score"] = team.points + team.control_points
this["team_size"] = team.team_members.len
- this["refs"] += "[REF(team)]"
- data["teams"] += list(this)
- if(!data["enabled"])
- if(team.ctf_enabled)
- data["enabled"] = "CTF is currently running!"
- else
- data["enabled"] = "CTF needs [CTF_REQUIRED_PLAYERS] players to start, currently [team.people_who_want_to_play.len]/[CTF_REQUIRED_PLAYERS] have signed up!"
+ this["refs"] += REF(team)
+ teams += list(this)
+
+ if (teams.len == 0)
+ // No CTF map has been spawned in yet
+ var/datum/ctf_voting_controller/ctf_controller = get_ctf_voting_controller(CTF_GHOST_CTF_GAME_ID)
+
+ data["voters"] = ctf_controller.volunteers.len
+ data["voters_required"] = CTF_REQUIRED_PLAYERS
+ data["voted"] = (user.ckey in ctf_controller.volunteers)
+ else
+ data["teams"] = teams
+
return data
@@ -50,3 +59,28 @@ GLOBAL_DATUM_INIT(ctf_panel, /datum/ctf_panel, new())
user.forceMove(get_turf(ctf_spawner))
ctf_spawner.attack_ghost(user)
return TRUE
+ if ("vote")
+ if (ctf_enabled())
+ to_chat(user, span_warning("CTF is already enabled!"))
+ return TRUE
+
+ var/datum/ctf_voting_controller/ctf_controller = get_ctf_voting_controller(CTF_GHOST_CTF_GAME_ID)
+ ctf_controller.vote(user)
+
+ return TRUE
+ if ("unvote")
+ if (ctf_enabled())
+ to_chat(user, span_warning("CTF is already enabled!"))
+ return TRUE
+
+ var/datum/ctf_voting_controller/ctf_controller = get_ctf_voting_controller(CTF_GHOST_CTF_GAME_ID)
+ ctf_controller.unvote(user)
+
+ return TRUE
+
+/datum/ctf_panel/proc/ctf_enabled()
+ for (var/obj/machinery/capture_the_flag/ctf_machine in GLOB.machines)
+ if (ctf_machine.ctf_enabled)
+ return TRUE
+
+ return FALSE
diff --git a/code/modules/capture_the_flag/ctf_voting.dm b/code/modules/capture_the_flag/ctf_voting.dm
new file mode 100644
index 00000000000..120da3b1a94
--- /dev/null
+++ b/code/modules/capture_the_flag/ctf_voting.dm
@@ -0,0 +1,45 @@
+GLOBAL_LIST_EMPTY(ctf_voting_controllers)
+
+/datum/ctf_voting_controller
+ /// The list of ckeys that want to play CTF
+ var/list/volunteers = list()
+
+ var/game_id
+
+/datum/ctf_voting_controller/New(game_id)
+ src.game_id = game_id
+
+/// Casts a vote in favor of CTF for user.
+/datum/ctf_voting_controller/proc/vote(mob/user)
+ if (user.ckey in volunteers)
+ return
+
+ volunteers += user.ckey
+
+ var/volunteer_count = volunteers.len
+ var/remaining = CTF_REQUIRED_PLAYERS - volunteer_count
+
+ if (remaining <= 0)
+ volunteers.Cut()
+ toggle_id_ctf(activated_id = game_id)
+ else
+ to_chat(user, span_notice("CTF has been requested. [volunteer_count]/[CTF_REQUIRED_PLAYERS] have readied up."))
+
+/// Removes an existing vote for user.
+/datum/ctf_voting_controller/proc/unvote(mob/user)
+ if (!(user.ckey in volunteers))
+ return
+
+ volunteers -= user.ckey
+ to_chat(user, span_notice("Removed vote for CTF."))
+
+/// Returns the existing [/datum/ctf_voting_controller] for the given ID, or makes one
+/proc/get_ctf_voting_controller(game_id)
+ RETURN_TYPE(/datum/ctf_voting_controller)
+
+ var/datum/ctf_voting_controller/controller = GLOB.ctf_voting_controllers[game_id]
+ if (isnull(controller))
+ controller = new(game_id)
+ GLOB.ctf_voting_controllers[game_id] = controller
+
+ return controller
diff --git a/tgstation.dme b/tgstation.dme
index f68ff42dd1b..f46b2b47efc 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -2616,6 +2616,7 @@
#include "code\modules\capture_the_flag\ctf_game.dm"
#include "code\modules\capture_the_flag\ctf_map_loading.dm"
#include "code\modules\capture_the_flag\ctf_panel.dm"
+#include "code\modules\capture_the_flag\ctf_voting.dm"
#include "code\modules\capture_the_flag\medieval_sim\medisim_classes.dm"
#include "code\modules\capture_the_flag\medieval_sim\medisim_game.dm"
#include "code\modules\cards\cardhand.dm"
diff --git a/tgui/packages/tgui/interfaces/CTFPanel.js b/tgui/packages/tgui/interfaces/CTFPanel.js
deleted file mode 100644
index 3adbcac1b6c..00000000000
--- a/tgui/packages/tgui/interfaces/CTFPanel.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import { useBackend } from '../backend';
-import { Box, Button, Section, Flex, Stack, Divider } from '../components';
-import { Window } from '../layouts';
-
-export const CTFPanel = (props, context) => {
- const { act, data } = useBackend(context);
- const teams = data.teams || [];
- const enabled = data.enabled || [];
- return (
-
-
-
- {enabled}
-
-
-
-
-
- {teams.map((team) => (
-
-
-
-
-
- {team.team_size} member
- {team.team_size === 1 ? '' : 's'}
-
-
-
-
-
- {team.score} point
- {team.score === 1 ? '' : 's'}
-
-
-
-
-
-
- ))}
-
-
-
- );
-};
diff --git a/tgui/packages/tgui/interfaces/CTFPanel.tsx b/tgui/packages/tgui/interfaces/CTFPanel.tsx
new file mode 100644
index 00000000000..ea213ac0420
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/CTFPanel.tsx
@@ -0,0 +1,107 @@
+import { BooleanLike } from '../../common/react';
+import { useBackend } from '../backend';
+import { Box, Button, Section, Flex, Stack } from '../components';
+import { Window } from '../layouts';
+
+type CTFPanelData =
+ | {
+ teams: {
+ name: string;
+ color: string;
+ score: number;
+ team_size: number;
+ refs: string[];
+ }[];
+ }
+ | {
+ voters: number;
+ voters_required: number;
+ voted: BooleanLike;
+ };
+
+export const CTFPanel = (props, context) => {
+ const { act, data } = useBackend(context);
+
+ return (
+
+
+ {'teams' in data ? (
+
+ {data.teams.map((team) => (
+
+
+
+
+
+ {team.team_size} member
+ {team.team_size === 1 ? '' : 's'}
+
+
+
+
+
+ {team.score} point
+ {team.score === 1 ? '' : 's'}
+
+
+
+
+
+ act('jump', {
+ refs: team.refs,
+ })
+ }
+ />
+
+
+ act('join', {
+ refs: team.refs,
+ })
+ }
+ />
+
+
+ ))}
+
+ ) : (
+
+
+
+ {data.voters}/{data.voters_required}
+
+
+
+ CTF voters
+
+
+
+
+ {
+ if (data.voted) {
+ act('unvote');
+ } else {
+ act('vote');
+ }
+ }}>
+ {data.voted ? 'Unvote for CTF' : 'Vote for CTF'}
+
+
+
+ )}
+
+
+ );
+};