diff --git a/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors.nut b/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors.nut index 21d0dda7a..4f42b4094 100644 --- a/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors.nut +++ b/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors.nut @@ -142,6 +142,21 @@ g_bool_TraitorWinDelyFlag <- false; //Delay displaying winner msg g_ent_HudAndVGui <- []; // Entindex of all UI elements that have ever been created, used to release them at end of each mission. 存储所有挑战使用到的UI实体索引,用于游戏结束时释放资源 +// The gameplay guide is a player-owned VGUI screen. Unlike the role skill +// menus it is deliberately attached to the player entity instead of a marine: +// spectators, dead players, and players who have not selected a marine yet can +// all read it. The client script resolves the guide tokens from the challenge +// translation table; this entity only carries the client's language and the +// open/closed state. +g_str_GameplayGuideVGuiScript <- "challenge_traitors_client_gameplay_guide.nut"; +g_str_GameplayGuidePlayerField <- "strGameplayGuideVGuiName"; +g_str_GameplayGuideGenerationField <- "intGameplayGuideGeneration"; +// rd_vgui_vscript's local-player input path is opt-in. Int0 is the replicated +// open bit and Int1 is the local page-generation counter; Int63 is reserved by +// the client implementation for this ownership marker. +g_int_GameplayGuideLocalMenuModeSlot <- 63; +g_int_GameplayGuideLocalMenuModeSentinel <- 0x5244564D; + function Update() { if (!g_bool_ClhallengeEnable) { //挑战没有启用,无限期等待(65535秒) return 65535; @@ -934,11 +949,30 @@ function ShowSpeciallRolesList() { } function DestroyHudAndVGui() { + // A fully-joined event can arrive after the mission has already ended. + // Mark initialization closed before destroying anything so no callback can + // recreate HUD/VGUI entities for a finished challenge. + g_bool_Initialized = false; foreach(entity in g_ent_HudAndVGui) { if (entity != null && entity.IsValid()) { entity.Destroy(); } } + local hPlayer = null; + while (hPlayer = Entities.FindByClassname(hPlayer, "player")) { + if (hPlayer == null || !hPlayer.IsValid()) { + continue; + } + hPlayer.ValidateScriptScope(); + local playerScope = hPlayer.GetScriptScope(); + if (g_str_GameplayGuidePlayerField in playerScope) { + delete playerScope[g_str_GameplayGuidePlayerField]; + } + if (g_str_GameplayGuideGenerationField in playerScope) { + delete playerScope[g_str_GameplayGuideGenerationField]; + } + } + g_ent_HudAndVGui.clear(); } function PlayMissionEndSound(strWinner) { @@ -996,6 +1030,10 @@ function OnGameplayStart() { DisplayGameInstructions(); //显示游戏指引 g_bool_Initialized = true; //设置初始化完成标识 + // The fully-joined event can run while the setup above is still in + // progress. Reconcile after publishing initialized so every current + // player has exactly one idempotently-created guide entity. + EnsurePlayerGameplayGuides(); SetInitialAmmo(); //设置初始弹药量 @@ -1406,6 +1444,7 @@ function CreatePlayerHudAndVGuiEntities() { local hPlayer = null; while (hPlayer = Entities.FindByClassname(hPlayer, "player")) { CreatePlayerHud(hPlayer); + CreatePlayerGameplayGuide(hPlayer); } //根据角色设置hud文本 //1.设置IAF队员 @@ -1424,6 +1463,16 @@ function CreatePlayerHudAndVGuiEntities() { } } +function EnsurePlayerGameplayGuides() { + if (!g_bool_ClhallengeEnable || g_bool_IafWin || g_bool_TraitorWin) { + return; + } + local hPlayer = null; + while (hPlayer = Entities.FindByClassname(hPlayer, "player")) { + CreatePlayerGameplayGuide(hPlayer); + } +} + function SetHudForIafPlayer(hMarine, role) { if (!hMarine || !hMarine.IsValid() || !hMarine.IsInhabited()) { return; @@ -1471,6 +1520,102 @@ function SetHudForTraitorPlayer(hMarine, role, timeOffset = 2.0) { hHud4.SetString(0, GenerateTraitorListHUD(strLanguage)); } +function NormalizeGameplayGuideLanguage(strLanguage) { + // The guide currently ships only English and Simplified Chinese. Do not + // expose a server-side "[language] token missing" diagnostic to clients + // using another language; use English as the safe fallback instead. + if (strLanguage == "schinese" || strLanguage == "english") { + return strLanguage; + } + return "english"; +} + +function GetGameplayGuideEntity(hPlayer) { + if (hPlayer == null || !hPlayer.IsValid()) { + return null; + } + hPlayer.ValidateScriptScope(); + local playerScope = hPlayer.GetScriptScope(); + if (!(g_str_GameplayGuidePlayerField in playerScope)) { + return null; + } + local strName = playerScope[g_str_GameplayGuidePlayerField]; + if (strName == null || strName == "") { + return null; + } + local hGuide = Entities.FindByName(null, strName); + if (hGuide == null || !hGuide.IsValid()) { + return null; + } + return hGuide; +} + +function UpdateGameplayGuideData(hPlayer, hGuide) { + if (hPlayer == null || !hPlayer.IsValid() || hGuide == null || !hGuide.IsValid()) { + return; + } + local strLanguage = NormalizeGameplayGuideLanguage(GetClientLanguage(hPlayer.entindex())); + // CRD_VGui_VScript has one network string slot (256 bytes). Only the + // language is sent; the client loads challenge_traitors_translations_all.nut + // and resolves all eleven guide keys locally, so long role text is never + // truncated by the VGUI network field. + hGuide.SetString(0, strLanguage); + // Slot 0 is the replicated visibility gate. The server owns this bit; + // the client only observes it and never sends a VGUI input back. + hGuide.SetInt(0, 0); + hGuide.SetInt(g_int_GameplayGuideLocalMenuModeSlot, g_int_GameplayGuideLocalMenuModeSentinel); + local generation = 0; + hPlayer.ValidateScriptScope(); + local playerScope = hPlayer.GetScriptScope(); + if (g_str_GameplayGuideGenerationField in playerScope) { + generation = playerScope[g_str_GameplayGuideGenerationField]; + } + hGuide.SetInt(1, generation); +} + +function CreatePlayerGameplayGuide(hPlayer) { + if (hPlayer == null || !hPlayer.IsValid() || !g_bool_ClhallengeEnable) { + return null; + } + hPlayer.ValidateScriptScope(); + local playerScope = hPlayer.GetScriptScope(); + if (!(g_str_GameplayGuideGenerationField in playerScope)) { + playerScope[g_str_GameplayGuideGenerationField] <- 0; + } + local hGuide = GetGameplayGuideEntity(hPlayer); + if (hGuide == null) { + hGuide = Entities.CreateByClassname("rd_vgui_vscript"); + if (hGuide == null || !hGuide.IsValid()) { + return null; + } + g_ent_HudAndVGui.append(hGuide); + hGuide.__KeyValueFromString("client_vscript", g_str_GameplayGuideVGuiScript); + hGuide.SetInt(g_int_GameplayGuideLocalMenuModeSlot, g_int_GameplayGuideLocalMenuModeSentinel); + hGuide.Spawn(); + hGuide.Activate(); + hGuide.SetEntity(0, hPlayer); + local strName = "VGuiGameplayGuide_" + UniqueString(); + hGuide.SetName(strName); + if (g_str_GameplayGuidePlayerField in playerScope) { + playerScope[g_str_GameplayGuidePlayerField] = strName; + } else { + playerScope[g_str_GameplayGuidePlayerField] <- strName; + } + } + + // Keep this function idempotent. It is called at game start and again by + // player_fullyjoined for late joiners, and a player must never get duplicate + // interactive screens. + hGuide.SetEntity(0, hPlayer); + // Every player-owned guide starts hidden. Keep this assignment here as + // well as in UpdateGameplayGuideData so a newly spawned entity cannot paint + // during the spawn/activation window before its other slots are populated. + hGuide.SetInt(0, 0); + hGuide.SetInt(g_int_GameplayGuideLocalMenuModeSlot, g_int_GameplayGuideLocalMenuModeSentinel); + UpdateGameplayGuideData(hPlayer, hGuide); + return hGuide; +} + function CreatePlayerHud(hPlayer) { hPlayer.ValidateScriptScope(); diff --git a/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors_client_gameplay_guide.nut b/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors_client_gameplay_guide.nut new file mode 100644 index 000000000..2fc439c4c --- /dev/null +++ b/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors_client_gameplay_guide.nut @@ -0,0 +1,583 @@ +// Public, client-only gameplay guide for the Traitors challenge. +// +// The server creates one rd_vgui_vscript per player and sends only the +// player's language in string slot 0. Keeping the text in the client-side +// translation table avoids the 256-byte rd_vgui_vscript network-string limit +// and, importantly, keeps this guide identical for spectators and Marines. + +isServer <- false; +IncludeScript("challenge_traitors_enums"); +IncludeScript("challenge_traitors_client_shared"); + +// The generated translation file creates a g_localizations table in the scope +// passed to IncludeScript. Keep that table in a private child scope instead +// of the VGUI script scope (or the client root), because one guide entity is +// created for every player and all of them execute this script independently. +// The table is loaded lazily only for the local player's open entity: parsing +// the generated file for every remote spectator would be wasteful. +g_guideTranslationLoaded <- false; +g_guideTranslationScope <- {}; + +GUIDE_PAGE_MAIN <- 0; +GUIDE_PAGE_TEAM_OBJECTIVES <- 1; +GUIDE_PAGE_MECHANICS <- 2; +GUIDE_PAGE_ROLES <- 3; +GUIDE_PAGE_TIPS <- 4; +GUIDE_MAIN_BUTTON_COUNT <- 4; +GUIDE_BACK_BUTTON_INDEX <- 4; +GUIDE_TIP_COUNT <- 11; + +// These are the complete public guide vocabulary. Do not put guide prose in +// this script: all text comes from challenge_traitors_translations_all.nut. +// Keys are stored without '#'. GetGuideTableString also accepts a leading +// '#' so older callers and both formal/test scripts use the same lookup path. +GUIDE_KEY_TITLE <- "challenge_traitors_guide_title"; +GUIDE_KEY_INTRO <- "challenge_traitors_guide_intro"; +GUIDE_KEY_INTRO_PARTS <- [ + "challenge_traitors_guide_intro_01", + "challenge_traitors_guide_intro_02", + "challenge_traitors_guide_intro_03", + "challenge_traitors_guide_intro_04", + "challenge_traitors_guide_intro_05" +]; +GUIDE_KEY_TEAM_BUTTON <- "challenge_traitors_guide_team_objectives_button"; +GUIDE_KEY_MECHANICS_BUTTON <- "challenge_traitors_guide_mechanics_button"; +GUIDE_KEY_ROLES_BUTTON <- "challenge_traitors_guide_roles_button"; +GUIDE_KEY_TIPS_BUTTON <- "challenge_traitors_guide_tips_button"; +GUIDE_KEY_TEAM_TITLE <- "challenge_traitors_guide_team_objectives_title"; +GUIDE_KEY_TEAM_TEXT <- "challenge_traitors_guide_team_objectives_text"; +GUIDE_KEY_TEAM_TEXT_PARTS <- [ + "challenge_traitors_guide_team_objectives_text_01", + "challenge_traitors_guide_team_objectives_text_02", + "challenge_traitors_guide_team_objectives_text_03", + "challenge_traitors_guide_team_objectives_text_04", + "challenge_traitors_guide_team_objectives_text_05", + "challenge_traitors_guide_team_objectives_text_06", + "challenge_traitors_guide_team_objectives_text_07", + "challenge_traitors_guide_team_objectives_text_08", + "challenge_traitors_guide_team_objectives_text_09", + "challenge_traitors_guide_team_objectives_text_10" +]; +GUIDE_KEY_MECHANICS_TITLE <- "challenge_traitors_guide_mechanics_title"; +GUIDE_KEY_MECHANICS_TEXT <- "challenge_traitors_guide_mechanics_text"; +GUIDE_KEY_MECHANICS_TEXT_PARTS <- [ + "challenge_traitors_guide_mechanics_text_01", + "challenge_traitors_guide_mechanics_text_02", + "challenge_traitors_guide_mechanics_text_03", + "challenge_traitors_guide_mechanics_text_04", + "challenge_traitors_guide_mechanics_text_05", + "challenge_traitors_guide_mechanics_text_06", + "challenge_traitors_guide_mechanics_text_07", + "challenge_traitors_guide_mechanics_text_08", + "challenge_traitors_guide_mechanics_text_09", + "challenge_traitors_guide_mechanics_text_10", + "challenge_traitors_guide_mechanics_text_11", + "challenge_traitors_guide_mechanics_text_12" +]; +GUIDE_KEY_ROLES_TITLE <- "challenge_traitors_guide_roles_title"; +GUIDE_KEY_ROLES_TEXT <- "challenge_traitors_guide_roles_text"; +GUIDE_KEY_ROLES_TEXT_PARTS <- [ + "challenge_traitors_guide_roles_text_01", + "challenge_traitors_guide_roles_text_02", + "challenge_traitors_guide_roles_text_03", + "challenge_traitors_guide_roles_text_04", + "challenge_traitors_guide_roles_text_05", + "challenge_traitors_guide_roles_text_06", + "challenge_traitors_guide_roles_text_07", + "challenge_traitors_guide_roles_text_08", + "challenge_traitors_guide_roles_text_09", + "challenge_traitors_guide_roles_text_10", + "challenge_traitors_guide_roles_text_11", + "challenge_traitors_guide_roles_text_12", + "challenge_traitors_guide_roles_text_13", + "challenge_traitors_guide_roles_text_14", + "challenge_traitors_guide_roles_text_15", + "challenge_traitors_guide_roles_text_16" +]; +GUIDE_KEY_TIPS_TITLE <- "challenge_traitors_guide_tips_title"; +GUIDE_KEY_TIP_01 <- "challenge_traitors_guide_tip_01"; +GUIDE_KEY_TIP_02 <- "challenge_traitors_guide_tip_02"; +GUIDE_KEY_TIP_03 <- "challenge_traitors_guide_tip_03"; +GUIDE_KEY_TIP_04 <- "challenge_traitors_guide_tip_04"; +GUIDE_KEY_TIP_05 <- "challenge_traitors_guide_tip_05"; +GUIDE_KEY_TIP_06 <- "challenge_traitors_guide_tip_06"; +GUIDE_KEY_TIP_07 <- "challenge_traitors_guide_tip_07"; +GUIDE_KEY_TIP_08 <- "challenge_traitors_guide_tip_08"; +GUIDE_KEY_TIP_09 <- "challenge_traitors_guide_tip_09"; +GUIDE_KEY_TIP_10 <- "challenge_traitors_guide_tip_10"; +GUIDE_KEY_TIP_11 <- "challenge_traitors_guide_tip_11"; + +FONT_GUIDE_TITLE <- self.LookupFont("DefaultLarge"); +FONT_GUIDE_BODY <- self.LookupFont("DefaultSmall"); +FONT_GUIDE_BUTTON <- self.LookupFont("DefaultLarge"); + +page <- GUIDE_PAGE_MAIN; +wasVisible <- false; +seenOpenGeneration <- -1; +mouseDown <- false; +hotButton <- -1; +buttonPressed <- false; + +panelX <- 0.0; +panelY <- 0.0; +panelRight <- 0.0; +panelBottom <- 0.0; +panelWidth <- 0.0; +panelHeight <- 0.0; +margin <- 0.0; +spacing <- 0.0; +titleHeight <- 0.0; +bodyLineHeight <- 0.0; +buttonHeight <- 0.0; +bodyX <- 0.0; +bodyY <- 0.0; +bodyRight <- 0.0; +backX0 <- 0.0; +backX1 <- 0.0; +bottomButtonY <- 0.0; + +guideTitle <- ""; +guideIntro <- ""; +guideTeamButton <- ""; +guideMechanicsButton <- ""; +guideRolesButton <- ""; +guideTipsButton <- ""; +guideTeamTitle <- ""; +guideTeamText <- ""; +guideMechanicsTitle <- ""; +guideMechanicsText <- ""; +guideRolesTitle <- ""; +guideRolesText <- ""; +guideTipsTitle <- ""; +guideTips <- []; +detailTitle <- ""; +detailText <- ""; +bodyLines <- []; + +function GuideLanguage() { + local strLanguage = self.GetString(0); + try { + strLanguage = strLanguage.tolower(); + } catch (exception) { + strLanguage = "english"; + } + if (strLanguage != "english" && strLanguage != "schinese") { + return "english"; + } + return strLanguage; +} + +function EnsureGuideTranslations() { + if (g_guideTranslationLoaded) { + return; + } + try { + // The generated file is data, not a module: it assigns g_localizations + // in the scope supplied here. Never include it into getroottable(), + // where it would collide with challenge/server localization globals. + if (!IncludeScript("challenge_traitors_translations_all.nut", g_guideTranslationScope)) { + return; + } + g_guideTranslationLoaded = true; + } catch (exception) { + g_guideTranslationLoaded = false; + } +} + +function GetGuideTableString(language, key) { + try { + if (!g_guideTranslationScope.rawin("g_localizations")) { + return null; + } + local localizations = g_guideTranslationScope.g_localizations; + if (localizations == null || !localizations.rawin(language)) { + return null; + } + local token = key; + if (token.len() > 0 && token.slice(0, 1) == "#") { + token = token.slice(1); + } + local languageTable = localizations[language]; + if (languageTable == null || !languageTable.rawin(token)) { + return null; + } + local result = languageTable[token]; + if (result == null || result == "") { + return null; + } + return result; + } catch (exception) { + return null; + } +} + +function ResolveGuideString(key) { + EnsureGuideTranslations(); + local language = GuideLanguage(); + // The generated table is keyed by token without the leading '#'. Guide + // prose has no role-substitution parameters, so reading this table directly + // is equivalent to GetLocalizedString while remaining safe in the private + // scope above. If a supported-language entry is absent, retry English; + // a missing token remains visible as '#token' for diagnosis. + local result = GetGuideTableString(language, key); + if (result == null && language != "english") { + result = GetGuideTableString("english", key); + } + return result == null ? "#" + (key.len() > 0 && key.slice(0, 1) == "#" ? key.slice(1) : key) : result; +} + +function ResolveGuideParts(partKeys, separator = "\n") { + local result = ""; + for (local i = 0; i < partKeys.len(); i++) { + if (i > 0) { + result += separator; + } + // Resolve each part independently. This deliberately keeps the helper + // small: a missing translation falls back to English (or visibly shows + // its token) without suppressing the other translated parts. + result += ResolveGuideString(partKeys[i]); + } + return result; +} + +function IsLocalGuideOwner() { + local hPlayer = GetLocalPlayer(); + return hPlayer != null && hPlayer.IsValid() && self.GetEntity(0) == hPlayer; +} + +function IsGuideOpenForLocalPlayer() { + return self.GetInt(0) != 0 && IsLocalGuideOwner(); +} + +function ResetGuidePage() { + page = GUIDE_PAGE_MAIN; + hotButton = -1; + buttonPressed = false; + mouseDown = false; +} + +function CalculateLayout() { + local screenWidth = ScreenWidth().tofloat(); + local screenHeight = ScreenHeight().tofloat(); + local scale = screenHeight / 768.0; + if (scale < 0.75) { + scale = 0.75; + } + + panelWidth = screenWidth * 0.86; + local maxWidth = 980.0 * scale; + if (panelWidth > maxWidth) { + panelWidth = maxWidth; + } + panelHeight = screenHeight * 0.84; + panelX = (screenWidth - panelWidth) * 0.5; + panelY = (screenHeight - panelHeight) * 0.5; + panelRight = panelX + panelWidth; + panelBottom = panelY + panelHeight; + margin = 18.0 * scale; + spacing = 8.0 * scale; + titleHeight = self.GetFontTall(FONT_GUIDE_TITLE); + bodyLineHeight = self.GetFontTall(FONT_GUIDE_BODY) + 2.0 * scale; + buttonHeight = self.GetFontTall(FONT_GUIDE_BUTTON) + 16.0 * scale; + + local backWidth = 180.0 * scale; + backX0 = panelX + margin; + backX1 = backX0 + backWidth; + + // The home page has fixed category buttons at the bottom. The body + // text uses the full width available inside the panel. + local headerHeight = titleHeight; + if (page == GUIDE_PAGE_MAIN && buttonHeight > headerHeight) { + headerHeight = buttonHeight; + } + bodyX = panelX + margin; + bodyY = panelY + margin + headerHeight + margin; + local bottomRows = page == GUIDE_PAGE_MAIN ? GUIDE_MAIN_BUTTON_COUNT : 1; + local bottomSpacing = page == GUIDE_PAGE_MAIN ? spacing * (GUIDE_MAIN_BUTTON_COUNT - 1) : 0.0; + bottomButtonY = panelBottom - margin - buttonHeight * bottomRows - bottomSpacing; + bodyRight = panelRight - margin; +} + +function GetUtf8CharLength(text, index) { + local length = 1; + try { + local byte = text[index]; + if (typeof(byte) == "string") { + byte = byte[0]; + } + if (typeof(byte) == "integer") { + if ((byte & 0xE0) == 0xC0) { + length = 2; + } else if ((byte & 0xF0) == 0xE0) { + length = 3; + } else if ((byte & 0xF8) == 0xF0) { + length = 4; + } + } + } catch (exception) {} + if (index + length > text.len()) { + return 1; + } + return length; +} + +function WrapGuideText(text, font, width) { + local result = []; + local current = ""; + local index = 0; + while (index < text.len()) { + local charLength = GetUtf8CharLength(text, index); + local character = text.slice(index, index + charLength); + index += charLength; + if (character == "\r") { + continue; + } + if (character == "\n") { + result.append(current); + current = ""; + continue; + } + if (current != "" && self.GetTextWide(font, current + character) > width) { + result.append(current); + current = ""; + } + current += character; + } + if (current != "" || result.len() == 0) { + result.append(current); + } + return result; +} + +function RefreshGuideContent() { + guideTitle = ResolveGuideString(GUIDE_KEY_TITLE); + guideIntro = ResolveGuideParts(GUIDE_KEY_INTRO_PARTS); + guideTeamButton = ResolveGuideString(GUIDE_KEY_TEAM_BUTTON); + guideMechanicsButton = ResolveGuideString(GUIDE_KEY_MECHANICS_BUTTON); + guideRolesButton = ResolveGuideString(GUIDE_KEY_ROLES_BUTTON); + guideTipsButton = ResolveGuideString(GUIDE_KEY_TIPS_BUTTON); + guideTeamTitle = ResolveGuideString(GUIDE_KEY_TEAM_TITLE); + guideTeamText = ResolveGuideParts(GUIDE_KEY_TEAM_TEXT_PARTS); + guideMechanicsTitle = ResolveGuideString(GUIDE_KEY_MECHANICS_TITLE); + guideMechanicsText = ResolveGuideParts(GUIDE_KEY_MECHANICS_TEXT_PARTS); + guideRolesTitle = ResolveGuideString(GUIDE_KEY_ROLES_TITLE); + guideRolesText = ResolveGuideParts(GUIDE_KEY_ROLES_TEXT_PARTS); + guideTipsTitle = ResolveGuideString(GUIDE_KEY_TIPS_TITLE); + guideTips = [ + ResolveGuideString(GUIDE_KEY_TIP_01), + ResolveGuideString(GUIDE_KEY_TIP_02), + ResolveGuideString(GUIDE_KEY_TIP_03), + ResolveGuideString(GUIDE_KEY_TIP_04), + ResolveGuideString(GUIDE_KEY_TIP_05), + ResolveGuideString(GUIDE_KEY_TIP_06), + ResolveGuideString(GUIDE_KEY_TIP_07), + ResolveGuideString(GUIDE_KEY_TIP_08), + ResolveGuideString(GUIDE_KEY_TIP_09), + ResolveGuideString(GUIDE_KEY_TIP_10), + ResolveGuideString(GUIDE_KEY_TIP_11) + ]; + + if (page == GUIDE_PAGE_TEAM_OBJECTIVES) { + detailTitle = guideTeamTitle; + detailText = guideTeamText; + } else if (page == GUIDE_PAGE_MECHANICS) { + detailTitle = guideMechanicsTitle; + detailText = guideMechanicsText; + } else if (page == GUIDE_PAGE_ROLES) { + detailTitle = guideRolesTitle; + detailText = guideRolesText; + } else if (page == GUIDE_PAGE_TIPS) { + detailTitle = guideTipsTitle; + detailText = ""; + } else { + detailTitle = guideTitle; + detailText = guideIntro; + } + CalculateLayout(); + local wrapWidth = bodyRight - bodyX; + if (page == GUIDE_PAGE_TIPS) { + // Wrap each tip independently so token order is stable without adding + // blank separator lines that would consume the no-scroll layout. + bodyLines = []; + for (local i = 0; i < GUIDE_TIP_COUNT; i++) { + local tipLines = WrapGuideText(guideTips[i], FONT_GUIDE_BODY, wrapWidth); + foreach (line in tipLines) { + if (line != "") { + bodyLines.append(line); + } + } + } + } else { + bodyLines = WrapGuideText(detailText, FONT_GUIDE_BODY, wrapWidth); + } +} + +function IsInside(x, y, rect) { + return x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]; +} + +function GetMainButtonRect(index) { + local x0 = panelX + margin; + local x1 = panelRight - margin; + local y0 = bottomButtonY + index * (buttonHeight + spacing); + return [x0, y0, x1, y0 + buttonHeight]; +} + +function GetGuideButtonAt(x, y) { + if (page == GUIDE_PAGE_MAIN) { + for (local i = 0; i < GUIDE_MAIN_BUTTON_COUNT; i++) { + if (IsInside(x, y, GetMainButtonRect(i))) { + return i; + } + } + return -1; + } + if (y >= bottomButtonY && y <= bottomButtonY + buttonHeight) { + if (x >= backX0 && x <= backX1) { + return GUIDE_BACK_BUTTON_INDEX; + } + } + return -1; +} + +function DrawGuideButton(x0, y0, x1, y1, text, index, enabled = true) { + local isHot = enabled && hotButton == index; + local isPressed = isHot && buttonPressed; + local r = 35; + local g = 45; + local b = 55; + local textR = 255; + local textG = 255; + local textB = 255; + if (!enabled) { + r = 18; + g = 22; + b = 26; + textR = 100; + textG = 100; + textB = 100; + } else if (isPressed) { + r = 115; + g = 105; + b = 45; + textR = 0; + textG = 0; + textB = 0; + } else if (isHot) { + r = 210; + g = 210; + b = 120; + textR = 0; + textG = 0; + textB = 0; + } + self.PaintRectangle(x0, y0, x1, y1, r, g, b, 230); + local textWidth = self.GetTextWide(FONT_GUIDE_BUTTON, text); + local textX = (x0 + x1 - textWidth) * 0.5; + local textY = y0 + (y1 - y0 - self.GetFontTall(FONT_GUIDE_BUTTON)) * 0.5; + self.PaintText(textX, textY, textR, textG, textB, 255, FONT_GUIDE_BUTTON, text); +} + +function PaintGuideText(x, y) { + for (local i = 0; i < bodyLines.len(); i++) { + self.PaintText(x, y + i * bodyLineHeight, 235, 235, 235, 255, FONT_GUIDE_BODY, bodyLines[i]); + } +} + +function Paint() { + // Hidden screens must not draw or consume input. The explicit player and + // Int(0) checks also prevent another client's VGUI entity from appearing in + // this client's view. + if (!IsGuideOpenForLocalPlayer()) { + return; + } + CalculateLayout(); + self.PaintRectangle(panelX, panelY, panelRight, panelBottom, 0, 0, 0, 225); + self.PaintRectangle(panelX + 3, panelY + 3, panelRight - 3, panelBottom - 3, 12, 18, 24, 225); + local titleText = page == GUIDE_PAGE_MAIN ? guideTitle : detailTitle; + local titleWidth = self.GetTextWide(FONT_GUIDE_TITLE, titleText); + local titleX = (panelX + panelRight - titleWidth) * 0.5; + self.PaintText(titleX, panelY + margin, 255, 255, 210, 255, FONT_GUIDE_TITLE, page == GUIDE_PAGE_MAIN ? guideTitle : detailTitle); + + if (page == GUIDE_PAGE_MAIN) { + PaintGuideText(bodyX, bodyY); + for (local i = 0; i < GUIDE_MAIN_BUTTON_COUNT; i++) { + local button = GetMainButtonRect(i); + local buttonText = i == 0 ? guideTeamButton : (i == 1 ? guideMechanicsButton : (i == 2 ? guideRolesButton : guideTipsButton)); + DrawGuideButton(button[0], button[1], button[2], button[3], buttonText, i); + } + } else { + PaintGuideText(bodyX, bodyY); + DrawGuideButton(backX0, bottomButtonY, backX1, bottomButtonY + buttonHeight, "<<", GUIDE_BACK_BUTTON_INDEX); + } +} + +function ActivateGuideButton(index) { + if (page == GUIDE_PAGE_MAIN) { + if (index == 0) { + page = GUIDE_PAGE_TEAM_OBJECTIVES; + } else if (index == 1) { + page = GUIDE_PAGE_MECHANICS; + } else if (index == 2) { + page = GUIDE_PAGE_ROLES; + } else if (index == 3) { + page = GUIDE_PAGE_TIPS; + } else { + return; + } + RefreshGuideContent(); + return; + } + if (index == GUIDE_BACK_BUTTON_INDEX) { + ResetGuidePage(); + RefreshGuideContent(); + } +} + +function Control(tbl) { + // Returning before touching mouse/key state is intentional: when hidden, + // the engine's normal controls continue receiving every input event. + if (!IsGuideOpenForLocalPlayer()) { + hotButton = -1; + mouseDown = false; + buttonPressed = false; + return; + } + CalculateLayout(); + hotButton = GetGuideButtonAt(tbl.mouse_x, tbl.mouse_y); + buttonPressed = false; + + if (tbl.mouse_left) { + mouseDown = true; + buttonPressed = hotButton >= 0; + } else if (mouseDown) { + local clickedButton = hotButton; + mouseDown = false; + buttonPressed = false; + if (clickedButton >= 0) { + ActivateGuideButton(clickedButton); + } + } +} + +function OnUpdate() { + self.ForceSync(); + local openForLocalPlayer = IsGuideOpenForLocalPlayer(); + local openGeneration = self.GetInt(1); + if (!openForLocalPlayer) { + if (wasVisible) { + // A close (Int(0) becoming zero) resets the page. The next open + // therefore always starts at MAIN. + ResetGuidePage(); + } + wasVisible = false; + seenOpenGeneration = openGeneration; + return; + } + if (!wasVisible || seenOpenGeneration != openGeneration) { + ResetGuidePage(); + } + wasVisible = true; + seenOpenGeneration = openGeneration; + RefreshGuideContent(); +} diff --git a/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors_events/ongameevent_player_fullyjoined.nut b/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors_events/ongameevent_player_fullyjoined.nut index 3508d3b81..c24896173 100644 --- a/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors_events/ongameevent_player_fullyjoined.nut +++ b/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors_events/ongameevent_player_fullyjoined.nut @@ -1,6 +1,15 @@ g_ModeScript.OnGameEvent_player_fullyjoined <- function(params) { local hPlayer = GetPlayerFromUserID(params["userid"]); - if (hPlayer != null) { + // During setup, OnGameplayStart performs a final reconciliation after it + // publishes g_bool_Initialized. After DestroyHudAndVGui marks the round + // closed, do not recreate HUDs or guides for late fully-joined events. + if (hPlayer != null && g_bool_Initialized && !g_bool_IafWin && !g_bool_TraitorWin) { CreatePlayerHud(hPlayer); + // A player can join after OnGameplayStart. Build the guide lazily in + // that case; CreatePlayerGameplayGuide is idempotent and also handles a + // stale entity name after a map transition. + if (g_bool_ClhallengeEnable) { + CreatePlayerGameplayGuide(hPlayer); + } } } \ No newline at end of file diff --git a/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors_events/onreceivedtextmessage.nut b/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors_events/onreceivedtextmessage.nut index 627626162..0ea36c37b 100644 --- a/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors_events/onreceivedtextmessage.nut +++ b/reactivedrop/content/traitors_challenge/scripts/vscripts/challenge_traitors_events/onreceivedtextmessage.nut @@ -1,5 +1,73 @@ g_bool_BombActivited <- false; +// This scripted user function is the bindable replacement for the legacy chat menu trigger. +// This script is included by challenge_traitors.nut in the challenge thinker +// scope, so the listener is only visible to this challenge's listener scope. +function UserConsoleCommand(player, value) { + if (player == null || !player.IsValid() || !g_bool_ClhallengeEnable || !g_bool_Initialized) { + return; + } + + // This command intentionally runs before the role/Marine checks below. + // The guide belongs to the player, not to a marine, so it remains available + // to spectators, dead players, and players who joined without a Marine. + if (value == "traitors_gameplay_info" || value == "rd_traitors_gameplay_info") { + ToggleGameplayGuide(player); + return; + } + + if (value != "traitors_use_skill" || g_bool_IafWin || g_bool_TraitorWin) { + return; + } + + if (g_marine_Silencer != null && g_marine_Silencer.IsValid() && player == g_marine_Silencer.GetCommander() && player.GetMarine() == g_marine_Silencer) { + ToggleVGuiMenu(player, g_marine_Silencer); + } else if (g_marine_Boomer != null && g_marine_Boomer.IsValid() && player == g_marine_Boomer.GetCommander() && player.GetMarine() == g_marine_Boomer) { + SetBomb(); + } else if (g_marine_Infector != null && g_marine_Infector.IsValid() && player == g_marine_Infector.GetCommander() && player.GetMarine() == g_marine_Infector) { + ToggleVGuiMenu(player, g_marine_Infector); + } else if (g_marine_Scanner != null && g_marine_Scanner.IsValid() && player == g_marine_Scanner.GetCommander() && player.GetMarine() == g_marine_Scanner) { + ToggleVGuiMenu(player, g_marine_Scanner); + } else if (g_marine_Biochemist != null && g_marine_Biochemist.IsValid() && player == g_marine_Biochemist.GetCommander() && player.GetMarine() == g_marine_Biochemist) { + ToggleVGuiMenu(player, g_marine_Biochemist); + } else if (g_marine_Shield != null && g_marine_Shield.IsValid() && player == g_marine_Shield.GetCommander() && player.GetMarine() == g_marine_Shield) { + ToggleVGuiMenu(player, g_marine_Shield); + } +} + +function ToggleGameplayGuide(hPlayer) { + if (hPlayer == null || !hPlayer.IsValid() || !g_bool_ClhallengeEnable || !g_bool_Initialized) { + return; + } + local hGuide = GetGameplayGuideEntity(hPlayer); + if (g_bool_IafWin || g_bool_TraitorWin) { + if (hGuide != null && hGuide.IsValid()) { + hGuide.SetInt(0, 0); + } + return; + } + if (hGuide == null || !hGuide.IsValid()) { + // Do not create an entity from a key press. The challenge start and + // fully-joined paths own creation; this keeps the command inert until the + // challenge has completed initialization. + return; + } + if (hGuide.GetInt(0) != 0) { + hGuide.SetInt(0, 0); + } else { + // Reset the local page whenever opening. The client also watches this + // replicated state and returns to MAIN after a close/reopen transition. + hPlayer.ValidateScriptScope(); + local playerScope = hPlayer.GetScriptScope(); + if (!(g_str_GameplayGuideGenerationField in playerScope)) { + playerScope[g_str_GameplayGuideGenerationField] <- 0; + } + playerScope[g_str_GameplayGuideGenerationField]++; + hGuide.SetInt(1, playerScope[g_str_GameplayGuideGenerationField]); + hGuide.SetInt(0, 1); + } +} + function OnReceivedTextMessage(recipient, sender, message) { // 这个函数会在服务端为所有玩家执行一次,因此需要通过这个判断避免重复执行 if (sender != recipient) { diff --git a/reactivedrop/resource/ui/basemodui/CRD_VGUI_Settings_Controls.res b/reactivedrop/resource/ui/basemodui/CRD_VGUI_Settings_Controls.res index bc272607b..99b7e0d3b 100644 --- a/reactivedrop/resource/ui/basemodui/CRD_VGUI_Settings_Controls.res +++ b/reactivedrop/resource/ui/basemodui/CRD_VGUI_Settings_Controls.res @@ -522,7 +522,7 @@ "wide" "176" "tall" "12" "navLeft" "BindRotateCameraLeft" - "navRight" "BindWheelEquipment2" + "navRight" "BindTraitorsUseSkill" "navUp" "BindPlayerList" "navDown" "BindRotateCameraRight" } @@ -536,7 +536,7 @@ "wide" "176" "tall" "12" "navLeft" "BindRotateCameraRight" - "navRight" "BindWheelEquipment2" + "navRight" "BindTraitorsGameplayInfo" "navUp" "BindRotateCameraLeft" "navDown" "BindSecondaryAttackAlt" } @@ -550,7 +550,7 @@ "wide" "176" "tall" "12" "navLeft" "BindSecondaryAttackAlt" - "navRight" "BindWheelEquipment2" + "navRight" "BindTraitorsGameplayInfo" "navUp" "BindRotateCameraRight" "navDown" "BindChooseMarine" } @@ -564,7 +564,7 @@ "wide" "176" "tall" "12" "navLeft" "BindChooseMarine" - "navRight" "BindWheelEquipment2" + "navRight" "BindTraitorsGameplayInfo" "navUp" "BindSecondaryAttackAlt" "navDown" "BtnResetDefaults" } @@ -761,7 +761,37 @@ "navLeft" "BindMissionOverview" "navRight" "BtnCustomWheels" "navUp" "BindWheelEquipment1" - "navDown" "BindWheelEquipment2" + "navDown" "BindTraitorsUseSkill" + } + + "BindTraitorsUseSkill" + { + "ControlName" "CRD_VGUI_KeyboardBind" + "fieldName" "BindTraitorsUseSkill" + "xpos" "202" [!$WIN32WIDE] + "xpos" "217" [$WIN32WIDE] + "ypos" "316" + "wide" "176" + "tall" "12" + "navLeft" "BindRotateCameraLeft" + "navRight" "BindWheelMarine" + "navUp" "BindWheelEquipment2" + "navDown" "BindTraitorsGameplayInfo" + } + + "BindTraitorsGameplayInfo" + { + "ControlName" "CRD_VGUI_KeyboardBind" + "fieldName" "BindTraitorsGameplayInfo" + "xpos" "202" [!$WIN32WIDE] + "xpos" "217" [$WIN32WIDE] + "ypos" "328" + "wide" "176" + "tall" "12" + "navLeft" "BindRotateCameraRight" + "navRight" "BindWheelMarine" + "navUp" "BindTraitorsUseSkill" + "navDown" "BtnResetDefaults" } "BindSelectMarine0" @@ -929,7 +959,7 @@ "command" "ResetDefaults" "navLeft" "BtnResetDefaults" "navRight" "SettingDeveloperConsole" - "navUp" "BindChooseMarine" + "navUp" "BindTraitorsGameplayInfo" "navDown" "BtnResetDefaults" } @@ -1022,6 +1052,21 @@ "font" "DefaultMedium" } + "LblCustomFunctions" + { + "ControlName" "Label" + "fieldName" "LblCustomFunctions" + "xpos" "194" [!$WIN32WIDE] + "xpos" "209" [$WIN32WIDE] + "ypos" "304" + "zpos" "-1" + "wide" "192" + "tall" "12" + "textAlignment" "north-west" + "labelText" "#rd_controls_category_custom_functions" + "font" "DefaultMedium" + } + "LblSelectMarine" { "ControlName" "Label" diff --git a/reactivedrop/scripts/kb_act.lst b/reactivedrop/scripts/kb_act.lst index a8e8f93c5..6f0459b8c 100644 --- a/reactivedrop/scripts/kb_act.lst +++ b/reactivedrop/scripts/kb_act.lst @@ -64,6 +64,12 @@ "+selectmarine6" "#asw_select_marine_6" "+selectmarine7" "#asw_select_marine_7" "+selectmarine8" "#asw_select_marine_8" + +"blank" "#rd_controls_category_custom_functions" +"blank" "==========================" +"scripted_user_func traitors_use_skill" "#rd_traitors_use_skill" +"scripted_user_func traitors_gameplay_info" "#rd_traitors_gameplay_info" + "blank" "==========================" "blank" "#Valve_Miscellaneous_Title" "blank" "==========================" diff --git a/src/game/client/swarm/asw_in_main.cpp b/src/game/client/swarm/asw_in_main.cpp index 1952458c5..c9f5bd511 100644 --- a/src/game/client/swarm/asw_in_main.cpp +++ b/src/game/client/swarm/asw_in_main.cpp @@ -309,6 +309,8 @@ Return 1 to allow engine to process the key, otherwise, act on it as needed */ int CASWInput::KeyEvent( int down, ButtonCode_t code, const char *pszCurrentBinding ) { + const bool bGameplayInfoBinding = pszCurrentBinding && Q_strcmp( pszCurrentBinding, "scripted_user_func traitors_gameplay_info" ) == 0; + if ( code >= KEY_FIRST && code <= KEY_LAST ) { SetControllerModeKeyboard( false ); @@ -320,7 +322,7 @@ int CASWInput::KeyEvent( int down, ButtonCode_t code, const char *pszCurrentBind // JOYPAD ADDED // asw - grab joypad presses here - if ( code >= JOYSTICK_FIRST && code <= KEY_XSTICK2_UP && GetControllerFocus() && !g_RD_Steam_Input.m_bInitialized ) + if ( !bGameplayInfoBinding && code >= JOYSTICK_FIRST && code <= KEY_XSTICK2_UP && GetControllerFocus() && !g_RD_Steam_Input.m_bInitialized ) { if ( down == 1 ) { @@ -339,30 +341,30 @@ int CASWInput::KeyEvent( int down, ButtonCode_t code, const char *pszCurrentBind } // notify ingame VGUI panels of mouse clicks - if ( code == MOUSE_LEFT ) + if ( !bGameplayInfoBinding && code == MOUSE_LEFT ) { if ( g_IngamePanelManager.SendMouseClick( false, down ? true : false ) ) return false; } - else if ( code == MOUSE_RIGHT ) + else if ( !bGameplayInfoBinding && code == MOUSE_RIGHT ) { if ( g_IngamePanelManager.SendMouseClick( true, down ? true : false ) ) return false; } // use key: if we have any info messages up, close them and leave as that's our keypress used - if ( down == 1 && pszCurrentBinding && Q_strcmp( pszCurrentBinding, "+use" ) == 0 && CASW_VGUI_Info_Message::CloseInfoMessage() ) + if ( !bGameplayInfoBinding && down == 1 && pszCurrentBinding && Q_strcmp( pszCurrentBinding, "+use" ) == 0 && CASW_VGUI_Info_Message::CloseInfoMessage() ) return false; CHudMenu *pMenu = GET_FULLSCREEN_HUDELEMENT( CHudMenu ); - if ( pMenu && pMenu->IsMenuOpen() && code >= KEY_F1 && code <= KEY_F10 ) + if ( !bGameplayInfoBinding && pMenu && pMenu->IsMenuOpen() && code >= KEY_F1 && code <= KEY_F10 ) { if ( down == 1 ) pMenu->SelectMenuItem( code - KEY_F1 + 1 ); return false; } - if ( down == 1 ) + if ( !bGameplayInfoBinding && down == 1 ) { FOR_EACH_VEC( CRD_VGui_VScript::s_InteractiveHUDEntities, i ) { diff --git a/src/game/client/swarm/vgui/rd_vgui_settings.h b/src/game/client/swarm/vgui/rd_vgui_settings.h index 4ce1c86b3..41c878c12 100644 --- a/src/game/client/swarm/vgui/rd_vgui_settings.h +++ b/src/game/client/swarm/vgui/rd_vgui_settings.h @@ -327,6 +327,8 @@ class CRD_VGUI_Settings_Controls : public CRD_VGUI_Settings_Panel_Base CRD_VGUI_Bind *m_pBindRotateCameraRight; CRD_VGUI_Bind *m_pBindSecondaryAttackAlt; CRD_VGUI_Bind *m_pBindChooseMarine; + vgui::EditablePanel *m_pBindTraitorsUseSkill; + vgui::EditablePanel *m_pBindTraitorsGameplayInfo; CRD_VGUI_Bind *m_pBindActivatePrimary; CRD_VGUI_Bind *m_pBindActivateSecondary; diff --git a/src/game/client/swarm/vgui/rd_vgui_settings_controls.cpp b/src/game/client/swarm/vgui/rd_vgui_settings_controls.cpp index b184e3a62..b41eaa35d 100644 --- a/src/game/client/swarm/vgui/rd_vgui_settings_controls.cpp +++ b/src/game/client/swarm/vgui/rd_vgui_settings_controls.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include "gameui/swarm/vgenericconfirmation.h" #include "gameui/swarm/vhybridbutton.h" @@ -29,6 +30,288 @@ static void ResetControlsToDefaults() int CRD_VGUI_Bind::s_iCursorX; int CRD_VGUI_Bind::s_iCursorY; +// Scripted user functions are keyboard-only until their Steam Input actions and +// localized manifest entries are available. Keep this control separate from +// CRD_VGUI_Bind so its general Steam Input action assertion remains intact. +class CRD_VGUI_KeyboardBind final : public vgui::EditablePanel +{ + DECLARE_CLASS_SIMPLE( CRD_VGUI_KeyboardBind, vgui::EditablePanel ); +public: + CRD_VGUI_KeyboardBind( vgui::Panel *parent, const char *panelName, const char *szLabel, const char *szBind, bool bUseRowLayout ); + + void ApplySchemeSettings( vgui::IScheme *pScheme ) override; + void OnKeyCodePressed( vgui::KeyCode keycode ) override; + void OnKeyCodeTyped( vgui::KeyCode keycode ) override; + void OnMouseReleased( vgui::MouseCode code ) override; + void OnCursorEntered() override; + void NavigateTo() override; + void OnThink() override; + void Paint() override; + +private: + void StartKeyboardCapture(); + void ClearKeyboardBind(); + const char *LookupKeyboardBind() const; + + vgui::Label *m_pLblKeyboardIcon; + vgui::Label *m_pLblKeyboardIconLong; + vgui::Panel *m_pPnlControllerIcon; + vgui::ImagePanel *m_pImgClearBind; + vgui::Label *m_pLblDescription; + vgui::Label *m_pLblNotBound; + char m_szLabel[256]; + char m_szBind[64]; + bool m_bUseRowLayout; + bool m_bCapturing; + static int s_iCursorX; + static int s_iCursorY; +}; + +int CRD_VGUI_KeyboardBind::s_iCursorX; +int CRD_VGUI_KeyboardBind::s_iCursorY; + +CRD_VGUI_KeyboardBind::CRD_VGUI_KeyboardBind( vgui::Panel *parent, const char *panelName, const char *szLabel, const char *szBind, bool bUseRowLayout ) : + BaseClass( parent, panelName ) +{ + SetConsoleStylePanel( true ); + + V_strncpy( m_szLabel, szLabel, sizeof( m_szLabel ) ); + V_strncpy( m_szBind, szBind, sizeof( m_szBind ) ); + m_bUseRowLayout = bUseRowLayout; + m_bCapturing = false; + + m_pLblKeyboardIcon = new vgui::Label( this, "LblKeyboardIcon", "" ); + m_pLblKeyboardIconLong = new vgui::Label( this, "LblKeyboardIconLong", "" ); + m_pPnlControllerIcon = new vgui::Panel( this, "PnlControllerIcon" ); + m_pImgClearBind = new vgui::ImagePanel( this, "ImgClearBind" ); + m_pLblDescription = new vgui::Label( this, "LblDescription", szLabel ); + m_pLblNotBound = new vgui::Label( this, "LblNotBound", "" ); +} + +void CRD_VGUI_KeyboardBind::ApplySchemeSettings( vgui::IScheme *pScheme ) +{ + BaseClass::ApplySchemeSettings( pScheme ); + + LoadControlSettings( m_bUseRowLayout ? "Resource/UI/BaseModUI/CRD_VGUI_Bind_Row.res" : "Resource/UI/BaseModUI/CRD_VGUI_Bind_Box.res" ); + + m_pLblDescription->SetText( m_szLabel ); + m_pPnlControllerIcon->SetVisible( false ); + + m_pLblKeyboardIcon->SetMouseInputEnabled( false ); + m_pLblKeyboardIconLong->SetMouseInputEnabled( false ); + m_pPnlControllerIcon->SetMouseInputEnabled( false ); + m_pImgClearBind->SetMouseInputEnabled( false ); + m_pLblDescription->SetMouseInputEnabled( false ); + m_pLblNotBound->SetMouseInputEnabled( false ); +} + +void CRD_VGUI_KeyboardBind::OnKeyCodePressed( vgui::KeyCode keycode ) +{ + int lastUser = GetJoystickForCode( keycode ); + CBaseModPanel::GetSingleton().SetLastActiveUserId( lastUser ); + + vgui::KeyCode code = GetBaseButtonCode( keycode ); + + switch ( code ) + { + case KEY_DELETE: + if ( m_pImgClearBind->IsVisible() ) + { + ClearKeyboardBind(); + break; + } + + break; + case KEY_SPACE: + case KEY_ENTER: + case KEY_PAD_ENTER: + StartKeyboardCapture(); + + break; + default: + BaseClass::OnKeyCodePressed( keycode ); + break; + } +} + +void CRD_VGUI_KeyboardBind::OnKeyCodeTyped( vgui::KeyCode keycode ) +{ + int lastUser = GetJoystickForCode( keycode ); + CBaseModPanel::GetSingleton().SetLastActiveUserId( lastUser ); + + // This control deliberately has no Steam Input action. Keep the normal + // controller navigation path, but never try to open a controller binding + // panel for the custom command. + if ( GetBaseButtonCode( keycode ) == KEY_XBUTTON_A ) + { + CBaseModPanel::GetSingleton().PlayUISound( UISOUND_INVALID ); + return; + } + + BaseClass::OnKeyCodePressed( keycode ); +} + +void CRD_VGUI_KeyboardBind::OnMouseReleased( vgui::MouseCode code ) +{ + if ( code == MOUSE_LEFT ) + { + if ( m_pImgClearBind->IsVisible() && m_pImgClearBind->IsCursorOver() ) + { + ClearKeyboardBind(); + return; + } + + StartKeyboardCapture(); + return; + } + + BaseClass::OnMouseReleased( code ); +} + +void CRD_VGUI_KeyboardBind::OnCursorEntered() +{ + BaseClass::OnCursorEntered(); + + if ( GetParent() ) + NavigateToChild( this ); +} + +void CRD_VGUI_KeyboardBind::NavigateTo() +{ + BaseClass::NavigateTo(); + RequestFocus(); +} + +const char *CRD_VGUI_KeyboardBind::LookupKeyboardBind() const +{ + return engine->Key_LookupBindingEx( m_szBind, -1, 0, 0 ); +} + +void CRD_VGUI_KeyboardBind::OnThink() +{ + BaseClass::OnThink(); + + const char *szKeyBind = LookupKeyboardBind(); + + if ( m_bCapturing ) + { + m_pLblKeyboardIcon->SetText( "" ); + m_pLblKeyboardIconLong->SetText( "" ); + m_pLblNotBound->SetVisible( false ); + m_pImgClearBind->SetVisible( false ); + + ButtonCode_t code = BUTTON_CODE_INVALID; + if ( engine->CheckDoneKeyTrapping( code ) ) + { + m_bCapturing = false; + vgui::input()->SetMouseCapture( NULL ); + vgui::input()->SetCursorPos( s_iCursorX, s_iCursorY ); + + if ( code != BUTTON_CODE_NONE && code != BUTTON_CODE_INVALID && code != KEY_ESCAPE ) + { + if ( szKeyBind ) + ClearKeyboardBind(); + + int iSlot = GET_ACTIVE_SPLITSCREEN_SLOT(); + engine->ClientCmd_Unrestricted( VarArgs( "cmd%d bind \"%s\" \"%s\"", iSlot + 1, g_pInputSystem->ButtonCodeToString( code ), m_szBind ) ); + CRD_VGUI_Settings::s_bWantSave = true; + } + + RequestFocus(); + } + + return; + } + + m_pImgClearBind->SetVisible( HasFocus() && szKeyBind ); + + if ( szKeyBind ) + { + if ( const wchar_t *wszTranslation = g_pVGuiLocalize->Find( szKeyBind ) ) + { + if ( V_wcslen( wszTranslation ) > 1 ) + { + m_pLblKeyboardIcon->SetText( "" ); + m_pLblKeyboardIconLong->SetText( wszTranslation ); + } + else + { + m_pLblKeyboardIcon->SetText( wszTranslation ); + m_pLblKeyboardIconLong->SetText( "" ); + } + } + else + { + if ( V_strlen( szKeyBind ) > 2 ) + { + m_pLblKeyboardIcon->SetText( "" ); + m_pLblKeyboardIconLong->SetText( szKeyBind ); + } + else + { + m_pLblKeyboardIcon->SetText( szKeyBind ); + m_pLblKeyboardIconLong->SetText( "" ); + } + } + m_pLblNotBound->SetVisible( false ); + } + else + { + m_pLblKeyboardIcon->SetText( "" ); + m_pLblKeyboardIconLong->SetText( "" ); + m_pLblNotBound->SetVisible( true ); + } +} + +void CRD_VGUI_KeyboardBind::Paint() +{ + BaseClass::Paint(); + + if ( !m_bUseRowLayout ) + return; + + int x, y, w, t; + const int nHighlight = 24; + + Color c = m_pLblKeyboardIcon->GetBgColor(); + if ( m_bCapturing ) + c.SetColor( c.r() + nHighlight * 2, c.g() + nHighlight * 2, c.b() + nHighlight * 2, c.a() ); + else if ( HasFocus() ) + c.SetColor( c.r() + nHighlight, c.g() + nHighlight, c.b() + nHighlight, c.a() ); + m_pLblKeyboardIcon->GetBounds( x, y, w, t ); + vgui::surface()->DrawSetColor( c ); + vgui::surface()->DrawFilledRect( YRES( 1 ), y - YRES( 1 ), x + w + YRES( 1 ), y + t + YRES( 1 ) ); + + c = m_pLblDescription->GetBgColor(); + if ( HasFocus() && !m_bCapturing ) + c.SetColor( c.r() + nHighlight, c.g() + nHighlight, c.b() + nHighlight, c.a() ); + m_pLblDescription->GetBounds( x, y, w, t ); + vgui::surface()->DrawSetColor( c ); + vgui::surface()->DrawFilledRect( x - YRES( 1 ), y - YRES( 1 ), x + w - YRES( 3 ), y + t + YRES( 1 ) ); + vgui::surface()->DrawFilledRectFade( x + w - YRES( 3 ), y - YRES( 1 ), x + w + YRES( 1 ), y + t + YRES( 1 ), 255, 0, true ); +} + +void CRD_VGUI_KeyboardBind::StartKeyboardCapture() +{ + m_bCapturing = true; + vgui::input()->GetCursorPos( s_iCursorX, s_iCursorY ); + vgui::input()->SetMouseFocus( GetVPanel() ); + vgui::input()->SetMouseCapture( GetVPanel() ); + engine->StartKeyTrapMode(); +} + +void CRD_VGUI_KeyboardBind::ClearKeyboardBind() +{ + const char *szKeyBind = LookupKeyboardBind(); + Assert( szKeyBind && *szKeyBind ); + if ( !szKeyBind ) + return; + + int iSlot = CBaseModPanel::GetSingleton().GetLastActiveUserId(); + engine->ClientCmd_Unrestricted( VarArgs( "cmd%d unbind \"%s\"", iSlot + 1, szKeyBind ) ); + CRD_VGUI_Settings::s_bWantSave = true; +} + CRD_VGUI_Bind::CRD_VGUI_Bind( vgui::Panel *parent, const char *panelName, const char *szLabel, const char *szBind, bool bUseRowLayout ) : BaseClass( parent, panelName ) { @@ -380,6 +663,10 @@ CRD_VGUI_Settings_Controls::CRD_VGUI_Settings_Controls( vgui::Panel *parent, con m_pBindSecondaryAttackAlt = new CRD_VGUI_Bind( this, "BindSecondaryAttackAlt", "#Valve_Secondary_Attack", "+secondary", true ); m_pBindChooseMarine = new CRD_VGUI_Bind( this, "BindChooseMarine", "#rd_str_selectloadout", "cl_select_loadout", true ); + // Custom functions + m_pBindTraitorsUseSkill = new CRD_VGUI_KeyboardBind( this, "BindTraitorsUseSkill", "#rd_traitors_use_skill", "scripted_user_func traitors_use_skill", true ); + m_pBindTraitorsGameplayInfo = new CRD_VGUI_KeyboardBind( this, "BindTraitorsGameplayInfo", "#rd_traitors_gameplay_info", "scripted_user_func traitors_gameplay_info", true ); + // Use Equipment m_pBindActivatePrimary = new CRD_VGUI_Bind( this, "BindActivatePrimary", "#rd_bind_ActivatePrimary", "ASW_ActivatePrimary", true ); m_pBindActivateSecondary = new CRD_VGUI_Bind( this, "BindActivateSecondary", "#rd_bind_ActivateSecondary", "ASW_ActivateSecondary", true ); diff --git a/src/game/shared/swarm/rd_vgui_vscript_shared.cpp b/src/game/shared/swarm/rd_vgui_vscript_shared.cpp index 69ad4a2ee..f14daea3c 100644 --- a/src/game/shared/swarm/rd_vgui_vscript_shared.cpp +++ b/src/game/shared/swarm/rd_vgui_vscript_shared.cpp @@ -20,6 +20,18 @@ #include "tier0/memdbgon.h" #ifdef CLIENT_DLL +namespace +{ +// An rd_vgui_vscript that uses data entity 0 as a local-player owner must +// explicitly opt in to the local-menu ownership path. Keep this marker in +// the highest integer slot so it is out of the way of ordinary HUD data. The +// value is a positive, Squirrel-safe 32-bit magic number (ASCII "RDVM"). +// Server VScript contract: set Int( 63, 0x5244564D ) on each local menu +// entity; Int( 0 ) remains that entity's open/closed visibility bit. +constexpr int RD_VGUI_VSCRIPT_LOCAL_MENU_MODE_SLOT = 63; +constexpr int RD_VGUI_VSCRIPT_LOCAL_MENU_MODE_SENTINEL = 0x5244564D; +} + CUtlVector CRD_VGui_VScript::s_InteractiveHUDEntities; #else LINK_ENTITY_TO_CLASS( rd_vgui_vscript, CRD_VGui_VScript ); @@ -427,6 +439,19 @@ void CRD_VGui_VScript::OnDataChanged( DataUpdateType_t type ) C_BasePlayer *CRD_VGui_VScript::GetPredictionOwner() { + // A client-only VGUI screen uses data entity 0 as its local player owner. + // Data integer 0 is the server-synchronised visibility bit. If the entity + // is a player but the bit is clear, return no owner instead of falling + // through to a stale marine interacter and consuming normal input. + C_BasePlayer *pDataPlayer = ToBasePlayer( m_hDataEntity.Get() ); + if ( pDataPlayer ) + { + const bool bLocalMenu = pDataPlayer == C_BasePlayer::GetLocalPlayer() && + m_iDataInt.Get( 0 ) != 0 && + m_iDataInt.Get( RD_VGUI_VSCRIPT_LOCAL_MENU_MODE_SLOT ) == RD_VGUI_VSCRIPT_LOCAL_MENU_MODE_SENTINEL; + return bLocalMenu ? pDataPlayer : NULL; + } + CASW_Inhabitable_NPC *pInteracter = m_hInteracter; if ( !pInteracter || !pInteracter->IsInhabited() ) return NULL;