Add after-mission statistics; record Workshop 0.0.125
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-10-30 12:59:32 +01:00
parent 970dd338f5
commit c6ee764c6c
15 changed files with 572 additions and 11 deletions
+2 -2
View File
@@ -2,7 +2,7 @@ SCR_GameOverScreenConfig : "{651583FFFB7FC765}Configs/GameOverScreen/BaseGameOve
m_aGameOverScreens {
ERR_EscapeGameOverScreenInfo "{6B193DC8DDC63507}" {
m_GameOverScreenId VICTORY
m_GameOverContentLayout "{C4507D07F3C54831}UI/layouts/HUD/GameOver/EndScreen/EndScreenContent_NoImage.layout"
m_GameOverContentLayout "{C63B4D1E09A572F8}UI/layouts/ERR/RoundResults.layout"
m_OptionalParams SCR_BaseGameOverScreenInfoOptional "{4984A5C1BFEFC04E}" {
m_sTitle "Escape complete"
m_sAudioOneShot "SOUND_ONVICTORY"
@@ -10,7 +10,7 @@ SCR_GameOverScreenConfig : "{651583FFFB7FC765}Configs/GameOverScreen/BaseGameOve
}
ERR_FailureGameOverScreenInfo "{E66F29A7A8AD7438}" {
m_GameOverScreenId LOOSE
m_GameOverContentLayout "{C4507D07F3C54831}UI/layouts/HUD/GameOver/EndScreen/EndScreenContent_NoImage.layout"
m_GameOverContentLayout "{C63B4D1E09A572F8}UI/layouts/ERR/RoundResults.layout"
m_OptionalParams SCR_BaseGameOverScreenInfoOptional "{3DBF0BDE715945FA}" {
m_sTitle "Escape failed"
m_sDebriefing "No surviving prisoners can complete the escape, or the extraction helicopter has been lost."
@@ -33,13 +33,22 @@ class ERR_EscapeGameOverScreenInfo : SCR_BaseGameOverScreenInfo
modded class SCR_GameOverScreenContentUIComponent
{
protected bool m_ERREscapeContent;
protected ERR_GameMode m_ERRStatisticsMode;
protected ERR_GameMode m_ERRFailureMode;
override void InitContent(SCR_GameOverScreenUIContentData endScreenUIContent)
{
SCR_PlayerController.ERR_OnEscapeResultChanged().Remove(ERR_RefreshEscapeContent);
if (m_ERRFailureMode) m_ERRFailureMode.OnEndReasonChanged().Remove(ERR_RefreshFailureContent);
m_ERRFailureMode = null;
if (m_ERRStatisticsMode) m_ERRStatisticsMode.ERR_OnStatisticsChanged().Remove(ERR_RefreshStatistics);
m_ERRStatisticsMode = null;
super.InitContent(endScreenUIContent);
if (ERR_EscapeGameOverScreenInfo.Cast(endScreenUIContent.m_GameOverInfo) || ERR_FailureGameOverScreenInfo.Cast(endScreenUIContent.m_GameOverInfo))
{
m_ERRStatisticsMode = ERR_GameMode.Get();
if (m_ERRStatisticsMode) m_ERRStatisticsMode.ERR_OnStatisticsChanged().Insert(ERR_RefreshStatistics);
ERR_RefreshStatistics();
}
if (ERR_FailureGameOverScreenInfo.Cast(endScreenUIContent.m_GameOverInfo))
{
m_ERRFailureMode = ERR_GameMode.Get();
@@ -71,10 +80,21 @@ modded class SCR_GameOverScreenContentUIComponent
TextWidget debrief = TextWidget.Cast(m_wRoot.FindAnyWidget(m_sDebriefingName));
if (debrief) debrief.SetText(info.GetDebriefing(-1, null));
}
protected void ERR_RefreshStatistics()
{
if (!m_wRoot || !m_ERRStatisticsMode || m_ERRStatisticsMode != ERR_GameMode.Get()) return;
TextWidget text = TextWidget.Cast(m_wRoot.FindAnyWidget("RoundStatistics"));
if (!text) return;
string summary = m_ERRStatisticsMode.ERR_RoundSummary();
if (summary.IsEmpty()) summary = "Waiting for mission statistics...";
text.SetText(summary);
}
override void HandlerDeattached(Widget w)
{
SCR_PlayerController.ERR_OnEscapeResultChanged().Remove(ERR_RefreshEscapeContent);
m_ERREscapeContent = false;
if (m_ERRStatisticsMode) m_ERRStatisticsMode.ERR_OnStatisticsChanged().Remove(ERR_RefreshStatistics);
m_ERRStatisticsMode = null;
if (m_ERRFailureMode) m_ERRFailureMode.OnEndReasonChanged().Remove(ERR_RefreshFailureContent);
m_ERRFailureMode = null;
super.HandlerDeattached(w);
@@ -0,0 +1,158 @@
// Round-local statistics are always available, independently of telemetry.
// Only authoritative participant bodies own records; transient connection IDs
// and native winner-ID lists never decide kill credit or escape results.
class ERR_RoundPlayerStats
{
UUID Account;
string Name;
IEntity Actor;
bool Connected;
bool Down;
int Kills;
int FriendlyKills;
int Deaths;
int Downed;
int Recoveries;
}
class ERR_RoundStatistics
{
protected ERR_GameMode m_Mode;
protected ref array<ref ERR_RoundPlayerStats> m_Players = {};
protected bool m_Finished;
protected bool m_Truncated;
protected int m_StartedAt = -1;
void ERR_RoundStatistics(ERR_GameMode mode) { m_Mode = mode; }
ERR_RoundPlayerStats Find(UUID account)
{
foreach (ERR_RoundPlayerStats row : m_Players) if (row.Account == account) return row;
return null;
}
protected ERR_RoundPlayerStats Body(IEntity actor)
{
if (!actor) return null;
foreach (ERR_RoundPlayerStats row : m_Players) if (row.Actor == actor) return row;
return null;
}
static string DisplayName(string name)
{
name.Replace("\n", " "); name.Replace("\r", " "); name.Replace("\t", " ");
if (name.Length() > 48) name = name.Substring(0, 48);
if (name.IsEmpty()) return "Player";
return name;
}
void RosterChanged(int playerId) { Sync(); }
void Sync()
{
if (!Replication.IsServer() || m_Finished || !m_Mode || !m_Mode.Players()) return;
array<ref ERR_PlayerLife> lives = {}; m_Mode.Players().Participants(lives);
foreach (ERR_PlayerLife life : lives)
{
ERR_RoundPlayerStats row = Find(life.AccountId);
if (!row)
{
if (m_Players.Count() >= 128) { m_Truncated = true; continue; }
row = new ERR_RoundPlayerStats(); row.Account = life.AccountId; m_Players.Insert(row);
}
row.Connected = life.Connected;
if (life.Connected) row.Name = DisplayName(GetGame().GetPlayerManager().GetPlayerName(life.ConnectionId));
if (row.Actor != life.Actor) { row.Actor = life.Actor; row.Down = false; }
}
}
// A consciousness edge is a down/recovery, not a death. Repeated callbacks
// cannot count the same incapacitation twice; corpse deletion is not a kill.
static void Consciousness(ERR_RoundPlayerStats row, bool conscious)
{
if (!row) return;
if (!conscious && !row.Down) row.Downed++;
if (conscious && row.Down) row.Recoveries++;
row.Down = !conscious;
}
void ConsciousnessChanged(IEntity actor, bool conscious)
{
if (!Replication.IsServer() || m_Finished || !m_Mode || !m_Mode.Active() || !ERR_Actors.Alive(actor)) return;
Sync(); Consciousness(Body(actor), conscious);
}
static void CountDeath(ERR_RoundPlayerStats victim, ERR_RoundPlayerStats killer, bool friendly, bool credit)
{
if (victim) { victim.Deaths++; victim.Down = false; }
if (!credit || !killer || victim == killer) return;
if (friendly) killer.FriendlyKills++;
else killer.Kills++;
}
void Killed(IEntity victim, IEntity killerEntity, Instigator instigator)
{
if (!Replication.IsServer() || m_Finished || !m_Mode || !m_Mode.Active() || !ChimeraCharacter.Cast(victim)) return;
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(victim.FindComponent(SCR_CharacterDamageManagerComponent));
if (!damage || !damage.IsDestroyed()) return;
Sync();
ERR_RoundPlayerStats killed = Body(victim);
// Native damage normally provides the original instigator body, including
// delayed explosives. Never recover credit from a possibly recycled player ID.
IEntity source = killerEntity;
if (!ChimeraCharacter.Cast(source) && instigator) source = instigator.GetInstigatorEntity();
ERR_RoundPlayerStats killer = Body(source);
if (!killed && !killer) return;
if (!damage.ERR_ClaimRoundDeath()) return;
bool credit = instigator && instigator.GetInstigatorType() != InstigatorType.INSTIGATOR_GM;
bool friendly = killed != null;
Faction faction = SCR_FactionManager.SGetFaction(victim);
if (faction && faction == m_Mode.PlayerFaction()) friendly = true;
CountDeath(killed, killer, friendly, credit);
}
void PhaseChanged(ERR_Phase previous, ERR_Phase current)
{
if (!Replication.IsServer() || m_Finished) return;
if (m_StartedAt < 0 && current >= ERR_Phase.PRISON && current <= ERR_Phase.EXTRACTION) m_StartedAt = System.GetTickCount();
if (ERR_MissionRules.Terminal(current)) Finish();
}
protected string Outcome(ERR_RoundPlayerStats row)
{
if (m_Mode.Extraction() && m_Mode.Extraction().DidEscape(row.Account)) return "Escaped";
if (row.Deaths > 0 && !ERR_Actors.Alive(row.Actor)) return "Dead";
if (!row.Connected) return "Disconnected - did not escape";
if (row.Down) return "Down - did not escape";
return "Did not escape";
}
void Finish()
{
if (!Replication.IsServer() || m_Finished || !m_Mode) return;
Sync(); m_Finished = true;
int seconds;
if (m_StartedAt >= 0) seconds = Math.Max(0, (System.GetTickCount() - m_StartedAt) / 1000);
int kills, friendly, deaths, downed, recovered, escaped;
string rows;
foreach (ERR_RoundPlayerStats row : m_Players)
{
kills += row.Kills; friendly += row.FriendlyKills; deaths += row.Deaths; downed += row.Downed; recovered += row.Recoveries;
if (m_Mode.Extraction() && m_Mode.Extraction().DidEscape(row.Account)) escaped++;
if (!rows.IsEmpty()) rows += "\n\n";
rows += DisplayName(row.Name) + " - " + Outcome(row) + "\n";
rows += string.Format("Kills: %1 Friendly kills: %2 Deaths: %3 Downed: %4 Recoveries: %5", row.Kills, row.FriendlyKills, row.Deaths, row.Downed, row.Recoveries);
}
string summary = string.Format("Duration: %1m %2s Participants: %3 Escaped: %4\n", seconds / 60, Math.Mod(seconds, 60), m_Players.Count(), escaped);
summary += string.Format("Total kills: %1 Friendly kills: %2 Deaths: %3 Downed: %4 Recoveries: %5\n\n", kills, friendly, deaths, downed, recovered);
summary += "Downed counts unconscious episodes; recoveries include self-recovery. Deaths count fatal losses.\n\n";
if (rows.IsEmpty()) rows = "No players participated in this round.";
summary += rows;
if (m_Truncated) summary += "\n\nOnly the first 128 participants are shown; totals cover those players.";
m_Mode.ERR_PublishRoundStatistics(summary);
}
}
modded class SCR_CharacterDamageManagerComponent
{
protected bool m_ERRRoundDeathCounted;
bool ERR_ClaimRoundDeath()
{
if (!Replication.IsServer() || m_ERRRoundDeathCounted) return false;
m_ERRRoundDeathCounted = true; return true;
}
}
modded class SCR_CharacterControllerComponent
{
override void OnConsciousnessChanged(bool conscious)
{
super.OnConsciousnessChanged(conscious);
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && mode.ERR_Statistics()) mode.ERR_Statistics().ConsciousnessChanged(GetOwner(), conscious);
}
}
@@ -0,0 +1,41 @@
modded class ERR_GameMode
{
protected ref ERR_RoundStatistics m_ERRStatistics;
[RplProp(onRplName: "ERR_OnRoundStatisticsReplicated")]
protected string m_ERRRoundSummary;
protected ref ScriptInvoker m_ERRStatisticsChanged = new ScriptInvoker();
ERR_RoundStatistics ERR_Statistics() { return m_ERRStatistics; }
string ERR_RoundSummary() { return m_ERRRoundSummary; }
ScriptInvoker ERR_OnStatisticsChanged() { return m_ERRStatisticsChanged; }
protected void ERR_OnRoundStatisticsReplicated() { m_ERRStatisticsChanged.Invoke(); }
void ERR_PublishRoundStatistics(string summary)
{
if (!Replication.IsServer() || !m_ERRRoundSummary.IsEmpty()) return;
m_ERRRoundSummary = summary;
Replication.BumpMe(); ERR_OnRoundStatisticsReplicated();
}
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !Players()) return;
m_ERRStatistics = new ERR_RoundStatistics(this);
OnPhaseChanged().Insert(m_ERRStatistics.PhaseChanged);
Players().OnChanged().Insert(m_ERRStatistics.RosterChanged);
}
protected override void OnControllableDestroyed(IEntity entity, IEntity killerEntity, notnull Instigator instigator)
{
if (m_ERRStatistics) m_ERRStatistics.Killed(entity, killerEntity, instigator);
super.OnControllableDestroyed(entity, killerEntity, instigator);
}
protected override void OnPlayerKilled(int playerId, IEntity playerEntity, IEntity killerEntity, notnull Instigator killer)
{
if (m_ERRStatistics) m_ERRStatistics.Killed(playerEntity, killerEntity, killer);
super.OnPlayerKilled(playerId, playerEntity, killerEntity, killer);
}
protected override void OnGameModeEnd(SCR_GameModeEndData endData)
{
// Freeze before native shutdown can detach bodies or remove roster ownership.
if (m_ERRStatistics) m_ERRStatistics.Finish();
super.OnGameModeEnd(endData);
}
}
@@ -36,6 +36,11 @@ class ERR_TelemetryPlayer
bool participated;
bool escaped;
int connectedSeconds;
int kills;
int friendlyKills;
int deaths;
int downed;
int recoveries;
}
class ERR_TelemetryMod
{
@@ -253,6 +258,13 @@ class ERR_Telemetry
if (tracked.authenticated && tracked.id == participant.AccountId)
{
tracked.participated = true;
ERR_RoundPlayerStats stats;
if (m_Mode.ERR_Statistics()) stats = m_Mode.ERR_Statistics().Find(participant.AccountId);
if (stats)
{
tracked.kills = stats.Kills; tracked.friendlyKills = stats.FriendlyKills;
tracked.deaths = stats.Deaths; tracked.downed = stats.Downed; tracked.recoveries = stats.Recoveries;
}
if (m_Mode.Extraction()) tracked.escaped = m_Mode.Extraction().DidEscape(participant.AccountId);
}
}
@@ -0,0 +1,52 @@
SizeLayoutWidgetClass {
Name "ERR_RoundResults"
WidthOverride 1100
HeightOverride 620
Slot LayoutSlot { HorizontalAlign 1 VerticalAlign 1 }
components { SCR_GameOverScreenContentUIComponent "{6E81CD1BC83BE245}" { } }
{
FrameWidgetClass {
Name "ResultFrame"
Slot AlignableSlot { HorizontalAlign 3 VerticalAlign 3 }
{
TextWidgetClass {
Name "GameOver_State"
Slot FrameWidgetSlot { Anchor 0 0 1 0 OffsetLeft 20 OffsetTop 0 OffsetRight 20 SizeY 52 OffsetBottom -52 }
Color 1 1 1 1 "Font Size" 36 "Min Font Size" 36
Text "Round ended"
}
TextWidgetClass {
Name "GameOver_Description"
Slot FrameWidgetSlot { Anchor 0 0 1 0 OffsetLeft 20 OffsetTop 58 OffsetRight 20 SizeY 64 OffsetBottom -122 }
Color 0.9 0.92 0.94 1 "Font Size" 22 "Min Font Size" 22 Wrap 1
}
TextWidgetClass {
Name "StatisticsHeading"
Slot FrameWidgetSlot { Anchor 0 0 1 0 OffsetLeft 20 OffsetTop 132 OffsetRight 20 SizeY 36 OffsetBottom -168 }
Color 0.9 0.8 0.5 1 "Font Size" 26 "Min Font Size" 26 Text "Mission statistics"
}
ScrollLayoutWidgetClass {
Name "StatisticsScroll"
Slot FrameWidgetSlot { Anchor 0 0 1 1 OffsetLeft 20 OffsetTop 178 OffsetRight 20 OffsetBottom 12 }
components { SCR_GamepadScrollComponent "{5707100AC31E657E}" { } }
{
VerticalLayoutWidgetClass {
Name "StatisticsContent"
Slot AlignableSlot { HorizontalAlign 3 VerticalAlign 0 Padding 0 0 18 0 }
{
TextWidgetClass {
Name "RoundStatistics"
Slot LayoutSlot { HorizontalAlign 3 Padding 0 0 0 8 SizeMode Auto }
Clipping Ancestor Color 0.9 0.92 0.94 1
Text "Waiting for mission statistics..."
"Font Size" 20 "Min Font Size" 20 Wrap 1
FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" }
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,11 @@
MetaFileClass {
Name "{C63B4D1E09A572F8}UI/layouts/ERR/RoundResults.layout"
Configurations {
LayoutResourceClass PC { }
LayoutResourceClass HEADLESS : PC { }
LayoutResourceClass XBOX_ONE : PC { }
LayoutResourceClass XBOX_SERIES : PC { }
LayoutResourceClass PS4 : PC { }
LayoutResourceClass PS5 : PC { }
}
}