581 lines
17 KiB
C#
581 lines
17 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using EasyChart.UGUI;
|
|
using UnityEngine;
|
|
|
|
public class uBehaviourRaderController : MonoBehaviour
|
|
{
|
|
private const int AxisActive = 0;
|
|
private const int AxisChart = 1;
|
|
private const int AxisFormation = 2;
|
|
private const int AxisAcc = 3;
|
|
private const int AxisRks = 4;
|
|
private const int AxisRank = 5;
|
|
|
|
[Header("Objects")]
|
|
public GameObject uRaderObj;
|
|
public URadarChartController radarChartController;
|
|
public UGUIChartBridge chartBridge;
|
|
public RankConfig rankConfig;
|
|
|
|
[Header("Async")]
|
|
public bool reloadOnEnable = true;
|
|
public int maxInitAttempts = 20;
|
|
public float retryDelaySeconds = 0.15f;
|
|
|
|
[Header("Radar Baseline")]
|
|
[Range(0f, 100f)] public float neutralAxisValue = 50f;
|
|
[Range(0f, 100f)] public float referenceAxisValue = 60f;
|
|
|
|
[Header("Score Targets")]
|
|
public float chartScoreTarget = 1000000f;
|
|
public float idolScoreTarget = 1000000f;
|
|
|
|
[Header("Activity Tuning")]
|
|
public float activeRecencyHalfLifeDays = 7f;
|
|
public float activeGapScaleDays = 2f;
|
|
public int activeConfidenceFullCount = 12;
|
|
|
|
[Header("Performance Tuning")]
|
|
public int topSampleCount = 10;
|
|
public int performanceConfidenceFullCount = 20;
|
|
public float normalizedSpreadPenalty = 0.30f;
|
|
|
|
private Coroutine loadRoutine;
|
|
private bool overlaySubscribed;
|
|
|
|
private void Start()
|
|
{
|
|
BeginLoad();
|
|
}
|
|
|
|
private void OnEnable()
|
|
{
|
|
BindOverlayVisibility();
|
|
ApplyOverlayVisibility(btmandtopController.CurrentOverlayPanelsVisible);
|
|
if (reloadOnEnable && Application.isPlaying)
|
|
{
|
|
BeginLoad();
|
|
}
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
UnsubscribeOverlayVisibility();
|
|
if (loadRoutine != null)
|
|
{
|
|
StopCoroutine(loadRoutine);
|
|
loadRoutine = null;
|
|
}
|
|
}
|
|
|
|
[ContextMenu("Refresh Behaviour Radar")]
|
|
public void RefreshNow()
|
|
{
|
|
BeginLoad();
|
|
}
|
|
|
|
private void BeginLoad()
|
|
{
|
|
if (!isActiveAndEnabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (loadRoutine != null)
|
|
{
|
|
StopCoroutine(loadRoutine);
|
|
}
|
|
|
|
loadRoutine = StartCoroutine(LoadAndApplyRoutine());
|
|
}
|
|
|
|
private IEnumerator LoadAndApplyRoutine()
|
|
{
|
|
for (int attempt = 0; attempt < maxInitAttempts; attempt++)
|
|
{
|
|
ResolveDependencies();
|
|
|
|
if (radarChartController != null &&
|
|
chartBridge != null &&
|
|
chartBridge.Profile != null)
|
|
{
|
|
ApplyOverlayVisibility(btmandtopController.CurrentOverlayPanelsVisible);
|
|
ApplySummary(BuildSummary(RecentPlayHistoryStore.GetRecords()));
|
|
loadRoutine = null;
|
|
yield break;
|
|
}
|
|
|
|
yield return new WaitForSecondsRealtime(retryDelaySeconds);
|
|
}
|
|
|
|
ApplySummary(BuildEmptySummary());
|
|
loadRoutine = null;
|
|
}
|
|
|
|
private void ResolveDependencies()
|
|
{
|
|
if (uRaderObj == null)
|
|
{
|
|
uRaderObj = gameObject;
|
|
}
|
|
|
|
if (radarChartController == null)
|
|
{
|
|
radarChartController = GetComponent<URadarChartController>();
|
|
if (radarChartController == null)
|
|
{
|
|
radarChartController = GetComponentInChildren<URadarChartController>(true);
|
|
}
|
|
}
|
|
|
|
if (chartBridge == null && radarChartController != null)
|
|
{
|
|
chartBridge = radarChartController.chartBridge;
|
|
}
|
|
|
|
if (chartBridge == null)
|
|
{
|
|
chartBridge = GetComponent<UGUIChartBridge>();
|
|
if (chartBridge == null)
|
|
{
|
|
chartBridge = GetComponentInChildren<UGUIChartBridge>(true);
|
|
}
|
|
}
|
|
|
|
if (rankConfig == null)
|
|
{
|
|
RankConfig[] configs = Resources.LoadAll<RankConfig>(string.Empty);
|
|
if (configs != null && configs.Length > 0)
|
|
{
|
|
rankConfig = configs[0];
|
|
}
|
|
}
|
|
}
|
|
|
|
private void BindOverlayVisibility()
|
|
{
|
|
if (overlaySubscribed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
btmandtopController.GlobalOverlayPanelVisibilityChanged += HandleOverlayPanelsVisibilityChanged;
|
|
overlaySubscribed = true;
|
|
}
|
|
|
|
private void UnsubscribeOverlayVisibility()
|
|
{
|
|
if (!overlaySubscribed)
|
|
{
|
|
overlaySubscribed = false;
|
|
return;
|
|
}
|
|
|
|
btmandtopController.GlobalOverlayPanelVisibilityChanged -= HandleOverlayPanelsVisibilityChanged;
|
|
overlaySubscribed = false;
|
|
}
|
|
|
|
private void HandleOverlayPanelsVisibilityChanged(bool visible)
|
|
{
|
|
ApplyOverlayVisibility(visible);
|
|
}
|
|
|
|
private void ApplyOverlayVisibility(bool overlayVisible)
|
|
{
|
|
if (uRaderObj == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (uRaderObj == gameObject)
|
|
{
|
|
Debug.LogWarning("[uBehaviourRaderController] uRaderObj should not be the same object as the controller host. Assign the radar display root instead.");
|
|
return;
|
|
}
|
|
|
|
uRaderObj.SetActive(!overlayVisible);
|
|
}
|
|
|
|
private void ApplySummary(BehaviourRadarSummary summary)
|
|
{
|
|
if (radarChartController == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
radarChartController.SetAxisLabel(AxisActive, "\u6d3b\u8dc3");
|
|
radarChartController.SetAxisLabel(AxisChart, "\u8c31\u9762");
|
|
radarChartController.SetAxisLabel(AxisFormation, "\u7f16\u961f");
|
|
radarChartController.SetAxisLabel(AxisAcc, "ACC");
|
|
radarChartController.SetAxisLabel(AxisRks, "RKS");
|
|
radarChartController.SetAxisLabel(AxisRank, "\u8bc4\u7ea7");
|
|
|
|
radarChartController.SetAxisValue(AxisActive, summary.active);
|
|
radarChartController.SetAxisValue(AxisChart, summary.chart);
|
|
radarChartController.SetAxisValue(AxisFormation, summary.formation);
|
|
radarChartController.SetAxisValue(AxisAcc, summary.acc);
|
|
radarChartController.SetAxisValue(AxisRks, summary.rks);
|
|
radarChartController.SetAxisValue(AxisRank, summary.rating);
|
|
|
|
for (int i = 0; i < 6; i++)
|
|
{
|
|
radarChartController.SetReferenceAxisValue(i, referenceAxisValue);
|
|
}
|
|
|
|
radarChartController.RebuildNow();
|
|
}
|
|
|
|
private BehaviourRadarSummary BuildSummary(IReadOnlyList<RecentPlayRecord> records)
|
|
{
|
|
BehaviourRadarSummary summary = BuildEmptySummary();
|
|
if (records == null || records.Count == 0)
|
|
{
|
|
return summary;
|
|
}
|
|
|
|
List<RecordSample> samples = BuildSamples(records);
|
|
if (samples.Count == 0)
|
|
{
|
|
return summary;
|
|
}
|
|
|
|
summary.active = ComputeActivityScore(samples);
|
|
summary.chart = ComputeChartScore(samples);
|
|
summary.formation = ComputeFormationScore(samples);
|
|
summary.acc = ComputeAccuracyScore(samples);
|
|
summary.rks = ComputeRksScore(samples);
|
|
summary.rating = ComputeRatingScore(samples);
|
|
return summary;
|
|
}
|
|
|
|
private BehaviourRadarSummary BuildEmptySummary()
|
|
{
|
|
return new BehaviourRadarSummary
|
|
{
|
|
active = neutralAxisValue,
|
|
chart = neutralAxisValue,
|
|
formation = neutralAxisValue,
|
|
acc = neutralAxisValue,
|
|
rks = neutralAxisValue,
|
|
rating = neutralAxisValue
|
|
};
|
|
}
|
|
|
|
private List<RecordSample> BuildSamples(IReadOnlyList<RecentPlayRecord> records)
|
|
{
|
|
List<RecordSample> samples = new List<RecordSample>(records.Count);
|
|
DateTime now = DateTime.Now;
|
|
|
|
for (int i = 0; i < records.Count; i++)
|
|
{
|
|
RecentPlayRecord record = records[i];
|
|
if (record == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
RecordSample sample = new RecordSample();
|
|
sample.weight = Mathf.Exp(-i / 24f);
|
|
sample.playedAtLocal = TryParseLocalTime(record, now, out sample.hasPlayableTime);
|
|
sample.accuracy = Mathf.Clamp(record.accuracy, 0f, 100f);
|
|
sample.rks = Mathf.Clamp(record.srks, 0f, 100f);
|
|
|
|
if (record.hasScoreBreakdown)
|
|
{
|
|
sample.chartScore = Mathf.Max(0, record.chartScore);
|
|
sample.idolScore = Mathf.Max(0, record.idolScore);
|
|
sample.hasScoreBreakdown = true;
|
|
}
|
|
|
|
if (record.hasRankMarker && record.rankTierCount > 0)
|
|
{
|
|
sample.rankTierCount = record.rankTierCount;
|
|
sample.rankIndex = Mathf.Clamp(record.rankIndex, 0, sample.rankTierCount);
|
|
sample.hasRankMarker = true;
|
|
}
|
|
else if (record.scoreReadable)
|
|
{
|
|
sample.rankTierCount = GetRankTierCount();
|
|
sample.rankIndex = CalculateRankIndex(record.totalScore);
|
|
sample.hasRankMarker = sample.rankTierCount > 0;
|
|
}
|
|
|
|
samples.Add(sample);
|
|
}
|
|
|
|
return samples;
|
|
}
|
|
|
|
private DateTime TryParseLocalTime(RecentPlayRecord record, DateTime now, out bool hasValue)
|
|
{
|
|
hasValue = false;
|
|
|
|
if (record != null && !string.IsNullOrWhiteSpace(record.playedAtUtc))
|
|
{
|
|
if (DateTime.TryParse(record.playedAtUtc, null, System.Globalization.DateTimeStyles.RoundtripKind, out DateTime utcTime))
|
|
{
|
|
hasValue = true;
|
|
return utcTime.ToLocalTime();
|
|
}
|
|
}
|
|
|
|
if (record != null && !string.IsNullOrWhiteSpace(record.playedAt))
|
|
{
|
|
if (DateTime.TryParseExact(
|
|
record.playedAt,
|
|
"MM-dd, HH:mm",
|
|
System.Globalization.CultureInfo.InvariantCulture,
|
|
System.Globalization.DateTimeStyles.None,
|
|
out DateTime partial))
|
|
{
|
|
DateTime candidate = new DateTime(now.Year, partial.Month, partial.Day, partial.Hour, partial.Minute, 0);
|
|
if (candidate > now.AddDays(1))
|
|
{
|
|
candidate = candidate.AddYears(-1);
|
|
}
|
|
|
|
hasValue = true;
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return now;
|
|
}
|
|
|
|
private float ComputeActivityScore(List<RecordSample> samples)
|
|
{
|
|
List<DateTime> playedDays = new List<DateTime>();
|
|
for (int i = 0; i < samples.Count; i++)
|
|
{
|
|
if (!samples[i].hasPlayableTime)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
playedDays.Add(samples[i].playedAtLocal.Date);
|
|
}
|
|
|
|
if (playedDays.Count == 0)
|
|
{
|
|
return neutralAxisValue;
|
|
}
|
|
|
|
playedDays.Sort((a, b) => b.CompareTo(a));
|
|
|
|
int uniqueDays = 0;
|
|
DateTime previousDay = DateTime.MinValue;
|
|
float totalGapDays = 0f;
|
|
int gapCount = 0;
|
|
DateTime newest = playedDays[0];
|
|
DateTime oldest = playedDays[playedDays.Count - 1];
|
|
|
|
for (int i = 0; i < playedDays.Count; i++)
|
|
{
|
|
DateTime day = playedDays[i];
|
|
if (day == previousDay)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (previousDay != DateTime.MinValue)
|
|
{
|
|
totalGapDays += Mathf.Max(0f, (float)(previousDay - day).TotalDays);
|
|
gapCount++;
|
|
}
|
|
|
|
previousDay = day;
|
|
uniqueDays++;
|
|
}
|
|
|
|
float spanDays = Mathf.Max(1f, (float)(newest - oldest).TotalDays + 1f);
|
|
float density = Mathf.Clamp01(uniqueDays / spanDays);
|
|
float averageGap = gapCount > 0 ? totalGapDays / gapCount : 0f;
|
|
float cadence = Mathf.Exp(-averageGap / Mathf.Max(0.25f, activeGapScaleDays));
|
|
float recencyDays = Mathf.Max(0f, (float)(DateTime.Now - newest).TotalDays);
|
|
float recency = Mathf.Exp(-recencyDays / Mathf.Max(0.25f, activeRecencyHalfLifeDays));
|
|
|
|
float raw = (density * 0.5f + cadence * 0.3f + recency * 0.2f) * 100f;
|
|
return BlendWithNeutral(raw, uniqueDays, activeConfidenceFullCount);
|
|
}
|
|
|
|
private float ComputeChartScore(List<RecordSample> samples)
|
|
{
|
|
return ComputeNormalizedPerformanceScore(
|
|
samples,
|
|
sample => sample.hasScoreBreakdown,
|
|
sample => Mathf.Clamp01(sample.chartScore / Mathf.Max(1f, chartScoreTarget)));
|
|
}
|
|
|
|
private float ComputeFormationScore(List<RecordSample> samples)
|
|
{
|
|
return ComputeNormalizedPerformanceScore(
|
|
samples,
|
|
sample => sample.hasScoreBreakdown,
|
|
sample => Mathf.Clamp01(sample.idolScore / Mathf.Max(1f, idolScoreTarget)));
|
|
}
|
|
|
|
private float ComputeAccuracyScore(List<RecordSample> samples)
|
|
{
|
|
return ComputeNormalizedPerformanceScore(
|
|
samples,
|
|
sample => sample.accuracy > 0f,
|
|
sample => Mathf.Clamp01(sample.accuracy / 100f));
|
|
}
|
|
|
|
private float ComputeRksScore(List<RecordSample> samples)
|
|
{
|
|
return ComputeNormalizedPerformanceScore(
|
|
samples,
|
|
sample => sample.rks > 0f,
|
|
sample => Mathf.Clamp01(sample.rks / 100f));
|
|
}
|
|
|
|
private float ComputeRatingScore(List<RecordSample> samples)
|
|
{
|
|
int tierCount = GetRankTierCount();
|
|
if (tierCount <= 0)
|
|
{
|
|
return neutralAxisValue;
|
|
}
|
|
|
|
return ComputeNormalizedPerformanceScore(
|
|
samples,
|
|
sample => sample.hasRankMarker && sample.rankTierCount > 0,
|
|
sample =>
|
|
{
|
|
int count = Mathf.Max(1, sample.rankTierCount);
|
|
return Mathf.Clamp01(sample.rankIndex / (float)count);
|
|
});
|
|
}
|
|
|
|
private float ComputeNormalizedPerformanceScore(
|
|
List<RecordSample> samples,
|
|
Func<RecordSample, bool> predicate,
|
|
Func<RecordSample, float> selector)
|
|
{
|
|
List<float> values = new List<float>();
|
|
float weightedSum = 0f;
|
|
float totalWeight = 0f;
|
|
|
|
for (int i = 0; i < samples.Count; i++)
|
|
{
|
|
RecordSample sample = samples[i];
|
|
if (!predicate(sample))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float value = Mathf.Clamp01(selector(sample));
|
|
values.Add(value);
|
|
weightedSum += value * sample.weight;
|
|
totalWeight += sample.weight;
|
|
}
|
|
|
|
if (values.Count == 0 || totalWeight <= 0f)
|
|
{
|
|
return neutralAxisValue;
|
|
}
|
|
|
|
float weightedAverage = weightedSum / totalWeight;
|
|
List<float> sorted = new List<float>(values);
|
|
sorted.Sort((a, b) => b.CompareTo(a));
|
|
|
|
int topCount = Mathf.Min(topSampleCount, sorted.Count);
|
|
float topAverage = 0f;
|
|
for (int i = 0; i < topCount; i++)
|
|
{
|
|
topAverage += sorted[i];
|
|
}
|
|
topAverage /= Mathf.Max(1, topCount);
|
|
|
|
float variance = 0f;
|
|
for (int i = 0; i < values.Count; i++)
|
|
{
|
|
float delta = values[i] - weightedAverage;
|
|
variance += delta * delta;
|
|
}
|
|
variance /= Mathf.Max(1, values.Count);
|
|
|
|
float spread = Mathf.Sqrt(variance);
|
|
float stability = 1f - Mathf.Clamp01(spread / Mathf.Max(0.05f, normalizedSpreadPenalty));
|
|
float raw = (weightedAverage * 0.55f + topAverage * 0.25f + stability * 0.20f) * 100f;
|
|
return BlendWithNeutral(raw, values.Count, performanceConfidenceFullCount);
|
|
}
|
|
|
|
private float BlendWithNeutral(float raw, int sampleCount, int fullConfidenceCount)
|
|
{
|
|
float confidence = Mathf.Clamp01(sampleCount / Mathf.Max(1f, fullConfidenceCount));
|
|
return Mathf.Lerp(neutralAxisValue, Mathf.Clamp(raw, 0f, 100f), confidence);
|
|
}
|
|
|
|
private int GetRankTierCount()
|
|
{
|
|
if (rankConfig == null || rankConfig.thresholds == null)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
return Mathf.Max(0, rankConfig.thresholds.Count);
|
|
}
|
|
|
|
private int CalculateRankIndex(int totalScore)
|
|
{
|
|
if (rankConfig == null || rankConfig.thresholds == null || rankConfig.thresholds.Count == 0 || totalScore <= 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
int bestLevel = 0;
|
|
float highestMatchingPercent = -1f;
|
|
int thresholdCount = rankConfig.thresholds.Count;
|
|
|
|
for (int i = 0; i < thresholdCount; i++)
|
|
{
|
|
RankThreshold threshold = rankConfig.thresholds[i];
|
|
if (threshold == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
float requiredScore = rankConfig.baseScore * threshold.thresholdPercent;
|
|
if (totalScore < requiredScore || threshold.thresholdPercent <= highestMatchingPercent)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
highestMatchingPercent = threshold.thresholdPercent;
|
|
bestLevel = thresholdCount - i;
|
|
}
|
|
|
|
return bestLevel;
|
|
}
|
|
|
|
[Serializable]
|
|
private struct BehaviourRadarSummary
|
|
{
|
|
public float active;
|
|
public float chart;
|
|
public float formation;
|
|
public float acc;
|
|
public float rks;
|
|
public float rating;
|
|
}
|
|
|
|
private struct RecordSample
|
|
{
|
|
public float weight;
|
|
public bool hasPlayableTime;
|
|
public DateTime playedAtLocal;
|
|
public int chartScore;
|
|
public int idolScore;
|
|
public bool hasScoreBreakdown;
|
|
public float accuracy;
|
|
public float rks;
|
|
public int rankIndex;
|
|
public int rankTierCount;
|
|
public bool hasRankMarker;
|
|
}
|
|
}
|