diff --git a/code/modules/deathmatch/deathmatch_controller.dm b/code/modules/deathmatch/deathmatch_controller.dm new file mode 100644 index 00000000000..c288daefd4e --- /dev/null +++ b/code/modules/deathmatch/deathmatch_controller.dm @@ -0,0 +1,125 @@ +/datum/deathmatch_controller + /// Assoc list of all lobbies (ckey = lobby) + var/list/datum/deathmatch_lobby/lobbies = list() + /// All deathmatch map templates + var/list/datum/lazy_template/deathmatch/maps = list() + /// All loadouts + var/list/datum/outfit/loadouts + +/datum/deathmatch_controller/New() + . = ..() + if (GLOB.deathmatch_game) + qdel(src) + CRASH("A deathmatch controller already exists.") + GLOB.deathmatch_game = src + + for (var/datum/lazy_template/deathmatch/template as anything in subtypesof(/datum/lazy_template/deathmatch)) + var/map_name = initial(template.name) + maps[map_name] = new template + loadouts = subtypesof(/datum/outfit/deathmatch_loadout) + +/datum/deathmatch_controller/proc/create_new_lobby(mob/host) + lobbies[host.ckey] = new /datum/deathmatch_lobby(host) + deadchat_broadcast(" has opened a new deathmatch lobby. (Join)", "[host]") + +/datum/deathmatch_controller/proc/remove_lobby(ckey) + var/lobby = lobbies[ckey] + lobbies[ckey] = null + lobbies.Remove(ckey) + qdel(lobby) + +/datum/deathmatch_controller/proc/passoff_lobby(host, new_host) + lobbies[new_host] = lobbies[host] + lobbies[host] = null + lobbies.Remove(host) + +/datum/deathmatch_controller/ui_state(mob/user) + return GLOB.observer_state + +/datum/deathmatch_controller/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, null) + if(!ui) + ui = new(user, src, "DeathmatchPanel") + ui.open() + +/datum/deathmatch_controller/ui_data(mob/user) + . = ..() + .["lobbies"] = list() + .["hosting"] = FALSE + .["admin"] = check_rights_for(user.client, R_ADMIN) + for (var/ckey in lobbies) + var/datum/deathmatch_lobby/lobby = lobbies[ckey] + if (user.ckey == ckey) + .["hosting"] = TRUE + if (user.ckey in lobby.observers+lobby.players) + .["playing"] = ckey + .["lobbies"] += list(list( + name = ckey, + players = lobby.players.len, + max_players = initial(lobby.map.max_players), + map = initial(lobby.map.name), + playing = lobby.playing + )) + +/datum/deathmatch_controller/proc/find_lobby_by_user(ckey) + for(var/lobbykey in lobbies) + var/datum/deathmatch_lobby/lobby = lobbies[lobbykey] + if(ckey in lobby.players+lobby.observers) + return lobby + +/datum/deathmatch_controller/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(. || !isobserver(usr)) + return + switch (action) + if ("host") + if (lobbies[usr.ckey]) + return + if(!SSticker.HasRoundStarted()) + tgui_alert(usr, "The round hasn't started yet!") + return + ui.close() + create_new_lobby(usr) + if ("join") + if (!lobbies[params["id"]]) + return + var/datum/deathmatch_lobby/playing_lobby = find_lobby_by_user(usr.ckey) + var/datum/deathmatch_lobby/chosen_lobby = lobbies[params["id"]] + if (!isnull(playing_lobby) && playing_lobby != chosen_lobby) + playing_lobby.leave(usr.ckey) + + if(isnull(playing_lobby)) + log_game("[usr.ckey] joined deathmatch lobby [params["id"]] as a player.") + chosen_lobby.join(usr) + + chosen_lobby.ui_interact(usr) + if ("spectate") + var/datum/deathmatch_lobby/playing_lobby = find_lobby_by_user(usr.ckey) + if (!lobbies[params["id"]]) + return + var/datum/deathmatch_lobby/chosen_lobby = lobbies[params["id"]] + // if the player is in this lobby + if(!isnull(playing_lobby) && playing_lobby != chosen_lobby) + playing_lobby.leave(usr.ckey) + else if(playing_lobby == chosen_lobby) + chosen_lobby.ui_interact(usr) + return + // they werent in the lobby, lets add them + if (!chosen_lobby.playing) + chosen_lobby.add_observer(usr) + chosen_lobby.ui_interact(usr) + else + chosen_lobby.spectate(usr) + log_game("[usr.ckey] joined deathmatch lobby [params["id"]] as an observer.") + if ("admin") + if (!check_rights(R_ADMIN)) + message_admins("[usr.key] has attempted to use admin functions in the deathmatch panel!") + log_admin("[key_name(usr)] tried to use the deathmatch panel admin functions without authorization.") + return + var/lobby = params["id"] + switch (params["func"]) + if ("Close") + remove_lobby(lobby) + log_admin("[key_name(usr)] removed deathmatch lobby [lobby].") + if ("View") + lobbies[lobby].ui_interact(usr) diff --git a/code/modules/deathmatch/deathmatch_lobby.dm b/code/modules/deathmatch/deathmatch_lobby.dm new file mode 100644 index 00000000000..bd5897c1da2 --- /dev/null +++ b/code/modules/deathmatch/deathmatch_lobby.dm @@ -0,0 +1,440 @@ +/datum/deathmatch_lobby + /// Ckey of the host + var/host + /// Assoc list of ckey to list() + var/list/players = list() + /// Assoc list of ckey to list() + var/list/observers = list() + /// The current chosen map + var/datum/lazy_template/deathmatch/map + /// Our turf reservation AKA where the arena is + var/datum/turf_reservation/location + /// Whether players hear deadchat and people through walls + var/global_chat = FALSE + /// Whether the lobby is currently playing + var/playing = FALSE + /// Number of total ready players + var/ready_count + /// List of loadouts, either gotten from the deathmatch controller or the map + var/list/loadouts + /// Current map player spawn locations, cleared after spawning + var/list/player_spawns = list() + +/datum/deathmatch_lobby/New(mob/player) + . = ..() + if (!player) + stack_trace("Attempted to create a deathmatch lobby without a host.") + return qdel(src) + host = player.ckey + map = GLOB.deathmatch_game.maps[pick(GLOB.deathmatch_game.maps)] + log_game("[host] created a deathmatch lobby.") + if (map.allowed_loadouts) + loadouts = map.allowed_loadouts + else + loadouts = GLOB.deathmatch_game.loadouts + add_player(player, loadouts[1], TRUE) + ui_interact(player) + +/datum/deathmatch_lobby/Destroy(force, ...) + . = ..() + for (var/key in players+observers) + var/datum/tgui/ui = SStgui.get_open_ui(get_mob_by_ckey(key), src) + if (ui) ui.close() + remove_ckey_from_play(key) + if(playing && !isnull(location)) + clear_reservation() + players = null + observers = null + map = null + location = null + loadouts = null + +/datum/deathmatch_lobby/proc/start_game() + if (playing) + return + playing = TRUE + + RegisterSignal(map, COMSIG_LAZY_TEMPLATE_LOADED, PROC_REF(find_spawns_and_start_delay)) + location = map.lazy_load() + if (!location) + to_chat(get_mob_by_ckey(host), span_warning("Couldn't reserve/load a map location (all locations used?), try again later, or contact a coder.")) + playing = FALSE + UnregisterSignal(map, COMSIG_LAZY_TEMPLATE_LOADED) + return FALSE + +/datum/deathmatch_lobby/proc/find_spawns_and_start_delay(datum/lazy_template/source, list/atoms) + SIGNAL_HANDLER + for(var/thing in atoms) + if(istype(thing, /obj/effect/landmark/deathmatch_player_spawn)) + player_spawns += thing + + UnregisterSignal(source, COMSIG_LAZY_TEMPLATE_LOADED) + addtimer(CALLBACK(src, PROC_REF(start_game_after_delay)), 8 SECONDS) + +/datum/deathmatch_lobby/proc/start_game_after_delay() + if (!length(player_spawns) || length(player_spawns) < length(players)) + stack_trace("Failed to get spawns when loading deathmatch map [map.name] for lobby [host].") + clear_reservation() + playing = FALSE + return FALSE + + for (var/key in players) + var/mob/dead/observer/observer = players[key]["mob"] + if (isnull(observer) || !observer.client) + log_game("Removed player [key] from deathmatch lobby [host], as they couldn't be found.") + remove_ckey_from_play(key) + continue + + // pick spawn and remove it. + var/picked_spawn = pick_n_take(player_spawns) + spawn_observer_as_player(key, get_turf(picked_spawn)) + qdel(picked_spawn) + + // Remove rest of spawns. + QDEL_LIST(player_spawns) + + for (var/observer_key in observers) + var/mob/observer = observers[observer_key]["mob"] + observer.forceMove(pick(location.reserved_turfs)) + + addtimer(CALLBACK(src, PROC_REF(game_took_too_long)), initial(map.automatic_gameend_time)) + log_game("Deathmatch game [host] started.") + announce(span_reallybig("GO!")) + return TRUE + +/datum/deathmatch_lobby/proc/spawn_observer_as_player(ckey, loc) + var/mob/dead/observer/observer = players[ckey]["mob"] + if (isnull(observer) || !observer.client) + remove_ckey_from_play(ckey) + return + + // equip player + var/datum/outfit/deathmatch_loadout/loadout = players[ckey]["loadout"] + if (!(loadout in loadouts)) + loadout = loadouts[1] + + observer.forceMove(loc) + var/datum/mind/observer_mind = observer.mind + var/mob/living/observer_current = observer.mind?.current + var/mob/living/carbon/human/new_player = observer.change_mob_type(/mob/living/carbon/human, delete_old_mob = TRUE) + if(!isnull(observer_mind) && observer_current) + new_player.AddComponent( \ + /datum/component/temporary_body, \ + old_mind = observer_mind, \ + old_body = observer_current, \ + ) + new_player.equipOutfit(loadout) // Loadout + players[ckey]["mob"] = new_player + + // register death handling. + RegisterSignals(new_player, list(COMSIG_LIVING_DEATH, COMSIG_MOB_GHOSTIZED, COMSIG_QDELETING), PROC_REF(player_died)) + if (global_chat) + ADD_TRAIT(new_player, TRAIT_SIXTHSENSE, INNATE_TRAIT) + ADD_TRAIT(new_player, TRAIT_XRAY_HEARING, INNATE_TRAIT) + +/datum/deathmatch_lobby/proc/game_took_too_long() + if (!location || QDELING(src)) + return + announce(span_reallybig("The players have took too long! Game ending!")) + end_game() + +/datum/deathmatch_lobby/proc/end_game() + if (!location) + CRASH("Reservation of deathmatch game [host] deleted during game.") + var/mob/winner + if(players.len) + var/list/winner_info = players[pick(players)] + if(!isnull(winner_info["mob"])) + winner = winner_info["mob"] //only one should remain anyway but incase of a draw + + announce(span_reallybig("THE GAME HAS ENDED.
THE WINNER IS: [winner ? winner.real_name : "no one"].")) + + for(var/ckey in players) + var/mob/loser = players[ckey]["mob"] + UnregisterSignal(loser, list(COMSIG_MOB_GHOSTIZED, COMSIG_QDELETING)) + players[ckey]["mob"] = null + loser.ghostize() + qdel(loser) + + clear_reservation() + GLOB.deathmatch_game.remove_lobby(host) + log_game("Deathmatch game [host] ended.") + +/datum/deathmatch_lobby/proc/player_died(mob/living/player, gibbed) + SIGNAL_HANDLER + if(isnull(player) || QDELING(src)) + return + + var/ckey = player.ckey + if(!islist(players[ckey])) // potentially the player info could hold a reference to this mob so we can figure the ckey out without worrying about ghosting and suicides n such + for(var/potential_ckey in players) + var/list/player_info = players[potential_ckey] + if(player_info["mob"] && player_info["mob"] == player) + ckey = potential_ckey + break + + if(!islist(players[ckey])) // if we STILL didnt find a good ckey + return + + players -= ckey + + var/mob/dead/observer/ghost = !player.client ? player.get_ghost() : player.ghostize() //this doesnt work on those who used the ghost verb + if(!isnull(ghost)) + add_observer(ghost, (host == ckey)) + + announce(span_reallybig("[player.real_name] HAS DIED.
[players.len] REMAIN.")) + + if(!gibbed && !QDELING(player)) // for some reason dusting or deleting in chasm storage messes up tgui bad + player.dust(TRUE, TRUE, TRUE) + if (players.len <= 1) + end_game() + +/datum/deathmatch_lobby/proc/add_observer(mob/mob, host = FALSE) + if (players[mob.ckey]) + CRASH("Tried to add [mob.ckey] as an observer while being a player.") + observers[mob.ckey] = list("mob" = mob, "host" = host) + +/datum/deathmatch_lobby/proc/add_player(mob/mob, loadout, host = FALSE) + if (observers[mob.ckey]) + CRASH("Tried to add [mob.ckey] as a player while being an observer.") + players[mob.ckey] = list("mob" = mob, "host" = host, "ready" = FALSE, "loadout" = loadout) + +/datum/deathmatch_lobby/proc/remove_ckey_from_play(ckey) + var/is_likely_player = (ckey in players) + var/list/main_list = is_likely_player ? players : observers + var/list/info = main_list[ckey] + if(is_likely_player && islist(info)) + ready_count -= info["ready"] + main_list -= ckey + +/datum/deathmatch_lobby/proc/announce(message) + for (var/key in players+observers) + var/mob/player = get_mob_by_ckey(key) + if (!player.client) + remove_ckey_from_play(key) + continue + to_chat(player.client, message) + +/datum/deathmatch_lobby/proc/leave(ckey) + if (host == ckey) + var/total_count = players.len + observers.len + if (total_count <= 1) // <= just in case. + GLOB.deathmatch_game.remove_lobby(host) + return + else + if (players[ckey] && players.len <= 1) + for (var/key in observers) + if (host == key) + continue + host = key + observers[key]["host"] = TRUE + break + else + for (var/key in players) + if (host == key) + continue + host = key + players[key]["host"] = TRUE + break + GLOB.deathmatch_game.passoff_lobby(ckey, host) + + remove_ckey_from_play(ckey) + +/datum/deathmatch_lobby/proc/join(mob/player) + if (playing || !player) + return + if(!(player.ckey in players+observers)) + if (players.len >= map.max_players) + add_observer(player) + else + add_player(player, loadouts[1]) + ui_interact(player) + +/datum/deathmatch_lobby/proc/spectate(mob/player) + if (!playing || !location || !player) + return + if (!observers[player.ckey]) + add_observer(player) + player.forceMove(pick(location.reserved_turfs)) + +/datum/deathmatch_lobby/proc/change_map(new_map) + if (!new_map || !GLOB.deathmatch_game.maps[new_map]) + return + map = GLOB.deathmatch_game.maps[new_map] + var/max_players = map.max_players + for (var/possible_unlucky_loser in players) + max_players-- + if (max_players <= 0) + var/loser_mob = players[possible_unlucky_loser]["mob"] + remove_ckey_from_play(possible_unlucky_loser) + add_observer(loser_mob) + + loadouts = map.allowed_loadouts ? map.allowed_loadouts : GLOB.deathmatch_game.loadouts + for (var/player_key in players) + if (players[player_key]["loadout"] in loadouts) + continue + players[player_key]["loadout"] = loadouts[1] + +/datum/deathmatch_lobby/proc/clear_reservation() + if(isnull(location) || isnull(map)) + return + for(var/turf/victimized_turf as anything in location.reserved_turfs) //remove this once clearing turf reservations is actually reliable + victimized_turf.empty() + map.reservations -= location + qdel(location) + +/datum/deathmatch_lobby/Topic(href, href_list) //This handles the chat Join button href, supposedly + var/mob/dead/observer/ghost = usr + if (!istype(ghost)) + return + if(href_list["join"]) + join(ghost) + +/datum/deathmatch_lobby/ui_state(mob/user) + return GLOB.observer_state + +/datum/deathmatch_lobby/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, null) + if(!ui) + ui = new(user, src, "DeathmatchLobby") + ui.open() + +/datum/deathmatch_lobby/ui_static_data(mob/user) + . = list() + .["maps"] = list() + for (var/map_key in GLOB.deathmatch_game.maps) + .["maps"] += map_key + +/datum/deathmatch_lobby/ui_data(mob/user) + . = list() + .["self"] = user.ckey + .["host"] = (user.ckey == host) + .["admin"] = check_rights_for(user.client, R_ADMIN) + .["global_chat"] = global_chat + .["playing"] = playing + .["loadouts"] = list() + for (var/datum/outfit/deathmatch_loadout/loadout as anything in loadouts) + .["loadouts"] += initial(loadout.display_name) + .["map"] = list() + .["map"]["name"] = map.name + .["map"]["desc"] = map.desc + .["map"]["time"] = map.automatic_gameend_time + .["map"]["min_players"] = map.min_players + .["map"]["max_players"] = map.max_players + if(!isnull(players[user.ckey]) && !isnull(players[user.ckey]["loadout"])) + var/datum/outfit/deathmatch_loadout/loadout = players[user.ckey]["loadout"] + .["loadoutdesc"] = initial(loadout.desc) + else + .["loadoutdesc"] = "You are an observer! As an observer, you can hear lobby announcements." + .["players"] = list() + for (var/player_key in players) + var/list/player_info = players[player_key] + var/mob/player_mob = player_info["mob"] + if (isnull(player_mob) || !player_mob.client) + leave(player_key) + continue + .["players"][player_key] = player_info.Copy() + var/datum/outfit/deathmatch_loadout/dm_loadout = player_info["loadout"] + .["players"][player_key]["loadout"] = initial(dm_loadout.display_name) + .["observers"] = list() + for (var/observer_key in observers) + var/mob/observer = observers[observer_key]["mob"] + if (isnull(observer) || !observer.client) + leave(observer_key) + continue + .["observers"][observer_key] = observers[observer_key] + +/datum/deathmatch_lobby/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(. || !isobserver(usr)) + return + switch(action) + if ("start_game") + if (usr.ckey != host) + return FALSE + if (map.min_players > players.len) + to_chat(usr, span_warning("Not enough players to start yet.")) + return FALSE + start_game() + return TRUE + if ("leave_game") + if (playing) + return FALSE + leave(usr.ckey) + ui.close() + GLOB.deathmatch_game.ui_interact(usr) + return TRUE + if ("change_loadout") + if (playing) + return FALSE + if (params["player"] != usr.ckey && host != usr.ckey) + return FALSE + for (var/datum/outfit/deathmatch_loadout/possible_loadout as anything in loadouts) + if (params["loadout"] != initial(possible_loadout.display_name)) + continue + players[params["player"]]["loadout"] = possible_loadout + break + return TRUE + if ("observe") + if (playing) + return FALSE + if (players[usr.ckey]) + remove_ckey_from_play(usr.ckey) + add_observer(usr, host == usr.ckey) + return TRUE + else if (observers[usr.ckey] && players.len < map.max_players) + remove_ckey_from_play(usr.ckey) + add_player(usr, loadouts[1], host == usr.ckey) + return TRUE + if ("ready") + players[usr.ckey]["ready"] ^= 1 // Toggle. + ready_count += (players[usr.ckey]["ready"] * 2) - 1 // scared? + if (ready_count >= players.len && players.len >= map.min_players) + start_game() + return TRUE + if ("host") // Host functions + if (playing || (usr.ckey != host && !check_rights(R_ADMIN))) + return FALSE + var/uckey = params["id"] + switch (params["func"]) + if ("Kick") + leave(uckey) + var/umob = get_mob_by_ckey(uckey) + var/datum/tgui/uui = SStgui.get_open_ui(umob, src) + uui?.close() + GLOB.deathmatch_game.ui_interact(umob) + return TRUE + if ("Transfer host") + if (host == uckey) + return FALSE + GLOB.deathmatch_game.passoff_lobby(host, uckey) + host = uckey + return TRUE + if ("Toggle observe") + var/umob = get_mob_by_ckey(uckey) + if (players[uckey]) + remove_ckey_from_play(uckey) + add_observer(umob, host == uckey) + else if (observers[uckey] && players.len < map.max_players) + remove_ckey_from_play(uckey) + add_player(umob, loadouts[1], host == uckey) + return TRUE + if ("change_map") + if (!(params["map"] in GLOB.deathmatch_game.maps)) + return FALSE + change_map(params["map"]) + return TRUE + if ("global_chat") + global_chat = !global_chat + return TRUE + if ("admin") // Admin functions + if (!check_rights(R_ADMIN)) + message_admins("[usr.key] has attempted to use admin functions in a deathmatch lobby!") + log_admin("[key_name(usr)] tried to use the deathmatch lobby admin functions without authorization.") + return + switch (params["func"]) + if ("Force start") + log_admin("[key_name(usr)] force started deathmatch lobby [host].") + start_game() + + diff --git a/code/modules/deathmatch/deathmatch_mapping.dm b/code/modules/deathmatch/deathmatch_mapping.dm new file mode 100644 index 00000000000..62eca161583 --- /dev/null +++ b/code/modules/deathmatch/deathmatch_mapping.dm @@ -0,0 +1,12 @@ +/area/deathmatch + name = "Deathmatch Arena" + requires_power = FALSE + has_gravity = STANDARD_GRAVITY + area_flags = UNIQUE_AREA | UNIQUE_AREA + +/area/deathmatch/fullbright + static_lighting = FALSE + base_lighting_alpha = 255 + +/obj/effect/landmark/deathmatch_player_spawn + name = "Deathmatch Player Spawner" diff --git a/tgui/packages/tgui/interfaces/DeathmatchLobby.tsx b/tgui/packages/tgui/interfaces/DeathmatchLobby.tsx new file mode 100644 index 00000000000..6c1a2221a4d --- /dev/null +++ b/tgui/packages/tgui/interfaces/DeathmatchLobby.tsx @@ -0,0 +1,212 @@ +import { BooleanLike } from 'common/react'; + +import { useBackend } from '../backend'; +import { + Box, + Button, + Divider, + Dropdown, + Flex, + Icon, + Section, + Table, +} from '../components'; +import { ButtonCheckbox } from '../components/Button'; +import { Window } from '../layouts'; + +type PlayerLike = { + [key: string]: { + host: number; + ready: BooleanLike; + }; +}; + +type Data = { + self: string; + host: BooleanLike; + admin: BooleanLike; + global_chat: BooleanLike; + playing: BooleanLike; + loadouts: string[]; + maps: string[]; + map: { + name: string; + desc: string; + time: number; + min_players: number; + max_players: number; + }; + loadoutdesc: string; + players: PlayerLike[]; + observers: PlayerLike[]; +}; + +export const DeathmatchLobby = (props) => { + const { act, data } = useBackend(); + return ( + + + + +
+ + + + Name + Loadout + Ready + + {Object.keys(data.players).map((player) => ( + + + {!!data.players[player].host && } + + + {(!( + (data.host && !data.players[player].host) || + data.admin + ) && {player}) || ( + + act('host', { + id: player, + func: value, + }) + } + /> + )} + + + + act('change_loadout', { + player: player, + loadout: value, + }) + } + /> + + + act('ready')} + /> + + + ))} + {Object.keys(data.observers).map((observer) => ( + + + {(!!data.observers[observer].host && ( + + )) || } + + + {(!( + (data.host && !data.observers[observer].host) || + data.admin + ) && {observer}) || ( + + act('host', { + id: observer, + func: value, + }) + } + /> + )} + + Observing + + ))} +
+
+
+ +
+ + {(!!data.host && ( + + act('host', { + func: 'change_map', + map: value, + }) + } + /> + )) || {data.map.name}} + + + {data.map.desc} + + Maximum Play Time: {`${data.map.time / 600}min`} +
+ Min players: {data.map.min_players} +
+ Max players: {data.map.max_players} +
+ Current players: {Object.keys(data.players).length} +
+ + act('host', { + func: 'global_chat', + }) + } + /> + + Loadout Description + + {data.loadoutdesc} + {!!data.playing && ( + <> + + + The game is currently in progress, or loading. + + + )} +
+
+
+