feat: Panorama skill HUD and menu, CSS 1.0.375

- Skill card and W/S/E target menu drawn as CCSCustomHudLayout panels via PanoramaManager
  (panorama/ sources), replacing PrintToCenterHtml and WASDMenuAPI; WASDMenuAPI.dll dropped
- Custom status lines (cooldowns, charges) overwrite the description on the card; Medic keeps its
  description with the charge count below
- css_setoverride toggles a per-player cooldown bypass
- YourSkillChatInfo also gates the round-start skill description in chat
- Language: "[css_useSkill]" becomes the configured button name, "Press ... key" wording,
  welcome message trimmed to its first line
- Build against CounterStrikeSharp.API 1.0.375
This commit is contained in:
Astra 2026-09-24 21:28:37 +01:00
parent 20dafd2a7c
commit 793370a55f
37 changed files with 1076 additions and 415 deletions

View file

@ -863,7 +863,16 @@ namespace src.player
private static readonly Dictionary<uint, jSkill_SkillInfo> nextRoundPicks = [];
public static void UpdateSkillHUD(CCSPlayerController? player, jSkill_PlayerInfo? skillPlayer, string? headerLine, string? centerLine, string? extraLine, bool isDescription)
// Matches any <tag> or </tag> - used only to strip the HTML jRandomSkills already builds
// into centerLine/extraLine (<font color=...>) before handing plain text to Panorama, which
// can't render injected HTML and can only take a color via a CSS class instead.
private static readonly Dictionary<int, PanoramaHudCache> panoramaHudCaches = [];
private static readonly Regex HtmlTagPattern = new("<[^>]+>", RegexOptions.Compiled);
public static void UpdateSkillHUD(
CCSPlayerController? player, jSkill_PlayerInfo? skillPlayer, string? headerLine, string? centerLine,
string? extraLine, bool isDescription, Skills skillForColor = Skills.None)
{
lock (setLock)
{
@ -875,6 +884,15 @@ namespace src.player
extraLine = Illiterate.GetRandomText(extraLine);
}
// Every skill draws through the Panorama card now. A skill's PrintHTML (cooldowns,
// fuel, countdowns - isDescription false) replaces the description line; the
// PrintToCenterHtml path below is only a fallback if the panel failed to spawn.
if (Instance.SkillHud != null)
{
UpdatePanoramaSkillHud(player, skillPlayer, centerLine, extraLine, isDescription, skillForColor);
return;
}
var config = Config.LoadedConfig.HtmlHudCustomisation;
var cache = skillPlayer?.HudCache;
@ -922,5 +940,99 @@ namespace src.player
player.PrintToCenterHtml(hudContent);
}
}
// The Panorama half of UpdateSkillHUD above. Change-detected per field via
// panoramaHudCaches, the same idea as HudCacheEntry but per-field rather than one content blob, since
// SetVariableFor/SetClassFor are separate native calls rather than one PrintToCenterHtml.
private static void UpdatePanoramaSkillHud(
CCSPlayerController player, jSkill_PlayerInfo? skillPlayer, string? centerLine,
string? extraLine, bool isDescription, Skills skillForColor)
{
var skillHud = Instance.SkillHud;
if (skillHud == null || skillPlayer == null) return;
// centerLine/extraLine arrive pre-wrapped in <font color=...> for the PrintToCenterHtml
// path above - Panorama can't render injected HTML, and color comes from the
// rarity-* class below instead, so the markup is just stripped rather than parsed.
string skillText = ToPlainText(centerLine);
string extraText = ToPlainText(extraLine);
string rarityClass = "rarity-" + SkillsInfo.GetValue<string>(skillForColor, "Rarity").ToLowerInvariant();
// Keyed by the viewer's slot, not skillPlayer: while controlling a bot (or spectating)
// skillPlayer is someone else's record, but the panel being drawn is this player's.
if (!panoramaHudCaches.TryGetValue(player.Slot, out var cache))
panoramaHudCaches[player.Slot] = cache = new PanoramaHudCache();
cache.TouchedThisTick = true;
// Open() before any SetVariableFor/SetClassFor - both silently no-op without a session,
// and Open() is what creates it (same lesson as PanoramaHudDemo's own OpenSkillMenu).
if (!cache.Open)
{
skillHud.Open(player);
cache.Open = true;
}
if (cache.Skill != skillText || cache.RarityClass != rarityClass)
{
skillHud.SetVariableFor(player, "hud_skill", skillText);
// Take the old rarity class off before adding the new one - Panorama has no
// mutually-exclusive class group, so both would otherwise stay on and whichever the
// stylesheet happens to list last would win, not whichever was set last.
if (cache.RarityClass != null)
skillHud.SetClassFor(player, "skillhud_skill", cache.RarityClass, false);
skillHud.SetClassFor(player, "skillhud_skill", rarityClass, true);
cache.Skill = skillText;
cache.RarityClass = rarityClass;
}
if (cache.Extra != extraText)
{
skillHud.SetVariableFor(player, "hud_extra", extraText);
skillHud.SetClassFor(player, "skillhud_extra", "empty", string.IsNullOrWhiteSpace(extraText));
cache.Extra = extraText;
}
if (cache.IsInfo != !isDescription)
{
skillHud.SetClassFor(player, "skillhud_extra", "info", !isDescription);
cache.IsInfo = !isDescription;
}
}
// Closes the Panorama skill HUD for anyone whose cache says it's open but who wasn't
// touched this HUD frame - warmup started, a menu opened, HUD got suppressed, or any of
// PlayerOnTick.cs's other early-return conditions before it ever reaches UpdateSkillHUD.
// Without this the panel would just keep showing stale content indefinitely, unlike
// PrintToCenterHtml which expires on its own after a few seconds. Called once per HUD frame
// from PlayerOnTick.cs, after the per-player loop, for exactly this reason.
private static string ToPlainText(string? html)
=> System.Net.WebUtility.HtmlDecode(
HtmlTagPattern.Replace((html ?? "").Replace("<br>", "\n", StringComparison.OrdinalIgnoreCase), ""));
public static void ClosePanoramaSkillHudForUntouched()
{
var skillHud = Instance.SkillHud;
if (skillHud == null) return;
foreach (var (slot, cache) in panoramaHudCaches)
{
if (!cache.Open) continue;
if (cache.TouchedThisTick)
{
cache.TouchedThisTick = false;
continue;
}
var player = Utilities.GetPlayerFromSlot(slot);
if (player is { IsValid: true }) skillHud.Close(player);
cache.Open = false;
cache.Skill = null;
cache.Extra = null;
cache.RarityClass = null;
cache.IsInfo = null;
}
}
}
}

View file

@ -1,4 +1,4 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Admin;
using CounterStrikeSharp.API.Modules.Utils;
@ -33,6 +33,9 @@ namespace src.player
if (player != null && player.IsValid)
UpdatePlayerHud(player, now);
}
// Closes the Panorama skill HUD for anyone whose panel is open but who wasn't
// touched this frame - see its own doc comment for why that's needed at all.
Event.ClosePanoramaSkillHudForUntouched();
PerfLog.Sample("OnTick(hud)", perfStart);
});
@ -62,7 +65,10 @@ namespace src.player
var gameRulesProxy = Utilities.FindAllEntitiesByDesignerName<CCSGameRulesProxy>("cs_gamerules").FirstOrDefault();
if (gameRulesProxy != null)
{
Instance.GameRules = gameRulesProxy.GameRules;
Instance.GameRulesProxy = gameRulesProxy;
}
}
private static void UpdateGameRules()
@ -70,7 +76,17 @@ namespace src.player
if (Instance?.GameRules == null || Instance.GameRules.Handle == IntPtr.Zero)
InitializeGameRules();
else if (Instance != null && Config.LoadedConfig.EnableFlashingHtmlHudFix && !Instance.GameRules.WarmupPeriod)
Instance.GameRules.GameRestart = Instance.GameRules.RestartRoundTime < Server.CurrentTime;
{
bool restart = Instance.GameRules.RestartRoundTime < Server.CurrentTime;
if (Instance.GameRules.GameRestart == restart) return;
Instance.GameRules.GameRestart = restart;
// The schema write has no networking hook of its own. GameRules isn't embedded in the
// proxy entity, so a CCSGameRules field offset can't be resolved against it ("N not
// resolved" spam) - mark the proxy's m_pGameRules pointer changed instead.
if (Instance.GameRulesProxy != null && Instance.GameRulesProxy.Handle != IntPtr.Zero)
Utilities.SetStateChanged(Instance.GameRulesProxy, "CCSGameRulesProxy", "m_pGameRules");
}
}
private static void UpdatePlayerHud(CCSPlayerController player, DateTime now)
@ -97,6 +113,10 @@ namespace src.player
string infoLine = string.Empty;
string skillLine = string.Empty;
string remainingLine = string.Empty;
// Which skill's Rarity should color the Panorama HUD (see UpdatePanoramaSkillHud) -
// not always skillPlayer.Skill, since the observer branch below shows a DIFFERENT
// player's skill, and the drawing-flicker branch shows a random one each frame.
Skills skillForColor = Skills.None;
bool showDescriptionHUD = skillPlayer.SkillDescriptionHudExpired >= now || Config.LoadedConfig.DisplayAlwaysDescription;
bool isDescription = true;
@ -114,6 +134,7 @@ namespace src.player
infoLine = player.GetTranslationWithoutIlliterate("drawing_skill");
skillLine = $"<font color='{randomSkill.Color}'>{player.GetSkillName(randomSkill.Skill)}</font>";
skillForColor = randomSkill.Skill;
}
else
{
@ -123,8 +144,8 @@ namespace src.player
if (skillInfo != null)
{
infoLine = player.GetTranslationWithoutIlliterate("your_skill");
skillLine = $"<font color='{skillInfo.Color}'>{player.GetSkillName(skillInfo.Skill, skillPlayer.SkillChance)}</font>";
skillForColor = skillInfo.Skill;
if (skillInfo.Skill != Skills.None)
{
@ -175,11 +196,17 @@ namespace src.player
infoLine = string.IsNullOrEmpty(observerSkill) ? pName : $"{observerSkill} {pName}";
if (observedSkill.SpecialSkill == Skills.None || observedSpecialInfo == null)
{
skillLine = $"<font color='{primaryColor}'>{primaryName}</font>";
skillForColor = observedSkill.Skill;
}
else
{
string specialName = player.GetSkillName(observedSpecialInfo.Skill);
skillLine = $"<font color='{observedSpecialInfo.Color}'>{specialName}({primaryName})</font>";
// The special skill's color is what wraps the WHOLE combined line above,
// not the primary skill's - matches that for the Panorama rarity class too.
skillForColor = observedSpecialInfo.Skill;
}
if (observedSkill.Skill != Skills.None && !string.IsNullOrEmpty(observedSkill.PrintHTML))
@ -194,7 +221,7 @@ namespace src.player
if (string.IsNullOrEmpty(skillLine)) return;
Event.UpdateSkillHUD(player, skillPlayer, infoLine, skillLine, remainingLine, isDescription);
Event.UpdateSkillHUD(player, skillPlayer, infoLine, skillLine, remainingLine, isDescription, skillForColor);
}
}
}

View file

@ -620,7 +620,7 @@ namespace src.player
UpdateSkillHudExpired(skillPlayer, randomSkill.Skill);
assigned++;
if (randomSkill.Display)
if (randomSkill.Display && Config.LoadedConfig.YourSkillChatInfo)
Instance?.AddTimer(.6f, () =>
{
var descTarget = Utilities.GetPlayerFromIndex((int)playerIndex);

View file

@ -74,7 +74,7 @@ namespace src.player.skills
string remainingLine = cooldown != 0
? $"{player.GetTranslation("hud_info", $"<font color='#FF0000'>{cooldown}</font>")}"
: $"<font color='#{(skillInfo == null || skillInfo.Count == 0 ? "FF0000" : "00FF00")}'>{(skillInfo == null ? 0 : skillInfo.Count)}/{SkillsInfo.GetValue<int>(skillName, "healthShotLimit")}</font>";
: $"{player.GetSkillDescription(skillName)}<br><font color='#{(skillInfo == null || skillInfo.Count == 0 ? "FF0000" : "00FF00")}'>{(skillInfo == null ? 0 : skillInfo.Count)}/{SkillsInfo.GetValue<int>(skillName, "healthShotLimit")}</font>";
playerInfo.PrintHTML = remainingLine;
}