Add after-mission statistics; record Workshop 0.0.125
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -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 { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user