From 3068779786048d0cfbe7ca54f77679ddbed69223 Mon Sep 17 00:00:00 2001 From: BartDrown <40639741+BartDrown@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:48:46 +0100 Subject: [PATCH 1/5] feat(ui): add LateJoin TGUI menu --- code/modules/mob/new_player/new_player.dm | 170 ++++++++++++++- tgui/packages/tgui/interfaces/LateJoin.tsx | 227 +++++++++++++++++++++ 2 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 tgui/packages/tgui/interfaces/LateJoin.tsx diff --git a/code/modules/mob/new_player/new_player.dm b/code/modules/mob/new_player/new_player.dm index b26d0f46d60..8377b064500 100644 --- a/code/modules/mob/new_player/new_player.dm +++ b/code/modules/mob/new_player/new_player.dm @@ -227,7 +227,11 @@ tgui_alert(src, "The server is full!", "Oh No!") return TRUE - LateChoices() + // Detect if we should use TGUI or legacy browser + if(use_tgui_latejoin()) + ui_interact(src) + else + LateChoices() // Use legacy fallback if(href_list["manifest"]) show_manifest(src, nano_state = GLOB.interactive_state) @@ -312,6 +316,9 @@ return FALSE if(jobban_isbanned(src.ckey,rank)) return FALSE + // Check setup restrictions (e.g., Church jobs require specific setup options) + if(client && client.prefs && job.is_restricted(client.prefs)) + return FALSE return TRUE /mob/new_player/proc/AttemptLateSpawn(rank, spawning_at) @@ -374,6 +381,167 @@ qdel(src) +// TGUI Detection - check if we should use TGUI or fallback to legacy browser +/mob/new_player/proc/use_tgui_latejoin() + // Check if client exists + if(!client) + return FALSE + + // Check if TGUI subsystem exists + if(!SStgui) + return FALSE + + // Try to get a window from the pool to verify availability + var/datum/tgui_window/window = SStgui.request_pooled_window(src) + if(!window) + // Pool exhausted or unavailable - use fallback + to_chat(src, span_warning("TGUI window pool exhausted, using legacy interface.")) + return FALSE + + // Return window to pool (we were just checking) + window.release_lock() + + return TRUE + +// TGUI Interface - modern UI for job selection +/mob/new_player/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "LateJoin") + ui.set_autoupdate(FALSE) // Disable autoupdate to prevent dexterity checks + ui.open() + +/mob/new_player/ui_state(mob/user) + return GLOB.always_state // No distance/access restrictions for new players + +/mob/new_player/ui_status(mob/user, datum/ui_state/state) + // Always allow interaction for new_player, skip all checks + return UI_INTERACTIVE + +/mob/new_player/ui_data(mob/user) + var/list/data = list() + + // Player info + data["playerName"] = client.prefs.be_random_name ? "friend" : client.prefs.real_name + + // Round info + data["roundDuration"] = DisplayTimeText(world.time - SSticker.round_start_time) + + // Evacuation status + data["isEvacuating"] = evacuation_controller.is_evacuating() + data["isEvacuated"] = evacuation_controller.has_evacuated() + + // Build job list grouped by department + var/list/departments = list() + + for(var/datum/department/dept in SSjob.departments) + var/list/dept_data = list( + "name" = dept.name, + "jobs" = list() + ) + + for(var/datum/job/job in dept.jobs) + // Count active players + var/active = 0 + for(var/mob/M in GLOB.player_list) + if(M.mind && M.client && M.mind.assigned_role == job.title) + if(M.client.inactivity <= 10 MINUTES) + active++ + + // Check availability (including experience requirements) + var/is_available = IsJobAvailable(job.title) + + var/list/job_data = list( + "title" = job.title, + "currentPositions" = job.current_positions, + "totalPositions" = job.total_positions, // -1 = unlimited + "activePlayers" = active, + "expRequired" = job.exp_requirements, + "expType" = job.exp_required_type, + "department" = job.department, + "available" = is_available, + "description" = job.description, + "supervisors" = job.supervisors, + ) + + dept_data["jobs"] += list(job_data) + + if(length(dept_data["jobs"])) + departments += list(dept_data) + + data["departments"] = departments + + return data + +/mob/new_player/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + // Don't call parent - we don't need the default checks for new_player + // Parent would check UI_INTERACTIVE status which may fail for new_player + + switch(action) + if("select_job") + var/job_title = params["job"] + if(!job_title) + return FALSE + + // Basic validation checks (copied from AttemptLateSpawn) + if(!SSticker.IsRoundInProgress()) + to_chat(src, span_red("The round is either not ready, or has already finished...")) + return TRUE + + if(!GLOB.enter_allowed) + to_chat(src, span_notice("There is an administrative lock on entering the game!")) + return TRUE + + if(!IsJobAvailable(job_title)) + to_chat(src, span_warning("[job_title] is not available. Please try another.")) + return TRUE + + // Spawn the character + spawning = 1 + close_spawn_windows() + + SSjob.AssignRole(src, job_title, 1) + var/datum/job/job = src.mind.assigned_job + var/mob/living/character = create_character() + + GLOB.joined_player_list += character.ckey + + // Handle AI special case + if(job_title == "AI") + character = character.AIize(move=0) + SSticker.minds += character.mind + var/obj/structure/AIcore/deactivated/C = empty_playable_ai_cores[1] + empty_playable_ai_cores -= C + character.forceMove(C.loc) + AnnounceArrival(character, job_title, "has been downloaded to the empty core in \the [character.loc.loc]") + log_manifest(character.mind.key, character.mind, character, latejoin = TRUE) + qdel(C) + qdel(src) + return TRUE + + // Normal spawn + var/datum/spawnpoint/spawnpoint = SSjob.get_spawnpoint_for(character.client, job_title, late = TRUE) + spawnpoint.put_mob(character) + character = SSjob.EquipRank(character, job_title) + character.lastarea = get_area(loc) + + if(SSjob.ShouldCreateRecords(job.title)) + if(character.mind.assigned_role != "Robot") + CreateModularRecord(character) + data_core.manifest_inject(character) + + AnnounceArrival(character, character.mind.assigned_role, spawnpoint.message) + log_manifest(character.mind.key, character.mind, character, latejoin = TRUE) + + qdel(src) + return TRUE + + if("close") + ui.close() + return TRUE + + return FALSE + /mob/new_player/proc/LateChoices() var/name = client.prefs.be_random_name ? "friend" : client.prefs.real_name diff --git a/tgui/packages/tgui/interfaces/LateJoin.tsx b/tgui/packages/tgui/interfaces/LateJoin.tsx new file mode 100644 index 00000000000..9755bfaf43b --- /dev/null +++ b/tgui/packages/tgui/interfaces/LateJoin.tsx @@ -0,0 +1,227 @@ +import { useBackend } from 'tgui/backend'; +import { + Box, + Button, + Flex, + LabeledList, + Section, + Stack, +} from 'tgui-core/components'; + +import { Window } from '../layouts'; + +type LateJoinData = { + playerName: string; + roundDuration: string; + isEvacuating: boolean; + isEvacuated: boolean; + departments: Department[]; +}; + +type Department = { + name: string; + jobs: Job[]; +}; + +type Job = { + title: string; + currentPositions: number; + totalPositions: number; // -1 = unlimited + activePlayers: number; + expRequired: number; + expType: string; + department: string; + available: boolean; + description: string; + supervisors: string; +}; + +// Department color scheme - backgrounds and accents +const DEPARTMENT_COLORS: Record = { + 'CEV Eris Command': { + bg: 'rgba(74, 144, 226, 0.25)', + accent: 'rgba(74, 144, 226, 0.8)', + button: 'rgba(74, 144, 226, 0.15)', + }, + 'Ironhammer Mercenary Company': { + bg: 'rgba(231, 76, 60, 0.25)', + accent: 'rgba(231, 76, 60, 0.8)', + button: 'rgba(231, 76, 60, 0.15)', + }, + 'Technomancer League': { + bg: 'rgba(243, 156, 18, 0.25)', + accent: 'rgba(243, 156, 18, 0.8)', + button: 'rgba(243, 156, 18, 0.15)', + }, + 'Moebius Labs: Medical Division': { + bg: 'rgba(80, 200, 120, 0.25)', + accent: 'rgba(80, 200, 120, 0.8)', + button: 'rgba(80, 200, 120, 0.15)', + }, + 'Moebius Labs: Research Division': { + bg: 'rgba(155, 89, 182, 0.25)', + accent: 'rgba(155, 89, 182, 0.8)', + button: 'rgba(155, 89, 182, 0.15)', + }, + 'Church of NeoTheology': { + bg: 'rgba(241, 196, 15, 0.25)', + accent: 'rgba(241, 196, 15, 0.8)', + button: 'rgba(241, 196, 15, 0.15)', + }, + 'Asters Merchant Guild': { + bg: 'rgba(39, 174, 96, 0.25)', + accent: 'rgba(39, 174, 96, 0.8)', + button: 'rgba(39, 174, 96, 0.15)', + }, + 'CEV Eris Civilian': { + bg: 'rgba(149, 165, 166, 0.25)', + accent: 'rgba(149, 165, 166, 0.8)', + button: 'rgba(149, 165, 166, 0.15)', + }, + 'Offship': { + bg: 'rgba(139, 69, 19, 0.25)', + accent: 'rgba(139, 69, 19, 0.8)', + button: 'rgba(139, 69, 19, 0.15)', + }, + 'Silicon': { + bg: 'rgba(52, 152, 219, 0.25)', + accent: 'rgba(52, 152, 219, 0.8)', + button: 'rgba(52, 152, 219, 0.15)', + }, +}; + +// Split departments into columns for better layout +const splitIntoColumns = (items: Department[], columns: number) => { + const result: Department[][] = Array.from({ length: columns }, () => []); + items.forEach((item, index) => { + result[index % columns].push(item); + }); + return result; +}; + +export const LateJoin = (props: any) => { + const { act, data } = useBackend(); + const { + playerName, + roundDuration, + isEvacuating: evacuating, + isEvacuated: evacuated, + departments, + } = data; + + // Convert DM numbers to proper booleans + const isEvacuating = !!evacuating; + const isEvacuated = !!evacuated; + + const columns = splitIntoColumns(departments || [], 3); + + return ( + + + + +
+ + + {roundDuration || 'Unknown'} + + {isEvacuating && !isEvacuated && ( + + The station is evacuating! + + )} + {isEvacuated && ( + + The station has been evacuated! + + )} + +
+
+ + + + {columns.map((column, columnIndex) => ( + + + {column.map((dept) => { + const deptColors = DEPARTMENT_COLORS[dept.name] || { + bg: 'rgba(149, 165, 166, 0.25)', + accent: 'rgba(149, 165, 166, 0.8)', + button: 'rgba(149, 165, 166, 0.15)', + }; + return ( + + + + {dept.name} + + + {dept.jobs.map((job) => { + const isAvailable = job.available; + const tooltipText = !isAvailable + ? 'Not available (check requirements, positions, or bans)' + : `You answer to ${job.supervisors}`; + + return ( + +