Skip to content

Commit 87f1ee2

Browse files
[minor] Float XP gains in the world
Every XP gain now shows as "+XP" text that rises and fades in the world, seen only by the player who earned it. Hits, kills, assists, and flashes float above the target, and gains with no target (efficient reloads and accuracy bonuses) float just in front of the player. Gains on one target within MergeSeconds add up in one floatie, so a spray reads "+14 XP". Skill shots, finished tiers, level-ups, and rank-ups are named under the number and drawn in the highlight color. The engine now reports XpEarned with fractions, so a hit on a bot shows "+0.25 XP" even though only whole points are credited. Floaties are billboarded point_worldtext entities, sized by distance so far ones stay readable, capped per player, and filtered from other players by the same transmit hook as the panel. Everything is configurable under Floaties.
1 parent 4548095 commit 87f1ee2

12 files changed

Lines changed: 640 additions & 18 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Nothing goes to NuGet. ktsu.Sdk marks every library packable from its targets, a
4545
- **`CounterStrikeProgression/CombatHandlers.cs`:** the combat events that become `CombatEvent` snapshots.
4646
- **`CounterStrikeProgression/LifecycleHandlers.cs`:** connects, spawns, rounds, the per-tick tracking, and the transmit filter.
4747
- **`CounterStrikeProgression/Panel/WorldTextPanel.cs`:** the side panel, a `point_worldtext` moved in front of the eyes every tick.
48+
- **`CounterStrikeProgression/Panel/XpFloaties.cs`:** the floating "+XP" text, one billboarded `point_worldtext` per floatie, risen and faded each tick. `Core/Floaties/FloatieMerger.cs` decides when gains on one target merge and what the text says. Panels and floaties are both hidden from everyone but their owner by the transmit filter in `LifecycleHandlers`.
4849

4950
### Dependencies
5051

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Copyright (c) 2023-2026 ktsu-dev contributors
2+
3+
namespace ktsu.CounterStrikeProgression.Core.Floaties;
4+
5+
using System.Globalization;
6+
using ktsu.CounterStrikeProgression.Core.Panel;
7+
using ktsu.CounterStrikeProgression.Core.Progression;
8+
9+
/// <summary>
10+
/// What an XP floatie should show after a gain.
11+
/// </summary>
12+
/// <param name="Key">Which floatie it is: the target's slot, or <see cref="FloatieMerger.SelfKey"/> for gains with no target.</param>
13+
/// <param name="IsNew">Whether a new floatie starts, rather than an existing one growing.</param>
14+
/// <param name="Text">The text to show.</param>
15+
/// <param name="Highlight">Whether it includes a skill shot, tier, level, or rank-up, which draws it in the highlight color.</param>
16+
internal sealed record FloatieUpdate(int Key, bool IsNew, string Text, bool Highlight);
17+
18+
/// <summary>
19+
/// Merges one player's XP gains into floaties. Gains on the same target close together add up in one
20+
/// floatie, so a spray reads "+14 XP" instead of fourteen "+1 XP".
21+
/// </summary>
22+
/// <param name="mergeSeconds">How long after a gain the next one on the same target still adds to it.</param>
23+
internal sealed class FloatieMerger(double mergeSeconds)
24+
{
25+
/// <summary>The key for gains with no target, such as reloads and accuracy bonuses.</summary>
26+
public const int SelfKey = -1;
27+
28+
private const int MaxLabels = 2;
29+
30+
private readonly Dictionary<int, Entry> entries = [];
31+
32+
/// <summary>
33+
/// Adds a gain.
34+
/// </summary>
35+
/// <param name="key">The target's slot, or <see cref="SelfKey"/>.</param>
36+
/// <param name="time">The game time in seconds.</param>
37+
/// <param name="result">What the event earned.</param>
38+
/// <returns>What to show, or <see langword="null"/> when the event earned no XP.</returns>
39+
public FloatieUpdate? Add(int key, double time, ProgressionResult result)
40+
{
41+
Ensure.NotNull(result);
42+
if (result.XpEarned <= 0)
43+
{
44+
return null;
45+
}
46+
47+
bool isNew = !entries.TryGetValue(key, out Entry? entry) || ((time - entry.LastTime) > mergeSeconds);
48+
if (isNew || (entry is null))
49+
{
50+
entry = new Entry();
51+
entries[key] = entry;
52+
}
53+
54+
entry.LastTime = time;
55+
entry.Xp += result.XpEarned;
56+
foreach (string label in Labels(result))
57+
{
58+
// The newest moment goes on top, and repeats aren't stacked.
59+
entry.Labels.Remove(label);
60+
entry.Labels.Insert(0, label);
61+
}
62+
63+
if (entry.Labels.Count > MaxLabels)
64+
{
65+
entry.Labels.RemoveRange(MaxLabels, entry.Labels.Count - MaxLabels);
66+
}
67+
68+
string text = entry.Labels.Count == 0
69+
? $"+{FormatXp(entry.Xp)} XP"
70+
: $"+{FormatXp(entry.Xp)} XP\n{string.Join("\n", entry.Labels)}";
71+
return new FloatieUpdate(key, isNew, text, entry.Labels.Count > 0);
72+
}
73+
74+
/// <summary>
75+
/// Forgets floaties too old to grow, so the next gain on that target starts a new one.
76+
/// </summary>
77+
/// <param name="time">The game time in seconds.</param>
78+
public void Expire(double time)
79+
{
80+
foreach (int key in entries.Where(e => (time - e.Value.LastTime) > mergeSeconds).Select(e => e.Key).ToList())
81+
{
82+
entries.Remove(key);
83+
}
84+
}
85+
86+
/// <summary>
87+
/// Writes an XP amount for a floatie: whole numbers with separators, and up to two decimals below 10 so a
88+
/// quarter point against a bot still shows.
89+
/// </summary>
90+
/// <param name="xp">The XP.</param>
91+
/// <returns>The amount as text.</returns>
92+
public static string FormatXp(double xp)
93+
{
94+
double rounded = Math.Round(xp, 2);
95+
return (rounded < 10) && (rounded != Math.Floor(rounded))
96+
? rounded.ToString("0.##", CultureInfo.InvariantCulture)
97+
: Math.Round(xp).ToString("N0", CultureInfo.InvariantCulture);
98+
}
99+
100+
/// <summary>
101+
/// Gets the moments in a result worth naming on a floatie.
102+
/// </summary>
103+
/// <param name="result">What the event earned.</param>
104+
/// <returns>Short labels, in the order they happened.</returns>
105+
public static IEnumerable<string> Labels(ProgressionResult result)
106+
{
107+
Ensure.NotNull(result);
108+
foreach (ProgressionNotice notice in result.Notices)
109+
{
110+
string? label = notice.Kind switch
111+
{
112+
NoticeKind.SkillShot => notice.SkillShot?.Name,
113+
NoticeKind.TierCompleted => $"{notice.ChallengeName} {PanelBuilder.Numeral(notice.Value)}",
114+
NoticeKind.WeaponLevelUp => $"{notice.Weapon?.DisplayName} level {notice.Value}",
115+
NoticeKind.RankUp => $"Rank up: {RankLadder.Names[notice.Value]}",
116+
_ => null,
117+
};
118+
119+
if (label is not null)
120+
{
121+
yield return label;
122+
}
123+
}
124+
}
125+
126+
private sealed class Entry
127+
{
128+
public double LastTime { get; set; }
129+
130+
public double Xp { get; set; }
131+
132+
public List<string> Labels { get; } = [];
133+
}
134+
}

‎CounterStrikeProgression.Core/Progression/ProgressionEngine.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ private void Credit(PlayerProgress player, WeaponProgress weapon, Award award, P
327327

328328
private void CreditRankXp(PlayerProgress player, double xp, ProgressionResult result)
329329
{
330+
result.XpEarned += Math.Max(0, xp);
330331
double total = player.PendingXp + xp;
331332
long whole = (long)Math.Floor(total);
332333
player.PendingXp = total - whole;

‎CounterStrikeProgression.Core/Progression/ProgressionNotice.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ internal sealed class ProgressionResult
5151
/// <summary>Gets the whole rank XP points credited.</summary>
5252
public long XpGained { get; internal set; }
5353

54+
/// <summary>
55+
/// Gets the rank XP earned, including fractions too small to credit yet, such as a quarter point for
56+
/// hitting a bot. It's what the XP floaties show.
57+
/// </summary>
58+
public double XpEarned { get; internal set; }
59+
5460
/// <summary>Gets the moments worth telling the player about, in the order they happened.</summary>
5561
public IList<ProgressionNotice> Notices { get; } = [];
5662
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) 2023-2026 ktsu-dev contributors
2+
3+
namespace ktsu.CounterStrikeProgression.Test;
4+
5+
using ktsu.CounterStrikeProgression.Core.Events;
6+
using ktsu.CounterStrikeProgression.Core.Floaties;
7+
using ktsu.CounterStrikeProgression.Core.Progression;
8+
9+
[TestClass]
10+
public sealed class FloatieTests
11+
{
12+
private const int Victim = 4;
13+
14+
[TestMethod]
15+
public void QuickHitsOnOneTargetAddUp()
16+
{
17+
ProgressionEngine engine = TestHelpers.Engine();
18+
PlayerProgress player = TestHelpers.Player();
19+
FloatieMerger merger = new(0.75);
20+
21+
FloatieUpdate? first = merger.Add(Victim, 1.0, engine.ApplyCombat(player, TestHelpers.Hit("ak47")));
22+
FloatieUpdate? second = merger.Add(Victim, 1.2, engine.ApplyCombat(player, TestHelpers.Hit("ak47")));
23+
FloatieUpdate? kill = merger.Add(Victim, 1.4, engine.ApplyCombat(player, TestHelpers.Kill("ak47")));
24+
25+
Assert.IsTrue(first!.IsNew);
26+
Assert.AreEqual("+1 XP", first.Text);
27+
Assert.IsFalse(second!.IsNew);
28+
Assert.AreEqual("+2 XP", second.Text);
29+
Assert.AreEqual("+12 XP", kill!.Text);
30+
Assert.IsFalse(kill.Highlight);
31+
}
32+
33+
[TestMethod]
34+
public void AGapStartsANewFloatie()
35+
{
36+
ProgressionEngine engine = TestHelpers.Engine();
37+
PlayerProgress player = TestHelpers.Player();
38+
FloatieMerger merger = new(0.75);
39+
40+
merger.Add(Victim, 1.0, engine.ApplyCombat(player, TestHelpers.Hit("ak47")));
41+
FloatieUpdate? later = merger.Add(Victim, 2.0, engine.ApplyCombat(player, TestHelpers.Hit("ak47")));
42+
43+
Assert.IsTrue(later!.IsNew);
44+
Assert.AreEqual("+1 XP", later.Text);
45+
}
46+
47+
[TestMethod]
48+
public void DifferentTargetsGetTheirOwnFloaties()
49+
{
50+
ProgressionEngine engine = TestHelpers.Engine();
51+
PlayerProgress player = TestHelpers.Player();
52+
FloatieMerger merger = new(0.75);
53+
54+
merger.Add(Victim, 1.0, engine.ApplyCombat(player, TestHelpers.Hit("ak47")));
55+
FloatieUpdate? other = merger.Add(Victim + 1, 1.1, engine.ApplyCombat(player, TestHelpers.Hit("ak47")));
56+
57+
Assert.IsTrue(other!.IsNew);
58+
Assert.AreEqual(Victim + 1, other.Key);
59+
}
60+
61+
[TestMethod]
62+
public void SkillShotsAreNamedAndHighlighted()
63+
{
64+
ProgressionEngine engine = TestHelpers.Engine(s => s.RareSkillTiers = [1000, 2000, 3000, 4000, 5000]);
65+
PlayerProgress player = TestHelpers.Player();
66+
FloatieMerger merger = new(0.75);
67+
68+
FloatieUpdate? update = merger.Add(Victim, 1.0, engine.ApplyCombat(player, TestHelpers.Hit("ak47") with { Attacker = new PlayerState { OnGround = false } }));
69+
70+
Assert.AreEqual("+26 XP\nJumpshot", update!.Text);
71+
Assert.IsTrue(update.Highlight);
72+
}
73+
74+
[TestMethod]
75+
public void OnlyTheNewestTwoMomentsAreShownWithoutRepeats()
76+
{
77+
ProgressionEngine engine = TestHelpers.Engine(s =>
78+
{
79+
s.RareSkillTiers = [1000, 2000, 3000, 4000, 5000];
80+
s.EpicSkillTiers = [1000, 2000, 3000, 4000, 5000];
81+
s.CommonSkillTiers = [1000, 2000, 3000, 4000, 5000];
82+
});
83+
PlayerProgress player = TestHelpers.Player();
84+
FloatieMerger merger = new(0.75);
85+
PlayerState airborne = new() { OnGround = false };
86+
87+
merger.Add(Victim, 1.0, engine.ApplyCombat(player, TestHelpers.Hit("ak47") with { Attacker = airborne }));
88+
merger.Add(Victim, 1.1, engine.ApplyCombat(player, TestHelpers.Hit("ak47") with { Attacker = airborne }));
89+
FloatieUpdate? update = merger.Add(Victim, 1.2, engine.ApplyCombat(player, TestHelpers.Kill("ak47") with { Attacker = airborne, AttackerBlind = true }));
90+
91+
string[] lines = update!.Text.Split('\n');
92+
Assert.HasCount(3, lines);
93+
Assert.AreEqual("Blind kill", lines[1]);
94+
Assert.AreEqual("Jumpshot", lines[2]);
95+
}
96+
97+
[TestMethod]
98+
public void BotHitsShowFractions()
99+
{
100+
ProgressionEngine engine = TestHelpers.Engine();
101+
PlayerProgress player = TestHelpers.Player();
102+
FloatieMerger merger = new(0.75);
103+
104+
FloatieUpdate? update = merger.Add(Victim, 1.0, engine.ApplyCombat(player, TestHelpers.Hit("ak47") with { VictimIsBot = true }));
105+
106+
Assert.AreEqual("+0.25 XP", update!.Text);
107+
}
108+
109+
[TestMethod]
110+
public void NothingEarnedShowsNothing()
111+
{
112+
ProgressionEngine engine = TestHelpers.Engine();
113+
PlayerProgress player = TestHelpers.Player();
114+
FloatieMerger merger = new(0.75);
115+
116+
Assert.IsNull(merger.Add(Victim, 1.0, engine.ApplyCombat(player, TestHelpers.Hit("ak47") with { IsTeammate = true })));
117+
}
118+
119+
[TestMethod]
120+
public void ExpiredFloatiesStartFresh()
121+
{
122+
ProgressionEngine engine = TestHelpers.Engine();
123+
PlayerProgress player = TestHelpers.Player();
124+
FloatieMerger merger = new(0.75);
125+
126+
merger.Add(Victim, 1.0, engine.ApplyCombat(player, TestHelpers.Hit("ak47")));
127+
merger.Expire(5.0);
128+
129+
Assert.IsTrue(merger.Add(Victim, 1.1, engine.ApplyCombat(player, TestHelpers.Hit("ak47")))!.IsNew);
130+
}
131+
132+
[TestMethod]
133+
[DataRow(0.25, "0.25")]
134+
[DataRow(2.5, "2.5")]
135+
[DataRow(3.0, "3")]
136+
[DataRow(12.75, "13")]
137+
[DataRow(1250.0, "1,250")]
138+
public void XpIsFormattedForFloaties(double xp, string expected) => Assert.AreEqual(expected, FloatieMerger.FormatXp(xp));
139+
}

‎CounterStrikeProgression/CombatHandlers.cs‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ namespace ktsu.CounterStrikeProgression;
44

55
using CounterStrikeSharp.API;
66
using CounterStrikeSharp.API.Core;
7+
using CounterStrikeSharp.API.Modules.Utils;
78
using ktsu.CounterStrikeProgression.Core.Events;
89
using ktsu.CounterStrikeProgression.Core.Progression;
910
using ktsu.CounterStrikeProgression.Core.Tracking;
@@ -145,19 +146,20 @@ private HookResult OnPlayerHurt(EventPlayerHurt @event, GameEventInfo info)
145146
// bullet_damage carries the wallbang count. If it hasn't arrived for this bullet yet, the hit waits one
146147
// frame for it rather than guessing the order CS2 sends the two in.
147148
int victimSlot = victim.Slot;
149+
Vector? victimAt = Copy(victimPawn?.AbsOrigin);
148150
if (BulletPenetrations(session, tick, victimSlot) is int known)
149151
{
150-
services.Apply(attacker, session, Engine.ApplyCombat(progress, hit with { Penetrations = known }));
152+
services.Apply(attacker, session, Engine.ApplyCombat(progress, hit with { Penetrations = known }), victimSlot, victimAt);
151153
}
152154
else
153155
{
154-
Server.NextFrame(() => ApplyLateHit(session, progress, hit, tick, victimSlot));
156+
Server.NextFrame(() => ApplyLateHit(session, progress, hit, tick, victimSlot, victimAt));
155157
}
156158

157159
return HookResult.Continue;
158160
}
159161

160-
private void ApplyLateHit(PlayerSession session, PlayerProgress progress, CombatEvent hit, int tick, int victimSlot)
162+
private void ApplyLateHit(PlayerSession session, PlayerProgress progress, CombatEvent hit, int tick, int victimSlot, Vector? victimAt)
161163
{
162164
CCSPlayerController? attacker = Utilities.GetPlayerFromSlot(session.Slot);
163165
if ((Sessions.Get(attacker) != session) || !ReferenceEquals(session.Progress, progress))
@@ -166,9 +168,12 @@ private void ApplyLateHit(PlayerSession session, PlayerProgress progress, Combat
166168
}
167169

168170
int penetrations = BulletPenetrations(session, tick, victimSlot) ?? 0;
169-
services.Apply(attacker!, session, Engine.ApplyCombat(progress, hit with { Penetrations = penetrations }));
171+
services.Apply(attacker!, session, Engine.ApplyCombat(progress, hit with { Penetrations = penetrations }), victimSlot, victimAt);
170172
}
171173

174+
// Positions are read from live entity memory, so a value kept for later is copied first.
175+
private static Vector? Copy(Vector? position) => position is null ? null : new Vector(position.X, position.Y, position.Z);
176+
172177
private static int? BulletPenetrations(PlayerSession session, int tick, int victimSlot)
173178
{
174179
(int bulletTick, int bulletVictim, int penetrations) = session.LastBullet;
@@ -218,7 +223,7 @@ private HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info)
218223
CombatEvent kill = BuildKill(@event, victim, attackerPawn, weapon, session.Combat.Read(Server.CurrentTime), context, session.LastHitVictim);
219224
session.Combat.KillMade();
220225
session.LastKillWeapon = weapon;
221-
services.Apply(attacker, session, Engine.ApplyCombat(progress, kill));
226+
services.Apply(attacker, session, Engine.ApplyCombat(progress, kill), victim.Slot, Copy(GameState.PawnOf(victim)?.AbsOrigin));
222227
return HookResult.Continue;
223228
}
224229

@@ -234,7 +239,7 @@ private HookResult OnPlayerBlind(EventPlayerBlind @event, GameEventInfo info)
234239
return HookResult.Continue;
235240
}
236241

237-
services.Apply(attacker, session, Engine.ApplyFlash(progress, new FlashEvent(flashbang, @event.BlindDuration, victim.IsBot)));
242+
services.Apply(attacker, session, Engine.ApplyFlash(progress, new FlashEvent(flashbang, @event.BlindDuration, victim.IsBot)), victim.Slot, Copy(GameState.PawnOf(victim)?.AbsOrigin));
238243
return HookResult.Continue;
239244
}
240245

@@ -321,7 +326,7 @@ private void CreditAssist(EventPlayerDeath @event, CCSPlayerController victim)
321326
: GameState.ResolveWeapon(GameState.ActiveWeapon(pawn)?.DesignerName, pawn);
322327
if (weapon is not null)
323328
{
324-
services.Apply(assister, session, Engine.ApplyAssist(progress, new AssistEvent(weapon, @event.Assistedflash, victim.IsBot)));
329+
services.Apply(assister, session, Engine.ApplyAssist(progress, new AssistEvent(weapon, @event.Assistedflash, victim.IsBot)), victim.Slot, Copy(GameState.PawnOf(victim)?.AbsOrigin));
325330
}
326331
}
327332

0 commit comments

Comments
 (0)