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

@ -0,0 +1,67 @@
using CounterStrikeSharp.API.Core;
using System.Collections;
using System.Collections.Concurrent;
using System.Reflection;
namespace src.utils
{
// Per-player cooldown bypass (css_setoverride). Skills keep their cooldown in their own static
// per-player state, so instead of touching every skill this clears that state just before the
// skill's UseSkill runs. Resetting only at use time (not every tick) matters: AimLock and Noclip
// reuse their Cooldown timestamp as the start of the active effect.
public static class CooldownOverride
{
private static readonly HashSet<ulong> _players = [];
private static readonly ConcurrentDictionary<Type, FieldInfo[]> _stateFields = new();
// Toggles the override and returns the new state.
public static bool Toggle(CCSPlayerController player)
{
if (_players.Remove(player.SteamID)) return false;
_players.Add(player.SteamID);
return true;
}
public static bool Has(CCSPlayerController player) => _players.Contains(player.SteamID);
public static void ResetBeforeUse(Type skillType, CCSPlayerController player)
{
foreach (var field in _stateFields.GetOrAdd(skillType, FindStateFields))
{
if (field.GetValue(null) is not IDictionary state) continue;
// Spectator keeps a plain index -> last-use time map.
if (field.FieldType.GetGenericArguments()[1] == typeof(DateTime))
{
state.Remove(player.Index);
continue;
}
var entry = state[player.Index];
if (entry == null) continue;
foreach (var prop in entry.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (!prop.CanWrite) continue;
if (prop.Name == "Cooldown" && prop.PropertyType == typeof(DateTime))
prop.SetValue(entry, DateTime.MinValue);
else if (prop.Name == "CanUse" && prop.PropertyType == typeof(bool))
prop.SetValue(entry, true);
else if (prop.Name is "NextUse" or "NextCamera" && prop.PropertyType == typeof(float))
prop.SetValue(entry, 0f);
}
}
}
// Static ConcurrentDictionary<uint, T> fields keyed by player index - the per-player skill
// state every cooldown skill uses. DateTime-valued ones only when they are last-use maps.
private static FieldInfo[] FindStateFields(Type skillType)
=> [.. skillType.GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)
.Where(f => f.FieldType.IsGenericType
&& f.FieldType.GetGenericTypeDefinition() == typeof(ConcurrentDictionary<,>)
&& f.FieldType.GetGenericArguments()[0] == typeof(uint)
&& (f.FieldType.GetGenericArguments()[1] != typeof(DateTime)
|| f.Name.Contains("use", StringComparison.OrdinalIgnoreCase)))];
}
}