Skip to content

Commit ef895d9

Browse files
committed
Hardened the menu
- Changing observers correctly recreates the menu - Simplified some code here and there - Switching to a third person or roaming camera falls back to the html menu - Dispose entities on player disconnect - Only transmit the menu entities to the desired player - Refresh menus on various inputs
1 parent 2741636 commit ef895d9

5 files changed

Lines changed: 282 additions & 76 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@ A native CSSUniversalMenuAPI implementation, mirroring the behavior of SourceMod
44

55
![](./docs/GunsMenuCropped.png)
66

7+
https://www.youtube.com/watch?v=Be7qop6pVpI
8+
79
Many thanks to [@T3Marius's CS2ScreenMenuAPI](https://github.com/T3Marius/CS2ScreenMenuAPI) for acting as reference when it comes to using the point_worldtext entity.

src/SharpModMenu/MenuDriver.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ internal sealed class MenuDriver : IMenuAPI
1313
{
1414
private Dictionary<ulong, PlayerMenuState> MenuStates = new();
1515
public List<(CBaseEntity ent, CCSPlayerController target)> MenuEntities { get; } = new();
16-
public List<PlayerMenuState> ActiveHtmlMenuStates { get; } = new();
16+
public List<PlayerMenuState> ActiveMenuStates { get; } = new();
1717

1818
internal PlayerMenuState GetMenuState(CCSPlayerController player, bool create = false)
1919
{
@@ -29,7 +29,8 @@ internal void PlayerDisconnected(CCSPlayerController? player)
2929
{
3030
if (player is null)
3131
return;
32-
MenuStates.Remove(player.SteamID);
32+
MenuStates.Remove(player.SteamID, out var value);
33+
value?.Dispose();
3334
}
3435

3536
public IMenu CreateMenu(CCSPlayerController player, CancellationToken ct = default)

src/SharpModMenu/PlayerExtensions.cs

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Numerics;
23

34
using CounterStrikeSharp.API;
@@ -18,34 +19,55 @@ public struct EyeAngles
1819
public Vector3 Up { get; set; }
1920
}
2021

22+
public enum ObserverMode
23+
{
24+
FirstPerson,
25+
ThirdPerson,
26+
Roaming,
27+
}
28+
29+
public record struct ObserverInfo(ObserverMode Mode, CCSPlayerPawnBase? Observing);
30+
2131
internal static class PlayerExtensions
2232
{
23-
public static CCSPlayerPawn? GetPlayerPawn(this CCSPlayerController player)
33+
public static ObserverInfo GetObserverInfo(this CCSPlayerController player)
2434
{
2535
if (player.Pawn.Value is not CBasePlayerPawn pawn)
26-
return null;
36+
return new() { Mode = ObserverMode.Roaming, Observing = null };
37+
38+
if (pawn.ObserverServices is not CPlayer_ObserverServices observerServices)
39+
return new() { Mode = ObserverMode.FirstPerson, Observing = pawn.As<CCSPlayerPawnBase>() };
2740

28-
if (pawn.LifeState == (byte)LifeState_t.LIFE_DEAD)
41+
var observerMode = (ObserverMode_t)observerServices.ObserverMode;
42+
var observing = observerServices.ObserverTarget?.Value?.As<CCSPlayerPawnBase>();
43+
44+
return new()
2945
{
30-
if (pawn.ObserverServices?.ObserverTarget.Value?.As<CBasePlayerPawn>() is not CBasePlayerPawn observer)
31-
return null;
32-
pawn = observer;
33-
}
34-
return pawn.As<CCSPlayerPawn>();
46+
Mode = observerMode switch
47+
{
48+
ObserverMode_t.OBS_MODE_NONE => ObserverMode.Roaming,
49+
ObserverMode_t.OBS_MODE_FIXED => ObserverMode.Roaming,
50+
ObserverMode_t.OBS_MODE_IN_EYE => ObserverMode.FirstPerson,
51+
ObserverMode_t.OBS_MODE_CHASE => ObserverMode.ThirdPerson,
52+
ObserverMode_t.OBS_MODE_ROAMING => ObserverMode.Roaming,
53+
ObserverMode_t.OBS_MODE_DIRECTED => ObserverMode.Roaming,
54+
_ => ObserverMode.Roaming,
55+
},
56+
Observing = observing,
57+
};
3558
}
3659

3760
public static Vector _Forward = new(), _Right = new(), _Up = new();
38-
public static EyeAngles? GetEyeAngles(this CCSPlayerController player)
61+
public static EyeAngles? GetEyeAngles(this ObserverInfo observerInfo)
3962
{
40-
var playerPawn = GetPlayerPawn(player);
41-
if (playerPawn is null)
63+
if (observerInfo.Observing is not CCSPlayerPawnBase pawn)
4264
return null;
4365

44-
var eyeAngles = playerPawn!.EyeAngles;
66+
var eyeAngles = pawn.EyeAngles;
4567
NativeAPI.AngleVectors(eyeAngles.Handle, _Forward.Handle, _Right.Handle, _Up.Handle);
4668

47-
var origin = new Vector3(playerPawn.AbsOrigin!.X, playerPawn.AbsOrigin!.Y, playerPawn.AbsOrigin!.Z);
48-
var viewOffset = new Vector3(playerPawn.ViewOffset.X, playerPawn.ViewOffset.Y, playerPawn.ViewOffset.Z);
69+
var origin = new Vector3(pawn.AbsOrigin!.X, pawn.AbsOrigin!.Y, pawn.AbsOrigin!.Z);
70+
var viewOffset = new Vector3(pawn.ViewOffset.X, pawn.ViewOffset.Y, pawn.ViewOffset.Z);
4971

5072
return new()
5173
{
@@ -57,10 +79,13 @@ internal static class PlayerExtensions
5779
};
5880
}
5981

60-
public static CCSGOViewModel? GetPredictedViewmodel(this CCSPlayerController player)
82+
public static CCSGOViewModel? GetPredictedViewmodel(this ObserverInfo observerInfo)
6183
{
62-
var pawn = GetPlayerPawn(player);
63-
if (pawn?.ViewModelServices is null)
84+
if (observerInfo.Observing is not CCSPlayerPawnBase pawn)
85+
return null;
86+
if (pawn.ViewModelServices is null)
87+
return null;
88+
if (observerInfo.Mode != ObserverMode.FirstPerson)
6489
return null;
6590

6691
var offset = Schema.GetSchemaOffset("CCSPlayer_ViewModelServices", "m_hViewModel");

src/SharpModMenu/PlayerMenuState.cs

Lines changed: 82 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -83,19 +83,20 @@ public void HandleInput(PlayerKey key, bool fromBind)
8383
}
8484
}
8585

86-
private bool _PresentingHtml;
87-
private bool PresentingHtml
86+
public bool PresentingHtml { get; set; }
87+
private bool _MenuActive;
88+
private bool MenuActive
8889
{
89-
get => _PresentingHtml;
90+
get => _MenuActive;
9091
set
9192
{
92-
if (value == _PresentingHtml)
93+
if (value == _MenuActive)
9394
return;
94-
_PresentingHtml = value;
95+
_MenuActive = value;
9596
if (value)
96-
Driver.ActiveHtmlMenuStates.Add(this);
97+
Driver.ActiveMenuStates.Add(this);
9798
else
98-
Driver.ActiveHtmlMenuStates.Remove(this);
99+
Driver.ActiveMenuStates.Remove(this);
99100
}
100101
}
101102

@@ -124,13 +125,11 @@ public void Refresh(bool sortPriorities = true)
124125
}
125126

126127
CurrentMenu = FocusStack.Count > 0 ? FocusStack[0] : null;
128+
MenuActive = CurrentMenu is not null;
127129

128130
if (CreateInitialInvisibleWorldTextEntity())
129131
{
130-
Server.NextFrame(() =>
131-
{
132-
Refresh(sortPriorities: false);
133-
});
132+
ForceRefresh = true;
134133
return;
135134
}
136135

@@ -140,6 +139,8 @@ public void Refresh(bool sortPriorities = true)
140139
}
141140
else
142141
{
142+
if (!PresentingHtml)
143+
DestroyEntities();
143144
DrawActiveMenuHtml();
144145
PresentingHtml = true;
145146
}
@@ -167,7 +168,6 @@ private void DestroyEntities()
167168
Background?.Remove();
168169

169170
ForegroundText = BackgroundText = Background = null;
170-
Console.WriteLine("DEBUG: DestroyEntities()");
171171
}
172172

173173
private static readonly Color ForegroundTextColor = Color.FromArgb(229, 150, 32); // 245, 177, 103 with a white bg, maybe 240, 160, 30 at 95% opacity?
@@ -180,40 +180,37 @@ private void CreateEntities()
180180
Background = CreateWorldText(textColor: Color.FromArgb(200, 127, 127, 127), true, -0.002f);
181181
}
182182

183-
private bool _Created = false;
183+
// sometimes creating an ent for a pawn requires it to be done twice, so
184+
// do it twice whenever our observed entity changes
185+
private nint? _CreatedFor = null;
184186
/// <summary>
185187
/// The first world text isn't shown for some reason, this creates a barebones version then immediately destroys it
186188
/// </summary>
187189
private bool CreateInitialInvisibleWorldTextEntity()
188190
{
189-
if (_Created)
191+
var observerInfo = Player.GetObserverInfo();
192+
193+
if (_CreatedFor.HasValue && _CreatedFor.Value == observerInfo.Observing?.Handle)
190194
return false;
191195

192-
var viewmodel = Player.GetPredictedViewmodel();
196+
var viewmodel = observerInfo.GetPredictedViewmodel();
193197
if (viewmodel is null)
194-
{
195-
CurrentMenu?.Close();
196198
return false;
197-
}
198199

199200
var entity = CreateWorldText(Color.Orange, drawBackground: false, depthOffset: 0.0f);
200201
if (entity is null)
201-
{
202-
CurrentMenu?.Close();
203202
return false;
204-
}
205203

206-
var maybeAngles = Player.GetEyeAngles();
204+
var maybeAngles = observerInfo.GetEyeAngles();
207205
if (!maybeAngles.HasValue)
208-
{
209-
CurrentMenu?.Close();
210206
return false;
211-
}
212207

213-
UpdateEntity(entity, viewmodel, "Hey", maybeAngles.Value.Position, maybeAngles.Value.Angle);
208+
UpdateEntity(entity, viewmodel, "Hey", maybeAngles.Value.Position, maybeAngles.Value.Angle, updateText: true, updateParent: true);
214209
entity.Remove();
215210

216-
_Created = true;
211+
_CreatedFor = observerInfo.Observing?.Handle ?? nint.Zero;
212+
213+
Console.WriteLine("CreateInitialInvisibleWorldTextEntity(): DONE");
217214
return true;
218215
}
219216

@@ -255,32 +252,40 @@ private CPointWorldText CreateWorldText(
255252
private static QAngle _Ang = new();
256253
private void UpdateEntity(
257254
CPointWorldText ent,
258-
CCSGOViewModel viewmodel,
259-
string? newText,
255+
CCSGOViewModel? viewmodel,
256+
string newText,
260257
Vector3 position,
261-
Vector3 angles)
258+
Vector3 angles,
259+
bool updateText = true,
260+
bool updateParent = true)
262261
{
263262
_Pos.X = position.X;
264263
_Pos.Y = position.Y;
265264
_Pos.Z = position.Z;
266265
_Ang.X = angles.X;
267266
_Ang.Y = angles.Y;
268267
_Ang.Z = angles.Z;
269-
if (newText is not null)
268+
269+
if (updateText)
270270
ent.MessageText = newText;
271271
ent.Teleport(_Pos, _Ang, null);
272-
ent.AcceptInput("SetParent", viewmodel, null, "!activator");
273-
if (newText is not null)
272+
273+
if (updateParent)
274+
ent.AcceptInput("SetParent", viewmodel, null, "!activator");
275+
276+
if (updateText)
274277
Utilities.SetStateChanged(ent, "CPointWorldText", "m_messageText");
275278
}
276279

277-
private readonly Vector MenuPosition = new(-7.0f, 0.0f);
280+
private readonly Vector MenuPosition = new(-6.9f, 0.0f);
278281

279-
private CCSGOViewModel? LastViewmodel { get; set; }
280282
private Menu? LastPresented { get; set; }
281283
private readonly StringBuilder ForegroundTextSb = new();
282284
private readonly StringBuilder BackgroundTextSb = new();
283285
private readonly StringBuilder BackgroundSb = new();
286+
private nint MenuCurrentObserver { get; set; } = nint.Zero;
287+
private ObserverMode MenuCurrentObserverMode { get; set; }
288+
private CCSGOViewModel? MenuCurrentViewmodel { get; set; }
284289
public bool DrawActiveMenu()
285290
{
286291
if (ReferenceEquals(CurrentMenu, LastPresented))
@@ -300,25 +305,18 @@ public bool DrawActiveMenu()
300305

301306
CurrentMenu.IsDirty = false;
302307

303-
bool allValid =
304-
(ForegroundText?.IsValid ?? false) &&
305-
(BackgroundText?.IsValid ?? false) &&
306-
(Background?.IsValid ?? false);
307-
if (!allValid)
308-
{
309-
DestroyEntities();
310-
CreateEntities();
311-
}
308+
var observerInfo = Player.GetObserverInfo();
309+
if (observerInfo.Mode != ObserverMode.FirstPerson)
310+
return false;
312311

313-
var maybeEyeAngles = Player.GetEyeAngles();
312+
var maybeEyeAngles = observerInfo.GetEyeAngles();
314313
if (!maybeEyeAngles.HasValue)
315314
return false;
316315
var eyeAngles = maybeEyeAngles.Value;
317316

318-
var predictedViewmodel = Player.GetPredictedViewmodel();
317+
var predictedViewmodel = observerInfo.GetPredictedViewmodel();
319318
if (predictedViewmodel is null)
320319
return false;
321-
LastViewmodel = predictedViewmodel;
322320

323321
ForegroundTextSb.Clear();
324322
BackgroundTextSb.Clear();
@@ -356,13 +354,25 @@ void writeLine(string text, bool background)
356354
X = 0.0f
357355
};
358356

357+
MenuCurrentObserver = observerInfo.Observing?.Handle ?? nint.Zero;
358+
MenuCurrentObserverMode = observerInfo.Mode;
359+
MenuCurrentViewmodel = predictedViewmodel;
360+
361+
bool allValid =
362+
(ForegroundText?.IsValid ?? false) &&
363+
(BackgroundText?.IsValid ?? false) &&
364+
(Background?.IsValid ?? false);
365+
if (!allValid)
366+
{
367+
DestroyEntities();
368+
CreateEntities();
369+
}
359370
UpdateEntity(ForegroundText!, predictedViewmodel, ForegroundTextSb.ToString(), position, angle);
360371
UpdateEntity(BackgroundText!, predictedViewmodel, BackgroundTextSb.ToString(), position, angle);
361372
UpdateEntity(Background!, predictedViewmodel, BackgroundSb.ToString(), position, angle);
362373
return true;
363374
}
364375

365-
366376
private readonly StringBuilder HtmlTextSb = new();
367377
public string? HtmlContent { get; set; } = null;
368378
public void DrawActiveMenuHtml()
@@ -373,7 +383,6 @@ public void DrawActiveMenuHtml()
373383
return;
374384
}
375385

376-
377386
bool firstLine = true;
378387
int linesWrote = 0;
379388
void writeLine(string text, bool background)
@@ -442,4 +451,28 @@ private void BuildMenuStrings(Menu currentMenu, Action<string, bool> writeLine)
442451
writeLine("0. Exit", true);
443452
}
444453
}
454+
455+
public bool ForceRefresh = true;
456+
public void Tick()
457+
{
458+
if (CurrentMenu is null)
459+
return;
460+
461+
if (PresentingHtml && HtmlContent is not null)
462+
Player.PrintToCenterHtml(HtmlContent);
463+
464+
var observerInfo = Player.GetObserverInfo();
465+
466+
bool refresh =
467+
ForceRefresh ||
468+
observerInfo.Mode != MenuCurrentObserverMode ||
469+
observerInfo.Observing?.Handle != MenuCurrentObserver;
470+
471+
if (refresh)
472+
{
473+
ForceRefresh = false;
474+
CurrentMenu.IsDirty = true;
475+
Refresh(sortPriorities: false);
476+
}
477+
}
445478
}

0 commit comments

Comments
 (0)