ui基本完毕,修了一大把的bug

This commit is contained in:
FloatGaming
2026-07-13 02:28:39 +08:00
parent 1e20d73e90
commit fd22501f71
958 changed files with 378289 additions and 41038 deletions
+17
View File
@@ -1266,6 +1266,18 @@ public class AllyCombatant : MonoBehaviour, ICombatant
// Trigger HP changed events
int delta = currentHP - old;
if (delta != 0 && teamUIController.Instance != null)
{
if (delta > 0)
{
teamUIController.Instance.RecordHeal(slotIndex, delta);
}
else
{
teamUIController.Instance.RecordDamageTaken(slotIndex, -delta);
}
}
if (delta != 0 && !skipPopup)
{
if (iNumberPrefabController.Instance != null)
@@ -1387,6 +1399,11 @@ public class AllyCombatant : MonoBehaviour, ICombatant
// Trigger mana changed events
int delta = currentMana - old;
if (delta > 0 && teamUIController.Instance != null)
{
teamUIController.Instance.RecordMana(slotIndex, delta);
}
if (delta != 0)
{
if (iNumberPrefabController.Instance != null)
-66
View File
@@ -1078,22 +1078,6 @@ public class EffectSystem : MonoBehaviour
{
// Use deferPopup: true for synchronization with hit fx
comp.ReceiveDamage(amount, source, true);
// Record stats if source is an ally
if (source != null && target.GetComponent<EnemyCombatant>() != null)
{
var ally = source.GetComponent<AllyCombatant>();
if (ally != null && teamUIController.Instance != null)
{
teamUIController.Instance.RecordDamage(ally.slotIndex, amount);
}
}
// Record damage taken if target is an ally
if (targetAlly != null && teamUIController.Instance != null)
{
teamUIController.Instance.RecordDamageTaken(targetAlly.slotIndex, amount);
}
}
else
{
@@ -1109,16 +1093,6 @@ public class EffectSystem : MonoBehaviour
{
// Use deferPopup: false for heals (usually immediate)
comp.ReceiveHeal(amount, source, false);
// Record stats if source is an ally
if (source != null)
{
var ally = source.GetComponent<AllyCombatant>();
if (ally != null && teamUIController.Instance != null)
{
teamUIController.Instance.RecordHeal(ally.slotIndex, amount);
}
}
}
else
{
@@ -1175,13 +1149,6 @@ public class EffectSystem : MonoBehaviour
comp.ReceiveDamage(totalAmount, source, false);
// No need to call TriggerQueuedPopups manually as deferPopup is false
if (source != null && targetEnemy != null)
{
var ally = source.GetComponent<AllyCombatant>();
if (ally != null && teamUIController.Instance != null) teamUIController.Instance.RecordDamage(ally.slotIndex, totalAmount);
}
if (targetAlly != null && teamUIController.Instance != null) teamUIController.Instance.RecordDamageTaken(targetAlly.slotIndex, totalAmount);
yield break;
}
@@ -1224,13 +1191,6 @@ public class EffectSystem : MonoBehaviour
comp.ReceiveDamage(perTick, source, false);
// No need to call TriggerQueuedPopups manually as deferPopup is false
if (source != null && targetEnemy != null)
{
var ally = source.GetComponent<AllyCombatant>();
if (ally != null && teamUIController.Instance != null) teamUIController.Instance.RecordDamage(ally.slotIndex, perTick);
}
if (targetAlly != null && teamUIController.Instance != null) teamUIController.Instance.RecordDamageTaken(targetAlly.slotIndex, perTick);
yield return new WaitForSeconds(tickInterval);
elapsed += tickInterval;
}
@@ -1265,13 +1225,6 @@ public class EffectSystem : MonoBehaviour
comp.ReceiveHeal(totalAmount, source, false);
// No need to call TriggerQueuedPopups manually as deferPopup is false
// Record stats
if (source != null)
{
var ally = source.GetComponent<AllyCombatant>();
if (ally != null && teamUIController.Instance != null) teamUIController.Instance.RecordHeal(ally.slotIndex, totalAmount);
}
yield break;
}
@@ -1286,13 +1239,6 @@ public class EffectSystem : MonoBehaviour
comp.ReceiveHeal(perTick, source, false);
// No need to call TriggerQueuedPopups manually as deferPopup is false
// Record stats
if (source != null)
{
var ally = source.GetComponent<AllyCombatant>();
if (ally != null && teamUIController.Instance != null) teamUIController.Instance.RecordHeal(ally.slotIndex, perTick);
}
yield return new WaitForSeconds(tickInterval);
elapsed += tickInterval;
}
@@ -1345,12 +1291,6 @@ public class EffectSystem : MonoBehaviour
if (ally != null)
{
ally.ModifyMana(Mathf.CeilToInt(totalAmount), true, true);
// Record stats
if (totalAmount > 0f && source != null)
{
var sourceAlly = source.GetComponent<AllyCombatant>();
if (sourceAlly != null && teamUIController.Instance != null) teamUIController.Instance.RecordMana(sourceAlly.slotIndex, totalAmount);
}
}
yield break;
}
@@ -1364,12 +1304,6 @@ public class EffectSystem : MonoBehaviour
if (ally != null)
{
ally.ModifyMana(Mathf.CeilToInt(perTick), true, true);
// Record stats
if (perTick > 0f && source != null)
{
var sourceAlly = source.GetComponent<AllyCombatant>();
if (sourceAlly != null && teamUIController.Instance != null) teamUIController.Instance.RecordMana(sourceAlly.slotIndex, perTick);
}
}
yield return new WaitForSeconds(tickInterval);
elapsed += tickInterval;
+9
View File
@@ -159,6 +159,15 @@ public class EnemyCombatant : MonoBehaviour, ICombatant
ModifyHP(-delta, true);
int actual = before - currentHP;
if (actual > 0 && source != null && teamUIController.Instance != null)
{
var sourceAlly = source.GetComponent<AllyCombatant>();
if (sourceAlly != null)
{
teamUIController.Instance.RecordDamage(sourceAlly.slotIndex, actual);
}
}
GameplaySkillLogger.RecordEnemyHpEvent(
gameObject.name,
"Damage",
+83 -16
View File
@@ -1,8 +1,10 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using System.Threading;
using UnityEngine;
public static class GameplaySkillLogger
@@ -18,6 +20,24 @@ public static class GameplaySkillLogger
private const float RapidDuplicateSkillThresholdSeconds = 0.05f;
// Off-thread writer: judge/skill events push formatted lines into this queue and a
// single background thread writes them to disk in FIFO order. This keeps the
// synchronous file I/O off the gameplay hot path (first-note hitch) while producing
// byte-identical output: the queue preserves enqueue order (enqueues happen under
// s_fileLock), and the writer thread is the sole owner of the file so a session
// truncate (header) can never interleave with an append.
private struct LogMessage
{
public bool Truncate; // true => WriteAllText (new session header); false => AppendAllText
public string Text;
}
private static readonly ConcurrentQueue<LogMessage> s_pendingLines = new ConcurrentQueue<LogMessage>();
private static readonly AutoResetEvent s_writeSignal = new AutoResetEvent(false);
private static Thread s_writerThread;
private static volatile bool s_writerRunning;
private static readonly object s_writerStartLock = new object();
public static string LogFilePath
{
get { return EnsureLogFilePath(); }
@@ -256,14 +276,9 @@ public static class GameplaySkillLogger
"# Format: [elapsedSec] [CATEGORY] key=value | key=value" + Environment.NewLine +
Environment.NewLine;
try
{
File.WriteAllText(path, header, s_utf8NoBom);
}
catch (Exception ex)
{
Debug.LogWarning("[GameplaySkillLogger] Failed to create session log: " + ex.Message);
}
EnsureWriterThread();
s_pendingLines.Enqueue(new LogMessage { Truncate = true, Text = header });
s_writeSignal.Set();
}
private static float AppendEventLineLocked(string category, string payload)
@@ -286,18 +301,70 @@ public static class GameplaySkillLogger
.Append("] ")
.AppendLine(payload);
try
{
File.AppendAllText(EnsureLogFilePath(), builder.ToString(), s_utf8NoBom);
}
catch (Exception ex)
{
Debug.LogWarning("[GameplaySkillLogger] Failed to append log: " + ex.Message);
}
EnsureWriterThread();
s_pendingLines.Enqueue(new LogMessage { Truncate = false, Text = builder.ToString() });
s_writeSignal.Set();
return elapsed;
}
private static void EnsureWriterThread()
{
if (s_writerRunning)
return;
lock (s_writerStartLock)
{
if (s_writerRunning)
return;
s_writerRunning = true;
s_writerThread = new Thread(WriterLoop)
{
Name = "GameplaySkillLoggerWriter",
IsBackground = true
};
s_writerThread.Start();
}
}
private static void WriterLoop()
{
while (s_writerRunning)
{
s_writeSignal.WaitOne(200);
DrainPendingLines();
}
// Final drain so nothing queued right before shutdown is lost.
DrainPendingLines();
}
private static void DrainPendingLines()
{
while (s_pendingLines.TryDequeue(out LogMessage message))
{
// Read the cached path directly: it is always resolved on the main thread
// (BeginSessionInternal -> EnsureLogFilePath) before the writer thread starts,
// so we must never call Application.dataPath from this background thread.
string path = s_logFilePath;
if (string.IsNullOrEmpty(path))
continue;
try
{
if (message.Truncate)
File.WriteAllText(path, message.Text, s_utf8NoBom);
else
File.AppendAllText(path, message.Text, s_utf8NoBom);
}
catch (Exception ex)
{
Debug.LogWarning("[GameplaySkillLogger] Failed to write log: " + ex.Message);
}
}
}
private static void EnsureSessionLocked()
{
if (!s_sessionActive)
+180
View File
@@ -1,6 +1,7 @@
using UnityEngine;
using TMPro;
using UnityEngine.UI;
using DG.Tweening;
public class ScoreManager : MonoBehaviour
{
@@ -56,6 +57,12 @@ public class ScoreManager : MonoBehaviour
private bool perfectBonusHooked = false;
private int perfectClearBonusPm = 0;
private bool perfectClearBonusApplied = false;
private int comboRecentPlusAmount = 0;
private int displayedRecentPlusAmount = 0;
private int lastKnownCombo = 0;
private float lastRecentPlusScoreTime = float.NegativeInfinity;
private Tween recentPlusAmountValueTween;
private const float RecentPlusResetDelaySeconds = 5f;
[Header("Judgement Statistics")]
public int countPerfect = 0;
@@ -126,6 +133,10 @@ public class ScoreManager : MonoBehaviour
}
ResetStatistics();
comboRecentPlusAmount = 0;
displayedRecentPlusAmount = 0;
lastKnownCombo = 0;
lastRecentPlusScoreTime = float.NegativeInfinity;
}
private void OnEnable()
@@ -140,6 +151,7 @@ public class ScoreManager : MonoBehaviour
private void OnDestroy()
{
KillRecentPlusAmountValueTween();
UnhookPerfectBonusEvent();
}
@@ -148,11 +160,13 @@ public class ScoreManager : MonoBehaviour
// Ensure the progress keys start at 0.
UpdateIdolscoreKeyScales();
TryHookPerfectBonusEvent();
RefreshAllScoreUi();
}
private void Update()
{
EnsureIdolscoreKeys();
UpdateRecentPlusAmountTimeout();
float dt = Time.unscaledDeltaTime;
if (dt <= 0f) return;
@@ -428,6 +442,8 @@ public class ScoreManager : MonoBehaviour
if (JudgeManager.IsDebugEnabled)
Debug.Log($"[ScoreManager] Added {pmDelta} to track {trackIndex} pm sum, idol delta {idolDelta}. New pm sums: R{red_pmScore_sum} G{green_pmScore_sum} Y{yellow_pmScore_sum} P{purple_pmScore_sum} B{blue_pmScore_sum} -> allSumPm={allSum_pmScore}. Idol sums: R{red_idolScore_sum} G{green_idolScore_sum} Y{yellow_idolScore_sum} P{purple_idolScore_sum} B{blue_idolScore_sum} -> allSumIdol={allSum_idolScore}");
UpdateRecentPlusAmountUi(pmActualDelta + idolActualDelta);
// Update UI in teamUIController if available (per-track pm sums + aggregate)
var ui = teamUIController.Instance;
if (ui != null)
@@ -514,6 +530,8 @@ public class ScoreManager : MonoBehaviour
iNumberPrefabController.SpawnForAllyStatic(trackIndex, type, actual);
}
UpdateRecentPlusAmountUi(actual);
red_idolScore_sum = idolScoreSums[0];
green_idolScore_sum = idolScoreSums[1];
yellow_idolScore_sum = idolScoreSums[2];
@@ -694,10 +712,172 @@ public class ScoreManager : MonoBehaviour
{
if (JudgeManager.IsDebugEnabled) Debug.LogWarning("[ScoreManager] teamUIController.currentTotalScore is null; cannot display total score.");
}
if (ui.currentScoreRankImage != null && ui.currentScoreRankConfig != null)
{
ui.currentScoreRankImage.sprite = ui.currentScoreRankConfig.GetRankSprite(totalScore);
}
}
else
{
if (JudgeManager.IsDebugEnabled) Debug.LogWarning("[ScoreManager] teamUIController.Instance is null; cannot update UI.");
}
}
public void ApplyExternalTotalScoreDelta(int delta)
{
if (delta == 0)
{
return;
}
long next = (long)totalScore + delta;
if (next < 0) next = 0;
if (next > int.MaxValue - 1L) next = int.MaxValue - 1L;
totalScore = (int)next;
UpdateRecentPlusAmountUi(delta);
RefreshAllScoreUi();
}
private void UpdateRecentPlusAmountUi(int scoreDelta)
{
var ui = teamUIController.Instance;
if (ui == null || ui.recentPlusAmountText == null)
{
return;
}
int comboNow = ui.CurrentCombo;
bool comboBroken = comboNow <= 0;
bool comboRestarted = comboNow > 0 && lastKnownCombo <= 0;
if (comboBroken)
{
ResetRecentPlusAmountState(ui, true);
lastKnownCombo = comboNow;
return;
}
if (comboRestarted)
{
ResetRecentPlusAmountState(ui, false);
}
if (scoreDelta != 0)
{
comboRecentPlusAmount += scoreDelta;
lastRecentPlusScoreTime = Time.unscaledTime;
}
ui.ResetRecentPlusAmountVisual();
AnimateRecentPlusAmountText(ui, comboRecentPlusAmount);
lastKnownCombo = comboNow;
}
public void RefreshAllScoreUi()
{
var ui = teamUIController.Instance;
if (ui == null)
{
return;
}
if (ui.currentTotalScore != null)
{
ui.currentTotalScore.text = totalScore.ToString();
}
if (ui.currentScoreRankImage != null && ui.currentScoreRankConfig != null)
{
ui.currentScoreRankImage.sprite = ui.currentScoreRankConfig.GetRankSprite(totalScore);
}
}
private void AnimateRecentPlusAmountText(teamUIController ui, int targetValue)
{
if (ui == null || ui.recentPlusAmountText == null)
{
return;
}
KillRecentPlusAmountValueTween();
int startValue = displayedRecentPlusAmount;
if (startValue == targetValue)
{
ui.recentPlusAmountText.text = FormatSignedScoreDelta(targetValue);
return;
}
recentPlusAmountValueTween = DOTween
.To(() => startValue, value =>
{
startValue = value;
displayedRecentPlusAmount = value;
ui.recentPlusAmountText.text = FormatSignedScoreDelta(value);
}, targetValue, 0.2f)
.SetEase(Ease.OutQuad)
.SetUpdate(true)
.OnComplete(() =>
{
displayedRecentPlusAmount = targetValue;
if (ui.recentPlusAmountText != null)
{
ui.recentPlusAmountText.text = FormatSignedScoreDelta(targetValue);
}
recentPlusAmountValueTween = null;
});
}
private void KillRecentPlusAmountValueTween()
{
if (recentPlusAmountValueTween == null)
{
return;
}
recentPlusAmountValueTween.Kill();
recentPlusAmountValueTween = null;
}
private void UpdateRecentPlusAmountTimeout()
{
if (comboRecentPlusAmount == 0)
{
return;
}
if (Time.unscaledTime - lastRecentPlusScoreTime < RecentPlusResetDelaySeconds)
{
return;
}
ResetRecentPlusAmountState(teamUIController.Instance, false);
}
private void ResetRecentPlusAmountState(teamUIController ui, bool killTweenOnly)
{
comboRecentPlusAmount = 0;
displayedRecentPlusAmount = 0;
lastRecentPlusScoreTime = float.NegativeInfinity;
KillRecentPlusAmountValueTween();
if (ui == null || ui.recentPlusAmountText == null)
{
return;
}
ui.recentPlusAmountText.text = FormatSignedScoreDelta(0);
if (killTweenOnly)
{
return;
}
}
private static string FormatSignedScoreDelta(int value)
{
return value >= 0 ? $"+{value}" : value.ToString();
}
}
+1 -20
View File
@@ -535,14 +535,7 @@ public class SkillBuilder : MonoBehaviour
public void ModifyTotalScore(int delta)
{
if (ScoreManager.Instance == null) { Debug.LogWarning("ModifyTotalScore: ScoreManager.Instance is null"); return; }
// adjust total and attempt to update UI
ScoreManager.Instance.totalScore += delta;
// try to force UI update via ScoreManager.RecalculateTotal() is not appropriate because it recalculates from allies
// so instead update displayed total directly if teamUIController present
if (teamUIController.Instance != null && teamUIController.Instance.currentTotalScore != null)
{
teamUIController.Instance.currentTotalScore.text = ScoreManager.Instance.totalScore.ToString();
}
ScoreManager.Instance.ApplyExternalTotalScoreDelta(delta);
}
// Documentation text normalized.
@@ -1238,24 +1231,12 @@ public class SkillBuilder : MonoBehaviour
var comp = target.GetComponent<ICombatant>();
if (comp == null) return false;
comp.ReceiveHeal(amount, source);
if (source != null)
{
var sourceAlly = source.GetComponent<AllyCombatant>();
if (sourceAlly != null && teamUIController.Instance != null)
teamUIController.Instance.RecordHeal(sourceAlly.slotIndex, amount);
}
return true;
case EffectType.IncreaseManaOverTime:
var ally = target.GetComponent<AllyCombatant>();
if (ally == null) return false;
ally.ModifyMana(Mathf.CeilToInt(amount), true, true);
if (amount > 0f && source != null)
{
var sourceAlly = source.GetComponent<AllyCombatant>();
if (sourceAlly != null && teamUIController.Instance != null)
teamUIController.Instance.RecordMana(sourceAlly.slotIndex, amount);
}
return true;
}
+2
View File
@@ -50,6 +50,8 @@ public class AllyHero_SO : ScriptableObject
public Sprite ally_hero_HD_image;
[Header("Inspector")]
public Sprite ally_hero_squareProfile;
[Header("Inspector")]
public Sprite ally_hero_settleDisplay;
[Header("spine")]
public SkeletonDataAsset ally_heroSpineData;
+6 -1
View File
@@ -1,4 +1,8 @@
using System.Diagnostics;
// NOTE: Windows-only. Launches an external WebView2Host.exe via Process.Start.
// Wrapped in UNITY_STANDALONE_WIN so the Windows build/logic is unchanged while
// Android compiles this class out.
#if UNITY_STANDALONE_WIN
using System.Diagnostics;
using UnityEngine;
public class LaunchH5 : MonoBehaviour
@@ -22,3 +26,4 @@ public class LaunchH5 : MonoBehaviour
Process.Start(psi);
}
}
#endif
@@ -29,7 +29,7 @@ RectTransform:
m_ConstrainProportionsScale: 1
m_Children:
- {fileID: 3426810894855294520}
- {fileID: 4787860189400675959}
- {fileID: 4600183637760829555}
- {fileID: 6563389421309098651}
- {fileID: 7481797042088762975}
- {fileID: 1351079257240028571}
@@ -39,7 +39,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 135.9, y: 0}
m_AnchoredPosition: {x: 135.9, y: -54}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &312614768471175586
@@ -142,7 +142,7 @@ RectTransform:
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 35, y: 25}
m_SizeDelta: {x: 30, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &4734727163133092417
CanvasRenderer:
@@ -217,7 +217,7 @@ RectTransform:
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 30}
m_SizeDelta: {x: 129.2, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1347893851219428802
CanvasRenderer:
@@ -249,11 +249,11 @@ MonoBehaviour:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: dfa10de3e2846524089b5073175cd151, type: 3}
m_FontSize: 25
m_FontSize: 28
m_FontStyle: 0
m_BestFit: 1
m_MinSize: 0
m_MaxSize: 25
m_MinSize: 14
m_MaxSize: 28
m_Alignment: 5
m_AlignByGeometry: 0
m_RichText: 1
@@ -296,7 +296,7 @@ RectTransform:
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 30}
m_SizeDelta: {x: 129.2, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &8957605422153280152
CanvasRenderer:
@@ -328,11 +328,11 @@ MonoBehaviour:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: dfa10de3e2846524089b5073175cd151, type: 3}
m_FontSize: 25
m_FontSize: 28
m_FontStyle: 0
m_BestFit: 1
m_MinSize: 0
m_MaxSize: 25
m_MinSize: 14
m_MaxSize: 28
m_Alignment: 5
m_AlignByGeometry: 0
m_RichText: 1
@@ -340,81 +340,6 @@ MonoBehaviour:
m_VerticalOverflow: 0
m_LineSpacing: 1
m_Text: 350234
--- !u!1 &1300686028162242880
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7276837415997336770}
- component: {fileID: 2417251892043802145}
- component: {fileID: 7331213209788149531}
m_Layer: 0
m_Name: bottom
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7276837415997336770
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1300686028162242880}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: -0.8}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4787860189400675959}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -3.1, y: 3.8}
m_SizeDelta: {x: 145.324, y: 141.255}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &2417251892043802145
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1300686028162242880}
m_CullTransparentMesh: 1
--- !u!114 &7331213209788149531
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1300686028162242880}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 0.6367924, b: 0.64803755, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 7f3b1220317607e44bbb8f0e1998044a, type: 3}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1.5
--- !u!1 &1365303792903341059
GameObject:
m_ObjectHideFlags: 0
@@ -451,7 +376,7 @@ RectTransform:
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 35, y: 25}
m_SizeDelta: {x: 30, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &4210288393527682798
CanvasRenderer:
@@ -525,8 +450,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -65, y: 0}
m_SizeDelta: {x: 90, y: 30}
m_AnchoredPosition: {x: -71.489944, y: 0}
m_SizeDelta: {x: 102.9798, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &8683684367698790289
CanvasRenderer:
@@ -549,7 +474,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -558,7 +483,7 @@ MonoBehaviour:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: dfa10de3e2846524089b5073175cd151, type: 3}
m_FontSize: 22
m_FontSize: 24
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 1
@@ -605,7 +530,7 @@ RectTransform:
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 30}
m_SizeDelta: {x: 129.2, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &8186726174179685067
CanvasRenderer:
@@ -637,11 +562,11 @@ MonoBehaviour:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: dfa10de3e2846524089b5073175cd151, type: 3}
m_FontSize: 25
m_FontSize: 28
m_FontStyle: 0
m_BestFit: 1
m_MinSize: 0
m_MaxSize: 25
m_MinSize: 14
m_MaxSize: 28
m_Alignment: 5
m_AlignByGeometry: 0
m_RichText: 1
@@ -675,16 +600,16 @@ RectTransform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1803836327666263803}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: -1}
m_LocalPosition: {x: 0, y: 0, z: -4.84}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4787860189400675959}
m_Father: {fileID: 4148755058004053777}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -3, y: 4.3}
m_SizeDelta: {x: 123.437, y: 123.437}
m_AnchoredPosition: {x: -213.69046, y: 54.585762}
m_SizeDelta: {x: 600, y: 165}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1406633831322811249
CanvasRenderer:
@@ -714,7 +639,7 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: eec334d03c52af847bfa12dc839ac536, type: 3}
m_Sprite: {fileID: 21300000, guid: f1d5deba8c5a71c439be778c5bd9a61b, type: 3}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
@@ -789,7 +714,7 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 4573847525859265706, guid: c0dab60f28c13404fbad49de3cf86dd0, type: 3}
m_Sprite: {fileID: 21300000, guid: 7b78ab1a3117c6243a284471316b0c65, type: 3}
m_Type: 3
m_PreserveAspect: 0
m_FillCenter: 1
@@ -898,7 +823,7 @@ RectTransform:
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 30}
m_SizeDelta: {x: 129.2, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &4306892612456958120
CanvasRenderer:
@@ -930,11 +855,11 @@ MonoBehaviour:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: dfa10de3e2846524089b5073175cd151, type: 3}
m_FontSize: 25
m_FontSize: 28
m_FontStyle: 0
m_BestFit: 1
m_MinSize: 0
m_MaxSize: 25
m_MinSize: 14
m_MaxSize: 28
m_Alignment: 5
m_AlignByGeometry: 0
m_RichText: 1
@@ -1023,7 +948,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
m_IsActive: 0
--- !u!224 &3426810894855294520
RectTransform:
m_ObjectHideFlags: 0
@@ -1117,7 +1042,7 @@ RectTransform:
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 35, y: 25}
m_SizeDelta: {x: 30, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &7316102496420409225
CanvasRenderer:
@@ -1356,7 +1281,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.8962264, g: 0.4523407, b: 0.768059, a: 1}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -1408,7 +1333,7 @@ RectTransform:
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 30}
m_SizeDelta: {x: 107.4343, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1151234173233781149
CanvasRenderer:
@@ -1431,7 +1356,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -1440,11 +1365,11 @@ MonoBehaviour:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: dfa10de3e2846524089b5073175cd151, type: 3}
m_FontSize: 22
m_FontSize: 24
m_FontStyle: 0
m_BestFit: 1
m_BestFit: 0
m_MinSize: 0
m_MaxSize: 22
m_MaxSize: 24
m_Alignment: 5
m_AlignByGeometry: 0
m_RichText: 1
@@ -1486,8 +1411,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -65, y: 0}
m_SizeDelta: {x: 90, y: 30}
m_AnchoredPosition: {x: -71.489944, y: 0}
m_SizeDelta: {x: 102.9798, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &8439903389052874367
CanvasRenderer:
@@ -1510,7 +1435,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -1519,7 +1444,7 @@ MonoBehaviour:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: dfa10de3e2846524089b5073175cd151, type: 3}
m_FontSize: 22
m_FontSize: 24
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 1
@@ -1629,8 +1554,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -77.114, y: 0}
m_SizeDelta: {x: 95.773, y: 30}
m_AnchoredPosition: {x: -81.71184, y: 0}
m_SizeDelta: {x: 104.9686, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &3533238549364255986
CanvasRenderer:
@@ -1662,7 +1587,7 @@ MonoBehaviour:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: dfa10de3e2846524089b5073175cd151, type: 3}
m_FontSize: 22
m_FontSize: 26
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 1
@@ -1745,8 +1670,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -77.114, y: 0}
m_SizeDelta: {x: 95.773, y: 30}
m_AnchoredPosition: {x: -81.71184, y: 0}
m_SizeDelta: {x: 104.9686, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1513130722049452815
CanvasRenderer:
@@ -1778,7 +1703,7 @@ MonoBehaviour:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: dfa10de3e2846524089b5073175cd151, type: 3}
m_FontSize: 22
m_FontSize: 26
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 1
@@ -1936,7 +1861,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -324.5, y: 45.2}
m_AnchoredPosition: {x: -217.4, y: 50.8}
m_SizeDelta: {x: 233, y: 36}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &4531879398774622215
@@ -1981,8 +1906,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -326.8, y: 95.52118}
m_SizeDelta: {x: 290.97, y: 40}
m_AnchoredPosition: {x: -115.5, y: 95.52118}
m_SizeDelta: {x: 336.6112, y: 40}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &3659676079848674504
CanvasRenderer:
@@ -2005,7 +1930,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -2060,8 +1985,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 1.6662}
m_SizeDelta: {x: 0, y: -3.3324}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &7287820284985504367
CanvasRenderer:
@@ -2084,7 +2009,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_Color: {r: 0.09803922, g: 0.32941177, b: 0.6, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -2092,8 +2017,8 @@ MonoBehaviour:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: bd4be44636ad6c24a9b117a7108a75e9, type: 3}
m_FontSize: 20
m_Font: {fileID: 12800000, guid: d254d20d93651ae448ff13d10dff30ab, type: 3}
m_FontSize: 25
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 1
@@ -2316,7 +2241,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 1}
m_AnchorMax: {x: 0.5, y: 1}
m_AnchoredPosition: {x: -293.1, y: -8.57}
m_AnchoredPosition: {x: -97.1, y: -14}
m_SizeDelta: {x: 200, y: 90}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &685427306910703188
@@ -2443,8 +2368,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 250, y: 100}
m_AnchoredPosition: {x: 0, y: 69.49}
m_SizeDelta: {x: 250, y: 165}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &6539971250549974765
MonoBehaviour:
@@ -2470,83 +2395,6 @@ MonoBehaviour:
this_allyManaRestored: {fileID: 6381579454208282139}
displayDetailContributionButton: {fileID: 2479934731397186594}
detailContributionGO: {fileID: 7749677393578589965}
--- !u!1 &6682492808549074205
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 4787860189400675959}
- component: {fileID: 2643565460332953122}
- component: {fileID: 7400641254697929517}
m_Layer: 0
m_Name: shadow
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &4787860189400675959
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6682492808549074205}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: -4.402}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 7276837415997336770}
- {fileID: 4600183637760829555}
m_Father: {fileID: 4148755058004053777}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -67.251, y: 53.9}
m_SizeDelta: {x: 147.235, y: 143.112}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &2643565460332953122
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6682492808549074205}
m_CullTransparentMesh: 1
--- !u!114 &7400641254697929517
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6682492808549074205}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0, g: 0, b: 0, a: 0.4745098}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 7f3b1220317607e44bbb8f0e1998044a, type: 3}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1.5
--- !u!1 &6853683024340712328
GameObject:
m_ObjectHideFlags: 0
@@ -2580,7 +2428,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -177.5, y: -5.2}
m_AnchoredPosition: {x: 71.38, y: -34.55}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &7151601246212580972
@@ -2684,7 +2532,7 @@ RectTransform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7380227020462892773}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalPosition: {x: 0, y: 0, z: 0.0001}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
@@ -2692,8 +2540,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0.17, y: -38.91}
m_SizeDelta: {x: 301.5, y: 22.17}
m_AnchoredPosition: {x: 57.903595, y: -43.9901}
m_SizeDelta: {x: 255.7965, y: 12.0376}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &7024596005729876765
CanvasRenderer:
@@ -2723,8 +2571,8 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 7f3b1220317607e44bbb8f0e1998044a, type: 3}
m_Type: 1
m_Sprite: {fileID: 21300000, guid: 3dc2408de993d094695786359ef267aa, type: 3}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
@@ -2768,7 +2616,7 @@ RectTransform:
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 30}
m_SizeDelta: {x: 107.4343, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &4931104231077369669
CanvasRenderer:
@@ -2791,7 +2639,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -2800,11 +2648,11 @@ MonoBehaviour:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: dfa10de3e2846524089b5073175cd151, type: 3}
m_FontSize: 22
m_FontSize: 24
m_FontStyle: 0
m_BestFit: 1
m_BestFit: 0
m_MinSize: 0
m_MaxSize: 22
m_MaxSize: 24
m_Alignment: 5
m_AlignByGeometry: 0
m_RichText: 1
@@ -2876,7 +2724,7 @@ RectTransform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7692321193941902280}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalPosition: {x: 0, y: 0, z: 0.0001}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
@@ -2885,8 +2733,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0.01, y: -38.91}
m_SizeDelta: {x: 301.5, y: 22.17}
m_AnchoredPosition: {x: 57.67989, y: -43.9901}
m_SizeDelta: {x: 255.7965, y: 12.0376}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &4552223981125847129
CanvasRenderer:
@@ -2916,8 +2764,8 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 7f3b1220317607e44bbb8f0e1998044a, type: 3}
m_Type: 1
m_Sprite: {fileID: 21300000, guid: 3dc2408de993d094695786359ef267aa, type: 3}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
@@ -2965,7 +2813,7 @@ RectTransform:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7749677393578589965}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: -5.087}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
@@ -2974,8 +2822,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -670.5, y: 54.942}
m_SizeDelta: {x: 302.667, y: 182.657}
m_AnchoredPosition: {x: 243.65, y: 53.535202}
m_SizeDelta: {x: 302.667, y: 170.0029}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &810551395488659022
CanvasRenderer:
@@ -3005,7 +2853,7 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 7f3b1220317607e44bbb8f0e1998044a, type: 3}
m_Sprite: {fileID: 21300000, guid: 45682aabe328fec4a8feb3e055f1dfb2, type: 3}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
@@ -3051,8 +2899,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -30.02781, y: 51.42924}
m_SizeDelta: {x: 60.056, y: 42}
m_AnchoredPosition: {x: -50.136425, y: 53.8}
m_SizeDelta: {x: 94.3793, y: 42}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &79757123884187993
CanvasRenderer:
@@ -3082,8 +2930,8 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 7f3b1220317607e44bbb8f0e1998044a, type: 3}
m_Type: 1
m_Sprite: {fileID: -566881566719626471, guid: aee8077d170e84b44afd422763951685, type: 3}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
@@ -3232,7 +3080,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.8773585, g: 0.59571314, b: 0.09518514, a: 1}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -3283,8 +3131,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -77.114, y: 0}
m_SizeDelta: {x: 95.773, y: 30}
m_AnchoredPosition: {x: -81.71184, y: 0}
m_SizeDelta: {x: 104.9686, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &492154790085150397
CanvasRenderer:
@@ -3316,7 +3164,7 @@ MonoBehaviour:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: dfa10de3e2846524089b5073175cd151, type: 3}
m_FontSize: 22
m_FontSize: 26
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 1
@@ -3514,7 +3362,7 @@ RectTransform:
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 35, y: 25}
m_SizeDelta: {x: 30, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &6314649216240834215
CanvasRenderer:
@@ -3663,8 +3511,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -77.114, y: 0}
m_SizeDelta: {x: 95.773, y: 30}
m_AnchoredPosition: {x: -81.71184, y: 0}
m_SizeDelta: {x: 104.9686, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1247215313835421715
CanvasRenderer:
@@ -3696,7 +3544,7 @@ MonoBehaviour:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: dfa10de3e2846524089b5073175cd151, type: 3}
m_FontSize: 22
m_FontSize: 26
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 1
@@ -151,14 +151,14 @@ public class loadSettlementTeamPrefab : MonoBehaviour
}
}
// Populate profile and name: prefer AllyHero_SO.ally_hero_squareProfile when available
// Populate profile and name: prefer AllyHero_SO.ally_hero_settleDisplay when available
bool setProfile = false;
if (resolvedHeroSO != null && card.ally_profile != null)
{
if (resolvedHeroSO.ally_hero_squareProfile != null)
if (resolvedHeroSO.ally_hero_settleDisplay != null)
{
card.ally_profile.sprite = resolvedHeroSO.ally_hero_squareProfile;
card.ally_profile.sprite = resolvedHeroSO.ally_hero_settleDisplay;
card.ally_profile.color = new Color(1f, 1f, 1f, 1f);
if (card.ally_name != null)
{
@@ -16,6 +16,8 @@ class UI_Panel_Character : MonoBehaviour
{
[SerializeField] float anim_Time = 0.5f;
[SerializeField, Range(0f, 1f)] float illustrationFadeStartAlpha = 0.25f;
[SerializeField] float illustrationBgTargetXOffset = 0f;
[SerializeField] float illustrationBgTargetYOffset = 0f;
float Anim_Speed => 1f / anim_Time;
[SerializeField] List<Animator> ui_Anim;
@@ -287,6 +289,8 @@ class UI_Panel_Character : MonoBehaviour
Vector2 targetPosBG = hasCachedPositions
? originalIllustrationBGPos
: Image_Character_Illustration_BG.rectTransform.anchoredPosition;
targetPosBG.x += illustrationBgTargetXOffset;
targetPosBG.y += illustrationBgTargetYOffset;
Image_Character_Illustration_BG.sprite = data.ally_hero_HD_image;
float startXBG = targetPosBG.x - 200;
+141 -12
View File
@@ -9,11 +9,18 @@ using Bansonic;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UI;
#if UNITY_EDITOR
using UnityEditor;
#endif
public class UI_Panel_Mail : MonoBehaviour
{
public static event Action OnUnreadStateChanged;
private static readonly Dictionary<string, Sprite> ServerMailImageCache = new Dictionary<string, Sprite>(StringComparer.Ordinal);
private static readonly HashSet<string> ServerMailImageLoadsInFlight = new HashSet<string>(StringComparer.Ordinal);
#if UNITY_EDITOR
private const string DefaultCoinRewardIconPath = "Assets/__UI_NEW/hallway/icon.90.90/icon_coin.png";
private const string DefaultMaterialRewardIconPath = "Assets/__UI_NEW/hallway/icon.90.90/icon_piece.png";
#endif
//1234567890
[SerializeField] float anim_Time = 0.5f;
@@ -31,6 +38,10 @@ public class UI_Panel_Mail : MonoBehaviour
[Header("rewards objects")]
public GameObject rewardSlotPrefab;
[SerializeField] Transform content_Reward_Slot;
[Header("reward fallback icons")]
[SerializeField] private Sprite playerExpRewardIcon;
[SerializeField] private Sprite coinRewardIcon;
[SerializeField] private Sprite materialRewardIcon;
[Header("texts")]
public Text thisMail_title;
@@ -47,11 +58,12 @@ public class UI_Panel_Mail : MonoBehaviour
[Header("buttons")]
public Button receive_this_mail;
public Button backButton;
private void Update()
{
if (Input.GetKeyDown(KeyCode.Escape))
{
gameObject.SetActive(false);
CloseMailPanel();
}
}
void Start()
@@ -60,12 +72,14 @@ public class UI_Panel_Mail : MonoBehaviour
{
item.speed = Anim_Speed;
}
MailRewardGrantService.ConfigureBasicRewardIcons(playerExpRewardIcon, coinRewardIcon, materialRewardIcon);
BindTopLevelButtons();
InitializeMailSource();
}
private void OnEnable()
{
MailRewardGrantService.ConfigureBasicRewardIcons(playerExpRewardIcon, coinRewardIcon, materialRewardIcon);
InitializeMailSource();
}
@@ -74,6 +88,48 @@ public class UI_Panel_Mail : MonoBehaviour
UnsubscribeMailService();
}
#if UNITY_EDITOR
private void OnValidate()
{
TryAssignDefaultRewardIcons();
}
private void Reset()
{
TryAssignDefaultRewardIcons();
}
private void TryAssignDefaultRewardIcons()
{
bool changed = false;
if (coinRewardIcon == null)
{
Sprite coinIcon = AssetDatabase.LoadAssetAtPath<Sprite>(DefaultCoinRewardIconPath);
if (coinIcon != null)
{
coinRewardIcon = coinIcon;
changed = true;
}
}
if (materialRewardIcon == null)
{
Sprite materialIcon = AssetDatabase.LoadAssetAtPath<Sprite>(DefaultMaterialRewardIconPath);
if (materialIcon != null)
{
materialRewardIcon = materialIcon;
changed = true;
}
}
if (changed && !Application.isPlaying)
{
EditorUtility.SetDirty(this);
}
}
#endif
void InitializeMailSource()
{
if (!isActiveAndEnabled)
@@ -445,14 +501,18 @@ public class UI_Panel_Mail : MonoBehaviour
void BindTopLevelButtons()
{
if (receive_this_mail == null)
if (receive_this_mail != null)
{
return;
receive_this_mail.onClick.RemoveListener(HandleReceiveCurrentMailClicked);
receive_this_mail.onClick.AddListener(HandleReceiveCurrentMailClicked);
RefreshReceiveButton();
}
receive_this_mail.onClick.RemoveListener(HandleReceiveCurrentMailClicked);
receive_this_mail.onClick.AddListener(HandleReceiveCurrentMailClicked);
RefreshReceiveButton();
if (backButton != null)
{
backButton.onClick.RemoveListener(HandleBackButtonClicked);
backButton.onClick.AddListener(HandleBackButtonClicked);
}
}
void HandleReceiveCurrentMailClicked()
@@ -465,6 +525,16 @@ public class UI_Panel_Mail : MonoBehaviour
OnMailReceived(selectedSlot, selectedMailData);
}
void HandleBackButtonClicked()
{
CloseMailPanel();
}
void CloseMailPanel()
{
gameObject.SetActive(false);
}
void RefreshReceiveButton()
{
if (receive_this_mail == null)
@@ -1282,6 +1352,27 @@ public static class MailRewardGrantService
private static readonly Dictionary<int, storeItemSO> StoreItemsById = new Dictionary<int, storeItemSO>();
private static readonly Dictionary<string, storeItemSO> StoreItemsByName = new Dictionary<string, storeItemSO>(StringComparer.OrdinalIgnoreCase);
private static bool _assetsLoaded;
private static Sprite _playerExpRewardIcon;
private static Sprite _coinRewardIcon;
private static Sprite _materialRewardIcon;
public static void ConfigureBasicRewardIcons(Sprite playerExpIcon, Sprite coinIcon, Sprite materialIcon)
{
if (playerExpIcon != null)
{
_playerExpRewardIcon = playerExpIcon;
}
if (coinIcon != null)
{
_coinRewardIcon = coinIcon;
}
if (materialIcon != null)
{
_materialRewardIcon = materialIcon;
}
}
public static void PopulateRewardDisplay(mail_so.rewardItem reward)
{
@@ -1296,15 +1387,15 @@ public static class MailRewardGrantService
return;
}
if (string.IsNullOrWhiteSpace(reward.rewardName))
if (ShouldOverrideRewardDisplayName(reward, resolved))
{
reward.rewardName = resolved.DisplayName;
}
if (string.IsNullOrWhiteSpace(reward.reward_description))
if (ShouldOverrideRewardDescription(reward, resolved))
{
reward.reward_description = resolved.Description;
}
if (reward.reward_image == null)
if (resolved.Icon != null)
{
reward.reward_image = resolved.Icon;
}
@@ -1585,13 +1676,13 @@ public static class MailRewardGrantService
switch (reward.reward_Type)
{
case mail_so.reward_type.exp_user:
resolved = CreateBasicResolved(ResolvedKind.PlayerExp, amount, rewardName, reward.reward_image, reward.reward_description, reward, "\u73a9\u5bb6\u7ecf\u9a8c");
resolved = CreateBasicResolved(ResolvedKind.PlayerExp, amount, rewardName, reward.reward_image != null ? reward.reward_image : _playerExpRewardIcon, reward.reward_description, reward, "\u73a9\u5bb6\u7ecf\u9a8c");
return true;
case mail_so.reward_type.money:
resolved = CreateBasicResolved(ResolvedKind.Coins, amount, rewardName, reward.reward_image, reward.reward_description, reward, "\u91d1\u5e01");
resolved = CreateBasicResolved(ResolvedKind.Coins, amount, rewardName, reward.reward_image != null ? reward.reward_image : _coinRewardIcon, reward.reward_description, reward, "\u91d1\u5e01");
return true;
case mail_so.reward_type.metarial:
resolved = CreateBasicResolved(ResolvedKind.Material, amount, rewardName, reward.reward_image, reward.reward_description, reward, "\u8bb0\u5fc6\u788e\u7247");
resolved = CreateBasicResolved(ResolvedKind.Material, amount, rewardName, reward.reward_image != null ? reward.reward_image : _materialRewardIcon, reward.reward_description, reward, "\u8bb0\u5fc6\u788e\u7247");
return true;
case mail_so.reward_type.expBottles_allies:
if (TryResolveExpBottle(reward, rewardKey, rewardName, amount, out resolved))
@@ -1644,6 +1735,44 @@ public static class MailRewardGrantService
};
}
private static bool ShouldOverrideRewardDisplayName(mail_so.rewardItem reward, ResolvedReward resolved)
{
if (reward == null || resolved == null)
{
return false;
}
switch (resolved.Kind)
{
case ResolvedKind.ExpBottle:
case ResolvedKind.GrowthMaterial:
case ResolvedKind.EquipmentConsumable:
case ResolvedKind.StoreItem:
return !string.IsNullOrWhiteSpace(resolved.DisplayName);
default:
return string.IsNullOrWhiteSpace(reward.rewardName) && !string.IsNullOrWhiteSpace(resolved.DisplayName);
}
}
private static bool ShouldOverrideRewardDescription(mail_so.rewardItem reward, ResolvedReward resolved)
{
if (reward == null || resolved == null)
{
return false;
}
switch (resolved.Kind)
{
case ResolvedKind.ExpBottle:
case ResolvedKind.GrowthMaterial:
case ResolvedKind.EquipmentConsumable:
case ResolvedKind.StoreItem:
return !string.IsNullOrWhiteSpace(resolved.Description);
default:
return string.IsNullOrWhiteSpace(reward.reward_description) && !string.IsNullOrWhiteSpace(resolved.Description);
}
}
private static bool TryResolveByRewardKey(mail_so.rewardItem reward, string rewardKey, string rewardName, int amount, out ResolvedReward resolved)
{
if (TryResolveExpBottle(reward, rewardKey, rewardName, amount, out resolved))
@@ -1502,6 +1502,7 @@ RectTransform:
- {fileID: 4507029859229390628}
- {fileID: 3166511394318740470}
- {fileID: 4201371652593379150}
- {fileID: 7304390476110563193}
m_Father: {fileID: 4008714089197239054}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
@@ -3837,6 +3838,41 @@ MonoBehaviour:
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!1 &3000813276228866063
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7304390476110563193}
m_Layer: 5
m_Name: putSettingsPrefabHere
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7304390476110563193
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3000813276228866063}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5634981615641515618}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: -504.3711}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &3004460849469971855
GameObject:
m_ObjectHideFlags: 0
@@ -4101,7 +4137,7 @@ RectTransform:
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -28, y: 1.3}
m_SizeDelta: {x: 53, y: 47}
m_SizeDelta: {x: 90, y: 90}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &5184938500494465621
CanvasRenderer:
@@ -4125,13 +4161,13 @@ MonoBehaviour:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 8139719423079072813, guid: 16f0765cc15fce04f828ecd44d1dbf91, type: 3}
m_Sprite: {fileID: 21300000, guid: 16f0765cc15fce04f828ecd44d1dbf91, type: 3}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
@@ -5862,7 +5898,9 @@ MonoBehaviour:
playerCoins_legacy: {fileID: 7376147910533281946}
player_mmrFragment: {fileID: 1718535194614905540}
player_rks: {fileID: 3469340257331446344}
rksProgressImage: {fileID: 4240146692151177402}
putPrefabsHere: {fileID: 6773918330215714056}
putSettingsPrefabHere: {fileID: 3000813276228866063}
settings_launch: {fileID: 865676738076373517}
userInfo_launch: {fileID: 1502937460034745443}
store_launch: {fileID: 5472029888669800656}
@@ -6106,7 +6144,7 @@ MonoBehaviour:
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 0
m_FillAmount: 0.457
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
@@ -8854,7 +8892,7 @@ RectTransform:
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -0.000041962, y: -0.000047684}
m_SizeDelta: {x: 52, y: 49}
m_SizeDelta: {x: 90, y: 90}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &5175879897170093321
CanvasRenderer:
@@ -8878,13 +8916,13 @@ MonoBehaviour:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 6309817061271938796, guid: 6fd4dd33d986d824d95f7b01579acc4d, type: 3}
m_Sprite: {fileID: 21300000, guid: 6fd4dd33d986d824d95f7b01579acc4d, type: 3}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
@@ -23,8 +23,10 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
public Text playerCoins_legacy;
public Text player_mmrFragment;
public Text player_rks;
public Image rksProgressImage;
[Header("put prefabs here")]
public GameObject putPrefabsHere;
public GameObject putSettingsPrefabHere;
[Header("son buttons")]
public Button settings_launch;
public Button userInfo_launch;
@@ -154,7 +156,9 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
PlayerRksService.OnRksChanged += HandleRksChanged;
UpdatePlayerCoinsLegacyText(PlayerEconomyLedger.EnsureInstance().GetCoins());
UpdatePlayerMmrFragmentText(PlayerEconomyLedger.EnsureInstance().GetMaterial());
UpdatePlayerRksText(PlayerRksService.GetBestOverallRks(player_SO));
float currentRks = PlayerRksService.GetBestOverallRks(player_SO);
UpdatePlayerRksText(currentRks);
UpdateRksProgressImage(currentRks);
if (button_Music == null)
{
@@ -223,7 +227,7 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
}
// Pre-instantiate settings prefab and keep it inactive, ensuring only one exists
if (settings_prefab != null && putPrefabsHere != null)
if (settings_prefab != null && GetSettingsParentTransform() != null)
{
// Try to find existing one in scene first
if (settingsInstance == null)
@@ -237,7 +241,8 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
if (settingsInstance == null)
{
settingsInstance = Instantiate(settings_prefab, putPrefabsHere.transform);
settingsInstance = Instantiate(settings_prefab, GetSettingsParentTransform());
AttachManagedPanelVisibilityRelay(settingsInstance, true);
EnsureSettingsEnterAnimator(settingsInstance);
EnsureSettingsLastSibling();
settingsInstance.SetActive(false);
@@ -261,8 +266,9 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
}
else
{
// ensure it's parented correctly under putPrefabsHere
settingsInstance.transform.SetParent(putPrefabsHere.transform, false);
// ensure it's parented correctly beside putPrefabsHere
settingsInstance.transform.SetParent(GetSettingsParentTransform(), false);
AttachManagedPanelVisibilityRelay(settingsInstance, true);
EnsureSettingsLastSibling();
EnsureSettingsEnterAnimator(settingsInstance);
settingsInstance.SetActive(false);
@@ -283,60 +289,106 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
if (userInfoInstance == null)
{
userInfoInstance = Instantiate(userInfo_prefab, putPrefabsHere.transform);
AttachManagedPanelVisibilityRelay(userInfoInstance, false);
userInfoInstance.SetActive(false);
}
else
{
userInfoInstance.transform.SetParent(putPrefabsHere.transform, false);
AttachManagedPanelVisibilityRelay(userInfoInstance, false);
userInfoInstance.SetActive(false);
}
}
lastSettingsVisibilityState = IsSettingsPanelVisible;
lastOverlayPanelsVisibilityState = AreOverlayPanelsVisible();
CurrentOverlayPanelsVisible = lastOverlayPanelsVisibilityState;
SyncManagedPanelVisibilityState(true);
}
private void FixedUpdate()
{
if(Input.GetKeyDown(KeyCode.Escape))
{
//ToggleSettingsPrefab();
}
}
private void Update()
private void OnTransformChildrenChanged()
{
if (!IsUiUiSceneActive())
{
if (CurrentSettingsVisible)
{
BroadcastSettingsVisibility(false);
}
if (CurrentOverlayPanelsVisible)
{
lastOverlayPanelsVisibilityState = false;
CurrentOverlayPanelsVisible = false;
GlobalOverlayPanelVisibilityChanged?.Invoke(false);
}
return;
}
bool visible = IsSettingsPanelVisible;
if (visible != lastSettingsVisibilityState)
topNavigationGeometryDirty = true;
EnsureTopNavigationFront();
}
internal void NotifyManagedPanelVisibilityChanged(bool includesSettingsPanel)
{
if (!ReferenceEquals(activeInstance, this))
{
BroadcastSettingsVisibility(visible);
return;
}
bool overlayVisible = AreOverlayPanelsVisible();
if (overlayVisible != lastOverlayPanelsVisibilityState)
if (!IsUiUiSceneActive())
{
lastOverlayPanelsVisibilityState = overlayVisible;
CurrentOverlayPanelsVisible = overlayVisible;
GlobalOverlayPanelVisibilityChanged?.Invoke(overlayVisible);
SetSettingsVisibilityState(false);
SetOverlayPanelsVisibilityState(false);
return;
}
if (includesSettingsPanel)
{
SetSettingsVisibilityState(IsSettingsPanelVisible);
}
SetOverlayPanelsVisibilityState(AreOverlayPanelsVisible());
}
private void SyncManagedPanelVisibilityState(bool forceNotify)
{
if (!IsUiUiSceneActive())
{
SetSettingsVisibilityState(false, forceNotify);
SetOverlayPanelsVisibilityState(false, forceNotify);
return;
}
SetSettingsVisibilityState(IsSettingsPanelVisible, forceNotify);
SetOverlayPanelsVisibilityState(AreOverlayPanelsVisible(), forceNotify);
}
private void AttachManagedPanelVisibilityRelay(GameObject panel, bool includesSettingsPanel)
{
if (panel == null)
{
return;
}
btmandtopPanelVisibilityRelay relay = panel.GetComponent<btmandtopPanelVisibilityRelay>();
if (relay == null)
{
relay = panel.AddComponent<btmandtopPanelVisibilityRelay>();
}
relay.Initialize(this, includesSettingsPanel);
}
private void SetSettingsVisibilityState(bool visible, bool forceNotify = false)
{
lastSettingsVisibilityState = visible;
if (!forceNotify && CurrentSettingsVisible == visible)
{
return;
}
CurrentSettingsVisible = visible;
SettingsVisibilityChanged?.Invoke(visible);
GlobalSettingsVisibilityChanged?.Invoke(visible);
}
private void SetOverlayPanelsVisibilityState(bool visible, bool forceNotify = false)
{
lastOverlayPanelsVisibilityState = visible;
if (!forceNotify && CurrentOverlayPanelsVisible == visible)
{
return;
}
CurrentOverlayPanelsVisible = visible;
GlobalOverlayPanelVisibilityChanged?.Invoke(visible);
topNavigationGeometryDirty = true;
EnsureTopNavigationFront();
}
@@ -554,6 +606,7 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
private void HandleRksChanged(float rksAmount)
{
UpdatePlayerRksText(rksAmount);
UpdateRksProgressImage(rksAmount);
}
private void InitializeMailRedPot()
@@ -644,6 +697,16 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
}
}
private void UpdateRksProgressImage(float rksAmount)
{
if (rksProgressImage == null)
{
return;
}
rksProgressImage.fillAmount = Mathf.Clamp01(Mathf.InverseLerp(0f, 100f, rksAmount));
}
private Text FindLegacyTextByLiteral(string literal)
{
if (string.IsNullOrEmpty(literal))
@@ -802,12 +865,13 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
private void ToggleSettingsPrefab()
{
if (settings_prefab == null || putPrefabsHere == null) return;
if (settings_prefab == null || GetSettingsParentTransform() == null) return;
// If we don't have an instance (it may have been destroyed), instantiate one
if (settingsInstance == null)
{
settingsInstance = Instantiate(settings_prefab, putPrefabsHere.transform);
settingsInstance = Instantiate(settings_prefab, GetSettingsParentTransform());
AttachManagedPanelVisibilityRelay(settingsInstance, true);
EnsureSettingsEnterAnimator(settingsInstance);
EnsureSettingsLastSibling();
@@ -830,22 +894,20 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
settingsInstance.SetActive(true);
EnsureSettingsLastSibling();
BroadcastSettingsVisibility(true);
BroadcastOverlayPanelsVisibility();
RegisterManagedPanel(settingsInstance, () =>
{
if (settingsInstance != null)
{
settingsInstance.SetActive(false);
BroadcastSettingsVisibility(false);
BroadcastOverlayPanelsVisibility();
}
});
NotifyManagedPanelVisibilityChanged(true);
return;
}
AttachManagedPanelVisibilityRelay(settingsInstance, true);
EnsureSettingsEnterAnimator(settingsInstance);
settingsInstance.transform.SetParent(putPrefabsHere.transform, false);
settingsInstance.transform.SetParent(GetSettingsParentTransform(), false);
EnsureSettingsLastSibling();
// Toggle active state
@@ -859,13 +921,10 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
if (settingsInstance != null)
{
settingsInstance.SetActive(false);
BroadcastSettingsVisibility(false);
BroadcastOverlayPanelsVisibility();
}
});
}
BroadcastSettingsVisibility(settingsInstance.activeSelf);
BroadcastOverlayPanelsVisibility();
NotifyManagedPanelVisibilityChanged(true);
}
public bool IsSettingsPanelVisible
@@ -889,16 +948,7 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
private void BroadcastSettingsVisibility(bool visible)
{
lastSettingsVisibilityState = visible;
if (CurrentSettingsVisible == visible)
{
SettingsVisibilityChanged?.Invoke(visible);
return;
}
CurrentSettingsVisible = visible;
SettingsVisibilityChanged?.Invoke(visible);
GlobalSettingsVisibilityChanged?.Invoke(visible);
SetSettingsVisibilityState(visible);
}
private bool IsUiUiSceneActive()
@@ -921,12 +971,7 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
private void BroadcastOverlayPanelsVisibility()
{
bool visible = AreOverlayPanelsVisible();
lastOverlayPanelsVisibilityState = visible;
CurrentOverlayPanelsVisible = visible;
GlobalOverlayPanelVisibilityChanged?.Invoke(visible);
topNavigationGeometryDirty = true;
EnsureTopNavigationFront();
SetOverlayPanelsVisibilityState(AreOverlayPanelsVisible());
}
private void RegisterManagedPanel(GameObject panel, System.Action closeAction)
@@ -1071,6 +1116,7 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
instance.SetActive(true);
}
AttachManagedPanelVisibilityRelay(instance, false);
TryAssignCanvasCamera(instance);
BroadcastOverlayPanelsVisibility();
EnsureTopNavigationFront();
@@ -1079,15 +1125,31 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
private void EnsureSettingsLastSibling()
{
if (settingsInstance == null || putPrefabsHere == null)
if (settingsInstance == null || GetSettingsParentTransform() == null)
{
return;
}
settingsInstance.transform.SetParent(putPrefabsHere.transform, false);
settingsInstance.transform.SetParent(GetSettingsParentTransform(), false);
settingsInstance.transform.SetAsLastSibling();
}
private Transform GetSettingsParentTransform()
{
if (putSettingsPrefabHere != null)
{
return putSettingsPrefabHere.transform;
}
if (putPrefabsHere == null)
{
return null;
}
Transform siblingParent = putPrefabsHere.transform.parent;
return siblingParent != null ? siblingParent : putPrefabsHere.transform;
}
private void CloseInfoPanels(GameObject keep)
{
CloseInstance(ref userInfoInstance, keep);
@@ -1147,7 +1209,7 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
{
if (activeInstance != null)
{
activeInstance.BroadcastOverlayPanelsVisibility();
activeInstance.SyncManagedPanelVisibilityState(false);
return;
}
@@ -1387,6 +1449,7 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
{
activeInstance.topNavigationGeometryDirty = true;
activeInstance.EnsureTopNavigationFront();
activeInstance.SyncManagedPanelVisibilityState(true);
activeInstance.ScheduleDeferredUiRefresh();
}
}
@@ -1587,3 +1650,31 @@ public class btmandtopController : MonoBehaviour, ICancelHandler
}
}
}
internal sealed class btmandtopPanelVisibilityRelay : MonoBehaviour
{
private btmandtopController owner;
private bool includesSettingsPanel;
public void Initialize(btmandtopController controller, bool isSettingsPanel)
{
owner = controller;
includesSettingsPanel = isSettingsPanel;
}
private void OnEnable()
{
if (owner != null)
{
owner.NotifyManagedPanelVisibilityChanged(includesSettingsPanel);
}
}
private void OnDisable()
{
if (owner != null)
{
owner.NotifyManagedPanelVisibilityChanged(includesSettingsPanel);
}
}
}
@@ -0,0 +1,177 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Explosion3DTestController : MonoBehaviour
{
[Header("Trigger")]
[SerializeField] private bool enableDebugKey = true;
[SerializeField] private KeyCode triggerKey = KeyCode.Alpha0;
[Header("Target")]
[SerializeField] private GameObject intactObject;
[SerializeField] private GameObject fracturedPrefab;
[SerializeField] private Transform spawnRoot;
[SerializeField] private bool disableIntactObjectOnExplode = true;
[SerializeField] private bool destroyPreviousFractureInstance = true;
[Header("Explosion Force")]
[SerializeField] private float explosionForce = 2.5f;
[SerializeField] private float explosionRadius = 1.25f;
[SerializeField] private float upwardModifier = 0.15f;
[SerializeField] private float randomForceMultiplier = 0.2f;
[SerializeField] private float randomTorque = 8f;
[Header("Containment")]
[SerializeField] private bool keepFragmentsClose = true;
[SerializeField] private float maxFragmentSpeed = 1.2f;
[SerializeField] private float fragmentDrag = 4f;
[SerializeField] private float fragmentAngularDrag = 6f;
[SerializeField] private bool disableFragmentGravity = false;
[Header("Lifecycle")]
[SerializeField] private bool autoCleanupFragments = false;
[SerializeField] private float cleanupDelay = 5f;
private GameObject spawnedFractureRoot;
private bool exploded;
private void Update()
{
if (!enableDebugKey)
return;
if (Input.GetKeyDown(triggerKey))
{
TriggerExplosion();
}
}
[ContextMenu("Trigger Explosion")]
public void TriggerExplosion()
{
if (exploded)
return;
if (fracturedPrefab == null)
{
Debug.LogWarning("[Explosion3DTest] Missing fracturedPrefab.");
return;
}
Transform sourceTransform = intactObject != null ? intactObject.transform : transform;
Transform parent = spawnRoot != null ? spawnRoot : sourceTransform.parent;
if (destroyPreviousFractureInstance && spawnedFractureRoot != null)
{
Destroy(spawnedFractureRoot);
spawnedFractureRoot = null;
}
spawnedFractureRoot = Instantiate(
fracturedPrefab,
sourceTransform.position,
sourceTransform.rotation,
parent);
spawnedFractureRoot.name = fracturedPrefab.name + "_Exploded";
if (disableIntactObjectOnExplode && intactObject != null)
{
intactObject.SetActive(false);
}
ApplyExplosionToFragments(spawnedFractureRoot, sourceTransform.position);
exploded = true;
if (autoCleanupFragments && cleanupDelay > 0f)
{
StartCoroutine(CleanupAfterDelay(cleanupDelay));
}
}
[ContextMenu("Reset Explosion")]
public void ResetExplosion()
{
exploded = false;
if (spawnedFractureRoot != null)
{
Destroy(spawnedFractureRoot);
spawnedFractureRoot = null;
}
if (intactObject != null)
{
intactObject.SetActive(true);
}
}
private void ApplyExplosionToFragments(GameObject fractureRoot, Vector3 explosionCenter)
{
if (fractureRoot == null)
return;
Rigidbody[] rigidbodies = fractureRoot.GetComponentsInChildren<Rigidbody>(true);
for (int i = 0; i < rigidbodies.Length; i++)
{
Rigidbody rb = rigidbodies[i];
if (rb == null)
continue;
rb.isKinematic = false;
rb.useGravity = !disableFragmentGravity;
rb.linearDamping = fragmentDrag;
rb.angularDamping = fragmentAngularDrag;
Vector3 fragmentCenter = rb.worldCenterOfMass;
Vector3 direction = fragmentCenter - explosionCenter;
if (direction.sqrMagnitude < 0.0001f)
{
direction = Random.onUnitSphere;
}
float randomScale = 1f + Random.Range(-randomForceMultiplier, randomForceMultiplier);
Vector3 force = direction.normalized * Mathf.Max(0f, explosionForce * randomScale);
force += Vector3.up * upwardModifier;
rb.linearVelocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
rb.AddForce(force, ForceMode.Impulse);
if (randomTorque > 0f)
{
Vector3 torqueAxis = Random.onUnitSphere * randomTorque;
rb.AddTorque(torqueAxis, ForceMode.Impulse);
}
if (keepFragmentsClose)
{
LimitFragmentVelocity(rb);
}
}
}
private void LimitFragmentVelocity(Rigidbody rb)
{
if (rb == null)
return;
float maxSpeed = Mathf.Max(0.01f, maxFragmentSpeed);
if (rb.linearVelocity.sqrMagnitude > maxSpeed * maxSpeed)
{
rb.linearVelocity = rb.linearVelocity.normalized * maxSpeed;
}
}
private IEnumerator CleanupAfterDelay(float delay)
{
yield return new WaitForSeconds(delay);
if (spawnedFractureRoot != null)
{
Destroy(spawnedFractureRoot);
spawnedFractureRoot = null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4049e381a6fd1314abb021f18344b744
@@ -0,0 +1,269 @@
using System.Collections.Generic;
using UnityEngine;
public class RuntimeVoxelExplosion3DTest : MonoBehaviour
{
[Header("Trigger")]
[SerializeField] private bool enableDebugKey = true;
[SerializeField] private KeyCode triggerKey = KeyCode.Alpha0;
[SerializeField] private bool supportKeypad0 = true;
[SerializeField] private bool debugLogs = true;
[Header("Target")]
[SerializeField] private GameObject targetObject;
[SerializeField] private Transform chunkParent;
[SerializeField] private bool disableTargetOnExplode = true;
[SerializeField] private bool destroyPreviousChunks = true;
[Header("Voxel Cut")]
[SerializeField] private int chunksX = 4;
[SerializeField] private int chunksY = 4;
[SerializeField] private int chunksZ = 4;
[SerializeField] private float chunkScaleMultiplier = 0.92f;
[SerializeField] private float occupancyPadding = 0.02f;
[SerializeField] private bool requireColliderOverlap = true;
[SerializeField] private int maxChunkCount = 128;
[Header("Explosion")]
[SerializeField] private float explosionForce = 1.2f;
[SerializeField] private float upwardForce = 0.08f;
[SerializeField] private float randomForceJitter = 0.12f;
[SerializeField] private float randomTorque = 5f;
[SerializeField] private float maxChunkSpeed = 0.9f;
[SerializeField] private float linearDamping = 5f;
[SerializeField] private float angularDamping = 7f;
[SerializeField] private bool disableGravity = false;
[Header("Cleanup")]
[SerializeField] private bool autoDestroyChunks = false;
[SerializeField] private float destroyDelay = 5f;
private readonly List<GameObject> spawnedChunks = new List<GameObject>();
private bool exploded;
private void Update()
{
if (!enableDebugKey)
return;
bool pressed = Input.GetKeyDown(triggerKey);
if (!pressed && supportKeypad0)
{
pressed = Input.GetKeyDown(KeyCode.Keypad0);
}
if (pressed)
{
if (debugLogs)
{
Debug.Log("[RuntimeVoxelExplosion3DTest] Explosion key pressed.");
}
TriggerExplosion();
}
}
[ContextMenu("Trigger Runtime Voxel Explosion")]
public void TriggerExplosion()
{
if (exploded)
{
if (debugLogs)
{
Debug.Log("[RuntimeVoxelExplosion3DTest] Already exploded, ignoring trigger.");
}
return;
}
GameObject target = targetObject != null ? targetObject : gameObject;
if (targetObject == null && debugLogs)
{
Debug.Log("[RuntimeVoxelExplosion3DTest] targetObject not assigned, using current GameObject.");
}
if (target == null)
{
Debug.LogWarning("[RuntimeVoxelExplosion3DTest] Missing targetObject.");
return;
}
Renderer[] renderers = target.GetComponentsInChildren<Renderer>(true);
if (renderers == null || renderers.Length == 0)
{
Debug.LogWarning("[RuntimeVoxelExplosion3DTest] Target has no renderer.");
return;
}
if (destroyPreviousChunks)
{
ClearSpawnedChunks();
}
Bounds bounds = CalculateCombinedBounds(renderers);
Collider[] colliders = target.GetComponentsInChildren<Collider>(true);
Material chunkMaterial = ResolveChunkMaterial(renderers);
int safeX = Mathf.Max(1, chunksX);
int safeY = Mathf.Max(1, chunksY);
int safeZ = Mathf.Max(1, chunksZ);
Vector3 cellSize = new Vector3(
bounds.size.x / safeX,
bounds.size.y / safeY,
bounds.size.z / safeZ);
int spawnedCount = 0;
for (int x = 0; x < safeX; x++)
{
for (int y = 0; y < safeY; y++)
{
for (int z = 0; z < safeZ; z++)
{
if (spawnedCount >= Mathf.Max(1, maxChunkCount))
break;
Vector3 center = new Vector3(
bounds.min.x + cellSize.x * (x + 0.5f),
bounds.min.y + cellSize.y * (y + 0.5f),
bounds.min.z + cellSize.z * (z + 0.5f));
if (requireColliderOverlap && colliders.Length > 0 && !CellOverlapsTarget(center, cellSize, colliders))
continue;
GameObject chunk = GameObject.CreatePrimitive(PrimitiveType.Cube);
chunk.name = $"RuntimeChunk_{x}_{y}_{z}";
chunk.transform.SetParent(chunkParent != null ? chunkParent : null, true);
chunk.transform.position = center;
chunk.transform.rotation = target.transform.rotation;
chunk.transform.localScale = Vector3.Scale(cellSize, Vector3.one * Mathf.Clamp(chunkScaleMultiplier, 0.01f, 1f));
Renderer chunkRenderer = chunk.GetComponent<Renderer>();
if (chunkRenderer != null && chunkMaterial != null)
{
chunkRenderer.sharedMaterial = chunkMaterial;
}
Rigidbody rb = chunk.AddComponent<Rigidbody>();
rb.mass = 0.08f;
rb.linearDamping = linearDamping;
rb.angularDamping = angularDamping;
rb.useGravity = !disableGravity;
ApplyChunkImpulse(rb, bounds.center);
spawnedChunks.Add(chunk);
spawnedCount++;
}
}
}
if (disableTargetOnExplode)
{
target.SetActive(false);
}
if (debugLogs)
{
Debug.Log($"[RuntimeVoxelExplosion3DTest] Spawned {spawnedCount} runtime chunks.");
}
exploded = true;
if (autoDestroyChunks && destroyDelay > 0f)
{
Invoke(nameof(ClearSpawnedChunks), destroyDelay);
}
}
[ContextMenu("Reset Runtime Voxel Explosion")]
public void ResetExplosion()
{
CancelInvoke(nameof(ClearSpawnedChunks));
ClearSpawnedChunks();
exploded = false;
if (targetObject != null)
{
targetObject.SetActive(true);
}
}
private Bounds CalculateCombinedBounds(Renderer[] renderers)
{
Bounds bounds = renderers[0].bounds;
for (int i = 1; i < renderers.Length; i++)
{
bounds.Encapsulate(renderers[i].bounds);
}
return bounds;
}
private Material ResolveChunkMaterial(Renderer[] renderers)
{
for (int i = 0; i < renderers.Length; i++)
{
if (renderers[i] != null && renderers[i].sharedMaterial != null)
return renderers[i].sharedMaterial;
}
return null;
}
private bool CellOverlapsTarget(Vector3 center, Vector3 cellSize, Collider[] colliders)
{
Bounds cellBounds = new Bounds(center, cellSize + Vector3.one * occupancyPadding);
for (int i = 0; i < colliders.Length; i++)
{
Collider col = colliders[i];
if (col == null || !col.enabled)
continue;
if (col.bounds.Intersects(cellBounds))
return true;
}
return false;
}
private void ApplyChunkImpulse(Rigidbody rb, Vector3 explosionCenter)
{
if (rb == null)
return;
Vector3 direction = rb.worldCenterOfMass - explosionCenter;
if (direction.sqrMagnitude < 0.0001f)
{
direction = Random.onUnitSphere;
}
float jitter = 1f + Random.Range(-randomForceJitter, randomForceJitter);
Vector3 impulse = direction.normalized * Mathf.Max(0f, explosionForce * jitter);
impulse += Vector3.up * upwardForce;
rb.linearVelocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
rb.AddForce(impulse, ForceMode.Impulse);
if (randomTorque > 0f)
{
rb.AddTorque(Random.onUnitSphere * randomTorque, ForceMode.Impulse);
}
if (rb.linearVelocity.sqrMagnitude > maxChunkSpeed * maxChunkSpeed)
{
rb.linearVelocity = rb.linearVelocity.normalized * maxChunkSpeed;
}
}
private void ClearSpawnedChunks()
{
for (int i = 0; i < spawnedChunks.Count; i++)
{
if (spawnedChunks[i] != null)
{
Destroy(spawnedChunks[i]);
}
}
spawnedChunks.Clear();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 619dd149d97efea45b1068f55dd7ed99
+6 -1
View File
@@ -1,4 +1,8 @@
using System.Diagnostics;
// NOTE: Windows-only. Launches an external Flutter .exe via Process.Start and
// controls its native window. Wrapped in UNITY_STANDALONE_WIN so the Windows
// build/logic is unchanged while Android compiles this class out.
#if UNITY_STANDALONE_WIN
using System.Diagnostics;
using System.IO;
using UnityEngine;
using System;
@@ -713,3 +717,4 @@ public class WebViewLauncher : MonoBehaviour
WebViewReady?.Invoke();
}
}
#endif
@@ -1,4 +1,8 @@
using UnityEngine;
// NOTE: Windows-only. Depends on WebViewWin32 (user32.dll) and WebViewLauncher,
// which are Windows-only. Wrapped in UNITY_STANDALONE_WIN so the Windows
// build/logic is unchanged while Android compiles this class out.
#if UNITY_STANDALONE_WIN
using UnityEngine;
using Bansonic;
public class WebViewPanelFollower : MonoBehaviour
@@ -154,3 +158,4 @@ public class WebViewPanelFollower : MonoBehaviour
return a.Left == b.Left && a.Top == b.Top && a.Right == b.Right && a.Bottom == b.Bottom;
}
}
#endif
+6 -1
View File
@@ -1,4 +1,8 @@
using System;
// NOTE: Windows-only. Uses user32.dll P/Invoke, which does not compile on Android.
// Wrapped in UNITY_STANDALONE_WIN so the Windows build/logic is unchanged while
// Android simply compiles this class out.
#if UNITY_STANDALONE_WIN
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using UnityEngine;
@@ -268,3 +272,4 @@ public static class WebViewWin32
}
}
}
#endif
@@ -1,3 +1,7 @@
// NOTE: Windows-only. Depends on WebViewLauncher (external Flutter .exe launcher),
// which is Windows-only. Wrapped in UNITY_STANDALONE_WIN so the Windows
// build/logic is unchanged while Android compiles this class out.
#if UNITY_STANDALONE_WIN
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
@@ -193,3 +197,4 @@ public class game_card_Prefab : MonoBehaviour, IPointerClickHandler
}
}
#endif
@@ -1,3 +1,7 @@
// NOTE: Windows-only. Depends on WebViewLauncher (external Flutter .exe / native
// window control), which is Windows-only. Wrapped in UNITY_STANDALONE_WIN so the
// Windows build/logic is unchanged while Android compiles this class out.
#if UNITY_STANDALONE_WIN
using UnityEngine;
using UnityEngine.UI;
using System.IO;
@@ -287,3 +291,4 @@ public class loadLittleGamesPrefab : MonoBehaviour
return raw.Replace("-", "\n").Replace("/", "\n").Replace("_", "\n");
}
}
#endif
@@ -12,26 +12,50 @@ public static class AllyHeroDeployLedgerStorage
private const string BackupFileName = ".ahd.bak";
private const string TempFileName = ".ahd.tmp";
private static string VaultDirectoryPath => Path.Combine(Application.persistentDataPath, VaultDirectoryName);
private static string VaultDirectoryPath => Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName);
private static string MainFilePath => Path.Combine(VaultDirectoryPath, MainFileName);
private static string BackupFilePath => Path.Combine(VaultDirectoryPath, BackupFileName);
private static string TempFilePath => Path.Combine(VaultDirectoryPath, TempFileName);
public static bool HasExistingSaveFile()
{
return File.Exists(MainFilePath) || File.Exists(BackupFilePath);
var mainCandidates = SaveIdentityUtility.GetVaultFilePathVariants(MainFileName);
for (int i = 0; i < mainCandidates.Count; i++)
{
if (File.Exists(mainCandidates[i]))
{
return true;
}
}
var backupCandidates = SaveIdentityUtility.GetVaultFilePathVariants(BackupFileName);
for (int i = 0; i < backupCandidates.Count; i++)
{
if (File.Exists(backupCandidates[i]))
{
return true;
}
}
return false;
}
public static bool TryLoad(out AllyHeroDeployLedgerPayload payload)
{
payload = CreateDefaultPayload();
if (TryReadPayload(MainFilePath, out payload))
if (TryReadPayloadFromVariants(MainFileName, out payload))
{
return true;
}
if (TryReadPayload(BackupFilePath, out payload))
if (TryReadPayloadFromVariants(BackupFileName, out payload))
{
TrySave(payload);
return true;
}
if (PlayerProgressBackupService.TryRestoreAllyHeroDeploy(out payload))
{
TrySave(payload);
return true;
@@ -60,6 +84,7 @@ public static class AllyHeroDeployLedgerStorage
File.Copy(TempFilePath, MainFilePath, true);
TryHidePath(MainFilePath);
File.Delete(TempFilePath);
PlayerProgressBackupService.SaveAllyHeroDeploy(payload);
return true;
}
catch (Exception ex)
@@ -96,17 +121,32 @@ public static class AllyHeroDeployLedgerStorage
return false;
}
string expectedSignature = ComputeSignature(envelope.payload);
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
if (!TryValidateSignature(envelope.payload, envelope.signature))
{
Debug.LogWarning("[AllyHeroDeployLedgerStorage] Save signature mismatch. Possible tampering detected.");
return false;
}
byte[] encryptedBytes = Convert.FromBase64String(envelope.payload);
byte[] plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
string payloadJson = Encoding.UTF8.GetString(plainBytes);
AllyHeroDeployLedgerPayload loadedPayload = JsonUtility.FromJson<AllyHeroDeployLedgerPayload>(payloadJson);
AllyHeroDeployLedgerPayload loadedPayload = null;
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int i = 0; i < identifierVariants.Count; i++)
{
try
{
byte[] plainBytes = XorTransform(encryptedBytes, BuildKeyBytes(identifierVariants[i]));
string payloadJson = Encoding.UTF8.GetString(plainBytes);
loadedPayload = JsonUtility.FromJson<AllyHeroDeployLedgerPayload>(payloadJson);
if (loadedPayload != null)
{
break;
}
}
catch
{
}
}
if (loadedPayload == null)
{
return false;
@@ -122,6 +162,21 @@ public static class AllyHeroDeployLedgerStorage
}
}
private static bool TryReadPayloadFromVariants(string fileName, out AllyHeroDeployLedgerPayload payload)
{
payload = CreateDefaultPayload();
var candidates = SaveIdentityUtility.GetVaultFilePathVariants(fileName);
for (int i = 0; i < candidates.Count; i++)
{
if (TryReadPayload(candidates[i], out payload))
{
return true;
}
}
return false;
}
private static string BuildEnvelopeJson(AllyHeroDeployLedgerPayload payload)
{
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
@@ -141,7 +196,7 @@ public static class AllyHeroDeployLedgerStorage
private static string ComputeSignature(string payloadBase64)
{
string signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
string signText = payloadBase64 + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
using (SHA256 sha = SHA256.Create())
{
byte[] bytes = Encoding.UTF8.GetBytes(signText);
@@ -151,14 +206,45 @@ public static class AllyHeroDeployLedgerStorage
}
private static byte[] BuildKeyBytes()
{
return BuildKeyBytes(SaveIdentityUtility.GetPrimaryApplicationIdentifier());
}
private static byte[] BuildKeyBytes(string applicationIdentifier)
{
using (SHA256 sha = SHA256.Create())
{
string seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
string seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
}
}
private static bool TryValidateSignature(string payloadBase64, string signature)
{
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int i = 0; i < identifierVariants.Count; i++)
{
string expectedSignature = ComputeSignature(payloadBase64, identifierVariants[i]);
if (string.Equals(expectedSignature, signature, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static string ComputeSignature(string payloadBase64, string applicationIdentifier)
{
string signText = payloadBase64 + "|" + applicationIdentifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
using (SHA256 sha = SHA256.Create())
{
byte[] bytes = Encoding.UTF8.GetBytes(signText);
byte[] hash = sha.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
}
private static byte[] XorTransform(byte[] source, byte[] key)
{
byte[] result = new byte[source.Length];
@@ -43,9 +43,13 @@ public static class DlcManifestService
AppendIfValid(result, Path.Combine(Application.persistentDataPath, RuntimeFolderName, ManifestFolderName));
AppendIfValid(result, Path.Combine(Application.persistentDataPath, RuntimeFolderName));
#if !UNITY_ANDROID
// 项目外(可执行文件同级目录)的 DLC 扫描仅在 PC 端有意义;
// 安卓无此目录概念,Application.dataPath 指向 APK,跳过以避免无效/异常路径。
string playerRoot = GetPlayerRootDirectory();
AppendIfValid(result, Path.Combine(playerRoot, RuntimeFolderName, ManifestFolderName));
AppendIfValid(result, Path.Combine(playerRoot, RuntimeFolderName));
#endif
return result.ToArray();
}
@@ -91,9 +91,13 @@ public static class DlcPackageArchiveService
AppendIfValid(result, Path.Combine(Application.persistentDataPath, RuntimeFolderName, PackageFolderName));
AppendIfValid(result, Path.Combine(Application.persistentDataPath, RuntimeFolderName));
#if !UNITY_ANDROID
// 项目外(可执行文件同级目录)的 DLC 扫描仅在 PC 端有意义;
// 安卓无此目录概念,Application.dataPath 指向 APK,跳过以避免无效/异常路径。
string playerRoot = GetPlayerRootDirectory();
AppendIfValid(result, Path.Combine(playerRoot, RuntimeFolderName, PackageFolderName));
AppendIfValid(result, Path.Combine(playerRoot, RuntimeFolderName));
#endif
return result.ToArray();
}
@@ -14,7 +14,7 @@ public static class DushMaterialLedgerStorage
private static string VaultDirectoryPath
{
get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); }
get { return Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName); }
}
private static string MainFilePath
@@ -36,12 +36,18 @@ public static class DushMaterialLedgerStorage
{
payload = CreateDefaultPayload();
if (TryReadPayload(MainFilePath, out payload))
if (TryReadPayloadFromVariants(MainFileName, out payload))
{
return true;
}
if (TryReadPayload(BackupFilePath, out payload))
if (TryReadPayloadFromVariants(BackupFileName, out payload))
{
TrySave(payload);
return true;
}
if (PlayerProgressBackupService.TryRestoreDushMaterial(out payload))
{
TrySave(payload);
return true;
@@ -70,6 +76,7 @@ public static class DushMaterialLedgerStorage
File.Copy(TempFilePath, MainFilePath, true);
TryHidePath(MainFilePath);
File.Delete(TempFilePath);
PlayerProgressBackupService.SaveDushMaterial(payload);
return true;
}
catch (Exception ex)
@@ -106,17 +113,32 @@ public static class DushMaterialLedgerStorage
return false;
}
var expectedSignature = ComputeSignature(envelope.payload);
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
if (!TryValidateSignature(envelope.payload, envelope.signature))
{
Debug.LogWarning("[DushMaterialLedgerStorage] Save signature mismatch. Possible tampering detected.");
return false;
}
var encryptedBytes = Convert.FromBase64String(envelope.payload);
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
var payloadJson = Encoding.UTF8.GetString(plainBytes);
var loadedPayload = JsonUtility.FromJson<DushMaterialLedgerPayload>(payloadJson);
DushMaterialLedgerPayload loadedPayload = null;
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int i = 0; i < identifierVariants.Count; i++)
{
try
{
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes(identifierVariants[i]));
var payloadJson = Encoding.UTF8.GetString(plainBytes);
loadedPayload = JsonUtility.FromJson<DushMaterialLedgerPayload>(payloadJson);
if (loadedPayload != null)
{
break;
}
}
catch
{
}
}
if (loadedPayload == null)
{
return false;
@@ -132,6 +154,21 @@ public static class DushMaterialLedgerStorage
}
}
private static bool TryReadPayloadFromVariants(string fileName, out DushMaterialLedgerPayload payload)
{
payload = CreateDefaultPayload();
var candidates = SaveIdentityUtility.GetVaultFilePathVariants(fileName);
for (int i = 0; i < candidates.Count; i++)
{
if (TryReadPayload(candidates[i], out payload))
{
return true;
}
}
return false;
}
private static string BuildEnvelopeJson(DushMaterialLedgerPayload payload)
{
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
@@ -151,7 +188,7 @@ public static class DushMaterialLedgerStorage
private static string ComputeSignature(string payloadBase64)
{
var signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
var signText = payloadBase64 + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
using (var sha = SHA256.Create())
{
var bytes = Encoding.UTF8.GetBytes(signText);
@@ -161,14 +198,45 @@ public static class DushMaterialLedgerStorage
}
private static byte[] BuildKeyBytes()
{
return BuildKeyBytes(SaveIdentityUtility.GetPrimaryApplicationIdentifier());
}
private static byte[] BuildKeyBytes(string applicationIdentifier)
{
using (var sha = SHA256.Create())
{
var seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
var seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
}
}
private static bool TryValidateSignature(string payloadBase64, string signature)
{
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int i = 0; i < identifierVariants.Count; i++)
{
var expectedSignature = ComputeSignature(payloadBase64, identifierVariants[i]);
if (string.Equals(expectedSignature, signature, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static string ComputeSignature(string payloadBase64, string applicationIdentifier)
{
var signText = payloadBase64 + "|" + applicationIdentifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
using (var sha = SHA256.Create())
{
var bytes = Encoding.UTF8.GetBytes(signText);
var hash = sha.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
}
private static byte[] XorTransform(byte[] source, byte[] key)
{
var result = new byte[source.Length];
@@ -12,7 +12,7 @@ public static class EquipmentConsumableLedgerStorage
private const string BackupFileName = ".eqc.bak";
private const string TempFileName = ".eqc.tmp";
private static string VaultDirectoryPath => Path.Combine(Application.persistentDataPath, VaultDirectoryName);
private static string VaultDirectoryPath => Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName);
private static string MainFilePath => Path.Combine(VaultDirectoryPath, MainFileName);
private static string BackupFilePath => Path.Combine(VaultDirectoryPath, BackupFileName);
private static string TempFilePath => Path.Combine(VaultDirectoryPath, TempFileName);
@@ -21,12 +21,18 @@ public static class EquipmentConsumableLedgerStorage
{
payload = CreateDefaultPayload();
if (TryReadPayload(MainFilePath, out payload))
if (TryReadPayloadFromVariants(MainFileName, out payload))
{
return true;
}
if (TryReadPayload(BackupFilePath, out payload))
if (TryReadPayloadFromVariants(BackupFileName, out payload))
{
TrySave(payload);
return true;
}
if (PlayerProgressBackupService.TryRestoreEquipmentConsumable(out payload))
{
TrySave(payload);
return true;
@@ -55,6 +61,7 @@ public static class EquipmentConsumableLedgerStorage
File.Copy(TempFilePath, MainFilePath, true);
TryHidePath(MainFilePath);
File.Delete(TempFilePath);
PlayerProgressBackupService.SaveEquipmentConsumable(payload);
return true;
}
catch (Exception ex)
@@ -91,17 +98,32 @@ public static class EquipmentConsumableLedgerStorage
return false;
}
var expectedSignature = ComputeSignature(envelope.payload);
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
if (!TryValidateSignature(envelope.payload, envelope.signature))
{
Debug.LogWarning("[EquipmentConsumableLedgerStorage] Save signature mismatch. Possible tampering detected.");
return false;
}
var encryptedBytes = Convert.FromBase64String(envelope.payload);
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
var payloadJson = Encoding.UTF8.GetString(plainBytes);
var loadedPayload = JsonUtility.FromJson<EquipmentConsumableLedgerPayload>(payloadJson);
EquipmentConsumableLedgerPayload loadedPayload = null;
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int i = 0; i < identifierVariants.Count; i++)
{
try
{
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes(identifierVariants[i]));
var payloadJson = Encoding.UTF8.GetString(plainBytes);
loadedPayload = JsonUtility.FromJson<EquipmentConsumableLedgerPayload>(payloadJson);
if (loadedPayload != null)
{
break;
}
}
catch
{
}
}
if (loadedPayload == null)
{
return false;
@@ -117,6 +139,21 @@ public static class EquipmentConsumableLedgerStorage
}
}
private static bool TryReadPayloadFromVariants(string fileName, out EquipmentConsumableLedgerPayload payload)
{
payload = CreateDefaultPayload();
var candidates = SaveIdentityUtility.GetVaultFilePathVariants(fileName);
for (int i = 0; i < candidates.Count; i++)
{
if (TryReadPayload(candidates[i], out payload))
{
return true;
}
}
return false;
}
private static string BuildEnvelopeJson(EquipmentConsumableLedgerPayload payload)
{
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
@@ -136,7 +173,7 @@ public static class EquipmentConsumableLedgerStorage
private static string ComputeSignature(string payloadBase64)
{
var signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
var signText = payloadBase64 + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
using (var sha = SHA256.Create())
{
var bytes = Encoding.UTF8.GetBytes(signText);
@@ -146,14 +183,45 @@ public static class EquipmentConsumableLedgerStorage
}
private static byte[] BuildKeyBytes()
{
return BuildKeyBytes(SaveIdentityUtility.GetPrimaryApplicationIdentifier());
}
private static byte[] BuildKeyBytes(string applicationIdentifier)
{
using (var sha = SHA256.Create())
{
var seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
var seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
}
}
private static bool TryValidateSignature(string payloadBase64, string signature)
{
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int i = 0; i < identifierVariants.Count; i++)
{
var expectedSignature = ComputeSignature(payloadBase64, identifierVariants[i]);
if (string.Equals(expectedSignature, signature, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static string ComputeSignature(string payloadBase64, string applicationIdentifier)
{
var signText = payloadBase64 + "|" + applicationIdentifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
using (var sha = SHA256.Create())
{
var bytes = Encoding.UTF8.GetBytes(signText);
var hash = sha.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
}
private static byte[] XorTransform(byte[] source, byte[] key)
{
var result = new byte[source.Length];
@@ -14,7 +14,7 @@ public static class ExpBottleLedgerStorage
private static string VaultDirectoryPath
{
get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); }
get { return Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName); }
}
private static string MainFilePath
@@ -36,12 +36,18 @@ public static class ExpBottleLedgerStorage
{
payload = CreateDefaultPayload();
if (TryReadPayload(MainFilePath, out payload))
if (TryReadPayloadFromVariants(MainFileName, out payload))
{
return true;
}
if (TryReadPayload(BackupFilePath, out payload))
if (TryReadPayloadFromVariants(BackupFileName, out payload))
{
TrySave(payload);
return true;
}
if (PlayerProgressBackupService.TryRestoreExpBottle(out payload))
{
TrySave(payload);
return true;
@@ -70,6 +76,7 @@ public static class ExpBottleLedgerStorage
File.Copy(TempFilePath, MainFilePath, true);
TryHidePath(MainFilePath);
File.Delete(TempFilePath);
PlayerProgressBackupService.SaveExpBottle(payload);
return true;
}
catch (Exception ex)
@@ -106,17 +113,32 @@ public static class ExpBottleLedgerStorage
return false;
}
var expectedSignature = ComputeSignature(envelope.payload);
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
if (!TryValidateSignature(envelope.payload, envelope.signature))
{
Debug.LogWarning("[ExpBottleLedgerStorage] Save signature mismatch. Possible tampering detected.");
return false;
}
var encryptedBytes = Convert.FromBase64String(envelope.payload);
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
var payloadJson = Encoding.UTF8.GetString(plainBytes);
var loadedPayload = JsonUtility.FromJson<ExpBottleLedgerPayload>(payloadJson);
ExpBottleLedgerPayload loadedPayload = null;
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int i = 0; i < identifierVariants.Count; i++)
{
try
{
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes(identifierVariants[i]));
var payloadJson = Encoding.UTF8.GetString(plainBytes);
loadedPayload = JsonUtility.FromJson<ExpBottleLedgerPayload>(payloadJson);
if (loadedPayload != null)
{
break;
}
}
catch
{
}
}
if (loadedPayload == null)
{
return false;
@@ -132,6 +154,21 @@ public static class ExpBottleLedgerStorage
}
}
private static bool TryReadPayloadFromVariants(string fileName, out ExpBottleLedgerPayload payload)
{
payload = CreateDefaultPayload();
var candidates = SaveIdentityUtility.GetVaultFilePathVariants(fileName);
for (int i = 0; i < candidates.Count; i++)
{
if (TryReadPayload(candidates[i], out payload))
{
return true;
}
}
return false;
}
private static string BuildEnvelopeJson(ExpBottleLedgerPayload payload)
{
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
@@ -151,7 +188,7 @@ public static class ExpBottleLedgerStorage
private static string ComputeSignature(string payloadBase64)
{
var signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
var signText = payloadBase64 + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
using (var sha = SHA256.Create())
{
var bytes = Encoding.UTF8.GetBytes(signText);
@@ -161,14 +198,45 @@ public static class ExpBottleLedgerStorage
}
private static byte[] BuildKeyBytes()
{
return BuildKeyBytes(SaveIdentityUtility.GetPrimaryApplicationIdentifier());
}
private static byte[] BuildKeyBytes(string applicationIdentifier)
{
using (var sha = SHA256.Create())
{
var seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
var seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
}
}
private static bool TryValidateSignature(string payloadBase64, string signature)
{
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int i = 0; i < identifierVariants.Count; i++)
{
var expectedSignature = ComputeSignature(payloadBase64, identifierVariants[i]);
if (string.Equals(expectedSignature, signature, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static string ComputeSignature(string payloadBase64, string applicationIdentifier)
{
var signText = payloadBase64 + "|" + applicationIdentifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
using (var sha = SHA256.Create())
{
var bytes = Encoding.UTF8.GetBytes(signText);
var hash = sha.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
}
private static byte[] XorTransform(byte[] source, byte[] key)
{
var result = new byte[source.Length];
@@ -14,7 +14,7 @@ public static class PlayerEconomyStorage
private static string VaultDirectoryPath
{
get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); }
get { return Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName); }
}
private static string MainFilePath
@@ -36,12 +36,18 @@ public static class PlayerEconomyStorage
{
payload = CreateDefaultPayload();
if (TryReadPayload(MainFilePath, out payload))
if (TryReadPayloadFromVariants(MainFileName, out payload))
{
return true;
}
if (TryReadPayload(BackupFilePath, out payload))
if (TryReadPayloadFromVariants(BackupFileName, out payload))
{
TrySave(payload);
return true;
}
if (PlayerProgressBackupService.TryRestoreEconomy(out payload))
{
TrySave(payload);
return true;
@@ -70,6 +76,7 @@ public static class PlayerEconomyStorage
File.Copy(TempFilePath, MainFilePath, true);
TryHidePath(MainFilePath);
File.Delete(TempFilePath);
PlayerProgressBackupService.SaveEconomy(payload);
return true;
}
catch (Exception ex)
@@ -107,17 +114,32 @@ public static class PlayerEconomyStorage
return false;
}
var expectedSignature = ComputeSignature(envelope.payload);
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
if (!TryValidateSignature(envelope.payload, envelope.signature))
{
Debug.LogWarning("[PlayerEconomyStorage] Save signature mismatch. Possible tampering detected.");
return false;
}
var encryptedBytes = Convert.FromBase64String(envelope.payload);
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
var payloadJson = Encoding.UTF8.GetString(plainBytes);
var loadedPayload = JsonUtility.FromJson<PlayerEconomyPayload>(payloadJson);
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
PlayerEconomyPayload loadedPayload = null;
for (int i = 0; i < identifierVariants.Count; i++)
{
try
{
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes(identifierVariants[i]));
var payloadJson = Encoding.UTF8.GetString(plainBytes);
loadedPayload = JsonUtility.FromJson<PlayerEconomyPayload>(payloadJson);
if (loadedPayload != null)
{
break;
}
}
catch
{
}
}
if (loadedPayload == null)
{
return false;
@@ -133,6 +155,21 @@ public static class PlayerEconomyStorage
}
}
private static bool TryReadPayloadFromVariants(string fileName, out PlayerEconomyPayload payload)
{
payload = CreateDefaultPayload();
var candidates = SaveIdentityUtility.GetVaultFilePathVariants(fileName);
for (int i = 0; i < candidates.Count; i++)
{
if (TryReadPayload(candidates[i], out payload))
{
return true;
}
}
return false;
}
private static string BuildEnvelopeJson(PlayerEconomyPayload payload)
{
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
@@ -152,7 +189,7 @@ public static class PlayerEconomyStorage
private static string ComputeSignature(string payloadBase64)
{
var signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
var signText = payloadBase64 + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
using (var sha = SHA256.Create())
{
var bytes = Encoding.UTF8.GetBytes(signText);
@@ -162,14 +199,45 @@ public static class PlayerEconomyStorage
}
private static byte[] BuildKeyBytes()
{
return BuildKeyBytes(SaveIdentityUtility.GetPrimaryApplicationIdentifier());
}
private static byte[] BuildKeyBytes(string applicationIdentifier)
{
using (var sha = SHA256.Create())
{
var seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
var seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
}
}
private static bool TryValidateSignature(string payloadBase64, string signature)
{
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int i = 0; i < identifierVariants.Count; i++)
{
var expectedSignature = ComputeSignature(payloadBase64, identifierVariants[i]);
if (string.Equals(expectedSignature, signature, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static string ComputeSignature(string payloadBase64, string applicationIdentifier)
{
var signText = payloadBase64 + "|" + applicationIdentifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
using (var sha = SHA256.Create())
{
var bytes = Encoding.UTF8.GetBytes(signText);
var hash = sha.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
}
private static byte[] XorTransform(byte[] source, byte[] key)
{
var result = new byte[source.Length];
@@ -78,7 +78,17 @@ public sealed class PlayerExperienceLedger : MonoBehaviour
loadedFromSave = SecureSaveVault.TryLoadJson("player_experience", "runtime", out payload, GetLegacySavePath());
if (payload == null)
{
payload = CreateDefaultPayload();
int backupExperience;
if (PlayerProgressBackupService.TryRestorePlayerExperience(out backupExperience))
{
payload = CreateDefaultPayload();
payload.playerExp = Mathf.Max(0, backupExperience);
loadedFromSave = true;
}
else
{
payload = CreateDefaultPayload();
}
}
initialized = true;
@@ -166,6 +176,7 @@ public sealed class PlayerExperienceLedger : MonoBehaviour
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
SecureSaveVault.SaveJson("player_experience", "runtime", payload, GetLegacySavePath());
PlayerProgressBackupService.SavePlayerExperience(payload.playerExp);
SyncToPlayerData();
NotifyExperienceChanged();
}
@@ -0,0 +1,555 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
[Serializable]
public class PlayerProgressBackupBundle
{
public int version = 1;
public long savedUtcTicks;
public PlayerEconomyPayload economy;
public int playerExperience;
public float bestOverallRks;
public PlayerSkillSaveData playerSkill;
public AllyHeroDeployLedgerPayload allyHeroDeploy;
public ExpBottleLedgerPayload expBottle;
public DushMaterialLedgerPayload dushMaterial;
public EquipmentConsumableLedgerPayload equipmentConsumable;
public StoreOwnershipPayload storeOwnership;
}
public static class PlayerProgressBackupService
{
private const string BackupFileName = "player_progress.bbackup";
private static PlayerProgressBackupBundle s_cachedBundle;
private static bool s_cacheLoaded;
private static bool s_isWriting;
public static bool TryRestoreEconomy(out PlayerEconomyPayload payload)
{
payload = null;
PlayerProgressBackupBundle bundle;
if (!TryLoadBundle(out bundle) || bundle == null || bundle.economy == null)
{
return false;
}
payload = CloneEconomy(bundle.economy);
return payload != null;
}
public static void SaveEconomy(PlayerEconomyPayload payload)
{
if (payload == null)
{
return;
}
UpdateBundle(bundle => bundle.economy = CloneEconomy(payload));
}
public static bool TryRestorePlayerExperience(out int experience)
{
experience = 0;
PlayerProgressBackupBundle bundle;
if (!TryLoadBundle(out bundle) || bundle == null)
{
return false;
}
if (bundle.playerExperience <= 0)
{
return false;
}
experience = Mathf.Max(0, bundle.playerExperience);
return true;
}
public static void SavePlayerExperience(int experience)
{
UpdateBundle(bundle => bundle.playerExperience = Mathf.Max(0, experience));
}
public static bool TryRestoreRks(out float rks)
{
rks = 0f;
PlayerProgressBackupBundle bundle;
if (!TryLoadBundle(out bundle) || bundle == null || bundle.bestOverallRks <= 0f)
{
return false;
}
rks = Mathf.Max(0f, bundle.bestOverallRks);
return true;
}
public static void SaveRks(float rks)
{
UpdateBundle(bundle => bundle.bestOverallRks = Mathf.Max(0f, rks));
}
public static bool TryRestorePlayerSkill(out PlayerSkillSaveData saveData)
{
saveData = null;
PlayerProgressBackupBundle bundle;
if (!TryLoadBundle(out bundle) || bundle == null || bundle.playerSkill == null)
{
return false;
}
saveData = ClonePlayerSkill(bundle.playerSkill);
return saveData != null;
}
public static void SavePlayerSkill(PlayerSkillSaveData saveData)
{
if (saveData == null)
{
return;
}
UpdateBundle(bundle => bundle.playerSkill = ClonePlayerSkill(saveData));
}
public static bool TryRestoreAllyHeroDeploy(out AllyHeroDeployLedgerPayload payload)
{
payload = null;
PlayerProgressBackupBundle bundle;
if (!TryLoadBundle(out bundle) || bundle == null || bundle.allyHeroDeploy == null)
{
return false;
}
payload = CloneAllyHeroDeploy(bundle.allyHeroDeploy);
return payload != null;
}
public static void SaveAllyHeroDeploy(AllyHeroDeployLedgerPayload payload)
{
if (payload == null)
{
return;
}
UpdateBundle(bundle => bundle.allyHeroDeploy = CloneAllyHeroDeploy(payload));
}
public static bool TryRestoreExpBottle(out ExpBottleLedgerPayload payload)
{
payload = null;
PlayerProgressBackupBundle bundle;
if (!TryLoadBundle(out bundle) || bundle == null || bundle.expBottle == null)
{
return false;
}
payload = CloneExpBottle(bundle.expBottle);
return payload != null;
}
public static void SaveExpBottle(ExpBottleLedgerPayload payload)
{
if (payload == null)
{
return;
}
UpdateBundle(bundle => bundle.expBottle = CloneExpBottle(payload));
}
public static bool TryRestoreDushMaterial(out DushMaterialLedgerPayload payload)
{
payload = null;
PlayerProgressBackupBundle bundle;
if (!TryLoadBundle(out bundle) || bundle == null || bundle.dushMaterial == null)
{
return false;
}
payload = CloneDushMaterial(bundle.dushMaterial);
return payload != null;
}
public static void SaveDushMaterial(DushMaterialLedgerPayload payload)
{
if (payload == null)
{
return;
}
UpdateBundle(bundle => bundle.dushMaterial = CloneDushMaterial(payload));
}
public static bool TryRestoreEquipmentConsumable(out EquipmentConsumableLedgerPayload payload)
{
payload = null;
PlayerProgressBackupBundle bundle;
if (!TryLoadBundle(out bundle) || bundle == null || bundle.equipmentConsumable == null)
{
return false;
}
payload = CloneEquipmentConsumable(bundle.equipmentConsumable);
return payload != null;
}
public static void SaveEquipmentConsumable(EquipmentConsumableLedgerPayload payload)
{
if (payload == null)
{
return;
}
UpdateBundle(bundle => bundle.equipmentConsumable = CloneEquipmentConsumable(payload));
}
public static bool TryRestoreStoreOwnership(out StoreOwnershipPayload payload)
{
payload = null;
PlayerProgressBackupBundle bundle;
if (!TryLoadBundle(out bundle) || bundle == null || bundle.storeOwnership == null)
{
return false;
}
payload = CloneStoreOwnership(bundle.storeOwnership);
return payload != null;
}
public static void SaveStoreOwnership(StoreOwnershipPayload payload)
{
if (payload == null)
{
return;
}
UpdateBundle(bundle => bundle.storeOwnership = CloneStoreOwnership(payload));
}
private static void UpdateBundle(Action<PlayerProgressBackupBundle> mutator)
{
if (mutator == null || s_isWriting)
{
return;
}
PlayerProgressBackupBundle bundle;
if (!TryLoadBundle(out bundle) || bundle == null)
{
bundle = CreateDefaultBundle();
}
mutator(bundle);
bundle.savedUtcTicks = DateTime.UtcNow.Ticks;
SaveBundle(bundle);
}
private static bool TryLoadBundle(out PlayerProgressBackupBundle bundle)
{
if (s_cacheLoaded)
{
bundle = s_cachedBundle;
return bundle != null;
}
s_cacheLoaded = true;
s_cachedBundle = null;
IReadOnlyList<string> candidates = SaveIdentityUtility.GetPersistentRootVariants();
for (int i = 0; i < candidates.Count; i++)
{
string root = candidates[i];
if (string.IsNullOrWhiteSpace(root))
{
continue;
}
string path = Path.Combine(root, BackupFileName);
if (!File.Exists(path))
{
continue;
}
try
{
string json = File.ReadAllText(path, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(json))
{
continue;
}
PlayerProgressBackupBundle loaded = JsonUtility.FromJson<PlayerProgressBackupBundle>(json);
if (loaded == null)
{
continue;
}
s_cachedBundle = loaded;
break;
}
catch (Exception ex)
{
Debug.LogWarning("[PlayerProgressBackup] Failed to read backup '" + path + "': " + ex.Message);
}
}
bundle = s_cachedBundle;
return bundle != null;
}
private static void SaveBundle(PlayerProgressBackupBundle bundle)
{
if (bundle == null)
{
return;
}
string root = SaveIdentityUtility.GetCanonicalPersistentRoot();
string path = Path.Combine(root, BackupFileName);
try
{
s_isWriting = true;
Directory.CreateDirectory(root);
string json = JsonUtility.ToJson(bundle, false);
File.WriteAllText(path, json, Encoding.UTF8);
s_cachedBundle = bundle;
s_cacheLoaded = true;
}
catch (Exception ex)
{
Debug.LogWarning("[PlayerProgressBackup] Failed to save backup '" + path + "': " + ex.Message);
}
finally
{
s_isWriting = false;
}
}
private static PlayerProgressBackupBundle CreateDefaultBundle()
{
return new PlayerProgressBackupBundle
{
version = 1,
savedUtcTicks = DateTime.UtcNow.Ticks
};
}
private static PlayerEconomyPayload CloneEconomy(PlayerEconomyPayload source)
{
if (source == null)
{
return null;
}
return new PlayerEconomyPayload
{
version = source.version,
lastUpdatedUtcTicks = source.lastUpdatedUtcTicks,
coins = source.coins,
material = source.material
};
}
private static PlayerSkillSaveData ClonePlayerSkill(PlayerSkillSaveData source)
{
if (source == null)
{
return null;
}
return new PlayerSkillSaveData
{
selectedSkillIndex = source.selectedSkillIndex,
postMatchRewardCounter = source.postMatchRewardCounter,
skillSwitchCooldownRemainingMatches = source.skillSwitchCooldownRemainingMatches
};
}
private static AllyHeroDeployLedgerPayload CloneAllyHeroDeploy(AllyHeroDeployLedgerPayload source)
{
if (source == null)
{
return null;
}
var clone = new AllyHeroDeployLedgerPayload
{
version = source.version,
lastUpdatedUtcTicks = source.lastUpdatedUtcTicks,
entries = new List<AllyHeroDeployEntry>()
};
if (source.entries != null)
{
for (int i = 0; i < source.entries.Count; i++)
{
AllyHeroDeployEntry entry = source.entries[i];
if (entry == null)
{
continue;
}
clone.entries.Add(new AllyHeroDeployEntry
{
heroId = entry.heroId,
currentExp = entry.currentExp,
unlockedTierIndex = entry.unlockedTierIndex,
levelLock = entry.levelLock,
autoBreakthroughEnabled = entry.autoBreakthroughEnabled,
deployCount = entry.deployCount,
finishCount = entry.finishCount,
mvpCount = entry.mvpCount,
joinDateUtcTicks = entry.joinDateUtcTicks
});
}
}
return clone;
}
private static ExpBottleLedgerPayload CloneExpBottle(ExpBottleLedgerPayload source)
{
if (source == null)
{
return null;
}
var clone = new ExpBottleLedgerPayload
{
version = source.version,
lastUpdatedUtcTicks = source.lastUpdatedUtcTicks,
entries = new List<ExpBottleEntry>()
};
if (source.entries != null)
{
for (int i = 0; i < source.entries.Count; i++)
{
ExpBottleEntry entry = source.entries[i];
if (entry == null)
{
continue;
}
clone.entries.Add(new ExpBottleEntry
{
key = entry.key,
count = entry.count
});
}
}
return clone;
}
private static DushMaterialLedgerPayload CloneDushMaterial(DushMaterialLedgerPayload source)
{
if (source == null)
{
return null;
}
var clone = new DushMaterialLedgerPayload
{
version = source.version,
lastUpdatedUtcTicks = source.lastUpdatedUtcTicks,
entries = new List<DushMaterialEntry>()
};
if (source.entries != null)
{
for (int i = 0; i < source.entries.Count; i++)
{
DushMaterialEntry entry = source.entries[i];
if (entry == null)
{
continue;
}
clone.entries.Add(new DushMaterialEntry
{
key = entry.key,
count = entry.count
});
}
}
return clone;
}
private static EquipmentConsumableLedgerPayload CloneEquipmentConsumable(EquipmentConsumableLedgerPayload source)
{
if (source == null)
{
return null;
}
var clone = new EquipmentConsumableLedgerPayload
{
version = source.version,
lastUpdatedUtcTicks = source.lastUpdatedUtcTicks,
entries = new List<EquipmentConsumableEntry>()
};
if (source.entries != null)
{
for (int i = 0; i < source.entries.Count; i++)
{
EquipmentConsumableEntry entry = source.entries[i];
if (entry == null)
{
continue;
}
clone.entries.Add(new EquipmentConsumableEntry
{
key = entry.key,
count = entry.count
});
}
}
return clone;
}
private static StoreOwnershipPayload CloneStoreOwnership(StoreOwnershipPayload source)
{
if (source == null)
{
return null;
}
var clone = new StoreOwnershipPayload
{
version = source.version,
lastUpdatedUtcTicks = source.lastUpdatedUtcTicks,
entries = new List<StoreOwnershipEntry>()
};
if (source.entries != null)
{
for (int i = 0; i < source.entries.Count; i++)
{
StoreOwnershipEntry entry = source.entries[i];
if (entry == null)
{
continue;
}
clone.entries.Add(new StoreOwnershipEntry
{
storeItemId = entry.storeItemId,
owned = entry.owned,
unlockedStorySonIds = entry.unlockedStorySonIds != null
? new List<int>(entry.unlockedStorySonIds)
: new List<int>()
});
}
}
return clone;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c620f726faf81ae4aa12a68cd611cb81
@@ -36,6 +36,10 @@ public static class PlayerRksService
{
bestOverallRks = Mathf.Max(0f, saveData.bestOverallRks);
}
else if (PlayerProgressBackupService.TryRestoreRks(out float backupRks))
{
bestOverallRks = Mathf.Max(0f, backupRks);
}
else if (player != null)
{
bestOverallRks = Mathf.Max(0f, player.URankingScore);
@@ -66,6 +70,7 @@ public static class PlayerRksService
{
bestOverallRks = calculated;
SecureSaveVault.SaveJson(SaveCategory, SaveKey, new PlayerRksSaveData { bestOverallRks = bestOverallRks });
PlayerProgressBackupService.SaveRks(bestOverallRks);
SyncPlayerSo(player);
OnRksChanged?.Invoke(bestOverallRks);
}
@@ -111,6 +111,11 @@ public sealed class PlayerSkillService : MonoBehaviour
public static void NotifySettlementCompleted(int idolScore)
{
if (GameConfig.autoPlayEnabled)
{
return;
}
EnsureInstance().HandleSettlementCompletedInternal(idolScore);
}
@@ -185,7 +190,10 @@ public sealed class PlayerSkillService : MonoBehaviour
initialized = true;
if (!SecureSaveVault.TryLoadJson(SaveCategory, SaveKey, out saveData) || saveData == null)
{
saveData = new PlayerSkillSaveData();
if (!PlayerProgressBackupService.TryRestorePlayerSkill(out saveData) || saveData == null)
{
saveData = new PlayerSkillSaveData();
}
}
ResolveSkillAssetIfNeeded();
@@ -692,5 +700,6 @@ public sealed class PlayerSkillService : MonoBehaviour
}
SecureSaveVault.SaveJson(SaveCategory, SaveKey, saveData);
PlayerProgressBackupService.SavePlayerSkill(saveData);
}
}
@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
public static class SaveIdentityUtility
{
public const string StableCompanyName = "FloatGamingStudio";
public const string StableProductName = "Bansonic";
public const string StableApplicationIdentifier = "com.FloatGamingStudio.Bansonic";
private static readonly string[] LegacyProductAliases =
{
"Bansonic",
"ban_total",
"EaseOut_Main_FreshNew"
};
private static readonly string[] LegacyCompanyAliases =
{
"FloatGamingStudio",
"DefaultCompany"
};
private static readonly string[] LegacyIdentifierAliases =
{
"com.DefaultCompany.Bansonic",
"com.FloatGamingStudio.ban_total",
"com.DefaultCompany.ban_total",
"com.FloatGamingStudio.EaseOut_Main_FreshNew",
"com.DefaultCompany.EaseOut_Main_FreshNew"
};
public static string GetPrimaryApplicationIdentifier()
{
return StableApplicationIdentifier;
}
public static string GetCanonicalPersistentRoot()
{
if (Application.platform == RuntimePlatform.WindowsEditor || Application.platform == RuntimePlatform.WindowsPlayer)
{
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string localLow = Path.GetFullPath(Path.Combine(localAppData, "..", "LocalLow"));
return Path.Combine(localLow, StableCompanyName, StableProductName);
}
return Application.persistentDataPath;
}
public static IReadOnlyList<string> GetApplicationIdentifierVariants()
{
var result = new List<string>();
AddDistinct(result, StableApplicationIdentifier);
AddDistinct(result, Application.identifier);
string currentCompany = SafeTrim(Application.companyName);
string currentProduct = SafeTrim(Application.productName);
if (!string.IsNullOrEmpty(currentCompany) && !string.IsNullOrEmpty(currentProduct))
{
AddDistinct(result, $"com.{currentCompany}.{currentProduct}");
}
for (int i = 0; i < LegacyIdentifierAliases.Length; i++)
{
AddDistinct(result, LegacyIdentifierAliases[i]);
}
for (int companyIndex = 0; companyIndex < LegacyCompanyAliases.Length; companyIndex++)
{
for (int productIndex = 0; productIndex < LegacyProductAliases.Length; productIndex++)
{
AddDistinct(result, $"com.{LegacyCompanyAliases[companyIndex]}.{LegacyProductAliases[productIndex]}");
}
}
return result;
}
public static IReadOnlyList<string> GetPersistentRootVariants(string extraLegacyPath = null)
{
var result = new List<string>();
AddDistinct(result, GetCanonicalPersistentRoot());
AddDistinct(result, Application.persistentDataPath);
string extraDirectory = string.IsNullOrEmpty(extraLegacyPath) ? null : Path.GetDirectoryName(extraLegacyPath);
AddDistinct(result, extraDirectory);
if (Application.platform == RuntimePlatform.WindowsEditor || Application.platform == RuntimePlatform.WindowsPlayer)
{
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string localLow = Path.GetFullPath(Path.Combine(localAppData, "..", "LocalLow"));
string currentCompany = SafeTrim(Application.companyName);
string currentProduct = SafeTrim(Application.productName);
if (!string.IsNullOrEmpty(currentCompany) && !string.IsNullOrEmpty(currentProduct))
{
AddDistinct(result, Path.Combine(localLow, currentCompany, currentProduct));
}
for (int companyIndex = 0; companyIndex < LegacyCompanyAliases.Length; companyIndex++)
{
for (int productIndex = 0; productIndex < LegacyProductAliases.Length; productIndex++)
{
AddDistinct(result, Path.Combine(localLow, LegacyCompanyAliases[companyIndex], LegacyProductAliases[productIndex]));
}
}
}
return result;
}
public static IReadOnlyList<string> GetVaultFilePathVariants(string fileName)
{
var result = new List<string>();
if (string.IsNullOrWhiteSpace(fileName))
{
return result;
}
IReadOnlyList<string> roots = GetPersistentRootVariants();
for (int i = 0; i < roots.Count; i++)
{
string root = roots[i];
if (string.IsNullOrWhiteSpace(root))
{
continue;
}
AddDistinct(result, Path.Combine(root, ".cache_bridge", fileName));
}
return result;
}
private static void AddDistinct(List<string> target, string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
for (int i = 0; i < target.Count; i++)
{
if (string.Equals(target[i], value, StringComparison.OrdinalIgnoreCase))
{
return;
}
}
target.Add(value);
}
private static string SafeTrim(string value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: acd513b6738225b46ab31a779eb308fa
+147 -46
View File
@@ -27,7 +27,7 @@ public static class SecureSaveVault
private static string VaultDirectoryPath
{
get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); }
get { return Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName); }
}
public static bool SaveJson<T>(string category, string key, T data, string legacyPlainPath = null)
@@ -121,20 +121,11 @@ public static class SecureSaveVault
return false;
}
string mainPath = GetFilePath(category, key, ".dat");
string backupPath = GetFilePath(category, key, ".bak");
if (TryReadEncryptedFile(category, key, mainPath, out json))
if (TryLoadFromCurrentOrLegacyEncryptedFiles(category, key, legacyPlainPath, out json))
{
return true;
}
if (TryReadEncryptedFile(category, key, backupPath, out json))
{
SaveRawJson(category, key, json, legacyPlainPath);
return true;
}
if (!string.IsNullOrEmpty(legacyPlainPath) && File.Exists(legacyPlainPath))
{
try
@@ -262,8 +253,7 @@ public static class SecureSaveVault
return false;
}
string expectedSignature = ComputeSignature(category, envelope.payload);
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
if (!TryValidateSignature(category, envelope.payload, envelope.signature))
{
Debug.LogWarning($"[SecureSaveVault] Signature mismatch ({category}/{key}). Possible tampering detected.");
return false;
@@ -329,7 +319,7 @@ public static class SecureSaveVault
private static string ComputeSignature(string category, string payloadBase64)
{
string signText = payloadBase64 + "|" + category + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
string signText = payloadBase64 + "|" + category + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
using (var sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(signText));
@@ -350,7 +340,7 @@ public static class SecureSaveVault
{
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.Key = BuildAesKey(category, key);
aes.Key = BuildAesKey(category, key, SaveIdentityUtility.GetPrimaryApplicationIdentifier());
aes.GenerateIV();
using (var encryptor = aes.CreateEncryptor())
{
@@ -374,28 +364,44 @@ public static class SecureSaveVault
return plainBytes != null;
}
#endif
using (var aes = Aes.Create())
IReadOnlyList<string> identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int i = 0; i < identifierVariants.Count; i++)
{
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.Key = BuildAesKey(category, key);
int ivLength = aes.BlockSize / 8;
if (protectedBytes == null || protectedBytes.Length <= ivLength)
using (var aes = Aes.Create())
{
return false;
}
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.Key = BuildAesKey(category, key, identifierVariants[i]);
int ivLength = aes.BlockSize / 8;
if (protectedBytes == null || protectedBytes.Length <= ivLength)
{
return false;
}
byte[] iv = new byte[ivLength];
byte[] cipher = new byte[protectedBytes.Length - ivLength];
Buffer.BlockCopy(protectedBytes, 0, iv, 0, ivLength);
Buffer.BlockCopy(protectedBytes, ivLength, cipher, 0, cipher.Length);
aes.IV = iv;
using (var decryptor = aes.CreateDecryptor())
{
plainBytes = decryptor.TransformFinalBlock(cipher, 0, cipher.Length);
return plainBytes != null;
byte[] iv = new byte[ivLength];
byte[] cipher = new byte[protectedBytes.Length - ivLength];
Buffer.BlockCopy(protectedBytes, 0, iv, 0, ivLength);
Buffer.BlockCopy(protectedBytes, ivLength, cipher, 0, cipher.Length);
aes.IV = iv;
try
{
using (var decryptor = aes.CreateDecryptor())
{
plainBytes = decryptor.TransformFinalBlock(cipher, 0, cipher.Length);
if (plainBytes != null)
{
return true;
}
}
}
catch
{
}
}
}
plainBytes = null;
return false;
}
catch (Exception ex)
{
@@ -416,7 +422,7 @@ public static class SecureSaveVault
try
{
protectedBytes = s_dpapiProtectMethod.Invoke(null, new object[] { plainBytes, BuildEntropy(category), s_dpapiCurrentUserScope }) as byte[];
protectedBytes = s_dpapiProtectMethod.Invoke(null, new object[] { plainBytes, BuildEntropy(category, SaveIdentityUtility.GetPrimaryApplicationIdentifier()), s_dpapiCurrentUserScope }) as byte[];
return protectedBytes != null && protectedBytes.Length > 0;
}
catch (Exception ex)
@@ -435,16 +441,24 @@ public static class SecureSaveVault
return false;
}
try
IReadOnlyList<string> identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int i = 0; i < identifierVariants.Count; i++)
{
plainBytes = s_dpapiUnprotectMethod.Invoke(null, new object[] { protectedBytes, BuildEntropy(category), s_dpapiCurrentUserScope }) as byte[];
return plainBytes != null && plainBytes.Length > 0;
}
catch
{
plainBytes = null;
return false;
try
{
plainBytes = s_dpapiUnprotectMethod.Invoke(null, new object[] { protectedBytes, BuildEntropy(category, identifierVariants[i]), s_dpapiCurrentUserScope }) as byte[];
if (plainBytes != null && plainBytes.Length > 0)
{
return true;
}
}
catch
{
}
}
plainBytes = null;
return false;
}
private static bool EnsureDpapi()
@@ -492,29 +506,116 @@ public static class SecureSaveVault
}
#endif
private static byte[] BuildEntropy(string category)
private static byte[] BuildEntropy(string category, string applicationIdentifier)
{
string seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed + "|" + category;
string seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed + "|" + category;
using (var sha = SHA256.Create())
{
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
}
}
private static byte[] BuildAesKey(string category, string key)
private static byte[] BuildAesKey(string category, string key, string applicationIdentifier)
{
return BuildEntropy(category);
using (var sha = SHA256.Create())
{
string seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed + "|" + category + "|" + key;
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
}
}
private static string ShortHash(string value)
{
using (var sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value + "|" + Application.identifier + "|" + SecretSeed));
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed));
return BitConverter.ToString(hash, 0, 12).Replace("-", string.Empty).ToLowerInvariant();
}
}
private static string ShortHash(string value, string applicationIdentifier)
{
using (var sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value + "|" + applicationIdentifier + "|" + SecretSeed));
return BitConverter.ToString(hash, 0, 12).Replace("-", string.Empty).ToLowerInvariant();
}
}
private static bool TryLoadFromCurrentOrLegacyEncryptedFiles(string category, string key, string legacyPlainPath, out string json)
{
json = null;
IReadOnlyList<string> roots = SaveIdentityUtility.GetPersistentRootVariants(legacyPlainPath);
for (int i = 0; i < roots.Count; i++)
{
string root = roots[i];
if (string.IsNullOrWhiteSpace(root))
{
continue;
}
IReadOnlyList<string> identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int variantIndex = 0; variantIndex < identifierVariants.Count; variantIndex++)
{
string applicationIdentifier = identifierVariants[variantIndex];
string mainPath = GetFilePathForRoot(root, category, key, ".dat", applicationIdentifier);
if (TryReadEncryptedFile(category, key, mainPath, out json))
{
SaveRawJson(category, key, json, legacyPlainPath);
return true;
}
string backupPath = GetFilePathForRoot(root, category, key, ".bak", applicationIdentifier);
if (TryReadEncryptedFile(category, key, backupPath, out json))
{
SaveRawJson(category, key, json, legacyPlainPath);
return true;
}
}
}
return false;
}
private static bool TryValidateSignature(string category, string payloadBase64, string signature)
{
IReadOnlyList<string> identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int i = 0; i < identifierVariants.Count; i++)
{
string expectedSignature = ComputeSignature(category, payloadBase64, identifierVariants[i]);
if (string.Equals(expectedSignature, signature, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static string ComputeSignature(string category, string payloadBase64, string applicationIdentifier)
{
string signText = payloadBase64 + "|" + category + "|" + applicationIdentifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
using (var sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(signText));
return Convert.ToBase64String(hash);
}
}
private static string GetCategoryDirectoryForRoot(string rootPath, string category, string applicationIdentifier)
{
string safeCategory = ShortHash("cat|" + category, applicationIdentifier);
return Path.Combine(rootPath, VaultDirectoryName, "." + safeCategory);
}
private static string GetFilePathForRoot(string rootPath, string category, string key, string extension, string applicationIdentifier)
{
string categoryDirectory = GetCategoryDirectoryForRoot(rootPath, category, applicationIdentifier);
string safeKey = ShortHash("key|" + key, applicationIdentifier);
return Path.Combine(categoryDirectory, "." + safeKey + extension);
}
private static void DeleteLegacyPlainFile(string legacyPlainPath)
{
if (string.IsNullOrEmpty(legacyPlainPath) || !File.Exists(legacyPlainPath))
@@ -14,7 +14,7 @@ public static class StoreOwnershipStorage
private static string VaultDirectoryPath
{
get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); }
get { return Path.Combine(SaveIdentityUtility.GetCanonicalPersistentRoot(), VaultDirectoryName); }
}
private static string MainFilePath
@@ -36,12 +36,18 @@ public static class StoreOwnershipStorage
{
payload = CreateDefaultPayload();
if (TryReadPayload(MainFilePath, out payload))
if (TryReadPayloadFromVariants(MainFileName, out payload))
{
return true;
}
if (TryReadPayload(BackupFilePath, out payload))
if (TryReadPayloadFromVariants(BackupFileName, out payload))
{
TrySave(payload);
return true;
}
if (PlayerProgressBackupService.TryRestoreStoreOwnership(out payload))
{
TrySave(payload);
return true;
@@ -70,6 +76,7 @@ public static class StoreOwnershipStorage
File.Copy(TempFilePath, MainFilePath, true);
TryHidePath(MainFilePath);
File.Delete(TempFilePath);
PlayerProgressBackupService.SaveStoreOwnership(payload);
return true;
}
catch (Exception ex)
@@ -106,17 +113,32 @@ public static class StoreOwnershipStorage
return false;
}
var expectedSignature = ComputeSignature(envelope.payload);
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
if (!TryValidateSignature(envelope.payload, envelope.signature))
{
Debug.LogWarning("[StoreOwnershipStorage] Save signature mismatch. Possible tampering detected.");
return false;
}
var encryptedBytes = Convert.FromBase64String(envelope.payload);
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
var payloadJson = Encoding.UTF8.GetString(plainBytes);
var loadedPayload = JsonUtility.FromJson<StoreOwnershipPayload>(payloadJson);
StoreOwnershipPayload loadedPayload = null;
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int i = 0; i < identifierVariants.Count; i++)
{
try
{
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes(identifierVariants[i]));
var payloadJson = Encoding.UTF8.GetString(plainBytes);
loadedPayload = JsonUtility.FromJson<StoreOwnershipPayload>(payloadJson);
if (loadedPayload != null)
{
break;
}
}
catch
{
}
}
if (loadedPayload == null)
{
return false;
@@ -132,6 +154,21 @@ public static class StoreOwnershipStorage
}
}
private static bool TryReadPayloadFromVariants(string fileName, out StoreOwnershipPayload payload)
{
payload = CreateDefaultPayload();
var candidates = SaveIdentityUtility.GetVaultFilePathVariants(fileName);
for (int i = 0; i < candidates.Count; i++)
{
if (TryReadPayload(candidates[i], out payload))
{
return true;
}
}
return false;
}
private static string BuildEnvelopeJson(StoreOwnershipPayload payload)
{
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
@@ -151,7 +188,7 @@ public static class StoreOwnershipStorage
private static string ComputeSignature(string payloadBase64)
{
var signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
var signText = payloadBase64 + "|" + SaveIdentityUtility.GetPrimaryApplicationIdentifier() + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
using (var sha = SHA256.Create())
{
var bytes = Encoding.UTF8.GetBytes(signText);
@@ -161,14 +198,45 @@ public static class StoreOwnershipStorage
}
private static byte[] BuildKeyBytes()
{
return BuildKeyBytes(SaveIdentityUtility.GetPrimaryApplicationIdentifier());
}
private static byte[] BuildKeyBytes(string applicationIdentifier)
{
using (var sha = SHA256.Create())
{
var seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
var seed = applicationIdentifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
}
}
private static bool TryValidateSignature(string payloadBase64, string signature)
{
var identifierVariants = SaveIdentityUtility.GetApplicationIdentifierVariants();
for (int i = 0; i < identifierVariants.Count; i++)
{
var expectedSignature = ComputeSignature(payloadBase64, identifierVariants[i]);
if (string.Equals(expectedSignature, signature, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static string ComputeSignature(string payloadBase64, string applicationIdentifier)
{
var signText = payloadBase64 + "|" + applicationIdentifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
using (var sha = SHA256.Create())
{
var bytes = Encoding.UTF8.GetBytes(signText);
var hash = sha.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
}
private static byte[] XorTransform(byte[] source, byte[] key)
{
var result = new byte[source.Length];
+2 -2
View File
@@ -994,8 +994,8 @@ MonoBehaviour:
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4283845183
m_fontColor: {r: 0.24705882, g: 0.2901961, b: 0.3372549, a: 1}
rgba: 4294967295
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8645c982f08afaa4f855b29f838448ed
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,123 @@
using UnityEngine;
/// <summary>
/// Runtime driver for materials made from the Rainbow-Cats dissolve / HDR-outline shaders.
/// Attach to any Renderer and it will animate the shader's exposed properties so you get the
/// dissolve fade or the pulsing HDR outline without touching material properties by hand.
///
/// Works on an instanced copy of the material via MaterialPropertyBlock, so multiple objects
/// sharing one material can dissolve independently.
/// </summary>
[RequireComponent(typeof(Renderer))]
public class DissolveEffectController : MonoBehaviour
{
public enum Effect
{
/// <summary>Drives "_Fade": 0 = fully dissolved/hidden, 1 = fully visible (Dissolve shader).</summary>
Dissolve,
/// <summary>Drives "_Emission": pulses the HDR outline glow (HDR Outline shader).</summary>
OutlinePulse
}
[Header("Which effect")]
public Effect effect = Effect.Dissolve;
[Header("Dissolve")]
[Tooltip("If true, the object animates from dissolved -> visible on enable. If false, visible -> dissolved.")]
public bool appear = true;
[Tooltip("Seconds the dissolve animation takes.")]
public float dissolveDuration = 1.5f;
[Tooltip("If true the animation plays automatically on enable. Otherwise call Play().")]
public bool playOnEnable = true;
[Header("Outline pulse")]
[Tooltip("Min emission intensity for the outline pulse.")]
public float pulseMin = 1f;
[Tooltip("Max emission intensity for the outline pulse.")]
public float pulseMax = 6f;
[Tooltip("Pulses per second.")]
public float pulseSpeed = 1.5f;
// Property IDs from the shader graphs' exposed reference names.
private static readonly int FadeId = Shader.PropertyToID("_Fade");
private static readonly int EmissionId = Shader.PropertyToID("_Emission");
private Renderer targetRenderer;
private MaterialPropertyBlock block;
private float timer;
private bool playing;
private void Awake()
{
targetRenderer = GetComponent<Renderer>();
block = new MaterialPropertyBlock();
}
private void OnEnable()
{
if (playOnEnable)
{
Play();
}
}
/// <summary>Restart the effect from the beginning.</summary>
public void Play()
{
timer = 0f;
playing = true;
}
/// <summary>Set the dissolve amount directly (0 = hidden, 1 = visible) and stop animating.</summary>
public void SetFade(float value)
{
playing = false;
targetRenderer.GetPropertyBlock(block);
block.SetFloat(FadeId, Mathf.Clamp01(value));
targetRenderer.SetPropertyBlock(block);
}
private void Update()
{
if (effect == Effect.OutlinePulse)
{
UpdatePulse();
return;
}
if (playing)
{
UpdateDissolve();
}
}
private void UpdateDissolve()
{
timer += Time.deltaTime;
float t = dissolveDuration > 0f ? Mathf.Clamp01(timer / dissolveDuration) : 1f;
float fade = appear ? t : 1f - t;
targetRenderer.GetPropertyBlock(block);
block.SetFloat(FadeId, fade);
targetRenderer.SetPropertyBlock(block);
if (t >= 1f)
{
playing = false;
}
}
private void UpdatePulse()
{
float wave = (Mathf.Sin(Time.time * pulseSpeed * Mathf.PI * 2f) + 1f) * 0.5f;
float emission = Mathf.Lerp(pulseMin, pulseMax, wave);
targetRenderer.GetPropertyBlock(block);
block.SetFloat(EmissionId, emission);
targetRenderer.SetPropertyBlock(block);
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 08240dd979e13634782be1a0c42dc369
+978
View File
@@ -0,0 +1,978 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Runtime-fractures the attached mesh object using OpenFracture's <see cref="Fragmenter"/>,
/// then pushes every produced fragment toward a configurable direction so the pieces
/// "drift"/fly off. Call <see cref="Shatter()"/> (or <see cref="Shatter(Vector3)"/> to
/// override the direction) from your own gameplay code.
/// </summary>
[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
[RequireComponent(typeof(Rigidbody))]
public class FractureAndDrift : MonoBehaviour
{
/// <summary>
/// How the cut-plane normal is chosen at each subdivision step.
/// </summary>
public enum SliceMode
{
/// <summary>Original OpenFracture behaviour: random normal over the enabled axes.</summary>
Random,
/// <summary>Always cut perpendicular to the fragment's current longest axis. Best for avoiding stretched
/// pieces on long/thin objects, because long dimensions get subdivided first.</summary>
LongestAxis,
/// <summary>Random normal biased by <see cref="axisWeights"/>. Higher weight on an axis produces more cuts
/// perpendicular to it, i.e. that dimension is subdivided more finely.</summary>
AxisWeighted
}
[Header("Fracture")]
[Tooltip("Fragment count / axes / inside material. Same options used by OpenFracture's Fracture component.")]
public FractureOptions fractureOptions = new FractureOptions();
[Tooltip("Random = stock OpenFracture. LongestAxis = always cut the longest side first (best anti-stretch). " +
"AxisWeighted = bias cuts toward the axes with higher weight.")]
public SliceMode sliceMode = SliceMode.Random;
[Tooltip("Only used in AxisWeighted mode. Higher weight on an axis = that dimension is subdivided more (more cuts perpendicular to it). " +
"e.g. (3,1,1) cuts a long-X object roughly 3x more along X.")]
public Vector3 axisWeights = Vector3.one;
[Tooltip("Only used in LongestAxis mode. Random tilt (0..1) added to the cut plane so repeated cuts aren't perfectly parallel. 0 = perfectly axis-aligned cuts.")]
[Range(0f, 1f)]
public float longestAxisJitter = 0.15f;
[Tooltip("If true, logs what the controlled fracture actually did (source mesh stats, how many slices " +
"succeeded vs came back empty, final fragment count). Turn this on once to diagnose 'still big blocks'.")]
public bool fractureDebugLogs = false;
[Header("Drift")]
[Tooltip("Direction the fragments fly toward. Interpreted in the space set by 'Direction Is Local'.")]
public Vector3 driftDirection = Vector3.up;
[Tooltip("If true, driftDirection is relative to this object's rotation; if false it is world space.")]
public bool directionIsLocal = false;
[Tooltip("Base speed (m/s) applied to each fragment along the drift direction.")]
public float driftSpeed = 5f;
[Tooltip("Extra random speed [0..value] added on top of driftSpeed for a natural burst.")]
public float driftSpeedRandomness = 1.5f;
[Tooltip("Random sideways scatter (cone half-angle in degrees) around the drift direction. 0 = perfectly straight.")]
[Range(0f, 90f)]
public float scatterAngle = 15f;
[Tooltip("Extra outward push (m/s) from the fracture center, gives an explosion feel. 0 = disabled.")]
public float explosionSpeed = 0f;
[Tooltip("Random angular velocity (rad/s) applied so fragments tumble.")]
public float spinSpeed = 4f;
[Header("Source Physics")]
[Tooltip("If true, the source object's Rigidbody is frozen (kinematic, no gravity) until Shatter() is called. " +
"This stops the object from falling/jittering under physics before it breaks. Highly recommended: the " +
"source body is only used to compute fragment mass, it does not need to simulate before shattering.")]
public bool freezeSourceUntilShatter = true;
[Header("Fragment Physics")]
[Tooltip("If true, fragments are affected by Unity's global gravity (always straight down) after being launched. " +
"Ignored when 'Use Custom Gravity' is enabled.")]
public bool fragmentsUseGravity = true;
[Tooltip("Caps how fast PhysX may push a fragment when it resolves an overlap (depenetration). This is the REAL " +
"fix for fragments 'accelerating'/exploding: freshly cut convex hulls overlap each other and any nearby " +
"geometry, and by default PhysX separates them with an almost unlimited velocity, injecting huge energy. " +
"A small value (1-2) makes overlaps resolve gently. Applies regardless of collision layers/ignores.")]
public float maxDepenetrationVelocity = 1f;
[Tooltip("If true, the freshly spawned fragments collide with each other. Leave OFF for a clean drift: at birth " +
"the convex fragment hulls overlap, and Unity resolves that overlap with large depenetration impulses " +
"that blast the pieces apart. Ignoring sibling collisions removes that explosion.")]
public bool fragmentsCollideWithEachOther = false;
[Tooltip("If true, fragments will NOT collide with other, still-unshattered FractureAndDrift objects in the scene. " +
"Keep ON: otherwise fragments smash into the solid neighbouring objects and fly off chaotically, which is " +
"why earlier-triggered objects looked wrong while the last-triggered one (no solid neighbours left) drifted cleanly.")]
public bool ignoreOtherSourceObjects = true;
[Tooltip("If true, fragments ignore Unity's global gravity and are instead continuously pulled toward " +
"'Custom Gravity' every physics step. Use this to make pieces drift toward an arbitrary direction.")]
public bool useCustomGravity = false;
[Tooltip("Acceleration vector (m/s^2) applied to every fragment each physics step when 'Use Custom Gravity' is on. " +
"e.g. (0,-9.81,0) = normal down; (5,0,0) = pulled toward +X; interpreted in the space set by 'Custom Gravity Is Local'.")]
public Vector3 customGravity = new Vector3(0f, -9.81f, 0f);
[Tooltip("If true, 'Custom Gravity' is relative to this object's rotation at shatter time; if false it is world space.")]
public bool customGravityIsLocal = false;
[Header("Cleanup")]
[Tooltip("Fallback: if nothing calls ReclaimFragments() first, the fragments are auto-reclaimed this many " +
"seconds after shattering. <= 0 keeps them forever (until something calls ReclaimFragments()).")]
public float fragmentLifetime = 5f;
[Tooltip("When reclaiming, fragments are destroyed one-by-one spread across this many seconds instead of all " +
"at once. This is the 'async collect' behaviour. 0 = destroy the whole batch together.")]
public float reclaimStagger = 1f;
[Tooltip("If true, each fragment smoothly shrinks to nothing before being destroyed, for a graceful collect " +
"instead of a hard pop.")]
public bool reclaimShrink = true;
[Tooltip("If true, this GameObject is deactivated after shattering (mirrors OpenFracture behaviour).")]
public bool deactivateSourceAfterShatter = true;
[Header("Prewarm")]
[Tooltip("If true, the expensive mesh slicing runs asynchronously in Start(): the fragments are built up front " +
"and kept hidden & frozen. Shatter() then becomes a cheap, instant reveal + launch, so there is no " +
"slicing hitch at the moment the track breaks. Leave off to slice on-demand when Shatter() is called.")]
public bool prewarmFragmentsOnStart = false;
[Header("Debug")]
[Tooltip("If true, pressing 'Debug Key' triggers the shatter at runtime. For testing only.")]
public bool enableDebugKey = false;
[Tooltip("Key that triggers the shatter when 'Enable Debug Key' is on.")]
public KeyCode debugKey = KeyCode.F;
private GameObject fragmentRoot;
private FragmentReclaimer fragmentReclaimer;
private bool hasShattered;
// Prewarm state: when prewarmFragmentsOnStart is on, the fragments are built ahead of time and parked
// here (hidden + kinematic). Shatter() then just reveals & launches them instead of slicing on the spot.
private bool prewarmComplete;
private bool prewarmInProgress;
private Vector3 pendingLaunchDirection;
private bool launchPending;
// Name of the dedicated physics layer every fragment is placed on. The layer-collision matrix is
// configured so this layer ignores itself, giving global cross-batch fragment isolation for free.
private const string FragmentLayerName = "FractureFragment";
private static int cachedFragmentLayer = -1;
private static bool fragmentLayerResolved;
private void Awake()
{
if (freezeSourceUntilShatter)
{
// The source body is only needed to read mass for fragment computation. Freeze it so the
// object doesn't fall or jitter under physics before Shatter() is called.
var body = GetComponent<Rigidbody>();
if (body != null)
{
body.isKinematic = true;
body.useGravity = false;
}
}
}
private void Start()
{
if (prewarmFragmentsOnStart)
{
StartCoroutine(PrewarmRoutine());
}
}
private void Update()
{
if (enableDebugKey && !hasShattered && Input.GetKeyDown(debugKey))
{
Shatter();
}
}
/// <summary>
/// Fractures the object and launches the fragments along the configured drift direction.
/// </summary>
public void Shatter()
{
Shatter(ResolveDriftDirection());
}
/// <summary>
/// Fractures the object and launches the fragments along <paramref name="worldDirection"/>.
/// </summary>
/// <param name="worldDirection">Direction in world space the fragments should fly toward.</param>
public void Shatter(Vector3 worldDirection)
{
if (hasShattered)
{
return;
}
Vector3 direction = worldDirection.sqrMagnitude > Mathf.Epsilon
? worldDirection.normalized
: transform.up;
// Prewarm path: the expensive slicing already ran (or is running) in Start().
if (prewarmFragmentsOnStart)
{
if (prewarmComplete)
{
// Fragments are pre-built and parked. This is now a cheap, instant reveal + launch.
hasShattered = true;
RevealAndLaunch(direction);
return;
}
if (prewarmInProgress)
{
// Slicing hasn't finished yet; remember the request and launch the moment it completes.
pendingLaunchDirection = direction;
launchPending = true;
return;
}
// Prewarm was requested but Start() hasn't run yet (or failed) — fall through to slice on-demand.
}
MeshFilter meshFilter = GetComponent<MeshFilter>();
if (meshFilter == null || meshFilter.sharedMesh == null)
{
Debug.LogWarning("[FractureAndDrift] No mesh to fracture.", this);
return;
}
hasShattered = true;
CreateFragmentRoot();
GameObject fragmentTemplate = CreateFragmentTemplate();
if (sliceMode != SliceMode.Random)
{
// Custom subdivision loop that controls cut orientation to avoid stretched fragments.
FractureControlled(fragmentTemplate);
Destroy(fragmentTemplate);
LaunchFragments(direction);
FinishShatter();
}
else if (fractureOptions.asynchronous)
{
StartCoroutine(Fragmenter.FractureAsync(
gameObject,
fractureOptions,
fragmentTemplate,
fragmentRoot.transform,
() =>
{
Destroy(fragmentTemplate);
LaunchFragments(direction);
FinishShatter();
}));
}
else
{
Fragmenter.Fracture(
gameObject,
fractureOptions,
fragmentTemplate,
fragmentRoot.transform);
Destroy(fragmentTemplate);
LaunchFragments(direction);
FinishShatter();
}
}
/// <summary>
/// Builds all fragments ahead of time in Start() and parks them hidden &amp; inactive, so that the
/// actual Shatter() is a cheap reveal + launch with no slicing hitch. Runs the slicing across frames
/// (the async slicer yields; the controlled/sync slicers run in one frame but still off the break moment).
/// </summary>
private IEnumerator PrewarmRoutine()
{
MeshFilter meshFilter = GetComponent<MeshFilter>();
if (meshFilter == null || meshFilter.sharedMesh == null)
{
yield break;
}
prewarmInProgress = true;
CreateFragmentRoot();
// Park the container inactive BEFORE slicing so every fragment spawns inactive and never simulates
// (falls/drifts) during the multi-frame prewarm. Shatter() reveals it later via SetActive(true).
fragmentRoot.SetActive(false);
GameObject fragmentTemplate = CreateFragmentTemplate();
if (sliceMode != SliceMode.Random)
{
FractureControlled(fragmentTemplate);
Destroy(fragmentTemplate);
}
else if (fractureOptions.asynchronous)
{
yield return StartCoroutine(Fragmenter.FractureAsync(
gameObject,
fractureOptions,
fragmentTemplate,
fragmentRoot.transform,
() => { }));
Destroy(fragmentTemplate);
}
else
{
Fragmenter.Fracture(
gameObject,
fractureOptions,
fragmentTemplate,
fragmentRoot.transform);
Destroy(fragmentTemplate);
}
// Container is already inactive (parked before slicing), so nothing simulated during prewarm.
prewarmInProgress = false;
prewarmComplete = true;
// If Shatter() was called while we were still slicing, honour it now.
if (launchPending)
{
launchPending = false;
hasShattered = true;
RevealAndLaunch(pendingLaunchDirection);
}
}
/// <summary>
/// Reveals the pre-built fragment container and launches its fragments. Used by the prewarm path.
/// </summary>
private void RevealAndLaunch(Vector3 direction)
{
if (fragmentRoot != null)
{
fragmentRoot.SetActive(true);
}
LaunchFragments(direction);
FinishShatter();
}
/// <summary>
/// Creates the collector object that holds the produced fragments (matches OpenFracture's convention).
/// </summary>
private void CreateFragmentRoot()
{
fragmentRoot = new GameObject($"{name}Fragments");
fragmentRoot.transform.SetParent(transform.parent);
fragmentRoot.transform.position = transform.position;
fragmentRoot.transform.rotation = transform.rotation;
fragmentRoot.transform.localScale = Vector3.one;
}
private void FinishShatter()
{
// Attach the reclaimer to the fragment container (which stays active). It waits either for an
// explicit ReclaimFragments() call or, as a fallback, for fragmentLifetime to elapse, then
// collects the fragments asynchronously (staggered) rather than destroying them all at once.
if (fragmentRoot != null)
{
fragmentReclaimer = fragmentRoot.AddComponent<FragmentReclaimer>();
fragmentReclaimer.Configure(fragmentLifetime, reclaimStagger, reclaimShrink);
}
if (deactivateSourceAfterShatter)
{
gameObject.SetActive(false);
}
}
/// <summary>
/// Begins the asynchronous, staggered collection of this track's fragments. Safe to call once the
/// object has shattered; a no-op otherwise. This overrides the fragmentLifetime fallback timer.
/// </summary>
public void ReclaimFragments()
{
if (fragmentReclaimer != null)
{
fragmentReclaimer.BeginReclaim();
}
}
/// <summary>
/// Iterates the freshly created fragments and applies the launch velocity + spin.
/// </summary>
private void LaunchFragments(Vector3 direction)
{
if (fragmentRoot == null)
{
return;
}
Vector3 center = transform.position;
var bodies = fragmentRoot.GetComponentsInChildren<Rigidbody>();
for (int i = 0; i < bodies.Length; i++)
{
Rigidbody body = bodies[i];
if (body == null)
{
continue;
}
// When custom gravity is on, disable Unity's global (down-only) gravity so the
// per-step custom acceleration is the only gravity acting on the fragment.
body.useGravity = !useCustomGravity && fragmentsUseGravity;
// Safety net: guarantee the depenetration clamp is applied to every produced body, even ones
// that might not have inherited it from the template (e.g. extra pieces from FindDisconnectedMeshes).
if (maxDepenetrationVelocity > 0f)
{
body.maxDepenetrationVelocity = maxDepenetrationVelocity;
}
Vector3 launchDir = ApplyScatter(direction);
float speed = driftSpeed + Random.value * Mathf.Max(0f, driftSpeedRandomness);
Vector3 velocity = launchDir * speed;
if (explosionSpeed > 0f)
{
Vector3 fromCenter = body.worldCenterOfMass - center;
if (fromCenter.sqrMagnitude > Mathf.Epsilon)
{
velocity += fromCenter.normalized * explosionSpeed;
}
}
body.linearVelocity = velocity;
if (spinSpeed > 0f)
{
body.angularVelocity = Random.insideUnitSphere * spinSpeed;
}
}
ConfigureFragmentCollisions();
if (useCustomGravity)
{
// The source object gets deactivated after shattering, so the custom-gravity driver
// must live on the fragment container (which stays active) to keep applying force.
Vector3 gravity = customGravityIsLocal ? transform.TransformDirection(customGravity) : customGravity;
var driver = fragmentRoot.AddComponent<FragmentGravityField>();
driver.gravity = gravity;
}
}
/// <summary>
/// Configures fragment collisions via the physics layer-collision matrix instead of per-pair
/// Physics.IgnoreCollision. All fragments live on <see cref="FragmentLayerName"/>; disabling that
/// layer's collision with itself makes EVERY fragment ignore EVERY other fragment - same batch or a
/// different track's batch, at any time, with no per-collider bookkeeping. This is why the earlier
/// pairwise approach kept leaking cross-batch collisions: newly spawned colliders were never paired
/// against batches that shattered later. The matrix rule has no such ordering dependency.
/// </summary>
private void ConfigureFragmentCollisions()
{
int fragmentLayer = ResolveFragmentLayer();
if (fragmentLayer < 0)
{
return;
}
// Fragments never collide with each other (unless explicitly opted in). This is a global matrix
// rule, so it covers same-batch AND cross-batch (another track shattering at the same time)
// with no per-collider bookkeeping and no ordering dependency.
Physics.IgnoreLayerCollision(fragmentLayer, fragmentLayer, !fragmentsCollideWithEachOther);
if (ignoreOtherSourceObjects)
{
// Fragments ignore the still-solid source objects. Done per-collider (NOT via the layer matrix)
// because sources sit on shared layers like Default that the ground/platforms also use - a
// whole-layer ignore would let fragments fall through the floor. Per-collider keeps it precise.
var fragmentColliders = fragmentRoot.GetComponentsInChildren<Collider>();
var sources = FindObjectsByType<FractureAndDrift>(FindObjectsSortMode.None);
for (int s = 0; s < sources.Length; s++)
{
FractureAndDrift source = sources[s];
// Skip sources that already shattered - their solid collider is gone. Keep 'this' one:
// its collider is still active this frame and the fragments spawn right on top of it.
if (source == null || (source.hasShattered && source != this))
{
continue;
}
var sourceCollider = source.GetComponent<Collider>();
if (sourceCollider == null)
{
continue;
}
for (int f = 0; f < fragmentColliders.Length; f++)
{
if (fragmentColliders[f] != null)
{
Physics.IgnoreCollision(fragmentColliders[f], sourceCollider, true);
}
}
}
}
}
/// <summary>
/// Resolves the dedicated fragment layer index, caching the lookup. Returns -1 if the project has no
/// layer named <see cref="FragmentLayerName"/> (falls back to no layer assignment).
/// </summary>
private static int ResolveFragmentLayer()
{
if (!fragmentLayerResolved)
{
cachedFragmentLayer = LayerMask.NameToLayer(FragmentLayerName);
fragmentLayerResolved = true;
if (cachedFragmentLayer < 0)
{
Debug.LogWarning($"[FractureAndDrift] Layer '{FragmentLayerName}' not found. Add it in " +
"Project Settings > Tags and Layers so fragments can be isolated from collisions.");
}
}
return cachedFragmentLayer;
}
/// <summary>
/// Rotates <paramref name="direction"/> by a random angle within the scatter cone.
/// </summary>
private Vector3 ApplyScatter(Vector3 direction)
{
if (scatterAngle <= 0f)
{
return direction;
}
// Random rotation within a cone of half-angle 'scatterAngle' around 'direction'.
float angle = Random.Range(0f, scatterAngle);
float roll = Random.Range(0f, 360f);
Quaternion cone = Quaternion.AngleAxis(angle, Vector3.right) * Quaternion.identity;
Quaternion spinAround = Quaternion.AngleAxis(roll, Vector3.forward);
Quaternion align = Quaternion.FromToRotation(Vector3.forward, direction);
return align * spinAround * cone * Vector3.forward;
}
private Vector3 ResolveDriftDirection()
{
return directionIsLocal ? transform.TransformDirection(driftDirection) : driftDirection;
}
/// <summary>
/// Builds a template GameObject each fragment clones. Mirrors OpenFracture's Fracture.CreateFragmentTemplate.
/// </summary>
private GameObject CreateFragmentTemplate()
{
GameObject obj = new GameObject("Fragment") { tag = tag };
// Put every fragment on the dedicated fragment layer. The physics layer-collision matrix is
// configured (once) so this layer never collides with itself, which cleanly stops ANY fragment
// from colliding with ANY other fragment - same batch or a different track's batch, at any time.
int fragmentLayer = ResolveFragmentLayer();
if (fragmentLayer >= 0)
{
obj.layer = fragmentLayer;
}
obj.AddComponent<MeshFilter>();
// Normal material in slot 0, cut-face material in slot 1.
var meshRenderer = obj.AddComponent<MeshRenderer>();
meshRenderer.sharedMaterials = new Material[2]
{
GetComponent<MeshRenderer>().sharedMaterial,
fractureOptions.insideMaterial
};
var thisCollider = GetComponent<Collider>();
var fragmentCollider = obj.AddComponent<MeshCollider>();
fragmentCollider.convex = true;
if (thisCollider != null)
{
fragmentCollider.sharedMaterial = thisCollider.sharedMaterial;
fragmentCollider.isTrigger = thisCollider.isTrigger;
}
var thisRigidBody = GetComponent<Rigidbody>();
var fragmentRigidBody = obj.AddComponent<Rigidbody>();
fragmentRigidBody.linearDamping = thisRigidBody.linearDamping;
fragmentRigidBody.angularDamping = thisRigidBody.angularDamping;
fragmentRigidBody.useGravity = fragmentsUseGravity;
// Root cause of the "fragments accelerate" blast: overlapping convex hulls are separated by PhysX
// with a near-unlimited velocity by default. Clamp it so overlaps resolve gently instead of
// launching pieces. Every fragment (both fracture paths) clones this template, so setting it here
// covers all of them, independent of any collision-layer/ignore configuration.
if (maxDepenetrationVelocity > 0f)
{
fragmentRigidBody.maxDepenetrationVelocity = maxDepenetrationVelocity;
}
return obj;
}
/// <summary>
/// Subdivision loop that mirrors <see cref="Fragmenter.Fracture"/>, but chooses the cut-plane
/// normal per <see cref="sliceMode"/> instead of a fully random one. This is what lets a given
/// axis be subdivided more finely so long/thin fragments don't come out stretched.
/// </summary>
private void FractureControlled(GameObject fragmentTemplate)
{
Mesh srcMesh = GetComponent<MeshFilter>().sharedMesh;
var sourceMesh = new FragmentData(srcMesh);
if (fractureDebugLogs)
{
Debug.Log($"[FractureAndDrift] '{name}' FractureControlled start. mode={sliceMode} " +
$"targetCount={fractureOptions.fragmentCount} sourceVerts={srcMesh.vertexCount} " +
$"sourceTris={srcMesh.triangles.Length / 3} subMeshes={srcMesh.subMeshCount} " +
$"srcBoundsSize={srcMesh.bounds.size} readable={srcMesh.isReadable}", this);
if (srcMesh.subMeshCount > 1)
{
Debug.LogWarning($"[FractureAndDrift] '{name}' source mesh has {srcMesh.subMeshCount} submeshes. " +
"OpenFracture only slices submesh 0 — geometry in other submeshes is dropped. " +
"Combine the model into a single submesh/material if pieces look missing or too coarse.", this);
}
}
var fragments = new Queue<FragmentData>();
fragments.Enqueue(sourceMesh);
// Subdivide the largest-remaining fragment each step until we hit the target count.
// Processing the largest one first (rather than stock FIFO) keeps fragment sizes even.
// Guard against degenerate slices: if a piece refuses to split (one side comes back empty),
// re-enqueuing it unchanged would spin forever AND leave that big block intact. We instead
// drop it into a "done" set so it stops being reconsidered, and bail out if nothing splits.
var done = new List<FragmentData>();
int guardIterations = fractureOptions.fragmentCount * 8 + 16;
int producedSplits = 0;
int degenerateSlices = 0;
while (fragments.Count + done.Count < fractureOptions.fragmentCount && fragments.Count > 0)
{
if (guardIterations-- <= 0)
{
if (fractureDebugLogs)
{
Debug.LogWarning($"[FractureAndDrift] '{name}' subdivision hit iteration guard; " +
"stopping early. The mesh likely can't be split further with the current settings.", this);
}
break;
}
FragmentData meshData = DequeueLargest(fragments);
meshData.CalculateBounds();
Vector3 normal = ChooseCutNormal(meshData.Bounds);
MeshSlicer.Slice(meshData,
normal,
meshData.Bounds.center,
fractureOptions.textureScale,
fractureOptions.textureOffset,
out FragmentData topSlice,
out FragmentData bottomSlice);
bool topEmpty = topSlice.triangleCount == 0;
bool bottomEmpty = bottomSlice.triangleCount == 0;
// A real split yields geometry on BOTH sides. If one side is empty the plane didn't actually
// divide this piece, so keep it aside as finished instead of looping on it forever.
if (topEmpty || bottomEmpty)
{
degenerateSlices++;
done.Add(meshData);
continue;
}
producedSplits++;
fragments.Enqueue(topSlice);
fragments.Enqueue(bottomSlice);
}
int i = 0;
var parentSize = srcMesh.bounds.size;
var parentMass = GetComponent<Rigidbody>().mass;
float density = (parentSize.x * parentSize.y * parentSize.z) / Mathf.Max(parentMass, Mathf.Epsilon);
foreach (FragmentData meshData in fragments)
{
CreateControlledFragment(meshData, fragmentTemplate, density, ref i);
}
foreach (FragmentData meshData in done)
{
CreateControlledFragment(meshData, fragmentTemplate, density, ref i);
}
if (fractureDebugLogs)
{
Debug.Log($"[FractureAndDrift] '{name}' FractureControlled done. successfulSplits={producedSplits} " +
$"degenerateSlices={degenerateSlices} fragmentsCreated={i}. " +
(degenerateSlices > producedSplits && producedSplits < 4
? "Most slices failed to divide the mesh -> that is why you still see big blocks. "
: ""), this);
}
}
/// <summary>
/// Picks the slice-plane normal for the next cut based on the current fragment bounds and slice mode.
/// </summary>
private Vector3 ChooseCutNormal(Bounds bounds)
{
if (sliceMode == SliceMode.LongestAxis)
{
Vector3 size = bounds.size;
// Normal points along the longest dimension, so the cut plane is perpendicular to it
// and splits that long dimension in half.
Vector3 normal = Vector3.right;
if (size.y >= size.x && size.y >= size.z) normal = Vector3.up;
else if (size.z >= size.x && size.z >= size.y) normal = Vector3.forward;
if (longestAxisJitter > 0f)
{
normal += Random.insideUnitSphere * longestAxisJitter;
}
return normal.sqrMagnitude > Mathf.Epsilon ? normal.normalized : Vector3.up;
}
// AxisWeighted: bias each component by its weight so heavier axes get more perpendicular cuts.
Vector3 w = axisWeights;
Vector3 weighted = new Vector3(
(fractureOptions.xAxis ? 1f : 0f) * Mathf.Max(0f, w.x) * Random.Range(-1f, 1f),
(fractureOptions.yAxis ? 1f : 0f) * Mathf.Max(0f, w.y) * Random.Range(-1f, 1f),
(fractureOptions.zAxis ? 1f : 0f) * Mathf.Max(0f, w.z) * Random.Range(-1f, 1f));
return weighted.sqrMagnitude > Mathf.Epsilon ? weighted.normalized : Vector3.up;
}
/// <summary>
/// Removes and returns the fragment with the largest bounding-box volume from the queue,
/// preserving the order of the remaining items.
/// </summary>
private static FragmentData DequeueLargest(Queue<FragmentData> fragments)
{
int count = fragments.Count;
FragmentData largest = null;
float largestVolume = float.MinValue;
// Rotate the queue once, tracking the largest, then rotate again dropping that one.
for (int i = 0; i < count; i++)
{
FragmentData candidate = fragments.Dequeue();
candidate.CalculateBounds();
Vector3 s = candidate.Bounds.size;
float volume = s.x * s.y * s.z;
if (volume > largestVolume)
{
largestVolume = volume;
largest = candidate;
}
fragments.Enqueue(candidate);
}
for (int i = 0; i < count; i++)
{
FragmentData candidate = fragments.Dequeue();
if (!ReferenceEquals(candidate, largest))
{
fragments.Enqueue(candidate);
}
}
return largest;
}
/// <summary>
/// Instantiates a fragment GameObject from mesh data. Mirrors the private Fragmenter.CreateFragment.
/// </summary>
private void CreateControlledFragment(FragmentData meshData, GameObject fragmentTemplate, float density, ref int i)
{
if (meshData.triangleCount == 0)
{
return;
}
Mesh[] meshes;
Mesh fragmentMesh = meshData.ToMesh();
if (fractureOptions.detectFloatingFragments)
{
meshes = MeshUtils.FindDisconnectedMeshes(fragmentMesh);
}
else
{
meshes = new Mesh[] { fragmentMesh };
}
for (int k = 0; k < meshes.Length; k++)
{
GameObject fragment = Instantiate(fragmentTemplate, fragmentRoot.transform);
fragment.name = $"Fragment{i}";
fragment.transform.localPosition = Vector3.zero;
fragment.transform.localRotation = Quaternion.identity;
fragment.transform.localScale = transform.localScale;
meshes[k].name = System.Guid.NewGuid().ToString();
fragment.GetComponent<MeshFilter>().sharedMesh = meshes[k];
var collider = fragment.GetComponent<MeshCollider>();
collider.sharedMesh = meshes[k];
collider.convex = true;
var size = meshes[k].bounds.size;
var rigidBody = fragment.GetComponent<Rigidbody>();
rigidBody.mass = (size.x * size.y * size.z) / Mathf.Max(density, Mathf.Epsilon);
i++;
}
}
}
/// <summary>
/// Continuously pulls a set of rigidbodies toward a fixed world-space acceleration every physics step.
/// Attached to the fragment container by <see cref="FractureAndDrift"/> when "Use Custom Gravity" is on,
/// so fragments drift toward an arbitrary direction instead of Unity's straight-down global gravity.
/// It lives on the surviving fragment root (not the source object, which is deactivated after shattering).
/// </summary>
public class FragmentGravityField : MonoBehaviour
{
/// <summary>World-space acceleration (m/s^2) applied to every fragment each FixedUpdate.</summary>
public Vector3 gravity;
private Rigidbody[] bodies;
private void FixedUpdate()
{
// Refresh the body list lazily: FindDisconnectedMeshes can add fragments a frame late,
// and null entries appear as the container is torn down at end of life.
if (bodies == null)
{
bodies = GetComponentsInChildren<Rigidbody>();
}
for (int i = 0; i < bodies.Length; i++)
{
Rigidbody body = bodies[i];
if (body == null || body.isKinematic)
{
continue;
}
// ForceMode.Acceleration ignores mass, so every fragment falls at the same rate (like real gravity).
body.AddForce(gravity, ForceMode.Acceleration);
}
}
}
/// <summary>
/// Collects (destroys) a batch of fragments asynchronously instead of all at once. Attached to the
/// surviving fragment container by <see cref="FractureAndDrift"/>. It either waits for an explicit
/// <see cref="BeginReclaim"/> call (driven by the track controller's fade timing) or, as a fallback,
/// auto-starts after <c>fallbackDelay</c> seconds. Fragments are destroyed one-by-one spread across
/// <c>stagger</c> seconds, optionally shrinking each one to nothing first for a graceful collect.
/// </summary>
public class FragmentReclaimer : MonoBehaviour
{
private float fallbackDelay;
private float stagger;
private bool shrink;
private bool reclaiming;
public void Configure(float fallbackDelay, float stagger, bool shrink)
{
this.fallbackDelay = fallbackDelay;
this.stagger = Mathf.Max(0f, stagger);
this.shrink = shrink;
// Fallback timer: if nothing calls BeginReclaim() first, start on our own after the delay.
if (fallbackDelay > 0f)
{
Invoke(nameof(BeginReclaim), fallbackDelay);
}
}
/// <summary>
/// Starts the staggered collection. Safe to call multiple times; only the first call takes effect.
/// </summary>
public void BeginReclaim()
{
if (reclaiming)
{
return;
}
reclaiming = true;
CancelInvoke(nameof(BeginReclaim));
StartCoroutine(ReclaimRoutine());
}
private IEnumerator ReclaimRoutine()
{
// Snapshot the current fragments (skip the top-level container transform itself).
var fragments = new List<Transform>();
foreach (Transform child in transform)
{
if (child != null)
{
fragments.Add(child);
}
}
int count = fragments.Count;
if (count == 0)
{
Destroy(gameObject);
yield break;
}
// Time budget between consecutive fragment removals.
float interval = count > 1 ? stagger / (count - 1) : 0f;
for (int i = 0; i < count; i++)
{
Transform fragment = fragments[i];
if (fragment != null)
{
if (shrink)
{
StartCoroutine(ShrinkAndDestroy(fragment.gameObject, Mathf.Max(0.05f, interval)));
}
else
{
Destroy(fragment.gameObject);
}
}
if (interval > 0f)
{
yield return new WaitForSeconds(interval);
}
}
// Once the last fragment is gone (allow shrink time to finish), remove the container.
yield return new WaitForSeconds(shrink ? Mathf.Max(0.05f, interval) : 0f);
Destroy(gameObject);
}
private IEnumerator ShrinkAndDestroy(GameObject fragment, float duration)
{
Transform t = fragment.transform;
Vector3 startScale = t.localScale;
float elapsed = 0f;
while (elapsed < duration && fragment != null)
{
elapsed += Time.deltaTime;
float k = Mathf.Clamp01(elapsed / duration);
t.localScale = Vector3.Lerp(startScale, Vector3.zero, k);
yield return null;
}
if (fragment != null)
{
Destroy(fragment);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7d79617e4a494b34e80c479dd978a949
+34 -3
View File
@@ -9,6 +9,9 @@ public class eula_and_warnings : MonoBehaviour
{
private const string MainSceneName = "Main_main";
private const string RankingConsentPrefKey = "before_everything_agree_ranking";
private const string FirstLaunchCompletedPrefKey = "before_everything_first_launch_completed";
private const string LightWarningAcceptedPrefKey = "before_everything_light_warning_accepted";
private const string UserInfoAcceptedPrefKey = "before_everything_user_info_accepted";
private const float LightWarningMinimumSeconds = 5f;
private const float FadeDuration = 0.3f;
private const float RequiredScrollReadThreshold = 0.1f;
@@ -44,6 +47,9 @@ public class eula_and_warnings : MonoBehaviour
private bool _isTransitioning;
private bool _mainScenePreloadRequested;
private bool _mainScenePreloadReady;
private bool _hasCompletedFirstLaunch;
private bool _hasAcceptedLightWarning;
private bool _hasAcceptedUserInfoEula;
private readonly List<GameObject> _hiddenMainSceneRoots = new List<GameObject>();
private void Awake()
@@ -55,6 +61,14 @@ public class eula_and_warnings : MonoBehaviour
private void Start()
{
LoadPersistedConsentState();
if (ShouldSkipConfirmationFlow())
{
ActivateMainScene();
return;
}
PlayLightWarningIntro();
}
@@ -96,6 +110,18 @@ public class eula_and_warnings : MonoBehaviour
_isTransitioning = false;
}
private void LoadPersistedConsentState()
{
_hasCompletedFirstLaunch = PlayerPrefs.GetInt(FirstLaunchCompletedPrefKey, 0) == 1;
_hasAcceptedLightWarning = PlayerPrefs.GetInt(LightWarningAcceptedPrefKey, 0) == 1;
_hasAcceptedUserInfoEula = PlayerPrefs.GetInt(UserInfoAcceptedPrefKey, 0) == 1;
}
private bool ShouldSkipConfirmationFlow()
{
return _hasCompletedFirstLaunch && _hasAcceptedLightWarning && _hasAcceptedUserInfoEula;
}
private void PlayLightWarningIntro()
{
if (lightWarningObj == null || lightWarningCG == null)
@@ -203,6 +229,10 @@ public class eula_and_warnings : MonoBehaviour
return;
}
_hasAcceptedLightWarning = true;
PlayerPrefs.SetInt(LightWarningAcceptedPrefKey, 1);
PlayerPrefs.Save();
TransitionCanvasGroup(lightWarningObj, lightWarningCG, false, () =>
{
BeginPreloadMainScene();
@@ -235,6 +265,10 @@ public class eula_and_warnings : MonoBehaviour
}
bool agreeRanking = uie_agreeRanking != null && uie_agreeRanking.isOn;
_hasAcceptedUserInfoEula = true;
_hasCompletedFirstLaunch = true;
PlayerPrefs.SetInt(UserInfoAcceptedPrefKey, 1);
PlayerPrefs.SetInt(FirstLaunchCompletedPrefKey, 1);
PlayerPrefs.SetInt(RankingConsentPrefKey, agreeRanking ? 1 : 0);
PlayerPrefs.Save();
@@ -248,9 +282,6 @@ public class eula_and_warnings : MonoBehaviour
private static void MarkPolicyRejectedAndQuit()
{
PlayerPrefs.SetInt(RankingConsentPrefKey, 0);
PlayerPrefs.Save();
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
@@ -223,6 +223,13 @@ public class friendCardPrefab : MonoBehaviour
ReleaseRuntimeAvatarResources();
string resolvedAvatarUrl = NetworkManager.ResolveAvatarUrl(data.AvatarUrl, data.SteamId);
if (playerMessagePrefab.TryGetCachedAvatarSprite(data.SteamId, resolvedAvatarUrl, out Sprite cachedSprite) && cachedSprite != null)
{
friendProfile.sprite = cachedSprite;
_avatarLoadRoutine = null;
yield break;
}
bool loaded = false;
if (NetworkManager.IsUsableAvatarUrl(resolvedAvatarUrl)
@@ -132,7 +132,14 @@ public class friendRequestPrefab : MonoBehaviour
}
bool loaded = false;
if (NetworkManager.IsUsableAvatarUrl(avatarUrl)
if (playerMessagePrefab.TryGetCachedAvatarSprite(steamId, avatarUrl, out Sprite cachedSprite) && cachedSprite != null)
{
profileImage.sprite = cachedSprite;
loaded = true;
}
if (!loaded
&& NetworkManager.IsUsableAvatarUrl(avatarUrl)
&& !avatarUrl.StartsWith(NetworkManager.SteamAvatarUrlPrefix, StringComparison.OrdinalIgnoreCase))
{
using (UnityWebRequest request = UnityWebRequestTexture.GetTexture(avatarUrl))
@@ -124,7 +124,14 @@ public class friendSearchPrefab : MonoBehaviour
}
bool loaded = false;
if (NetworkManager.IsUsableAvatarUrl(avatarUrl)
if (playerMessagePrefab.TryGetCachedAvatarSprite(steamId, avatarUrl, out Sprite cachedSprite) && cachedSprite != null)
{
profileImage.sprite = cachedSprite;
loaded = true;
}
if (!loaded
&& NetworkManager.IsUsableAvatarUrl(avatarUrl)
&& !avatarUrl.StartsWith(NetworkManager.SteamAvatarUrlPrefix, StringComparison.OrdinalIgnoreCase))
{
using (UnityWebRequest request = UnityWebRequestTexture.GetTexture(avatarUrl))
+9
View File
@@ -6,6 +6,7 @@ public class gTransBlack : MonoBehaviour
{
[SerializeField] private Canvas b_Canvas;
[SerializeField] private CanvasGroup b_CG;
[SerializeField] private Text nowLoadingText;
public Canvas Canvas => b_Canvas;
public CanvasGroup CanvasGroup => b_CG;
@@ -38,6 +39,14 @@ public class gTransBlack : MonoBehaviour
ApplySceneCamera();
}
public void SetNowLoadingText(string text)
{
if (nowLoadingText != null)
{
nowLoadingText.text = text;
}
}
private void EnsureReferences()
{
if (b_Canvas == null)
@@ -45,6 +45,195 @@ public class AnimationController : MonoBehaviour
// Track active particle instances for immediate cleanup
private List<GameObject> activeParticles = new List<GameObject>();
// Per-hit FX pooling. PlayDestroyAnimation used to Instantiate + Destroy three
// objects on every judge (hit particle, hit ring, judge text prefab). Under dense
// note bursts that is the dominant GC + instantiation spike. We now reuse instances
// from a pool keyed by template, and cache each instance's ParticleSystem[] +
// computed lifetime so the hot path never calls GetComponentsInChildren per hit.
// Business behavior is unchanged: same templates, same tint, same lifetime, same
// parenting; instances are recycled instead of created and destroyed.
private sealed class FxCacheEntry
{
public ParticleSystem[] Systems;
public float Lifetime;
}
private readonly Dictionary<GameObject, Queue<GameObject>> fxPools =
new Dictionary<GameObject, Queue<GameObject>>();
private readonly Dictionary<GameObject, FxCacheEntry> fxCache =
new Dictionary<GameObject, FxCacheEntry>();
private readonly Dictionary<GameObject, GameObject> fxInstanceTemplate =
new Dictionary<GameObject, GameObject>();
// Instances currently rented out. Return is guarded by this set so the timed
// RecycleFxRoutine and an explicit StopHoldParticles cleanup can never return the
// same instance twice (which would double-enqueue it and hand it out concurrently).
private readonly HashSet<GameObject> rentedFx = new HashSet<GameObject>();
// Per-rent token. Each rent bumps a counter and records it for the instance. The
// timed RecycleFxRoutine captures its token and only returns the instance if the
// token still matches, so a stale timer from a previous rent cannot recycle an
// instance that was already returned early (StopHoldParticles) and re-rented.
private int fxRentCounter;
private readonly Dictionary<GameObject, int> fxRentId = new Dictionary<GameObject, int>();
private Transform fxPoolRoot;
private void EnsureFxPoolRoot()
{
if (fxPoolRoot == null)
{
var root = new GameObject("AnimationControllerFxPool");
root.transform.SetParent(transform, false);
root.SetActive(false);
fxPoolRoot = root.transform;
}
}
// Creates one instance of the template, caches its particle systems + lifetime,
// warms the play path once, and leaves it stopped/cleared ready for reuse.
private GameObject CreateFxInstance(GameObject template)
{
if (template == null)
return null;
GameObject instance = Instantiate(template);
var systems = instance.GetComponentsInChildren<ParticleSystem>(true);
if (systems == null)
systems = new ParticleSystem[0];
var entry = new FxCacheEntry
{
Systems = systems,
Lifetime = ComputeFxLifetime(systems)
};
foreach (var ps in systems)
{
if (ps == null) continue;
try { ps.Play(true); } catch { }
try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { }
try { ps.Clear(true); } catch { }
}
fxCache[instance] = entry;
fxInstanceTemplate[instance] = template;
return instance;
}
private GameObject RentFxInstance(GameObject template, out FxCacheEntry entry, out int rentToken)
{
entry = null;
rentToken = -1;
if (template == null)
return null;
if (fxPools.TryGetValue(template, out var pool))
{
while (pool.Count > 0)
{
GameObject pooled = pool.Dequeue();
if (pooled != null && fxCache.TryGetValue(pooled, out entry))
{
rentToken = MarkRented(pooled);
return pooled;
}
}
}
GameObject created = CreateFxInstance(template);
if (created != null)
{
fxCache.TryGetValue(created, out entry);
rentToken = MarkRented(created);
}
return created;
}
private int CurrentRentToken(GameObject instance)
{
return fxRentId.TryGetValue(instance, out int token) ? token : -1;
}
private int MarkRented(GameObject instance)
{
rentedFx.Add(instance);
int token = ++fxRentCounter;
fxRentId[instance] = token;
return token;
}
private void ReturnFxInstance(GameObject instance)
{
if (instance == null)
return;
// Idempotent: if this instance was already returned, ignore the second call.
if (!rentedFx.Remove(instance))
return;
fxRentId.Remove(instance);
activeParticles.Remove(instance);
if (fxCache.TryGetValue(instance, out var entry) && entry.Systems != null)
{
foreach (var ps in entry.Systems)
{
if (ps == null) continue;
try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { }
try { ps.Clear(true); } catch { }
}
}
instance.SetActive(false);
EnsureFxPoolRoot();
instance.transform.SetParent(fxPoolRoot, false);
if (!fxInstanceTemplate.TryGetValue(instance, out var template) || template == null)
{
// Unknown origin (should not happen for pooled instances): drop it.
Destroy(instance);
return;
}
if (!fxPools.TryGetValue(template, out var pool))
{
pool = new Queue<GameObject>();
fxPools[template] = pool;
}
pool.Enqueue(instance);
}
private IEnumerator RecycleFxRoutine(GameObject instance, int rentToken, float delay)
{
yield return new WaitForSeconds(delay);
if (instance == null)
yield break;
// Only recycle if this instance is still on the same rent. If it was
// returned early (StopHoldParticles) and re-rented in the meantime, its
// token changed and this stale timer must not touch it.
if (CurrentRentToken(instance) != rentToken)
yield break;
ReturnFxInstance(instance);
}
private static float ComputeFxLifetime(ParticleSystem[] systems)
{
float maxLifetime = 0f;
if (systems == null)
return maxLifetime;
foreach (var ps in systems)
{
if (ps == null) continue;
var main = ps.main;
var lifetime = main.startLifetime;
float life = lifetime.constantMax;
if (life <= 0f) life = lifetime.constant;
if (life > maxLifetime) maxLifetime = life;
}
return maxLifetime;
}
[Header("Inspector")]
public GameObject perfect_judge_prefab;
public GameObject great_judge_prefab;
@@ -107,12 +296,16 @@ public class AnimationController : MonoBehaviour
holdParticleCoroutine = null;
}
// Immediately destroy all active particle instances
foreach (var p in activeParticles)
// Immediately return all active pooled instances (was Destroy). Returning is
// idempotent and token-guarded, so a later timed RecycleFxRoutine for the same
// instance becomes a no-op. Iterate a snapshot because ReturnFxInstance removes
// the instance from activeParticles (mutating the list mid-enumeration would throw).
var snapshot = activeParticles.ToArray();
foreach (var p in snapshot)
{
if (p != null)
{
Destroy(p);
ReturnFxInstance(p);
}
}
activeParticles.Clear();
@@ -235,8 +428,8 @@ public class AnimationController : MonoBehaviour
}
SpawnJudgePrefabByTrackText(color);
// Instantiate a copy of the scene object
GameObject particleInstance = Instantiate(source, spawnPos, Quaternion.identity);
// Rent a pooled copy of the scene particle source (was Instantiate + Destroy per hit).
GameObject particleInstance = RentFxInstance(source, out var particleEntry, out int particleToken);
if (particleInstance == null)
{
Debug.LogError("Failed to instantiate hit_particular_object");
@@ -244,104 +437,67 @@ public class AnimationController : MonoBehaviour
return;
}
// Place at spawn position, then optionally parent under the effect object so it
// moves with UI/slot (worldPositionStays=true keeps world pos at spawnPos).
particleInstance.transform.SetParent(parentTransform, false);
particleInstance.transform.position = spawnPos;
particleInstance.transform.rotation = Quaternion.identity;
particleInstance.SetActive(true);
// Find all ParticleSystems on the instance (root + children) and set their startColor
var systems = particleInstance.GetComponentsInChildren<ParticleSystem>(true);
var systems = particleEntry != null ? particleEntry.Systems : null;
if (systems != null && systems.Length > 0)
{
float maxLifetime = 0f;
foreach (var ps in systems)
{
if (ps == null) continue;
var main = ps.main;
// Set start color (works for most setups)
main.startColor = trackColor;
// Play the system
ps.Play();
// determine lifetime (use constantMax for safety)
var lifetime = main.startLifetime;
float life = lifetime.constantMax;
if (life <= 0f) life = lifetime.constant; // fallback
if (life > maxLifetime) maxLifetime = life;
}
// Optionally parent the instance under the effect object so it moves with UI/slot
if (parentTransform != null)
{
particleInstance.transform.SetParent(parentTransform, true);
}
// Recycle after cached lifetime + small buffer (token-guarded).
StartCoroutine(RecycleFxRoutine(particleInstance, particleToken, Mathf.Max(0.5f, particleEntry.Lifetime + 0.1f)));
// Destroy after max lifetime + small buffer
Destroy(particleInstance, Mathf.Max(0.5f, maxLifetime + 0.1f));
// Track for immediate cleanup
// Track for immediate cleanup on hold stop.
activeParticles.Add(particleInstance);
}
else
{
Debug.LogError("No ParticleSystem components found on hit_particular_object instance");
Destroy(particleInstance);
ReturnFxInstance(particleInstance);
TriggerAnimatorEffect(color);
}
// NEW: instantiate and play ring effect (overlay) if assigned
// Ring effect (overlay) if assigned.
if (hit_ring_object != null)
{
GameObject ringInstance = Instantiate(hit_ring_object, spawnPos, Quaternion.identity);
GameObject ringInstance = RentFxInstance(hit_ring_object, out var ringEntry, out int ringToken);
if (ringInstance != null)
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"AnimationController: instantiated hit_ring_object '{ringInstance.name}' at {spawnPos}. parentTransform={(parentTransform != null ? parentTransform.name : "null")} ");
// Parent before activation to avoid transform surprises when Simulation Space = Local
if (parentTransform != null)
{
ringInstance.transform.SetParent(parentTransform, false);
// keep world position at spawnPos
ringInstance.transform.position = spawnPos;
if (JudgeManager.IsDebugEnabled) Debug.Log("AnimationController: ringInstance parent set to " + parentTransform.name);
}
// Activate after parenting
// Parent before activation to avoid transform surprises when Simulation Space = Local.
ringInstance.transform.SetParent(parentTransform, false);
ringInstance.transform.position = spawnPos;
ringInstance.transform.rotation = Quaternion.identity;
ringInstance.SetActive(true);
var ringSystems = ringInstance.GetComponentsInChildren<ParticleSystem>(true);
if (JudgeManager.IsDebugEnabled) Debug.Log("AnimationController: ringSystems count=" + (ringSystems != null ? ringSystems.Length : 0));
var ringSystems = ringEntry != null ? ringEntry.Systems : null;
if (ringSystems != null && ringSystems.Length > 0)
{
float ringMaxLife = 0f;
foreach (var rps in ringSystems)
{
if (rps == null) continue;
var rmain = rps.main;
// log important runtime properties for debugging
if (JudgeManager.IsDebugEnabled) Debug.Log($"ring PS: {rps.gameObject.name} simulationSpace={rmain.simulationSpace} startLifetime={rmain.startLifetime.constant} startColor={rmain.startColor.color}");
// reuse same track color
rmain.startColor = trackColor;
rps.Play();
var rlifetime = rmain.startLifetime;
float rlife = rlifetime.constantMax;
if (rlife <= 0f) rlife = rlifetime.constant;
if (rlife > ringMaxLife) ringMaxLife = rlife;
// Also log emission rate if available
try
{
var emission = rps.emission;
var rate = emission.rateOverTime;
if (JudgeManager.IsDebugEnabled) Debug.Log($"ring PS emission rateOverTime.constant (approx) = {rate.constant}");
}
catch { }
}
Destroy(ringInstance, Mathf.Max(0.5f, ringMaxLife + 0.1f));
// Track for immediate cleanup
StartCoroutine(RecycleFxRoutine(ringInstance, ringToken, Mathf.Max(0.5f, ringEntry.Lifetime + 0.1f)));
activeParticles.Add(ringInstance);
}
else
{
if (JudgeManager.IsDebugEnabled) Debug.LogWarning("hit_ring_object has no ParticleSystem components");
Destroy(ringInstance);
ReturnFxInstance(ringInstance);
}
}
else
@@ -452,11 +608,18 @@ public class AnimationController : MonoBehaviour
if (prefabToUse != null)
{
// Documentation text normalized.
GameObject instance = Instantiate(prefabToUse, spawnTransform.position, Quaternion.identity);
// Rent a pooled copy of the judge-text prefab (was Instantiate + Destroy per hit).
GameObject instance = RentFxInstance(prefabToUse, out _, out int judgeToken);
if (instance == null)
return;
// Documentation text normalized.
Destroy(instance, 1.0f);
instance.transform.SetParent(null, false);
instance.transform.position = spawnTransform.position;
instance.transform.rotation = Quaternion.identity;
instance.SetActive(true);
// Same 1.0s visible lifetime as before, token-guarded recycle instead of Destroy.
StartCoroutine(RecycleFxRoutine(instance, judgeToken, 1.0f));
}
}
@@ -473,50 +636,45 @@ public class AnimationController : MonoBehaviour
private IEnumerator PrewarmCoroutine(int perTemplate)
{
// List of templates to warm
// Templates that flow through PlayDestroyAnimation on every judge. Seeding the
// FX pool here (instead of instantiate-then-destroy) means the first real hits
// rent a ready instance rather than paying Instantiate + first-Play on the hot
// path. CreateFxInstance already warms each instance's play path once.
var templates = new List<GameObject>();
if (hit_particular_object != null) templates.Add(hit_particular_object);
if (hit_ring_object != null) templates.Add(hit_ring_object);
if (perfect_judge_prefab != null) templates.Add(perfect_judge_prefab);
if (great_judge_prefab != null) templates.Add(great_judge_prefab);
if (good_judge_prefab != null) templates.Add(good_judge_prefab);
if (miss_judge_prefab != null) templates.Add(miss_judge_prefab);
EnsureFxPoolRoot();
for (int i = 0; i < templates.Count; i++)
{
var prefab = templates[i];
var template = templates[i];
for (int j = 0; j < perTemplate; j++)
{
GameObject inst = null;
try
{
inst = Instantiate(prefab, this.transform);
inst.SetActive(true);
var systems = inst.GetComponentsInChildren<ParticleSystem>(true);
if (systems != null)
inst = CreateFxInstance(template);
if (inst != null)
{
foreach (var ps in systems)
inst.SetActive(false);
inst.transform.SetParent(fxPoolRoot, false);
if (!fxPools.TryGetValue(template, out var pool))
{
try { ps.Play(); } catch { }
pool = new Queue<GameObject>();
fxPools[template] = pool;
}
pool.Enqueue(inst);
}
}
catch { }
// wait a frame to allow any internal initialization to run
yield return null;
// stop and destroy the instance to free memory - the warmup work is done
if (inst != null)
{
var systems = inst.GetComponentsInChildren<ParticleSystem>(true);
if (systems != null)
{
foreach (var ps in systems)
{
try { ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); } catch { }
}
}
Destroy(inst);
}
// small yield to spread work across frames
// spread work across frames to avoid a long load frame
yield return null;
}
}
@@ -504,7 +504,11 @@ public class GameManager : MonoBehaviour
var judgeFx = Animation_GenerateJudgementSituationPrefab.Instance
?? SceneObjectLookupCache.FindAny<Animation_GenerateJudgementSituationPrefab>();
judgeFx?.PrewarmJudgePrefabs(1);
judgeFx?.PrewarmJudgePrefabs(5);
var trackHitFx = TrackJudgeHitEffectController.Instance
?? SceneObjectLookupCache.FindAny<TrackJudgeHitEffectController>();
trackHitFx?.PrewarmTrackParticles(10);
var fxEvent = effectEventController.Instance ?? SceneObjectLookupCache.FindAny<effectEventController>();
if (fxEvent != null && fxEvent.isActiveAndEnabled)
+41 -11
View File
@@ -43,6 +43,20 @@ public class HoldNote : BaseNote
// whether this middle segment was held from the start (used for logic checks)
private bool hasBeenHeldFromStart = false;
// Previous-frame held state for this track, used to derive key-down / key-up edges
// from the platform-agnostic InputManager.IsTrackHeld table instead of polling
// Input.GetKeyDown/Up directly (which touch cannot drive on Android).
private bool prevTrackHeld = false;
// Query the shared held-state table (keyboard OR touch). Falls back to legacy
// Input.GetKey if the InputManager is somehow absent so editor/standalone still works.
private bool IsHeld()
{
var im = InputManager.Instance;
if (im != null) return im.IsTrackHeld(trackIndex);
return Input.GetKey(keyToPress);
}
[Header("Judge configuration")]
public NoteJudgeConfig judgeConfig; // judge windows configuration
private NoteData noteData;
@@ -56,9 +70,9 @@ public class HoldNote : BaseNote
private List<UnityEngine.UI.Graphic> cachedUIComponents = new List<UnityEngine.UI.Graphic>();
[Header("Hold judgement adjustments")]
[Tooltip("Multiplier applied to judgement windows for hold notes. >1 makes hold judgement more lenient (wider windows).")]
[Range(1f, 2f)]
public float holdWindowMultiplier = 1.3f;
[Tooltip("Multiplier applied to judgement windows for hold notes. Lower values make hold judgement stricter.")]
[Range(0.1f, 2f)]
public float holdWindowMultiplier = 0.8f;
public float visualSpeedMultiplier = 1f; // injected from NoteSpawner to adapt windows when visual speed changes
@@ -434,6 +448,16 @@ public class HoldNote : BaseNote
bool debugEnabled = JudgeManager.IsDebugEnabled;
float now = Time.time;
// Sample held state and derive key-down/up edges at the very top, BEFORE any
// early return. Unity's Input.GetKeyDown/Up are global per-frame edges that
// don't depend on whether we polled last frame; updating prevTrackHeld every
// frame reproduces that. If we only sampled after the early exits below, a key
// already held when the note enters range would produce a false keyDown.
bool keyHeld = IsHeld();
bool keyDown = keyHeld && !prevTrackHeld;
bool keyUp = !keyHeld && prevTrackHeld;
prevTrackHeld = keyHeld;
// Optimization: Early exit if the note is far from judgment line and hasn't entered yet
if (!hasEnteredLine)
{
@@ -448,10 +472,6 @@ public class HoldNote : BaseNote
return;
}
bool keyHeld = Input.GetKey(keyToPress);
bool keyDown = Input.GetKeyDown(keyToPress);
bool keyUp = Input.GetKeyUp(keyToPress);
// If start was judged and player is holding key, start hold effects
if (!isHoldActive && jm != null && jm.IsStartJudged(noteID) && !jm.HasNoteReleased(noteID) && keyHeld)
{
@@ -694,8 +714,8 @@ public class HoldNote : BaseNote
}
else if (segment == NoteSegment.Middle)
{
hasBeenHeldFromStart = Input.GetKey(keyToPress);
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Middle enter: color={noteColor}, isStartJudged={JudgeManager.Instance.IsStartJudged(noteID)}, hasReleased={JudgeManager.Instance.HasNoteReleased(noteID)}, isKeyHeld={Input.GetKey(keyToPress)}, hasBeenHeldFromStart={hasBeenHeldFromStart}");
hasBeenHeldFromStart = IsHeld();
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] Middle enter: color={noteColor}, isStartJudged={JudgeManager.Instance.IsStartJudged(noteID)}, hasReleased={JudgeManager.Instance.HasNoteReleased(noteID)}, isKeyHeld={IsHeld()}, hasBeenHeldFromStart={hasBeenHeldFromStart}");
if (autoReturnCoroutine != null)
StopCoroutine(autoReturnCoroutine);
@@ -716,7 +736,7 @@ public class HoldNote : BaseNote
// If the key is still being held down when end segment enters judge zone, immediately judge.
// Autoplay must ignore physical input to guarantee Perfect at scheduledEndTime.
if (!GameConfig.autoPlayEnabled && Input.GetKey(keyToPress) && JudgeManager.Instance.IsStartJudged(noteID) && !isJudged)
if (!GameConfig.autoPlayEnabled && IsHeld() && JudgeManager.Instance.IsStartJudged(noteID) && !isJudged)
{
if (JudgeManager.IsDebugEnabled) Debug.Log($"[HoldNote] End entered while key still held: {noteColor}, forcing Perfect");
// Record release time as current time (player is still holding)
@@ -980,6 +1000,11 @@ public class HoldNote : BaseNote
JudgeSoundManager.Instance?.PlayJudgeSound(result);
}
if (result != "Miss")
{
TrackJudgeHitEffectController.PlayTrackHitFx(trackIndex);
}
// Do not spawn the judgement animation prefab for Start (head) presses to avoid duplicated prefabs
if (segment != NoteSegment.Start)
{
@@ -1152,6 +1177,11 @@ public class HoldNote : BaseNote
teamUIController.Instance?.OnJudgeResult(result);
}
if (result != "Miss")
{
TrackJudgeHitEffectController.PlayTrackHitFx(trackIndex);
}
// Ensure END always spawns judgement prefab (including Miss)
Animation_GenerateJudgementSituationPrefab.Instance?.SpawnJudgePrefab(noteColor, result);
@@ -1295,7 +1325,7 @@ public class HoldNote : BaseNote
// record release time and register release in JudgeManager
if (!hasReleased)
{
if (Input.GetKey(keyToPress))
if (IsHeld())
{
releaseTime = scheduledEndTime;
}
@@ -3,6 +3,10 @@ using TMPro;
using System;
using UnityEngine.UI;
// Runs before default-order scripts so the per-track held-state table (trackHeld) is
// updated from keyboard/touch this frame *before* hold notes read it. This preserves the
// frame-accurate timing the old direct Input.GetKey polling had in HoldNote.
[DefaultExecutionOrder(-100)]
public class InputManager : MonoBehaviour
{
public static InputManager Instance { get; private set; }
@@ -42,6 +46,21 @@ public class InputManager : MonoBehaviour
private KeyCode[] cachedKeys = new KeyCode[5];
private bool pauseBlockedLastFrame = false;
// Per-track held state. Written by both keyboard (Update) and touch (PressTrack/
// ReleaseTrack) so hold-note logic can query one platform-agnostic source instead
// of polling Input.GetKey directly (which cannot be driven by touch on Android).
private bool[] trackHeld = new bool[5];
/// <summary>
/// True while the given track (0-4) is currently held, regardless of whether the
/// source is keyboard or touch. Replaces direct Input.GetKey polling in hold notes.
/// </summary>
public bool IsTrackHeld(int trackIndex)
{
if (trackIndex < 0 || trackIndex >= trackHeld.Length) return false;
return trackHeld[trackIndex];
}
private void Awake()
{
if (Instance == null)
@@ -111,45 +130,61 @@ public class InputManager : MonoBehaviour
KeyCode key = cachedKeys[i];
if (key == KeyCode.None) continue;
int index = i; // TrackColors array index matches track index logic here
if (Input.GetKeyDown(key))
{
OnKeyPressed?.Invoke(key);
// Documentation text normalized.
// Update lane sprite alpha
if (laneSprites != null && index >= 0 && index < laneSprites.Length)
{
SetSpriteAlpha(laneSprites[index], pressedAlpha);
}
// Documentation text normalized.
if (trackKeyTexts != null && index >= 0 && index < trackKeyTexts.Length)
{
var txt = trackKeyTexts[index];
if (txt != null)
txt.color = keyActiveColor;
}
}
PressTrack(i);
if (Input.GetKeyUp(key))
{
OnKeyReleased?.Invoke(key);
ReleaseTrack(i);
}
}
// Update lane sprite alpha
if (laneSprites != null && index >= 0 && index < laneSprites.Length)
{
SetSpriteAlpha(laneSprites[index], 0f);
}
/// <summary>
/// Begin holding a track (0-4). Called by keyboard Update on key-down and by touch
/// regions on pointer-down. Sets the held-state table, raises OnKeyPressed with the
/// track's bound key so event-driven tap notes keep working, and updates lane visuals.
/// </summary>
public void PressTrack(int index)
{
if (index < 0 || index >= TrackColors.Length) return;
if (trackHeld[index]) return; // already held; avoid duplicate press events
// Documentation text normalized.
if (trackKeyTexts != null && index >= 0 && index < trackKeyTexts.Length)
{
var txt = trackKeyTexts[index];
if (txt != null)
txt.color = keyInactiveColor;
}
}
trackHeld[index] = true;
KeyCode key = cachedKeys[index];
if (key != KeyCode.None)
OnKeyPressed?.Invoke(key);
if (laneSprites != null && index < laneSprites.Length)
SetSpriteAlpha(laneSprites[index], pressedAlpha);
if (trackKeyTexts != null && index < trackKeyTexts.Length)
{
var txt = trackKeyTexts[index];
if (txt != null) txt.color = keyActiveColor;
}
}
/// <summary>
/// Release a track (0-4). Called by keyboard Update on key-up and by touch regions
/// on pointer-up. Clears the held-state table, raises OnKeyReleased, and resets visuals.
/// </summary>
public void ReleaseTrack(int index)
{
if (index < 0 || index >= TrackColors.Length) return;
if (!trackHeld[index]) return; // not held; nothing to release
trackHeld[index] = false;
KeyCode key = cachedKeys[index];
if (key != KeyCode.None)
OnKeyReleased?.Invoke(key);
if (laneSprites != null && index < laneSprites.Length)
SetSpriteAlpha(laneSprites[index], 0f);
if (trackKeyTexts != null && index < trackKeyTexts.Length)
{
var txt = trackKeyTexts[index];
if (txt != null) txt.color = keyInactiveColor;
}
}
@@ -169,6 +204,7 @@ public class InputManager : MonoBehaviour
for (int i = 0; i < TrackColors.Length; i++)
{
string color = TrackColors[i];
trackHeld[i] = false; // clear held state so hold notes don't see a stale press after pause/clear
KeyCode key = KeyBindingManager.GetKeyForColor(color);
if (key == KeyCode.None) continue;
try { OnKeyReleased?.Invoke(key); } catch { }
@@ -15,4 +15,4 @@ MonoBehaviour:
perfectRange: 0.1
greatRange: 0.15
goodRange: 0.2
missRange: 0.25
missRange: 0.2
+20 -4
View File
@@ -159,7 +159,11 @@ public class Note : BaseNote
private void Awake()
{
anim = GetComponent<AnimationController>();
anim = AnimationController.Global;
if (anim == null)
{
anim = GetComponent<AnimationController>();
}
controller = GetComponent<NoteController>();
}
@@ -298,6 +302,11 @@ public class Note : BaseNote
effectEventController.TryTriggerMultiNoteShake();
}
if (judgeResult != "Miss")
{
TrackJudgeHitEffectController.PlayTrackHitFx(TrackIndex);
}
globalNoteEffect.TryTrigger(noteData != null ? noteData.noteFunction : null);
Judge();
@@ -464,6 +473,11 @@ public class Note : BaseNote
effectEventController.TryTriggerMultiNoteShake();
}
if (judgeResult != "Miss")
{
TrackJudgeHitEffectController.PlayTrackHitFx(TrackIndex);
}
globalNoteEffect.TryTrigger(noteData != null ? noteData.noteFunction : null);
}
@@ -498,12 +512,14 @@ public class Note : BaseNote
hasLock = false;
}
ReturnToPool();
if (anim != null)
var animationController = AnimationController.Global ?? anim;
if (animationController != null)
{
anim.PlayDestroyAnimation(noteColor);
animationController.PlayDestroyAnimation(noteColor);
}
ReturnToPool();
// notify global judge manager that this short note has been finally judged (hit)
JudgeManager.Instance?.NotifyNoteJudged();
}
@@ -14,5 +14,5 @@ public class NoteJudgeConfig : ScriptableObject
[Tooltip("Good")]
public float goodRange = 0.3f;
[Tooltip("Miss")]
public float missRange = 0.5f;
public float missRange = 0.2f;
}
+9 -8
View File
@@ -115,7 +115,7 @@ public class NotePool : MonoBehaviour
var anim = AnimationController.Global ?? SceneObjectLookupCache.FindAny<AnimationController>();
if (anim != null)
{
anim.PrewarmParticles(2);
anim.PrewarmParticles(6);
if (verboseLogging) Debug.Log("NotePool: requested AnimationController prewarm");
}
}
@@ -192,6 +192,7 @@ public class NotePool : MonoBehaviour
if (pool.Count > 0)
{
GameObject obj = pool.Pop();
GamePlay.PoolItem pi = null;
if (obj == null)
{
// Documentation text normalized.
@@ -199,26 +200,26 @@ public class NotePool : MonoBehaviour
else
{
// Documentation text normalized.
GamePlay.PoolItem pi = obj.GetComponent<GamePlay.PoolItem>();
pi = obj.GetComponent<GamePlay.PoolItem>();
if (pi == null || prefab == null || pi.prefabName != prefab.name)
{
// Documentation text normalized.
if (pi == null && verboseLogging) Debug.LogWarning("PoolItem missing on pooled object, replacing.");
Destroy(obj);
obj = InstantiateAndPrepare(prefab);
pi = obj != null ? obj.GetComponent<GamePlay.PoolItem>() : null;
}
}
obj.transform.SetParent(null);
obj.SetActive(true);
// mark as taken from pool
GamePlay.PoolItem takenPi = obj.GetComponent<GamePlay.PoolItem>();
if (takenPi != null)
// mark as taken from pool (reuse the PoolItem already resolved above)
if (pi != null)
{
takenPi.inPool = false;
if (takenPi.initialLocalScaleCaptured)
pi.inPool = false;
if (pi.initialLocalScaleCaptured)
{
obj.transform.localScale = takenPi.initialLocalScale;
obj.transform.localScale = pi.initialLocalScale;
}
}
@@ -100,6 +100,7 @@ public class NoteSpawner : MonoBehaviour
private Dictionary<int, int> noteIndexToHoldId = new Dictionary<int, int>();
private const string NoteSpeedPrefKey = "noteSpeedMultiplier";
private const float NoteSpeedDefault = 1f;
private const float BaseTravelDistance = 10.75f;
private Coroutine spawnCoroutine;
@@ -118,13 +119,25 @@ public class NoteSpawner : MonoBehaviour
private void Awake()
{
EnsureDefaultNoteSpeedPreference();
// Load saved visual speed multiplier before any spawning logic uses it
float saved = PlayerPrefs.GetFloat(NoteSpeedPrefKey, speedMultiplier);
float saved = PlayerPrefs.GetFloat(NoteSpeedPrefKey, NoteSpeedDefault);
saved = Mathf.Clamp(saved, SpeedMultiplierMin, SpeedMultiplierMax);
speedMultiplier = saved;
if (JudgeManager.IsDebugEnabled) Debug.Log($"[NoteSpawner] Loaded speedMultiplier={speedMultiplier} from PlayerPrefs");
}
private static void EnsureDefaultNoteSpeedPreference()
{
if (PlayerPrefs.HasKey(NoteSpeedPrefKey))
{
return;
}
PlayerPrefs.SetFloat(NoteSpeedPrefKey, NoteSpeedDefault);
PlayerPrefs.Save();
}
private void OnEnable()
{
// Documentation text normalized.
@@ -429,7 +442,7 @@ public class NoteSpawner : MonoBehaviour
}
int segmentCount = Mathf.CeilToInt(noteData.length / segmentInterval);
if (segmentCount < 1) segmentCount = 1; // Documentation text normalized.
if (segmentCount < 2) segmentCount = 2; // Force at least one middle segment between start and end.
float actualSegmentInterval = segmentCount > 0 ? noteData.length / segmentCount : segmentInterval;
Transform spawnPoint = spawnPoints[noteData.trackIndex];
@@ -0,0 +1,996 @@
using System;
using System.Collections;
using System.Collections.Generic;
using KD.Destro2D;
using UnityEngine;
public class TrackCrashController : MonoBehaviour
{
public static TrackCrashController Instance { get; private set; }
public enum TrackCrashShape
{
Auto,
Radial,
RectCut
}
[System.Serializable]
public class TrackEntry
{
public int trackIndex;
public GameObject trackObject;
public bool hideOriginalWhenCrashed = true;
public float restoreDelay = 0f;
}
[Header("Track Crash")]
[SerializeField] private List<TrackEntry> tracks = new List<TrackEntry>(5);
[SerializeField] private bool useDestro2D = true;
[SerializeField] private bool autoRestoreOnTrackCrash = false;
[SerializeField] private float defaultRestoreDelay = 0f;
[SerializeField] private bool enableDebugKeyTrigger = true;
[SerializeField] private bool debugLogs = true;
[SerializeField] private bool useAutoFractureSize = true;
[SerializeField] private float fractureCoreRadius = 0.05f;
[SerializeField] private float fractureOuterRadius = 0.45f;
[SerializeField] private float fractureOuterRadiusScale = 1.1f;
[SerializeField] private float fractureNoiseScale = 10f;
[SerializeField] private float fractureThickness = 0.12f;
[SerializeField] private int fractureLines = 8;
[SerializeField] private TrackCrashShape trackCrashShape = TrackCrashShape.Auto;
[SerializeField] private float rectCutOversize = 1.15f;
[SerializeField] private float chunkDetectDelay = 0.08f;
[SerializeField] private int rectCutCount = 3;
[SerializeField] private float rectCutBandScale = 0.05f;
[SerializeField] private float rectCutPositionSpan = 0.65f;
[SerializeField] private int rectCrossCutCount = 1;
[SerializeField] private float rectCrossCutBandScale = 0.22f;
[SerializeField] private float chunkExplodeForce = 2.4f;
[SerializeField] private float chunkExplodeTorque = 30f;
[SerializeField] private Vector2 chunkWindDirection = Vector2.zero;
[SerializeField] private float chunkWindForce = 0f;
[SerializeField] private Vector2 chunkGravityDirection = Vector2.down;
[SerializeField] private bool keepDestro2DBaseTrackVisible = true;
[SerializeField] private bool keepChunksInPlace = true;
[SerializeField] private bool disableChunkGravity = true;
[SerializeField] private bool autoTuneSplitHandler = true;
[SerializeField] private int splitHandlerMaxChunkCount = 12;
[SerializeField] private int splitHandlerMinCount = 3;
[SerializeField] private bool manualChunkMotion = true;
[SerializeField] private float chunkGravityStrength = 6f;
[SerializeField] private float chunkLinearDamping = 4f;
[SerializeField] private float chunkAngularDamping = 5f;
[SerializeField] private float chunkMotionDuration = 0.45f;
[SerializeField] private bool useRuntimeSpriteFallback = true;
[SerializeField] private int runtimeFallbackColumns = 3;
[SerializeField] private int runtimeFallbackRows = 12;
[SerializeField] private int runtimeFallbackMinVisiblePieces = 6;
[SerializeField] private float runtimeFallbackRandomOffset = 0.015f;
private readonly Dictionary<int, TrackEntry> trackMap = new Dictionary<int, TrackEntry>();
private readonly HashSet<int> crashedTracks = new HashSet<int>();
private readonly HashSet<int> processingTracks = new HashSet<int>();
private readonly Dictionary<int, Coroutine> restoreRoutines = new Dictionary<int, Coroutine>();
private readonly Dictionary<int, GameObject> fractureProxies = new Dictionary<int, GameObject>();
private void Awake()
{
if (Instance == null)
{
Instance = this;
}
else if (Instance != this)
{
Destroy(gameObject);
return;
}
RebuildCache();
}
private void OnValidate()
{
if (!Application.isPlaying)
return;
RebuildCache();
}
private void Update()
{
if (!enableDebugKeyTrigger)
return;
if (Input.GetKeyDown(KeyCode.Alpha1) || Input.GetKeyDown(KeyCode.Keypad1))
{
if (debugLogs) Debug.Log("[TrackCrash] Debug key 1 pressed.");
TriggerTrackCrash(0);
}
else if (Input.GetKeyDown(KeyCode.Alpha2) || Input.GetKeyDown(KeyCode.Keypad2))
{
if (debugLogs) Debug.Log("[TrackCrash] Debug key 2 pressed.");
TriggerTrackCrash(1);
}
else if (Input.GetKeyDown(KeyCode.Alpha3) || Input.GetKeyDown(KeyCode.Keypad3))
{
if (debugLogs) Debug.Log("[TrackCrash] Debug key 3 pressed.");
TriggerTrackCrash(2);
}
else if (Input.GetKeyDown(KeyCode.Alpha4) || Input.GetKeyDown(KeyCode.Keypad4))
{
if (debugLogs) Debug.Log("[TrackCrash] Debug key 4 pressed.");
TriggerTrackCrash(3);
}
else if (Input.GetKeyDown(KeyCode.Alpha5) || Input.GetKeyDown(KeyCode.Keypad5))
{
if (debugLogs) Debug.Log("[TrackCrash] Debug key 5 pressed.");
TriggerTrackCrash(4);
}
}
private void RebuildCache()
{
trackMap.Clear();
for (int i = 0; i < tracks.Count; i++)
{
TrackEntry entry = tracks[i];
if (entry == null || entry.trackObject == null)
continue;
trackMap[entry.trackIndex] = entry;
}
}
public static void TrackCrash(int trackIndex)
{
var controller = Instance != null ? Instance : SceneObjectLookupCache.FindAny<TrackCrashController>();
if (controller == null)
return;
controller.TriggerTrackCrash(trackIndex);
}
public static void RestoreTrack(int trackIndex)
{
var controller = Instance != null ? Instance : SceneObjectLookupCache.FindAny<TrackCrashController>();
if (controller == null)
return;
controller.RestoreTrackInternal(trackIndex);
}
public void TriggerTrackCrash(int trackIndex)
{
if (!trackMap.TryGetValue(trackIndex, out TrackEntry entry) || entry == null || entry.trackObject == null)
{
if (debugLogs) Debug.LogWarning($"[TrackCrash] Missing track entry or track object for index {trackIndex}.");
return;
}
if (crashedTracks.Contains(trackIndex))
{
if (debugLogs) Debug.Log($"[TrackCrash] Track {trackIndex} is already crashed.");
return;
}
if (processingTracks.Contains(trackIndex))
{
if (debugLogs) Debug.Log($"[TrackCrash] Track {trackIndex} is already processing.");
return;
}
StartCoroutine(TriggerTrackCrashRoutine(trackIndex, entry));
}
private IEnumerator TriggerTrackCrashRoutine(int trackIndex, TrackEntry entry)
{
processingTracks.Add(trackIndex);
try
{
DestroyFractureProxy(trackIndex);
bool fractureSucceeded = !useDestro2D;
SplitHandler splitHandler = null;
int chunkCountBefore = -1;
int chunkCountAfter = -1;
float outerRadius = fractureOuterRadius;
bool waitForChunkDetection = false;
bool usedDestro2D = false;
bool usedRuntimeSpriteFallback = false;
Vector2 fractureCenter = entry.trackObject.transform.position;
Destro2DMain resolvedDestro = null;
SpriteRenderer resolvedSpriteRenderer = null;
TrackCrashShape resolvedShape = TrackCrashShape.Radial;
GameObject fractureTarget = entry.trackObject;
if (useDestro2D)
{
fractureTarget = CreateFractureProxy(trackIndex, entry);
var destro = fractureTarget != null ? fractureTarget.GetComponent<KD.Destro2D.Destro2DMain>() : null;
if (destro == null)
{
destro = fractureTarget != null ? fractureTarget.GetComponentInChildren<KD.Destro2D.Destro2DMain>(true) : null;
}
if (destro != null)
{
usedDestro2D = true;
resolvedDestro = destro;
if (debugLogs) Debug.Log($"[TrackCrash] Fracturing track {trackIndex} on {fractureTarget.name}.");
try
{
splitHandler = destro.GetComponent<SplitHandler>();
TuneSplitHandler(splitHandler);
chunkCountBefore = splitHandler != null ? splitHandler.splitChunks.Count : -1;
var spriteRenderer = ResolveDestroSpriteRenderer(destro, fractureTarget);
resolvedSpriteRenderer = spriteRenderer;
resolvedShape = ResolveCrashShape(spriteRenderer);
if (spriteRenderer != null)
{
fractureCenter = spriteRenderer.bounds.center;
if (useAutoFractureSize)
{
float extent = Mathf.Max(spriteRenderer.bounds.extents.x, spriteRenderer.bounds.extents.y);
outerRadius = Mathf.Max(fractureOuterRadius, extent * fractureOuterRadiusScale);
}
}
TriggerDestro2D(destro, spriteRenderer, fractureCenter, outerRadius);
waitForChunkDetection = true;
}
catch (Exception ex)
{
fractureSucceeded = false;
if (debugLogs) Debug.LogError($"[TrackCrash] Fracture failed for track {trackIndex}: {ex}");
}
}
else if (debugLogs)
{
Debug.LogWarning($"[TrackCrash] No Destro2DMain found on track {trackIndex} object {(fractureTarget != null ? fractureTarget.name : "NULL")} or its children.");
}
}
if (waitForChunkDetection)
{
yield return new WaitForSecondsRealtime(Mathf.Max(0.01f, chunkDetectDelay));
chunkCountAfter = splitHandler != null ? splitHandler.splitChunks.Count : -1;
fractureSucceeded = splitHandler == null || chunkCountAfter > chunkCountBefore;
if (!fractureSucceeded && resolvedDestro != null && resolvedSpriteRenderer != null && resolvedShape == TrackCrashShape.RectCut)
{
if (debugLogs)
{
Debug.Log($"[TrackCrash] Track {trackIndex} primary cut did not split. Applying aggressive fallback cut.");
}
ApplyAggressiveRectCut(resolvedDestro, resolvedSpriteRenderer, fractureCenter);
yield return new WaitForSecondsRealtime(Mathf.Max(0.01f, chunkDetectDelay));
chunkCountAfter = splitHandler != null ? splitHandler.splitChunks.Count : -1;
fractureSucceeded = splitHandler == null || chunkCountAfter > chunkCountBefore;
}
if (fractureSucceeded && splitHandler != null)
{
int visibleChunkCount = CountLiveChunks(splitHandler);
if (visibleChunkCount < 2)
{
fractureSucceeded = false;
if (debugLogs)
{
Debug.Log($"[TrackCrash] Track {trackIndex} Destro2D only produced {visibleChunkCount} visible chunk(s); switching to runtime sprite fallback.");
}
}
}
if (fractureSucceeded && splitHandler != null)
{
PrepareChunksForFinalState(splitHandler, fractureCenter);
}
if (debugLogs)
{
Debug.Log($"[TrackCrash] Track {trackIndex} chunk count before={chunkCountBefore}, after={chunkCountAfter}, success={fractureSucceeded}, outerRadius={outerRadius}.");
}
}
if (!fractureSucceeded && useRuntimeSpriteFallback)
{
DestroyFractureProxy(trackIndex);
fractureTarget = CreateRuntimeSpriteFallbackProxy(trackIndex, entry, out int pieceCount);
fractureSucceeded = fractureTarget != null && pieceCount >= runtimeFallbackMinVisiblePieces;
usedRuntimeSpriteFallback = fractureSucceeded;
if (debugLogs)
{
Debug.Log($"[TrackCrash] Track {trackIndex} runtime sprite fallback pieces={pieceCount}, success={fractureSucceeded}.");
}
}
if (!fractureSucceeded)
{
DestroyFractureProxy(trackIndex);
yield break;
}
crashedTracks.Add(trackIndex);
bool shouldHideOriginalObject = entry.hideOriginalWhenCrashed;
if (usedDestro2D)
{
shouldHideOriginalObject = true;
}
if (shouldHideOriginalObject)
{
entry.trackObject.SetActive(false);
}
if (usedDestro2D && !usedRuntimeSpriteFallback && fractureTarget != null)
{
if (keepDestro2DBaseTrackVisible)
{
fractureTarget.SetActive(true);
}
else
{
fractureTarget.SetActive(false);
}
}
if (usedRuntimeSpriteFallback && fractureTarget != null)
{
fractureTarget.SetActive(true);
}
if (autoRestoreOnTrackCrash)
{
float delay = entry.restoreDelay > 0f ? entry.restoreDelay : defaultRestoreDelay;
if (delay > 0f)
{
if (restoreRoutines.TryGetValue(trackIndex, out var routine) && routine != null)
{
StopCoroutine(routine);
}
restoreRoutines[trackIndex] = StartCoroutine(RestoreAfterDelay(trackIndex, delay));
}
}
}
finally
{
processingTracks.Remove(trackIndex);
}
}
private void TriggerDestro2D(Destro2DMain destro, SpriteRenderer spriteRenderer, Vector2 fractureCenter, float outerRadius)
{
if (destro == null)
return;
TrackCrashShape shape = ResolveCrashShape(spriteRenderer);
if (shape == TrackCrashShape.RectCut)
{
ApplyRectCut(destro, spriteRenderer, fractureCenter);
return;
}
destro.DynamicFracture(fractureCenter, fractureCoreRadius, outerRadius, fractureNoiseScale, fractureThickness, fractureLines);
}
private TrackCrashShape ResolveCrashShape(SpriteRenderer spriteRenderer)
{
if (trackCrashShape != TrackCrashShape.Auto)
return trackCrashShape;
if (spriteRenderer == null)
return TrackCrashShape.Radial;
Vector3 size = spriteRenderer.bounds.size;
return size.y > size.x * 2f || size.x > size.y * 2f
? TrackCrashShape.RectCut
: TrackCrashShape.Radial;
}
private void ApplyRectCut(Destro2DMain destro, SpriteRenderer spriteRenderer, Vector2 fractureCenter)
{
ApplyRectCutInternal(destro, spriteRenderer, fractureCenter, Mathf.Max(1, rectCutCount), Mathf.Clamp01(rectCutPositionSpan), Mathf.Max(0.005f, rectCutBandScale));
ApplyCrossRectCutInternal(destro, spriteRenderer, fractureCenter, Mathf.Max(0, rectCrossCutCount), Mathf.Max(0.01f, rectCrossCutBandScale));
}
private void ApplyAggressiveRectCut(Destro2DMain destro, SpriteRenderer spriteRenderer, Vector2 fractureCenter)
{
ApplyRectCutInternal(
destro,
spriteRenderer,
fractureCenter,
Mathf.Max(3, rectCutCount + 2),
Mathf.Max(0.75f, Mathf.Clamp01(rectCutPositionSpan)),
Mathf.Max(0.08f, rectCutBandScale));
ApplyCrossRectCutInternal(
destro,
spriteRenderer,
fractureCenter,
Mathf.Max(1, rectCrossCutCount + 1),
Mathf.Max(0.12f, rectCrossCutBandScale));
}
private void ApplyRectCutInternal(Destro2DMain destro, SpriteRenderer spriteRenderer, Vector2 fractureCenter, int cutCount, float span, float bandScale)
{
if (destro == null || spriteRenderer == null)
return;
Vector3 extents = spriteRenderer.bounds.extents;
bool verticalTrack = extents.y >= extents.x;
for (int i = 0; i < cutCount; i++)
{
float t = cutCount == 1 ? 0.5f : (float)i / (cutCount - 1);
float offsetNormalized = Mathf.Lerp(-span, span, t);
Vector2 cutPosition = fractureCenter;
RectDestruction rect = new RectDestruction();
if (verticalTrack)
{
cutPosition.y += extents.y * offsetNormalized;
rect.l = Mathf.Max(fractureThickness, extents.x * rectCutOversize);
rect.b = Mathf.Max(fractureThickness, extents.y * bandScale);
}
else
{
cutPosition.x += extents.x * offsetNormalized;
rect.l = Mathf.Max(fractureThickness, extents.x * bandScale);
rect.b = Mathf.Max(fractureThickness, extents.y * rectCutOversize);
}
rect.Setup(destro.gameObject);
destro.DynamicDestroyWorld(cutPosition, new List<Destruction> { rect });
}
}
private void ApplyLongitudinalCut(Destro2DMain destro, SpriteRenderer spriteRenderer, Vector2 fractureCenter)
{
if (destro == null || spriteRenderer == null)
return;
Vector3 extents = spriteRenderer.bounds.extents;
bool verticalTrack = extents.y >= extents.x;
RectDestruction rect = new RectDestruction();
if (verticalTrack)
{
rect.l = Mathf.Max(fractureThickness * 0.6f, extents.x * 0.1f);
rect.b = Mathf.Max(fractureThickness, extents.y * rectCutOversize);
}
else
{
rect.l = Mathf.Max(fractureThickness, extents.x * rectCutOversize);
rect.b = Mathf.Max(fractureThickness * 0.6f, extents.y * 0.1f);
}
rect.Setup(destro.gameObject);
destro.DynamicDestroyWorld(fractureCenter, new List<Destruction> { rect });
}
private void ApplyCrossRectCutInternal(Destro2DMain destro, SpriteRenderer spriteRenderer, Vector2 fractureCenter, int cutCount, float bandScale)
{
if (destro == null || spriteRenderer == null || cutCount <= 0)
return;
Vector3 extents = spriteRenderer.bounds.extents;
bool verticalTrack = extents.y >= extents.x;
float usableSpan = 0.6f;
for (int i = 0; i < cutCount; i++)
{
float t = cutCount == 1 ? 0.5f : (float)i / (cutCount - 1);
float offsetNormalized = Mathf.Lerp(-usableSpan, usableSpan, t);
Vector2 cutPosition = fractureCenter;
RectDestruction rect = new RectDestruction();
if (verticalTrack)
{
cutPosition.x += extents.x * offsetNormalized;
rect.l = Mathf.Max(fractureThickness, extents.x * bandScale);
rect.b = Mathf.Max(fractureThickness, extents.y * rectCutOversize);
}
else
{
cutPosition.y += extents.y * offsetNormalized;
rect.l = Mathf.Max(fractureThickness, extents.x * rectCutOversize);
rect.b = Mathf.Max(fractureThickness, extents.y * bandScale);
}
rect.Setup(destro.gameObject);
destro.DynamicDestroyWorld(cutPosition, new List<Destruction> { rect });
}
}
private void TuneSplitHandler(SplitHandler splitHandler)
{
if (!autoTuneSplitHandler || splitHandler == null)
return;
splitHandler.MaxChunkCount = Mathf.Max(splitHandler.MaxChunkCount, splitHandlerMaxChunkCount);
splitHandler.minCount = Mathf.Max(1, Mathf.Min(splitHandler.minCount, splitHandlerMinCount));
}
private SpriteRenderer ResolveDestroSpriteRenderer(Destro2DMain destro, GameObject fallbackObject)
{
if (destro != null)
{
SpriteRenderer directRenderer = destro.GetComponent<SpriteRenderer>();
if (directRenderer != null)
return directRenderer;
SpriteRenderer childRenderer = destro.GetComponentInChildren<SpriteRenderer>(true);
if (childRenderer != null)
return childRenderer;
}
if (fallbackObject != null)
{
SpriteRenderer entryRenderer = fallbackObject.GetComponent<SpriteRenderer>();
if (entryRenderer != null)
return entryRenderer;
return fallbackObject.GetComponentInChildren<SpriteRenderer>(true);
}
return null;
}
private void PushChunksAway(SplitHandler splitHandler, Vector2 fractureCenter)
{
if (splitHandler == null || splitHandler.splitChunks == null || splitHandler.splitChunks.Count == 0)
return;
Vector2 normalizedWindDirection = chunkWindDirection.sqrMagnitude > 0.0001f
? chunkWindDirection.normalized
: Vector2.zero;
foreach (GameObject chunk in splitHandler.splitChunks)
{
if (chunk == null)
continue;
Rigidbody2D rb = chunk.GetComponent<Rigidbody2D>();
SplitHandler chunkSplitHandler = chunk.GetComponent<SplitHandler>();
if (rb == null)
continue;
Vector2 worldChunkCenter = chunk.transform.position;
if (chunkSplitHandler != null)
{
worldChunkCenter = chunk.transform.TransformPoint(chunkSplitHandler.chunkCentre);
}
Vector2 direction = worldChunkCenter - fractureCenter;
if (direction.sqrMagnitude < 0.0001f)
{
direction = UnityEngine.Random.insideUnitCircle.normalized;
}
Vector2 forceVector = direction.normalized * chunkExplodeForce;
if (normalizedWindDirection != Vector2.zero && chunkWindForce > 0f)
{
forceVector += normalizedWindDirection * chunkWindForce;
}
rb.linearVelocity = Vector2.zero;
rb.angularVelocity = 0f;
rb.AddForce(forceVector, ForceMode2D.Impulse);
if (chunkExplodeTorque > 0f)
{
float torqueSign = UnityEngine.Random.value > 0.5f ? 1f : -1f;
rb.AddTorque(chunkExplodeTorque * torqueSign, ForceMode2D.Impulse);
}
}
}
private void PrepareChunksForFinalState(SplitHandler splitHandler, Vector2 fractureCenter)
{
if (splitHandler == null || splitHandler.splitChunks == null || splitHandler.splitChunks.Count == 0)
return;
if (manualChunkMotion)
{
StartCoroutine(DriveChunksManually(splitHandler, fractureCenter));
return;
}
if (!keepChunksInPlace)
{
PushChunksAway(splitHandler, fractureCenter);
return;
}
foreach (GameObject chunk in splitHandler.splitChunks)
{
if (chunk == null)
continue;
Rigidbody2D rb = chunk.GetComponent<Rigidbody2D>();
if (rb == null)
continue;
rb.linearVelocity = Vector2.zero;
rb.angularVelocity = 0f;
if (disableChunkGravity)
{
rb.gravityScale = 0f;
}
rb.constraints = RigidbodyConstraints2D.FreezePosition | RigidbodyConstraints2D.FreezeRotation;
}
}
private System.Collections.IEnumerator RestoreAfterDelay(int trackIndex, float delay)
{
yield return new WaitForSecondsRealtime(delay);
RestoreTrackInternal(trackIndex);
restoreRoutines.Remove(trackIndex);
processingTracks.Remove(trackIndex);
}
private void RestoreTrackInternal(int trackIndex)
{
if (!trackMap.TryGetValue(trackIndex, out TrackEntry entry) || entry == null || entry.trackObject == null)
return;
DestroyFractureProxy(trackIndex);
if (entry.trackObject != null)
{
entry.trackObject.SetActive(true);
}
crashedTracks.Remove(trackIndex);
}
public void RebuildFromSceneLookup()
{
RebuildCache();
}
private IEnumerator DriveChunksManually(SplitHandler splitHandler, Vector2 fractureCenter)
{
List<Rigidbody2D> rigidbodies = new List<Rigidbody2D>();
List<Vector2> velocities = new List<Vector2>();
List<float> angularVelocities = new List<float>();
Vector2 normalizedWind = chunkWindDirection.sqrMagnitude > 0.0001f ? chunkWindDirection.normalized : Vector2.zero;
Vector2 normalizedGravity = disableChunkGravity || chunkGravityDirection.sqrMagnitude <= 0.0001f
? Vector2.zero
: chunkGravityDirection.normalized;
foreach (GameObject chunk in splitHandler.splitChunks)
{
if (chunk == null)
continue;
Rigidbody2D rb = chunk.GetComponent<Rigidbody2D>();
if (rb == null)
continue;
SplitHandler chunkSplitHandler = chunk.GetComponent<SplitHandler>();
Vector2 worldChunkCenter = chunk.transform.position;
if (chunkSplitHandler != null)
{
worldChunkCenter = chunk.transform.TransformPoint(chunkSplitHandler.chunkCentre);
}
Vector2 radialDirection = worldChunkCenter - fractureCenter;
if (radialDirection.sqrMagnitude < 0.0001f)
{
radialDirection = UnityEngine.Random.insideUnitCircle.normalized;
}
Vector2 velocity = Vector2.zero;
if (!keepChunksInPlace)
{
velocity += radialDirection.normalized * chunkExplodeForce;
velocity += normalizedWind * chunkWindForce;
}
rigidbodies.Add(rb);
velocities.Add(velocity);
angularVelocities.Add(UnityEngine.Random.Range(-chunkExplodeTorque, chunkExplodeTorque));
rb.gravityScale = 0f;
rb.linearVelocity = Vector2.zero;
rb.angularVelocity = 0f;
rb.constraints = RigidbodyConstraints2D.FreezeRotation;
}
float elapsed = 0f;
while (elapsed < chunkMotionDuration)
{
float dt = Time.deltaTime;
for (int i = 0; i < rigidbodies.Count; i++)
{
Rigidbody2D rb = rigidbodies[i];
if (rb == null)
continue;
Vector2 velocity = velocities[i];
float angularVelocity = angularVelocities[i];
velocity += normalizedGravity * chunkGravityStrength * dt;
velocity += normalizedWind * chunkWindForce * dt;
velocity = Vector2.Lerp(velocity, Vector2.zero, Mathf.Clamp01(chunkLinearDamping * dt));
angularVelocity = Mathf.Lerp(angularVelocity, 0f, Mathf.Clamp01(chunkAngularDamping * dt));
rb.position += velocity * dt;
rb.rotation += angularVelocity * dt;
velocities[i] = velocity;
angularVelocities[i] = angularVelocity;
}
elapsed += dt;
yield return null;
}
for (int i = 0; i < rigidbodies.Count; i++)
{
if (rigidbodies[i] == null)
continue;
rigidbodies[i].linearVelocity = Vector2.zero;
rigidbodies[i].angularVelocity = 0f;
if (keepChunksInPlace)
{
rigidbodies[i].constraints = RigidbodyConstraints2D.FreezePosition | RigidbodyConstraints2D.FreezeRotation;
}
}
}
private GameObject CreateFractureProxy(int trackIndex, TrackEntry entry)
{
if (entry == null || entry.trackObject == null)
return null;
GameObject proxy = Instantiate(entry.trackObject, entry.trackObject.transform.parent);
proxy.name = entry.trackObject.name + "_FractureProxy";
proxy.transform.position = entry.trackObject.transform.position;
proxy.transform.rotation = entry.trackObject.transform.rotation;
proxy.transform.localScale = entry.trackObject.transform.localScale;
fractureProxies[trackIndex] = proxy;
return proxy;
}
private void DestroyFractureProxy(int trackIndex)
{
if (!fractureProxies.TryGetValue(trackIndex, out GameObject proxy))
return;
fractureProxies.Remove(trackIndex);
if (proxy != null)
{
Destroy(proxy);
}
}
private int CountLiveChunks(SplitHandler splitHandler)
{
if (splitHandler == null || splitHandler.splitChunks == null)
return 0;
int count = 0;
foreach (GameObject chunk in splitHandler.splitChunks)
{
if (chunk == null || !chunk.activeInHierarchy)
continue;
SpriteRenderer spriteRenderer = chunk.GetComponent<SpriteRenderer>();
if (spriteRenderer == null || spriteRenderer.sprite == null)
continue;
count++;
}
return count;
}
private GameObject CreateRuntimeSpriteFallbackProxy(int trackIndex, TrackEntry entry, out int pieceCount)
{
pieceCount = 0;
if (entry == null || entry.trackObject == null)
return null;
SpriteRenderer sourceRenderer = ResolveDestroSpriteRenderer(null, entry.trackObject);
if (sourceRenderer == null || sourceRenderer.sprite == null || sourceRenderer.sprite.texture == null)
return null;
Sprite sourceSprite = sourceRenderer.sprite;
Rect sourceRect = sourceSprite.rect;
if (sourceRect.width < 2f || sourceRect.height < 2f)
return null;
int columns = Mathf.Max(1, runtimeFallbackColumns);
int rows = Mathf.Max(1, runtimeFallbackRows);
float aspect = sourceRect.height / Mathf.Max(1f, sourceRect.width);
if (aspect > 2f)
{
rows = Mathf.Max(rows, Mathf.CeilToInt(aspect * columns * 1.75f));
}
GameObject proxy = new GameObject(entry.trackObject.name + "_RuntimeFractureProxy");
proxy.transform.SetParent(entry.trackObject.transform.parent, false);
proxy.transform.position = sourceRenderer.transform.position;
proxy.transform.rotation = sourceRenderer.transform.rotation;
proxy.transform.localScale = sourceRenderer.transform.lossyScale;
fractureProxies[trackIndex] = proxy;
TrackCrashRuntimeSpriteCleanup cleanup = proxy.AddComponent<TrackCrashRuntimeSpriteCleanup>();
List<Transform> pieceTransforms = new List<Transform>();
List<Vector2> pieceVelocities = new List<Vector2>();
List<float> pieceAngularVelocities = new List<float>();
float pixelsPerUnit = Mathf.Max(1f, sourceSprite.pixelsPerUnit);
float minLocalX = -sourceSprite.pivot.x / pixelsPerUnit;
float minLocalY = -sourceSprite.pivot.y / pixelsPerUnit;
for (int row = 0; row < rows; row++)
{
float normalizedY0 = (float)row / rows;
float normalizedY1 = (float)(row + 1) / rows;
float yMin = sourceRect.y + (sourceRect.height * normalizedY0);
float yMax = sourceRect.y + (sourceRect.height * normalizedY1);
yMin = Mathf.Clamp(yMin, sourceRect.y, sourceRect.yMax - 1f);
yMax = Mathf.Clamp(yMax, yMin + 1f, sourceRect.yMax);
for (int col = 0; col < columns; col++)
{
float normalizedX0 = (float)col / columns;
float normalizedX1 = (float)(col + 1) / columns;
float xMin = sourceRect.x + (sourceRect.width * normalizedX0);
float xMax = sourceRect.x + (sourceRect.width * normalizedX1);
xMin = Mathf.Clamp(xMin, sourceRect.x, sourceRect.xMax - 1f);
xMax = Mathf.Clamp(xMax, xMin + 1f, sourceRect.xMax);
Rect pieceRect = Rect.MinMaxRect(xMin, yMin, xMax, yMax);
if (pieceRect.width < 1f || pieceRect.height < 1f)
continue;
GameObject piece = new GameObject($"Piece_{row}_{col}");
piece.transform.SetParent(proxy.transform, false);
SpriteRenderer pieceRenderer = piece.AddComponent<SpriteRenderer>();
pieceRenderer.sprite = Sprite.Create(
sourceSprite.texture,
pieceRect,
new Vector2(0.5f, 0.5f),
pixelsPerUnit,
0,
SpriteMeshType.FullRect);
cleanup.Register(pieceRenderer.sprite);
pieceRenderer.sharedMaterial = sourceRenderer.sharedMaterial;
pieceRenderer.color = sourceRenderer.color;
pieceRenderer.flipX = sourceRenderer.flipX;
pieceRenderer.flipY = sourceRenderer.flipY;
pieceRenderer.maskInteraction = sourceRenderer.maskInteraction;
pieceRenderer.sortingLayerID = sourceRenderer.sortingLayerID;
pieceRenderer.sortingOrder = sourceRenderer.sortingOrder;
pieceRenderer.renderingLayerMask = sourceRenderer.renderingLayerMask;
piece.layer = sourceRenderer.gameObject.layer;
float localPieceX = (pieceRect.x - sourceRect.x);
float localPieceY = (pieceRect.y - sourceRect.y);
float localCenterX = minLocalX + (localPieceX + pieceRect.width * 0.5f) / pixelsPerUnit;
float localCenterY = minLocalY + (localPieceY + pieceRect.height * 0.5f) / pixelsPerUnit;
Vector2 randomOffset = runtimeFallbackRandomOffset > 0f
? UnityEngine.Random.insideUnitCircle * runtimeFallbackRandomOffset
: Vector2.zero;
piece.transform.localPosition = new Vector3(localCenterX + randomOffset.x, localCenterY + randomOffset.y, 0f);
piece.transform.localRotation = Quaternion.identity;
piece.transform.localScale = Vector3.one;
pieceTransforms.Add(piece.transform);
Vector2 radialDirection = ((Vector2)piece.transform.position - (Vector2)sourceRenderer.bounds.center);
if (radialDirection.sqrMagnitude < 0.0001f)
{
radialDirection = UnityEngine.Random.insideUnitCircle.normalized;
}
Vector2 initialVelocity = Vector2.zero;
if (!keepChunksInPlace)
{
initialVelocity += radialDirection.normalized * chunkExplodeForce;
if (chunkWindDirection.sqrMagnitude > 0.0001f)
{
initialVelocity += chunkWindDirection.normalized * chunkWindForce;
}
}
pieceVelocities.Add(initialVelocity);
pieceAngularVelocities.Add(keepChunksInPlace ? 0f : UnityEngine.Random.Range(-chunkExplodeTorque, chunkExplodeTorque));
pieceCount++;
}
}
if (pieceCount <= 0)
{
Destroy(proxy);
fractureProxies.Remove(trackIndex);
return null;
}
if (manualChunkMotion && !keepChunksInPlace)
{
StartCoroutine(DriveRuntimeSpritePieces(pieceTransforms, pieceVelocities, pieceAngularVelocities));
}
return proxy;
}
private IEnumerator DriveRuntimeSpritePieces(
List<Transform> pieceTransforms,
List<Vector2> pieceVelocities,
List<float> pieceAngularVelocities)
{
Vector2 normalizedWind = chunkWindDirection.sqrMagnitude > 0.0001f ? chunkWindDirection.normalized : Vector2.zero;
Vector2 normalizedGravity = disableChunkGravity || chunkGravityDirection.sqrMagnitude <= 0.0001f
? Vector2.zero
: chunkGravityDirection.normalized;
float elapsed = 0f;
while (elapsed < chunkMotionDuration)
{
float dt = Time.deltaTime;
for (int i = 0; i < pieceTransforms.Count; i++)
{
Transform pieceTransform = pieceTransforms[i];
if (pieceTransform == null)
continue;
Vector2 velocity = pieceVelocities[i];
float angularVelocity = pieceAngularVelocities[i];
velocity += normalizedGravity * chunkGravityStrength * dt;
velocity += normalizedWind * chunkWindForce * dt;
velocity = Vector2.Lerp(velocity, Vector2.zero, Mathf.Clamp01(chunkLinearDamping * dt));
angularVelocity = Mathf.Lerp(angularVelocity, 0f, Mathf.Clamp01(chunkAngularDamping * dt));
pieceTransform.position += (Vector3)(velocity * dt);
pieceTransform.Rotate(0f, 0f, angularVelocity * dt, Space.Self);
pieceVelocities[i] = velocity;
pieceAngularVelocities[i] = angularVelocity;
}
elapsed += dt;
yield return null;
}
}
}
public sealed class TrackCrashRuntimeSpriteCleanup : MonoBehaviour
{
private readonly List<Sprite> runtimeSprites = new List<Sprite>();
public void Register(Sprite sprite)
{
if (sprite != null)
{
runtimeSprites.Add(sprite);
}
}
private void OnDestroy()
{
for (int i = 0; i < runtimeSprites.Count; i++)
{
if (runtimeSprites[i] != null)
{
Destroy(runtimeSprites[i]);
}
}
runtimeSprites.Clear();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3f3a5dd0d9a44f0e95d4a5fb83a0f001
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,443 @@
using System.Collections;
using System.Collections.Generic;
using SpriteGlow;
using UnityEngine;
public class TrackJudgeHitEffectController : MonoBehaviour
{
public static TrackJudgeHitEffectController Instance { get; private set; }
[Header("Track Sprite FX")]
[Tooltip("0-4 maps to track 1-5.")]
[SerializeField] private GameObject[] trackSpriteFxObjects = new GameObject[5];
[Header("Track Glow FX")]
[Tooltip("0-4 maps to track 1-5.")]
[SerializeField] private SpriteGlowEffect[] trackGlowEffects = new SpriteGlowEffect[5];
[SerializeField, Range(0f, 1f)] private float trackGlowSpriteAlpha = 0f;
[Header("Particle FX")]
[SerializeField] private GameObject trackParticlePrefab;
[SerializeField] private Vector3 trackParticleLocalPosition = new Vector3(0f, -5.29f, -0.13f);
[SerializeField] private Vector3 trackParticleLocalEulerAngles = new Vector3(70f, 0f, 0f);
[SerializeField] private Vector3 trackParticleLocalScale = Vector3.one;
[Header("Track Colors")]
[SerializeField] private Color[] trackColors = new Color[5]
{
new Color(1f, 0.44f, 0.42f, 1f),
new Color(0.22f, 0.96f, 0.79f, 1f),
new Color(1f, 0.88f, 0.63f, 1f),
new Color(0.95f, 0.51f, 0.89f, 1f),
new Color(0.48f, 0.98f, 1f, 1f)
};
[Header("Fade")]
[SerializeField, Range(0f, 1f)] private float initialAlpha = 10f / 255f;
[SerializeField] private float fadeDuration = 0.18f;
[SerializeField] private float glowFadeDuration = 0.18f;
[SerializeField] private bool useUnscaledTime = true;
private readonly Dictionary<int, Coroutine> activeFadeRoutines = new Dictionary<int, Coroutine>();
private readonly Dictionary<int, Coroutine> activeGlowFadeRoutines = new Dictionary<int, Coroutine>();
private readonly Queue<GameObject> particlePool = new Queue<GameObject>();
private Transform particlePoolRoot;
// Per-instance cache: the particle systems and computed lifetime are resolved once
// when an instance is created, so the burst hot path never calls
// GetComponentsInChildren (an allocating call) per hit. Business behavior is
// unchanged: same systems, same tint, same lifetime.
private sealed class ParticleCacheEntry
{
public ParticleSystem[] Systems;
public float Lifetime;
}
private readonly Dictionary<GameObject, ParticleCacheEntry> particleCache =
new Dictionary<GameObject, ParticleCacheEntry>();
private void Awake()
{
if (Instance == null)
{
Instance = this;
ResetAllGlowAlpha();
ResetAllGlowSpriteAlpha();
}
else if (Instance != this)
{
Destroy(gameObject);
return;
}
}
public static void PlayTrackHitFx(int trackIndex)
{
var controller = Instance != null ? Instance : SceneObjectLookupCache.FindAny<TrackJudgeHitEffectController>();
if (controller == null)
return;
controller.PlayTrackHitFxInternal(trackIndex);
}
private void PlayTrackHitFxInternal(int trackIndex)
{
if (trackIndex < 0 || trackIndex >= 5)
return;
GameObject trackFxObject = GetTrackFxObject(trackIndex);
if (trackFxObject == null)
return;
if (activeGlowFadeRoutines.TryGetValue(trackIndex, out Coroutine glowRoutine) && glowRoutine != null)
{
StopCoroutine(glowRoutine);
activeGlowFadeRoutines.Remove(trackIndex);
}
if (activeFadeRoutines.TryGetValue(trackIndex, out Coroutine fadeRoutine) && fadeRoutine != null)
{
StopCoroutine(fadeRoutine);
activeFadeRoutines.Remove(trackIndex);
}
trackFxObject.SetActive(true);
PlayGlowFx(trackIndex);
SpriteRenderer spriteRenderer = trackFxObject.GetComponent<SpriteRenderer>();
if (spriteRenderer == null)
{
spriteRenderer = trackFxObject.GetComponentInChildren<SpriteRenderer>(true);
}
if (spriteRenderer != null)
{
Color c = spriteRenderer.color;
c.a = initialAlpha;
spriteRenderer.color = c;
activeFadeRoutines[trackIndex] = StartCoroutine(FadeSpriteRoutine(trackIndex, spriteRenderer));
}
SpawnTrackParticle(trackIndex, trackFxObject.transform);
}
private void ResetAllGlowAlpha()
{
if (trackGlowEffects == null)
return;
for (int i = 0; i < trackGlowEffects.Length; i++)
{
SetGlowAlpha(trackGlowEffects[i], 0f);
}
}
private void ResetAllGlowSpriteAlpha()
{
if (trackGlowEffects == null)
return;
for (int i = 0; i < trackGlowEffects.Length; i++)
{
ResetGlowSpriteAlpha(trackGlowEffects[i]);
}
}
private GameObject GetTrackFxObject(int trackIndex)
{
if (trackSpriteFxObjects == null || trackIndex < 0 || trackIndex >= trackSpriteFxObjects.Length)
return null;
return trackSpriteFxObjects[trackIndex];
}
private SpriteGlowEffect GetTrackGlowEffect(int trackIndex)
{
if (trackGlowEffects == null || trackIndex < 0 || trackIndex >= trackGlowEffects.Length)
return null;
return trackGlowEffects[trackIndex];
}
private void ResetGlowSpriteAlpha(SpriteGlowEffect glowEffect)
{
if (glowEffect == null || glowEffect.Renderer == null)
return;
Color color = glowEffect.Renderer.color;
color.a = trackGlowSpriteAlpha;
glowEffect.Renderer.color = color;
}
private void PlayGlowFx(int trackIndex)
{
SpriteGlowEffect glowEffect = GetTrackGlowEffect(trackIndex);
if (glowEffect == null)
return;
ResetGlowSpriteAlpha(glowEffect);
SetGlowAlpha(glowEffect, 1f);
if (activeGlowFadeRoutines.TryGetValue(trackIndex, out Coroutine routine) && routine != null)
{
StopCoroutine(routine);
activeGlowFadeRoutines.Remove(trackIndex);
}
activeGlowFadeRoutines[trackIndex] = StartCoroutine(FadeGlowRoutine(trackIndex, glowEffect));
}
private void SetGlowAlpha(SpriteGlowEffect glowEffect, float alpha)
{
if (glowEffect == null)
return;
Color color = glowEffect.GlowColor;
color.a = alpha;
glowEffect.GlowColor = color;
}
private IEnumerator FadeSpriteRoutine(int trackIndex, SpriteRenderer spriteRenderer)
{
float duration = Mathf.Max(0.01f, fadeDuration);
float elapsed = 0f;
Color startColor = spriteRenderer != null ? spriteRenderer.color : Color.white;
Color endColor = startColor;
endColor.a = 0f;
while (elapsed < duration)
{
if (spriteRenderer == null)
yield break;
elapsed += useUnscaledTime ? Time.unscaledDeltaTime : Time.deltaTime;
float t = Mathf.Clamp01(elapsed / duration);
Color c = Color.LerpUnclamped(startColor, endColor, t);
spriteRenderer.color = c;
yield return null;
}
if (spriteRenderer != null)
{
Color c = spriteRenderer.color;
c.a = 0f;
spriteRenderer.color = c;
}
activeFadeRoutines.Remove(trackIndex);
}
private IEnumerator FadeGlowRoutine(int trackIndex, SpriteGlowEffect glowEffect)
{
float duration = Mathf.Max(0.01f, glowFadeDuration);
float elapsed = 0f;
Color startColor = glowEffect != null ? glowEffect.GlowColor : Color.white;
Color endColor = startColor;
endColor.a = 0f;
while (elapsed < duration)
{
if (glowEffect == null)
yield break;
elapsed += useUnscaledTime ? Time.unscaledDeltaTime : Time.deltaTime;
float t = Mathf.Clamp01(elapsed / duration);
Color c = Color.LerpUnclamped(startColor, endColor, t);
glowEffect.GlowColor = c;
yield return null;
}
if (glowEffect != null)
{
Color c = glowEffect.GlowColor;
c.a = 0f;
glowEffect.GlowColor = c;
}
activeGlowFadeRoutines.Remove(trackIndex);
}
private void SpawnTrackParticle(int trackIndex, Transform parent)
{
if (trackParticlePrefab == null || parent == null)
return;
GameObject instance = RentParticleInstance(out ParticleCacheEntry entry);
if (instance == null)
return;
instance.transform.SetParent(parent, false);
instance.transform.localPosition = trackParticleLocalPosition;
instance.transform.localRotation = Quaternion.Euler(trackParticleLocalEulerAngles);
instance.transform.localScale = trackParticleLocalScale;
instance.SetActive(true);
Color tint = GetTrackColor(trackIndex);
ApplyParticleColor(entry, tint);
StartCoroutine(RecycleParticleRoutine(instance, entry, Mathf.Max(0.05f, entry.Lifetime)));
}
// Prewarm the track particle pool during load so the first judge does not pay
// the Instantiate + first-Play() cost on the hot path. Business behavior is
// unchanged: the same prefab is used, instances are just reused instead of
// being created and destroyed per hit.
public void PrewarmTrackParticles(int count)
{
if (trackParticlePrefab == null || count <= 0)
return;
EnsurePoolRoot();
for (int i = 0; i < count; i++)
{
GameObject instance = CreateParticleInstance();
if (instance == null)
continue;
instance.transform.SetParent(particlePoolRoot, false);
instance.SetActive(false);
particlePool.Enqueue(instance);
}
}
private void EnsurePoolRoot()
{
if (particlePoolRoot == null)
{
var root = new GameObject("TrackParticlePool");
root.transform.SetParent(transform, false);
root.SetActive(false);
particlePoolRoot = root.transform;
}
}
// Central instance factory: instantiates the prefab, resolves and caches its
// particle systems + computed lifetime once, and leaves it stopped/cleared.
private GameObject CreateParticleInstance()
{
if (trackParticlePrefab == null)
return null;
GameObject instance = Instantiate(trackParticlePrefab);
var systems = instance.GetComponentsInChildren<ParticleSystem>(true);
if (systems == null)
systems = new ParticleSystem[0];
var entry = new ParticleCacheEntry
{
Systems = systems,
Lifetime = ComputeLifetime(systems)
};
// Warm the play path once (forces emission buffer allocation and first-Play
// cost during load), then reset to a clean stopped state ready for reuse.
foreach (var ps in systems)
{
if (ps == null) continue;
ps.Play(true);
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
ps.Clear(true);
}
particleCache[instance] = entry;
return instance;
}
private GameObject RentParticleInstance(out ParticleCacheEntry entry)
{
while (particlePool.Count > 0)
{
GameObject pooled = particlePool.Dequeue();
if (pooled != null && particleCache.TryGetValue(pooled, out entry))
return pooled;
}
GameObject created = CreateParticleInstance();
if (created != null && particleCache.TryGetValue(created, out entry))
return created;
entry = null;
return created;
}
private IEnumerator RecycleParticleRoutine(GameObject instance, ParticleCacheEntry entry, float delay)
{
if (useUnscaledTime)
yield return new WaitForSecondsRealtime(delay);
else
yield return new WaitForSeconds(delay);
if (instance == null)
yield break;
if (entry != null && entry.Systems != null)
{
foreach (var ps in entry.Systems)
{
if (ps == null) continue;
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
ps.Clear(true);
}
}
instance.SetActive(false);
EnsurePoolRoot();
instance.transform.SetParent(particlePoolRoot, false);
particlePool.Enqueue(instance);
}
private Color GetTrackColor(int trackIndex)
{
if (trackColors != null && trackIndex >= 0 && trackIndex < trackColors.Length)
{
return trackColors[trackIndex];
}
return Color.white;
}
private void ApplyParticleColor(ParticleCacheEntry entry, Color tint)
{
if (entry == null || entry.Systems == null)
return;
foreach (var ps in entry.Systems)
{
if (ps == null) continue;
var main = ps.main;
main.startColor = tint;
if (!ps.isPlaying)
{
ps.Play(true);
}
}
}
private static float ComputeLifetime(ParticleSystem[] systems)
{
float maxLifetime = 0.5f;
if (systems == null)
return maxLifetime;
foreach (var ps in systems)
{
if (ps == null) continue;
var main = ps.main;
float startLifetime = 0.5f;
if (main.startLifetime.mode == ParticleSystemCurveMode.Constant)
startLifetime = main.startLifetime.constant;
else if (main.startLifetime.mode == ParticleSystemCurveMode.TwoConstants)
startLifetime = main.startLifetime.constantMax;
else
startLifetime = main.startLifetime.constantMax;
maxLifetime = Mathf.Max(maxLifetime, main.duration + startLifetime + 0.25f);
}
return maxLifetime;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7ccce61c2f0d4dd5b4f0b7d9f7c3a001
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,61 @@
using UnityEngine;
using UnityEngine.EventSystems;
/// <summary>
/// Touch/click region that drives one gameplay track (0-4) through InputManager,
/// exactly as if the bound keyboard key were pressed/released. Attach to a UI
/// element (Image with Raycast Target on) laid over a lane; set trackIndex in the
/// inspector. Uses EventSystem pointer callbacks so it supports multi-touch (each
/// finger routes its own down/up to the region it started on) and works on Android
/// touch as well as desktop mouse.
///
/// Judgment/scoring is unchanged: this only injects the same press/release the
/// keyboard path produces. On a track already held by keyboard, PressTrack/
/// ReleaseTrack are idempotent so there is no double-trigger.
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class TrackTouchRegion : MonoBehaviour, IPointerDownHandler, IPointerUpHandler
{
[Tooltip("Track this region controls: 0=red 1=green 2=yellow 3=purple 4=blue")]
[Range(0, 4)]
public int trackIndex = 0;
// The pointerId that pressed this region, so we only release on the matching
// pointer's up event (relevant for multi-touch where several fingers are down).
private int activePointerId = int.MinValue;
private bool pressed = false;
public void OnPointerDown(PointerEventData eventData)
{
if (pressed) return; // already driven by another finger; ignore extras
var im = InputManager.Instance;
if (im == null) return;
activePointerId = eventData.pointerId;
pressed = true;
im.PressTrack(trackIndex);
}
public void OnPointerUp(PointerEventData eventData)
{
if (!pressed || eventData.pointerId != activePointerId) return;
var im = InputManager.Instance;
pressed = false;
activePointerId = int.MinValue;
if (im != null) im.ReleaseTrack(trackIndex);
}
private void OnDisable()
{
// If the region is hidden/destroyed mid-hold, make sure the track is released
// so a note is not left stuck in the held state.
if (pressed)
{
pressed = false;
activePointerId = int.MinValue;
var im = InputManager.Instance;
if (im != null) im.ReleaseTrack(trackIndex);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b671f741e30e73840abdef6732a1145f
@@ -25,6 +25,9 @@ public class effectEventController : MonoBehaviour
public float playerSkillCameraOffsetAmount = 0.6f;
public CameraOffsetAxis playerSkillCameraOffsetAxis = CameraOffsetAxis.Y;
[Header("Track Crash")]
[SerializeField] private TrackCrashController trackCrashController;
private Transform cachedShakeTarget;
private Transform cachedCameraDriftPivot;
private Transform cachedCameraSkillPivot;
@@ -54,6 +57,7 @@ public class effectEventController : MonoBehaviour
{
CacheShakeTarget();
ResetCameraDriftIfNeeded();
CacheTrackCrashController();
}
private void Update()
@@ -73,6 +77,14 @@ public class effectEventController : MonoBehaviour
}
}
private void CacheTrackCrashController()
{
if (trackCrashController == null)
{
trackCrashController = SceneObjectLookupCache.FindAny<TrackCrashController>();
}
}
private void PrewarmShakeComponent()
{
if (cachedShakeTarget == null)
@@ -265,6 +277,15 @@ public class effectEventController : MonoBehaviour
controller.TriggerMultiNoteShakeInternal();
}
public static void TryTriggerTrackCrash(int trackIndex)
{
effectEventController controller = Instance != null ? Instance : SceneObjectLookupCache.FindAny<effectEventController>();
if (controller == null)
return;
controller.TriggerTrackCrashInternal(trackIndex);
}
private void TriggerMultiNoteShakeInternal()
{
if (!enableMultiNoteScreenShake)
@@ -311,4 +332,13 @@ public class effectEventController : MonoBehaviour
shake.sum = new Vector3[2];
shake.StartShake();
}
private void TriggerTrackCrashInternal(int trackIndex)
{
CacheTrackCrashController();
if (trackCrashController == null)
return;
trackCrashController.TriggerTrackCrash(trackIndex);
}
}
@@ -12,6 +12,9 @@ public class GfxController : MonoBehaviour
{
public static GfxController Instance { get; private set; }
// 复用的属性块:命中特效透明度覆写用,避免每次命中访问 r.materials 克隆材质。
private MaterialPropertyBlock _fxPropertyBlock;
[Header("Combatant Objects")]
public GameObject enemyMoveObject;
public GameObject ally01MoveObject;
@@ -688,23 +691,28 @@ public class GfxController : MonoBehaviour
// 设置排序层级
r.sortingOrder = sortingOrder;
// 处理材质透明度
// 处理材质透明度:用 MaterialPropertyBlock 覆写 alpha,避免访问 r.materials
// 触发材质实例化(每次命中都会克隆并泄漏材质,产生 GC)。视觉结果等价。
if (transparency < 1f)
{
foreach (var mat in r.materials)
var sharedMat = r.sharedMaterial;
if (sharedMat != null)
{
if (mat.HasProperty("_Color"))
if (_fxPropertyBlock == null) _fxPropertyBlock = new MaterialPropertyBlock();
r.GetPropertyBlock(_fxPropertyBlock);
if (sharedMat.HasProperty("_Color"))
{
Color c = mat.color;
Color c = sharedMat.color;
c.a *= transparency;
mat.color = c;
_fxPropertyBlock.SetColor("_Color", c);
}
else if (mat.HasProperty("_BaseColor")) // URP 命名
else if (sharedMat.HasProperty("_BaseColor")) // URP 命名
{
Color c = mat.GetColor("_BaseColor");
Color c = sharedMat.GetColor("_BaseColor");
c.a *= transparency;
mat.SetColor("_BaseColor", c);
_fxPropertyBlock.SetColor("_BaseColor", c);
}
r.SetPropertyBlock(_fxPropertyBlock);
}
}
}
@@ -445,6 +445,10 @@ namespace GameServer.Client
{
song_id = CurrentRoom != null ? CurrentRoom.song_id : string.Empty,
cycle_id = 0,
server_name = "雅莉梦璃高新产业园",
leaderboard_cycle_days = 3,
rank_version_id = 20260101,
next_settle_at = "2026-01-01 00:00:00",
rankings = BuildRoomRankingEntries()
};
return result;
@@ -628,10 +632,24 @@ namespace GameServer.Client
}
await EnsureSocialConnected();
await SendSocialAction("SEND_WORLD_MESSAGE", new
JObject resp = await SendSocialAction("SEND_WORLD_MESSAGE", new
{
content = trimmed
});
JObject messageObject = resp["message"] as JObject ?? resp;
ArenaRoomChatMessage message = messageObject.ToObject<ArenaRoomChatMessage>();
if (message == null)
{
return;
}
if (string.IsNullOrWhiteSpace(message.sender_id))
{
message.sender_id = NetworkManager.Instance != null ? NetworkManager.Instance.SteamId : string.Empty;
}
AppendWorldChatMessage(message);
}
public async Task<List<ArenaRoomChatMessage>> LoadPrivateHistory(string partnerId, int limit, long? beforeId = null)
@@ -62,6 +62,13 @@ public class GameServerBridge : MonoBehaviour
return;
}
if (!LeaderboardConsentUtility.HasLeaderboardConsent())
{
Debug.Log("[Bridge] 玩家未同意加入排行榜,跳过世界排行榜提交");
_hasSubmitted = true;
return;
}
_hasSubmitted = true;
try
@@ -143,6 +150,10 @@ public class GameServerBridge : MonoBehaviour
await Task.Delay(3000);
SubmitCurrentSettlement();
}
else if (result == "OPT_OUT")
{
Debug.Log("[Bridge] 当前已关闭排行榜加入选项,未提交世界排行榜。");
}
else
{
Debug.LogWarning($"╔══════════════════════════════════════════════════════╗");
@@ -105,6 +105,36 @@ namespace GameServer.Client
}
}
public async Task<LeaderboardData> ForceRefresh(string songId)
{
if (string.IsNullOrWhiteSpace(songId))
{
throw new ArgumentException("songId is required", nameof(songId));
}
NetworkManager nm = NetworkManager.Instance;
if (nm == null)
{
throw new InvalidOperationException("NetworkManager is not available.");
}
Invalidate(songId);
Task<LeaderboardData> task = FetchAndCache(songId, nm);
_inflight[songId] = task;
try
{
return await task;
}
finally
{
if (_inflight.TryGetValue(songId, out Task<LeaderboardData> current) && current == task)
{
_inflight.Remove(songId);
}
}
}
public void Invalidate(string songId)
{
if (string.IsNullOrWhiteSpace(songId))
@@ -113,6 +143,7 @@ namespace GameServer.Client
}
_cache.Remove(songId);
_inflight.Remove(songId);
}
private async void HandleSceneLoaded(Scene scene, LoadSceneMode mode)
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Newtonsoft.Json;
using UnityEngine;
namespace GameServer.Client
{
@@ -88,12 +90,35 @@ namespace GameServer.Client
[JsonProperty("avatar_url")] public string avatar_url;
[JsonProperty("player_level")] public int player_level;
[JsonProperty("leaderboard_opt_out")] public bool leaderboard_opt_out;
[JsonProperty("leaderboard_can_change_today")] public bool leaderboard_can_change_today;
[JsonProperty("leaderboard_changed_today")] public bool leaderboard_changed_today;
[JsonProperty("leaderboard_next_change_at")] public string leaderboard_next_change_at;
[JsonProperty("leaderboard_cycle_id")] public int leaderboard_cycle_id;
[JsonProperty("total_play_seconds")] public double total_play_seconds;
[JsonProperty("total_plays")] public int total_plays;
[JsonProperty("actime")] public string actime;
[JsonProperty("u_reg_id")] public int u_reg_id;
}
[Serializable]
public class LeaderboardMembershipResponse
{
[JsonProperty("success")] public bool success;
[JsonProperty("error_code")] public string error_code;
[JsonProperty("message")] public string message;
[JsonProperty("steam_id")] public string steam_id;
[JsonProperty("cycle_id")] public int cycle_id;
[JsonProperty("is_joined")] public bool is_joined;
[JsonProperty("leaderboard_opt_out")] public bool leaderboard_opt_out;
[JsonProperty("changed_today")] public bool changed_today;
[JsonProperty("can_change_today")] public bool can_change_today;
[JsonProperty("next_change_at")] public string next_change_at;
[JsonProperty("server_name")] public string server_name;
[JsonProperty("leaderboard_cycle_days")] public int leaderboard_cycle_days;
[JsonProperty("rank_version_id")] public int rank_version_id;
[JsonProperty("next_settle_at")] public string next_settle_at;
}
[Serializable]
public class LeaderboardEntry
{
@@ -118,6 +143,10 @@ namespace GameServer.Client
[JsonProperty("song_id")] public string song_id;
[JsonProperty("cycle_id")] public int cycle_id;
[JsonProperty("is_locked")] public bool is_locked;
[JsonProperty("server_name")] public string server_name;
[JsonProperty("leaderboard_cycle_days")] public int leaderboard_cycle_days;
[JsonProperty("rank_version_id")] public int rank_version_id;
[JsonProperty("next_settle_at")] public string next_settle_at;
[JsonProperty("rankings")] public List<LeaderboardEntry> rankings;
}
@@ -499,4 +528,124 @@ namespace GameServer.Client
// itemID 77001 = 角色 3020677003 = 歌曲177004 = 歌曲2(见 Resources/so/storeSO/
}
public static class LeaderboardConsentUtility
{
public const string RankingConsentPrefKey = "before_everything_agree_ranking";
private const string WarningPrefix = "下次排行榜更新时间:";
public static bool HasLeaderboardConsent()
{
return PlayerPrefs.GetInt(RankingConsentPrefKey, 0) == 1;
}
public static void SetLeaderboardConsent(bool accepted)
{
PlayerPrefs.SetInt(RankingConsentPrefKey, accepted ? 1 : 0);
PlayerPrefs.Save();
}
public static async Task<string> BuildNextSettleWarningTextAsync()
{
string nextSettleAt = await TryGetNextSettleAtAsync();
return string.IsNullOrWhiteSpace(nextSettleAt)
? WarningPrefix + "--"
: WarningPrefix + nextSettleAt;
}
public static async Task<string> TryGetNextSettleAtAsync()
{
LeaderboardData data = await TryGetLeaderboardMetaAsync();
return data != null && !string.IsNullOrWhiteSpace(data.next_settle_at)
? data.next_settle_at.Trim()
: string.Empty;
}
public static async Task<LeaderboardData> TryGetLeaderboardMetaAsync()
{
string songId = ResolveReferenceSongId();
if (string.IsNullOrWhiteSpace(songId))
{
return null;
}
LeaderboardCacheService cache = LeaderboardCacheService.Instance;
if (cache != null
&& cache.TryGetCached(songId, out LeaderboardData cached)
&& cached != null
&& !string.IsNullOrWhiteSpace(cached.next_settle_at))
{
return cached;
}
NetworkManager networkManager = NetworkManager.Instance;
if (networkManager == null)
{
return null;
}
try
{
return await networkManager.FetchLeaderboardFromServer(songId);
}
catch (Exception ex)
{
Debug.LogWarning($"[LeaderboardConsentUtility] Failed to fetch leaderboard meta: {ex.Message}");
return null;
}
}
private static string ResolveReferenceSongId()
{
SongData selectedSong = SongDataHolder.SelectedSongData;
if (selectedSong != null && selectedSong.songID > 0)
{
return selectedSong.songID.ToString();
}
if (BeatmapManager.pendingSongData != null && BeatmapManager.pendingSongData.songID > 0)
{
return BeatmapManager.pendingSongData.songID.ToString();
}
BeatmapManager beatmapManager = BeatmapManager.Instance;
if (beatmapManager != null
&& beatmapManager.assignedSongData != null
&& beatmapManager.assignedSongData.songID > 0)
{
return beatmapManager.assignedSongData.songID.ToString();
}
SongDataLibrary library = SongDataLibrary.Instance;
if (library != null && library.IsLoaded)
{
List<SongData> songs = library.GetAllSongs();
if (songs != null)
{
for (int i = 0; i < songs.Count; i++)
{
SongData song = songs[i];
if (song != null && song.songID > 0)
{
return song.songID.ToString();
}
}
}
}
SongData[] indexedSongs = RuntimeResourcesCache.LoadSongIndex();
if (indexedSongs != null)
{
for (int i = 0; i < indexedSongs.Length; i++)
{
SongData song = indexedSongs[i];
if (song != null && song.songID > 0)
{
return song.songID.ToString();
}
}
}
return string.Empty;
}
}
}
@@ -239,6 +239,57 @@ public class NetworkManager : MonoBehaviour
return data;
}
public async Task<LeaderboardMembershipResponse> GetLeaderboardMembership()
{
if (OnlineModeSettings.IsLocalOnlyMode)
{
return new LeaderboardMembershipResponse
{
success = true,
steam_id = SteamId,
is_joined = LeaderboardConsentUtility.HasLeaderboardConsent(),
leaderboard_opt_out = !LeaderboardConsentUtility.HasLeaderboardConsent(),
can_change_today = true,
changed_today = false,
next_change_at = string.Empty,
};
}
string steamIdQuery = Uri.EscapeDataString(SteamId ?? string.Empty);
return await GetJson<LeaderboardMembershipResponse>(
BuildApiUrl($"/api/profile/leaderboard-membership?steam_id={steamIdQuery}"),
CancellationToken.None);
}
public async Task<LeaderboardMembershipResponse> UpdateLeaderboardMembership(bool isJoined)
{
if (OnlineModeSettings.IsLocalOnlyMode)
{
LeaderboardConsentUtility.SetLeaderboardConsent(isJoined);
return new LeaderboardMembershipResponse
{
success = true,
steam_id = SteamId,
is_joined = isJoined,
leaderboard_opt_out = !isJoined,
can_change_today = true,
changed_today = false,
next_change_at = string.Empty,
};
}
var req = new
{
steam_id = SteamId,
is_joined = isJoined,
leaderboard_opt_out = !isJoined
};
return await PutJson<LeaderboardMembershipResponse>(
BuildApiUrl("/api/profile/leaderboard-membership"),
req,
CancellationToken.None);
}
public async Task<ProfileData> GetPlayerByUid(int uid)
{
if (uid <= 0)
@@ -525,6 +576,12 @@ public class NetworkManager : MonoBehaviour
return "LOCAL_ONLY";
}
if (!LeaderboardConsentUtility.HasLeaderboardConsent())
{
Debug.Log("[NetworkManager] Leaderboard submission skipped because the player has not consented.");
return "OPT_OUT";
}
try
{
await PerformHandshakeAsync(CancellationToken.None);
@@ -673,7 +730,7 @@ public class NetworkManager : MonoBehaviour
if (VerboseLogs) Debug.Log($"[NetworkManager] Ping: {resp.message}");
}
public async Task<LeaderboardData> GetLeaderboard(string songId)
public async Task<LeaderboardData> GetLeaderboard(string songId, bool forceRefresh = false)
{
if (string.IsNullOrWhiteSpace(songId))
{
@@ -692,9 +749,17 @@ public class NetworkManager : MonoBehaviour
{
SetState(ConnectionState.LoadingLeaderboard);
LeaderboardCacheService cache = LeaderboardCacheService.Instance;
LeaderboardData data = cache != null
? await cache.GetOrFetch(songId)
: await FetchLeaderboardFromServer(songId);
LeaderboardData data;
if (cache != null)
{
data = forceRefresh
? await cache.ForceRefresh(songId)
: await cache.GetOrFetch(songId);
}
else
{
data = await FetchLeaderboardFromServer(songId);
}
OnLeaderboardLoaded?.Invoke(data);
SetState(ConnectionState.Ready);
return data;
@@ -1503,6 +1568,42 @@ public class NetworkManager : MonoBehaviour
}
}
private async Task<T> PutJson<T>(string url, object payload, CancellationToken token)
{
if (OnlineModeSettings.IsLocalOnlyMode)
{
throw CreateLocalOnlyException($"HTTP PUT is disabled in local-only mode. url={url}");
}
string json = JsonConvert.SerializeObject(payload);
using (UnityWebRequest request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPUT))
{
byte[] body = Encoding.UTF8.GetBytes(json);
request.uploadHandler = new UploadHandlerRaw(body);
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
request.timeout = 15;
if (VerboseLogs) Debug.Log($"[NetworkManager] HTTP PUT {url}");
await SendRequestAsync(request.SendWebRequest(), token);
string responseText = request.downloadHandler != null ? request.downloadHandler.text : string.Empty;
if (request.result != UnityWebRequest.Result.Success && string.IsNullOrWhiteSpace(responseText))
{
throw new Exception($"{request.result}: {request.error}");
}
try
{
return JsonConvert.DeserializeObject<T>(responseText);
}
catch (Exception ex)
{
throw new Exception($"Failed to parse response: {ex.Message}. Body={responseText}");
}
}
}
private async Task<T> GetJson<T>(string url, CancellationToken token)
{
if (OnlineModeSettings.IsLocalOnlyMode)
@@ -1612,6 +1713,10 @@ public class NetworkManager : MonoBehaviour
song_id = songId,
cycle_id = 0,
is_locked = false,
server_name = "雅莉梦璃高新产业园",
leaderboard_cycle_days = 3,
rank_version_id = 20260101,
next_settle_at = "2026-01-01 00:00:00",
rankings = new List<LeaderboardEntry>()
};
}
@@ -54,12 +54,12 @@ MonoBehaviour:
iManaMinusSprite: {fileID: 21300000, guid: ab8b33f8f7d77e242b0da39cdebae043, type: 3}
iIscorePlusSprite: {fileID: 21300000, guid: e49d4f09f0f1b0646bf8fccb5dc816b7, type: 3}
iIscoreMinusSprite: {fileID: 21300000, guid: 629ef934809d32949a40ad47b1d13356, type: 3}
iDamageColor: {r: 0.97647065, g: 0.38823533, b: 0.45098042, a: 0}
iHealColor: {r: 0.19215688, g: 0.8117648, b: 0.63529414, a: 0}
iManaPlusColor: {r: 0, g: 0.9490197, b: 0.9960785, a: 0}
iManaMinusColor: {r: 0.30980393, g: 0.67058825, b: 0.9960785, a: 0}
iIscorePlusColor: {r: 0.73333335, g: 0.8000001, b: 1, a: 0}
iIscoreMinusColor: {r: 0.7019608, g: 0.38823533, b: 0.92549026, a: 0}
iDamageColor: {r: 0.97647065, g: 0.38823533, b: 0.45098042, a: 1}
iHealColor: {r: 0.19215688, g: 0.8117648, b: 0.63529414, a: 1}
iManaPlusColor: {r: 0, g: 0.9490197, b: 0.9960785, a: 1}
iManaMinusColor: {r: 0.30980393, g: 0.67058825, b: 0.9960785, a: 1}
iIscorePlusColor: {r: 0.73333335, g: 0.8000001, b: 1, a: 1}
iIscoreMinusColor: {r: 0.7019608, g: 0.38823533, b: 0.92549026, a: 1}
this_prefab_gravity: -9.81
x_ofst: 0
y_ofst: 0
@@ -70,6 +70,66 @@ MonoBehaviour:
jump_force: 125
fadeout_awaitTime: 0.25
fadeout_time: 0.25
verticalTravelDistance: 110
horizontalDriftMin: 18
horizontalDriftMax: 42
verticalMotionCurve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 0
inSlope: 0
outSlope: 4.5
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
- serializedVersion: 3
time: 0.58
value: 1
inSlope: 0.12
outSlope: 0.12
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
- serializedVersion: 3
time: 1
value: 0.42
inSlope: -2.4
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
horizontalMotionCurve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 0
inSlope: 0
outSlope: 2.2
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
- serializedVersion: 3
time: 1
value: 1
inSlope: 0.2
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
--- !u!1 &5029723704172947119
GameObject:
m_ObjectHideFlags: 0
@@ -54,12 +54,12 @@ MonoBehaviour:
iManaMinusSprite: {fileID: 21300000, guid: ab8b33f8f7d77e242b0da39cdebae043, type: 3}
iIscorePlusSprite: {fileID: 21300000, guid: e49d4f09f0f1b0646bf8fccb5dc816b7, type: 3}
iIscoreMinusSprite: {fileID: 21300000, guid: 629ef934809d32949a40ad47b1d13356, type: 3}
iDamageColor: {r: 0.97647065, g: 0.38823533, b: 0.45098042, a: 0}
iHealColor: {r: 0.19215688, g: 0.8117648, b: 0.63529414, a: 0}
iManaPlusColor: {r: 0, g: 0.9490197, b: 0.9960785, a: 0}
iManaMinusColor: {r: 0.30980393, g: 0.67058825, b: 0.9960785, a: 0}
iIscorePlusColor: {r: 0.73333335, g: 0.8000001, b: 1, a: 0}
iIscoreMinusColor: {r: 0.7019608, g: 0.38823533, b: 0.92549026, a: 0}
iDamageColor: {r: 0.97647065, g: 0.38823533, b: 0.45098042, a: 1}
iHealColor: {r: 0.19215688, g: 0.8117648, b: 0.63529414, a: 1}
iManaPlusColor: {r: 0, g: 0.9490197, b: 0.9960785, a: 1}
iManaMinusColor: {r: 0.30980393, g: 0.67058825, b: 0.9960785, a: 1}
iIscorePlusColor: {r: 0.73333335, g: 0.8000001, b: 1, a: 1}
iIscoreMinusColor: {r: 0.7019608, g: 0.38823533, b: 0.92549026, a: 1}
this_prefab_gravity: -9.81
x_ofst: 0
y_ofst: 0
@@ -70,6 +70,66 @@ MonoBehaviour:
jump_force: 125
fadeout_awaitTime: 0.5
fadeout_time: 0.25
verticalTravelDistance: 110
horizontalDriftMin: 18
horizontalDriftMax: 42
verticalMotionCurve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 0
inSlope: 0
outSlope: 4.5
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
- serializedVersion: 3
time: 0.58
value: 1
inSlope: 0.12
outSlope: 0.12
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
- serializedVersion: 3
time: 1
value: 0.42
inSlope: -2.4
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
horizontalMotionCurve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 0
inSlope: 0
outSlope: 2.2
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
- serializedVersion: 3
time: 1
value: 1
inSlope: 0.2
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
--- !u!1 &5029723704172947119
GameObject:
m_ObjectHideFlags: 0
@@ -137,8 +197,8 @@ MonoBehaviour:
m_Calls: []
m_text: 2147483647
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 452fa97492cae71479cf143f29930751, type: 2}
m_sharedMaterial: {fileID: -346136068272202111, guid: 452fa97492cae71479cf143f29930751, type: 2}
m_fontAsset: {fileID: 11400000, guid: 7cad3f54ac7b53d4885f00c16f1a6929, type: 2}
m_sharedMaterial: {fileID: -346136068272202111, guid: 7cad3f54ac7b53d4885f00c16f1a6929, type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
@@ -39,6 +39,22 @@ public class playerInstantNumbersPrefab : MonoBehaviour
public float fadeout_awaitTime;
public float fadeout_time = 0.25f;
[Header("motion feel")]
[Tooltip("Total height reached by the popup before it starts dropping back down.")]
public float verticalTravelDistance = 110f;
[Tooltip("Random horizontal drift distance applied over the popup lifetime.")]
public float horizontalDriftMin = 18f;
public float horizontalDriftMax = 42f;
[Tooltip("Controls the fast-rise, slow-peak, then drop feel. 0=start, 1=end.")]
public AnimationCurve verticalMotionCurve = new AnimationCurve(
new Keyframe(0f, 0f, 0f, 4.5f),
new Keyframe(0.58f, 1f, 0.12f, 0.12f),
new Keyframe(1f, 0.42f, -2.4f, 0f));
[Tooltip("Controls horizontal drift over lifetime. This is multiplied by a random left/right distance.")]
public AnimationCurve horizontalMotionCurve = new AnimationCurve(
new Keyframe(0f, 0f, 0f, 2.2f),
new Keyframe(1f, 1f, 0.2f, 0f));
private RectTransform _rectTransform;
private CanvasGroup _canvasGroup;
private Coroutine _animationCoroutine;
@@ -151,10 +167,16 @@ public class playerInstantNumbersPrefab : MonoBehaviour
Vector2 baseOffset = new Vector2(x_ofst, y_ofst);
Vector2 randomOffset = Random.insideUnitCircle * xy_rdm_spawnP;
Vector2 finalOffset = baseOffset + randomOffset;
Vector2 spawnOffset = baseOffset + randomOffset;
if (useAnchored) _rectTransform.anchoredPosition = startAnchoredPos + finalOffset;
else transform.localPosition = startLocalPos + new Vector3(finalOffset.x, finalOffset.y, 0f);
float horizontalDirection = Random.value < 0.5f ? -1f : 1f;
float horizontalDistance = horizontalDirection * Random.Range(
Mathf.Min(horizontalDriftMin, horizontalDriftMax),
Mathf.Max(horizontalDriftMin, horizontalDriftMax));
float verticalDistance = Mathf.Max(0f, verticalTravelDistance);
if (useAnchored) _rectTransform.anchoredPosition = startAnchoredPos + spawnOffset;
else transform.localPosition = startLocalPos + new Vector3(spawnOffset.x, spawnOffset.y, 0f);
float zRot = Random.Range(-rdm_rotation_range, rdm_rotation_range);
transform.localRotation = Quaternion.Euler(0f, 0f, zRot);
@@ -162,28 +184,25 @@ public class playerInstantNumbersPrefab : MonoBehaviour
float scale = Random.Range(rdm_scale_min, rdm_scale_max);
transform.localScale = new Vector3(scale, scale, 1f);
float vy = jump_force;
float gravity = this_prefab_gravity;
float fadeDelay = Mathf.Max(0f, fadeout_awaitTime);
float fadeDuration = Mathf.Max(0.0001f, fadeout_time);
float totalDuration = Mathf.Max(0.01f, fadeDelay + fadeDuration);
float t = 0f;
while (t < fadeDelay + fadeDuration)
while (t < totalDuration)
{
float dt = Time.deltaTime;
vy += gravity * dt;
float normalized = Mathf.Clamp01(t / totalDuration);
float x = EvaluateCurve(horizontalMotionCurve, normalized) * horizontalDistance;
float y = EvaluateCurve(verticalMotionCurve, normalized) * verticalDistance;
if (useAnchored)
{
var p = _rectTransform.anchoredPosition;
p.y += vy * dt;
_rectTransform.anchoredPosition = p;
_rectTransform.anchoredPosition = startAnchoredPos + spawnOffset + new Vector2(x, y);
}
else
{
var p = transform.localPosition;
p.y += vy * dt;
transform.localPosition = p;
transform.localPosition = startLocalPos + new Vector3(spawnOffset.x + x, spawnOffset.y + y, 0f);
}
if (t >= fadeDelay)
@@ -197,10 +216,35 @@ public class playerInstantNumbersPrefab : MonoBehaviour
yield return null;
}
float finalX = EvaluateCurve(horizontalMotionCurve, 1f) * horizontalDistance;
float finalY = EvaluateCurve(verticalMotionCurve, 1f) * verticalDistance;
if (useAnchored)
{
_rectTransform.anchoredPosition = startAnchoredPos + spawnOffset + new Vector2(finalX, finalY);
}
else
{
transform.localPosition = startLocalPos + new Vector3(spawnOffset.x + finalX, spawnOffset.y + finalY, 0f);
}
if (_canvasGroup != null)
{
_canvasGroup.alpha = 0f;
}
_animationCoroutine = null;
if (!iNumberPrefabController.ReturnInstance(this))
{
Destroy(gameObject);
}
}
private static float EvaluateCurve(AnimationCurve curve, float time)
{
if (curve == null || curve.length == 0)
{
return time;
}
return curve.Evaluate(time);
}
}
@@ -7,7 +7,8 @@ using UnityEngine.UI;
public class rankingListPrefab : MonoBehaviour
{
private static string RoomRankingServerText => LocalizationService.Get("ranking.room_server", "根据房间成绩动态排名,所在服务端:雅莉梦璃高新产业园");
private const string FallbackServerName = "雅莉梦璃高新产业园";
private const int FallbackCycleDays = 3;
[Header("shutbutton")]
public Button shutbutton;
@@ -116,17 +117,8 @@ public class rankingListPrefab : MonoBehaviour
return;
}
LeaderboardData data = null;
LeaderboardCacheService cache = LeaderboardCacheService.Instance;
if (cache != null && cache.TryGetCached(currentSongId, out LeaderboardData cached))
{
data = cached;
}
else
{
SetEmptyState(false, LocalizationService.Get("ranking.loading", "正在拉取排行榜..."));
data = await nm.GetLeaderboard(currentSongId);
}
SetEmptyState(false, LocalizationService.Get("ranking.loading", "正在拉取排行榜..."));
LeaderboardData data = await nm.GetLeaderboard(currentSongId, true);
if (data == null || data.rankings == null || data.rankings.Count == 0)
{
@@ -246,7 +238,7 @@ public class rankingListPrefab : MonoBehaviour
if (versionandServer != null)
{
versionandServer.text = isRoomRanking ? RoomRankingServerText : string.Empty;
versionandServer.text = BuildVersionAndServerText(data);
}
if (yourPositionText == null)
@@ -286,6 +278,50 @@ public class rankingListPrefab : MonoBehaviour
yourPositionText.text = LocalizationService.GetFormat("ranking.your_position_value", localEntry.total_score, localEntry.rank);
}
private string BuildVersionAndServerText(LeaderboardData data)
{
string serverName = data != null && !string.IsNullOrWhiteSpace(data.server_name)
? data.server_name
: FallbackServerName;
int cycleDays = data != null && data.leaderboard_cycle_days > 0
? data.leaderboard_cycle_days
: FallbackCycleDays;
int rankVersionId = data != null && data.rank_version_id > 0
? data.rank_version_id
: BuildFallbackRankVersionId(cycleDays);
string nextSettleAt = data != null && !string.IsNullOrWhiteSpace(data.next_settle_at)
? data.next_settle_at
: BuildFallbackNextSettleAt(cycleDays);
return $"每{cycleDays}自然日进行一次排行榜更新。排行榜版本{rankVersionId},下次统计日期{nextSettleAt} 所在服务端:{serverName}";
}
private static string BuildFallbackNextSettleAt(int cycleDays)
{
DateTime epoch = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Local);
DateTime now = DateTime.Now;
int elapsedDays = (now.Date - epoch.Date).Days;
int nextSettleDays = ((elapsedDays / Mathf.Max(1, cycleDays)) + 1) * Mathf.Max(1, cycleDays);
DateTime nextSettle = epoch.AddDays(nextSettleDays);
if (nextSettle <= now)
{
nextSettle = nextSettle.AddDays(Mathf.Max(1, cycleDays));
}
return nextSettle.ToString("yyyy-MM-dd HH:mm:ss");
}
private static int BuildFallbackRankVersionId(int cycleDays)
{
string nextSettleAt = BuildFallbackNextSettleAt(cycleDays);
if (DateTime.TryParseExact(nextSettleAt, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsed))
{
return int.Parse(parsed.ToString("yyyyMMdd"));
}
return 0;
}
private static string FormatSubmitTime(LeaderboardEntry entry)
{
if (entry == null)
@@ -58,7 +58,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0, g: 0, b: 0, a: 1}
m_Color: {r: 0.14117648, g: 0.34117648, b: 0.7764706, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -66,8 +66,8 @@ MonoBehaviour:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: bd4be44636ad6c24a9b117a7108a75e9, type: 3}
m_FontSize: 18
m_Font: {fileID: 12800000, guid: d254d20d93651ae448ff13d10dff30ab, type: 3}
m_FontSize: 20
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 0
@@ -137,7 +137,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0, g: 0, b: 0, a: 1}
m_Color: {r: 0.14117648, g: 0.34117648, b: 0.7764706, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -145,8 +145,8 @@ MonoBehaviour:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: bd4be44636ad6c24a9b117a7108a75e9, type: 3}
m_FontSize: 18
m_Font: {fileID: 12800000, guid: d254d20d93651ae448ff13d10dff30ab, type: 3}
m_FontSize: 20
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 0
@@ -196,7 +196,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: -22.589}
m_AnchoredPosition: {x: 0, y: -10}
m_SizeDelta: {x: 1100, y: 539.869}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &3236275801312876574
@@ -332,7 +332,7 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 492b08f39f232d64e9ce8eb248b5daea, type: 3}
m_Sprite: {fileID: 21300000, guid: 3410813869a49f942b4e0198c93eb3ec, type: 3}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
@@ -486,6 +486,81 @@ MonoBehaviour:
m_VerticalOverflow: 0
m_LineSpacing: 1
m_Text: "\u62C9\u53D6\u6392\u884C\u699C\u5931\u8D25"
--- !u!1 &3019233615837960289
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 455604901716592107}
- component: {fileID: 2199194977054993423}
- component: {fileID: 1477107587712217919}
m_Layer: 0
m_Name: btm
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &455604901716592107
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3019233615837960289}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3018378993363515480}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 10}
m_SizeDelta: {x: 1050, y: 588.9733}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &2199194977054993423
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3019233615837960289}
m_CullTransparentMesh: 1
--- !u!114 &1477107587712217919
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3019233615837960289}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 0.39215687}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 7edca03691f782d46ac361a681ce5685, type: 3}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!1 &3483362754365948650
GameObject:
m_ObjectHideFlags: 0
@@ -525,7 +600,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -53.89, y: 260.83}
m_AnchoredPosition: {x: -53.89, y: 272.9}
m_SizeDelta: {x: 888.204, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &3005028479092236020
@@ -714,7 +789,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 292.2}
m_AnchoredPosition: {x: 0, y: 332.1}
m_SizeDelta: {x: 1400, y: 24}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &6664561848449007207
@@ -738,7 +813,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_Color: {r: 0.14117648, g: 0.34117648, b: 0.7764706, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -1144,7 +1219,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0, g: 0, b: 0, a: 1}
m_Color: {r: 0.14117648, g: 0.34117648, b: 0.7764706, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -1152,8 +1227,8 @@ MonoBehaviour:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: bd4be44636ad6c24a9b117a7108a75e9, type: 3}
m_FontSize: 18
m_Font: {fileID: 12800000, guid: d254d20d93651ae448ff13d10dff30ab, type: 3}
m_FontSize: 20
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 0
@@ -1195,6 +1270,7 @@ RectTransform:
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 455604901716592107}
- {fileID: 2611590964116081061}
- {fileID: 4148558001495883808}
- {fileID: 7280264963986874822}
@@ -1237,7 +1313,7 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: 7f3b1220317607e44bbb8f0e1998044a, type: 3}
m_Sprite: {fileID: 2139583702370686162, guid: f034ddca36ad627428d85c127caac8da, type: 3}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
@@ -1305,7 +1381,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0, g: 0, b: 0, a: 1}
m_Color: {r: 0.14117648, g: 0.34117648, b: 0.7764706, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -1313,8 +1389,8 @@ MonoBehaviour:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: bd4be44636ad6c24a9b117a7108a75e9, type: 3}
m_FontSize: 18
m_Font: {fileID: 12800000, guid: d254d20d93651ae448ff13d10dff30ab, type: 3}
m_FontSize: 20
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 0
@@ -1360,8 +1436,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: -324.4}
m_SizeDelta: {x: 1400, y: 24}
m_AnchoredPosition: {x: 0, y: -317}
m_SizeDelta: {x: 1400, y: 28}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &341918732991438692
CanvasRenderer:
@@ -1384,7 +1460,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_Color: {r: 0.14117648, g: 0.34117648, b: 0.7764706, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -1393,7 +1469,7 @@ MonoBehaviour:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: dfa10de3e2846524089b5073175cd151, type: 3}
m_FontSize: 22
m_FontSize: 28
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 0
@@ -1463,7 +1539,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_Color: {r: 0.41960785, g: 0.5411765, b: 0.8039216, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -1471,8 +1547,8 @@ MonoBehaviour:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: bd4be44636ad6c24a9b117a7108a75e9, type: 3}
m_FontSize: 16
m_Font: {fileID: 12800000, guid: d254d20d93651ae448ff13d10dff30ab, type: 3}
m_FontSize: 18
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 0
@@ -1543,7 +1619,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0, g: 0, b: 0, a: 1}
m_Color: {r: 0.14117648, g: 0.34117648, b: 0.7764706, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -1551,8 +1627,8 @@ MonoBehaviour:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: bd4be44636ad6c24a9b117a7108a75e9, type: 3}
m_FontSize: 18
m_Font: {fileID: 12800000, guid: d254d20d93651ae448ff13d10dff30ab, type: 3}
m_FontSize: 20
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 0
@@ -1998,7 +2074,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 312.4}
m_AnchoredPosition: {x: 0, y: 374.2}
m_SizeDelta: {x: 1400, y: 81.672}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &4558535259015521777
@@ -2022,7 +2098,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -2031,12 +2107,12 @@ MonoBehaviour:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: dfa10de3e2846524089b5073175cd151, type: 3}
m_FontSize: 40
m_FontSize: 32
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 0
m_MaxSize: 230
m_Alignment: 1
m_Alignment: 4
m_AlignByGeometry: 0
m_RichText: 1
m_HorizontalOverflow: 0
@@ -1,4 +1,4 @@
%YAML 1.1
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &118873712428923354
GameObject:
@@ -30,13 +30,13 @@ RectTransform:
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 4266895856353985475}
- {fileID: 7346852462872466010}
m_Father: {fileID: 5127964937674371758}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -60, y: 8.5}
m_SizeDelta: {x: 15, y: 15}
m_AnchoredPosition: {x: -60, y: 0}
m_SizeDelta: {x: 20, y: 20}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &9219216993890693327
CanvasRenderer:
@@ -87,6 +87,7 @@ GameObject:
- component: {fileID: 4266895856353985475}
- component: {fileID: 3787018861283084079}
- component: {fileID: 267584174794072845}
- component: {fileID: 8475271739115695170}
m_Layer: 0
m_Name: rwdName
m_TagString: Untagged
@@ -101,18 +102,18 @@ RectTransform:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1059233533614862680}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3546975410283370972}
m_Father: {fileID: 7346852462872466010}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 64.438, y: 0}
m_SizeDelta: {x: 105.799, y: 15}
m_Pivot: {x: 0.5, y: 0.5}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 0, y: -10}
m_SizeDelta: {x: 91, y: 15}
m_Pivot: {x: 0, y: 0.5}
--- !u!222 &3787018861283084079
CanvasRenderer:
m_ObjectHideFlags: 0
@@ -134,7 +135,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_Color: {r: 0.41960785, g: 0.5411765, b: 0.8039216, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -142,7 +143,7 @@ MonoBehaviour:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: bd4be44636ad6c24a9b117a7108a75e9, type: 3}
m_Font: {fileID: 12800000, guid: d254d20d93651ae448ff13d10dff30ab, type: 3}
m_FontSize: 15
m_FontStyle: 0
m_BestFit: 0
@@ -154,7 +155,115 @@ MonoBehaviour:
m_HorizontalOverflow: 0
m_VerticalOverflow: 0
m_LineSpacing: 1
m_Text: "\u54C1\u540D"
m_Text: "\u54C1\u540D\u662F12123"
--- !u!114 &8475271739115695170
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1059233533614862680}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3245ec927659c4140ac4f8d17403cc18, type: 3}
m_Name:
m_EditorClassIdentifier:
m_HorizontalFit: 2
m_VerticalFit: 0
--- !u!1 &2029636835483568584
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 6759631360171910247}
- component: {fileID: 4215255755343680456}
- component: {fileID: 793626109444426662}
- component: {fileID: 2852475067541627356}
m_Layer: 0
m_Name: space
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &6759631360171910247
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2029636835483568584}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 7346852462872466010}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 91, y: -10}
m_SizeDelta: {x: 8, y: 15}
m_Pivot: {x: 0, y: 0.5}
--- !u!222 &4215255755343680456
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2029636835483568584}
m_CullTransparentMesh: 1
--- !u!114 &793626109444426662
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2029636835483568584}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.41960785, g: 0.5411765, b: 0.8039216, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: d254d20d93651ae448ff13d10dff30ab, type: 3}
m_FontSize: 15
m_FontStyle: 0
m_BestFit: 0
m_MinSize: 0
m_MaxSize: 40
m_Alignment: 3
m_AlignByGeometry: 0
m_RichText: 1
m_HorizontalOverflow: 0
m_VerticalOverflow: 0
m_LineSpacing: 1
m_Text: ' '
--- !u!114 &2852475067541627356
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2029636835483568584}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3245ec927659c4140ac4f8d17403cc18, type: 3}
m_Name:
m_EditorClassIdentifier:
m_HorizontalFit: 2
m_VerticalFit: 0
--- !u!1 &5437409420464746555
GameObject:
m_ObjectHideFlags: 0
@@ -185,13 +294,12 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 3546975410283370972}
- {fileID: 682261594771255014}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: -26.7554}
m_SizeDelta: {x: 150, y: 35}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 150, y: 20}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &8491477728461931688
MonoBehaviour:
@@ -208,9 +316,9 @@ MonoBehaviour:
reward_icon: {fileID: 6251169732777221245}
reward_name: {fileID: 267584174794072845}
reward_count: {fileID: 6866410370984929931}
coin_sprite: {fileID: 21300000, guid: 16f0765cc15fce04f828ecd44d1dbf91, type: 3}
jiyisuipian: {fileID: 21300000, guid: 6fd4dd33d986d824d95f7b01579acc4d, type: 3}
player_exp: {fileID: 21300000, guid: af3ce18394be1b448a9e38602526eb80, type: 3}
coin_sprite: {fileID: 21300000, guid: ddce9ea8759b6ca47b6d177982d1ed1e, type: 3}
jiyisuipian: {fileID: 21300000, guid: 9753216db376a75409e917958811eb28, type: 3}
player_exp: {fileID: 21300000, guid: de1739af68092d2478ed972daa5a9820, type: 3}
--- !u!1 &7159171791523732843
GameObject:
m_ObjectHideFlags: 0
@@ -222,6 +330,7 @@ GameObject:
- component: {fileID: 682261594771255014}
- component: {fileID: 8122003116666225205}
- component: {fileID: 6866410370984929931}
- component: {fileID: 2071136781458211337}
m_Layer: 0
m_Name: rwdACT
m_TagString: Untagged
@@ -241,13 +350,13 @@ RectTransform:
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5127964937674371758}
m_Father: {fileID: 7346852462872466010}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -5.157, y: -8.48}
m_SizeDelta: {x: 124.989, y: 18}
m_Pivot: {x: 0.5, y: 0.5}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 99, y: -10}
m_SizeDelta: {x: 96, y: 18}
m_Pivot: {x: 0, y: 0.5}
--- !u!222 &8122003116666225205
CanvasRenderer:
m_ObjectHideFlags: 0
@@ -269,7 +378,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_Color: {r: 0.41960785, g: 0.5411765, b: 0.8039216, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
@@ -277,7 +386,7 @@ MonoBehaviour:
m_PersistentCalls:
m_Calls: []
m_FontData:
m_Font: {fileID: 12800000, guid: bd4be44636ad6c24a9b117a7108a75e9, type: 3}
m_Font: {fileID: 12800000, guid: d254d20d93651ae448ff13d10dff30ab, type: 3}
m_FontSize: 16
m_FontStyle: 0
m_BestFit: 0
@@ -289,4 +398,98 @@ MonoBehaviour:
m_HorizontalOverflow: 0
m_VerticalOverflow: 0
m_LineSpacing: 1
m_Text: +10086
m_Text: +100861123
--- !u!114 &2071136781458211337
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7159171791523732843}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3245ec927659c4140ac4f8d17403cc18, type: 3}
m_Name:
m_EditorClassIdentifier:
m_HorizontalFit: 2
m_VerticalFit: 0
--- !u!1 &9110073405764519532
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7346852462872466010}
- component: {fileID: 1268090614278476671}
- component: {fileID: 3900282080562181984}
m_Layer: 0
m_Name: hori
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7346852462872466010
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 9110073405764519532}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 4266895856353985475}
- {fileID: 6759631360171910247}
- {fileID: 682261594771255014}
m_Father: {fileID: 3546975410283370972}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 13.800049, y: 0.00002861023}
m_SizeDelta: {x: 0, y: 20}
m_Pivot: {x: 0, y: 0.5}
--- !u!114 &1268090614278476671
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 9110073405764519532}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 30649d3a9faa99c48a7b1166b86bf2a0, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Padding:
m_Left: 0
m_Right: 0
m_Top: 0
m_Bottom: 0
m_ChildAlignment: 3
m_Spacing: 0
m_ChildForceExpandWidth: 1
m_ChildForceExpandHeight: 1
m_ChildControlWidth: 0
m_ChildControlHeight: 0
m_ChildScaleWidth: 0
m_ChildScaleHeight: 0
m_ReverseArrangement: 0
--- !u!114 &3900282080562181984
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 9110073405764519532}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3245ec927659c4140ac4f8d17403cc18, type: 3}
m_Name:
m_EditorClassIdentifier:
m_HorizontalFit: 2
m_VerticalFit: 0
@@ -72,6 +72,7 @@ public class settlementController : MonoBehaviour
public Text mvp_score;
public Image mvp_heroIcon;
public Text mvp_heroName;
public Text legacy_text;
// Documentation text normalized.
[Header("Inspector")]
@@ -643,7 +644,7 @@ public class settlementController : MonoBehaviour
goodHitPercent_Text.text = targetGoodPercent.ToString("F1") + "%";
missHitPercent_Text.text = targetMissPercent.ToString("F1") + "%";
accuracy_Text.text = targetAccuracyPercent.ToString("F3") + "%";
accuracy_Text.text = targetAccuracyPercent.ToString("F1") + "%";
// Timing Statistics
if (earlyHitCount_Text != null) earlyHitCount_Text.text = sm.countEarly.ToString();
@@ -769,6 +770,29 @@ public class settlementController : MonoBehaviour
settlementRewardsGranted = true;
if (GameConfig.autoPlayEnabled)
{
moneyToGive_thisLevel = 0;
if (reward_money_Text != null)
{
reward_money_Text.text = "+0";
}
if (reward_idolEXP_bottle_Text != null)
{
reward_idolEXP_bottle_Text.text = "+0";
}
if (reward_playerEXP_Text != null)
{
reward_playerEXP_Text.text = "+0";
}
RebuildSettlementRewardVisuals(0, 0, 0);
return;
}
int coinReward = Mathf.CeilToInt((targetTotalScore / 10000f) * 0.75f);
int materialReward = Mathf.CeilToInt((targetTotalScore / 10000f) * 0.20f);
int playerExpReward = Mathf.FloorToInt(targetTotalScore / 100000f);
@@ -1142,7 +1166,7 @@ public class settlementController : MonoBehaviour
PrepareTextForCountUp(pmScoreSum_Text, false, 0);
PrepareTextForCountUp(idolScoreSum_Text, false, 0);
PrepareTextForCountUp(accuracy_Text, true, 0f, "F3");
PrepareTextForCountUp(accuracy_Text, true, 0f, "F1");
PrepareTextForCountUp(finalScore_Text, false, 0);
PrepareTextForCountUp(thisLevel_currentPercentage_Text, true, 0f, "F2");
PrepareTextForCountUp(perfectHitCount_Text, false, 0);
@@ -1169,7 +1193,7 @@ public class settlementController : MonoBehaviour
Coroutine scoreBottomIn = null;
Coroutine shenstarsIn = null;
if (scoreBottomRt != null)
scoreBottomIn = StartCoroutine(AnimateAnchoredXAndFade(scoreBottomRt, -1646.9f, -432.4f, introMoveDuration, true));
scoreBottomIn = StartCoroutine(AnimateAnchoredXAndFade(scoreBottomRt, -1646.9f, 0f, introMoveDuration, true));
if (shenstarsRt != null)
shenstarsIn = StartCoroutine(AnimateAnchoredXAndFade(shenstarsRt, -1602.1f, -437.5785f, introMoveDuration, true));
if (scoreBottomIn != null || shenstarsIn != null)
@@ -1182,7 +1206,7 @@ public class settlementController : MonoBehaviour
// score numbers: pm -> idol -> accuracy
yield return StartCoroutine(AnimateIntText(pmScoreSum_Text, targetPmScore, introNumberDuration));
yield return StartCoroutine(AnimateIntText(idolScoreSum_Text, targetIdolScore, introNumberDuration));
yield return StartCoroutine(AnimateFloatPercentText(accuracy_Text, targetAccuracyPercent, introNumberDuration, "F3"));
yield return StartCoroutine(AnimateFloatPercentText(accuracy_Text, targetAccuracyPercent, introNumberDuration, "F1"));
// total + percent after above, and flash once completed
Coroutine totalScoreIn = StartCoroutine(AnimateIntText(finalScore_Text, targetTotalScore, introNumberDuration));
@@ -1287,7 +1311,7 @@ public class settlementController : MonoBehaviour
if (rightBottomRt != null) { SetAnchoredX(rightBottomRt, 1677.75f); SetCanvasAlpha(rightBottomRt, 1f); }
if (quhuiImageTr != null) SetCanvasAlpha(quhuiImageTr, 1f);
if (scoreBottomRt != null) { SetAnchoredX(scoreBottomRt, -432.4f); SetCanvasAlpha(scoreBottomRt, 1f); }
if (scoreBottomRt != null) { SetAnchoredX(scoreBottomRt, 0f); SetCanvasAlpha(scoreBottomRt, 1f); }
if (shenstarsRt != null) { SetAnchoredX(shenstarsRt, -437.5785f); SetCanvasAlpha(shenstarsRt, 1f); }
if (rewardRt != null) { SetAnchoredY(rewardRt, 0f); SetCanvasAlpha(rewardRt, 1f); }
if (lihuiTr != null) SetCanvasAlpha(lihuiTr, 1f);
@@ -1311,7 +1335,7 @@ public class settlementController : MonoBehaviour
if (pmScoreSum_Text != null) { pmScoreSum_Text.text = targetPmScore.ToString(); SetCanvasAlpha(pmScoreSum_Text.transform, 1f); }
if (idolScoreSum_Text != null) { idolScoreSum_Text.text = targetIdolScore.ToString(); SetCanvasAlpha(idolScoreSum_Text.transform, 1f); }
if (accuracy_Text != null) { accuracy_Text.text = targetAccuracyPercent.ToString("F3") + "%"; SetCanvasAlpha(accuracy_Text.transform, 1f); }
if (accuracy_Text != null) { accuracy_Text.text = targetAccuracyPercent.ToString("F1") + "%"; SetCanvasAlpha(accuracy_Text.transform, 1f); }
if (finalScore_Text != null) { finalScore_Text.text = targetTotalScore.ToString(); SetCanvasAlpha(finalScore_Text.transform, 1f); }
if (thisLevel_currentPercentage_Text != null) { thisLevel_currentPercentage_Text.text = targetTotalPercent.ToString("F2") + "%"; SetCanvasAlpha(thisLevel_currentPercentage_Text.transform, 1f); }
@@ -2123,6 +2147,8 @@ public class settlementController : MonoBehaviour
mvp_hero_hd_image.sprite = null;
mvp_hero_hd_image.color = new Color(mvp_hero_hd_image.color.r, mvp_hero_hd_image.color.g, mvp_hero_hd_image.color.b, 0f);
}
// Activate legacy_text when no MVP conditions are met
if (legacy_text != null) legacy_text.gameObject.SetActive(true);
return;
}
@@ -2186,6 +2212,9 @@ public class settlementController : MonoBehaviour
if (mvp_heroIcon != null) mvp_heroIcon.sprite = heroSO.ally_hero_squareProfile;
if (mvp_score != null) mvp_score.text = max.ToString();
// Deactivate legacy_text when MVP is successfully displayed
if (legacy_text != null) legacy_text.gameObject.SetActive(false);
Debug.Log($"[SettlementController] MVP assigned from slot {topIndex+1} with score {max}: {heroSO.ally_heroName}");
}
else
@@ -2197,6 +2226,10 @@ public class settlementController : MonoBehaviour
mvp_hero_hd_image.sprite = null;
mvp_hero_hd_image.color = new Color(mvp_hero_hd_image.color.r, mvp_hero_hd_image.color.g, mvp_hero_hd_image.color.b, 0f);
}
// Activate legacy_text when hero SO is not found
if (legacy_text != null) legacy_text.gameObject.SetActive(true);
Debug.LogWarning("[SettlementController] MVP SO or HD image not found");
}
}
@@ -0,0 +1,533 @@
using System.Collections;
using UnityEngine;
public class trackFractureController : MonoBehaviour
{
private const int TrackCount = 5;
private static readonly int FadeId = Shader.PropertyToID("_Fade");
[System.Serializable]
private class TrackSpriteFadeEntry
{
public SpriteRenderer spriteRendererA;
public SpriteRenderer spriteRendererB;
}
[Header("Tracks")]
[SerializeField] private GameObject[] trackObjects = new GameObject[TrackCount];
[SerializeField] private Material[] dissolveMaterials = new Material[TrackCount];
[SerializeField] private TrackSpriteFadeEntry[] trackSpriteFadeEntries = new TrackSpriteFadeEntry[TrackCount];
[SerializeField] private bool autoFetchFractureAndDrift = true;
[SerializeField] private bool autoApplyDissolveMaterialOnStart = true;
[Tooltip("If true, each track gets its own runtime CLONE of its dissolve material. The clone REPLACES the " +
"material in the renderer slot, so you can no longer drive _Fade through the original material asset " +
"reference. Only needed when several tracks share one material asset. If false (default), the assigned " +
"dissolve material is used directly on the renderer, so controlling that material's _Fade (via this " +
"controller or externally) drives the dissolve.")]
[SerializeField] private bool cloneDissolveMaterialAtRuntime = false;
[Header("Fracture Fade")]
[Tooltip("float1: seconds to wait AFTER a track fractures before its dissolve _Fade starts changing.")]
[SerializeField] private float fractureFadeDelay = 0.2f;
[Tooltip("Seconds over which the linked SpriteRenderer pair fades from alpha 1 to 0 after fracture.")]
[SerializeField] private float spriteRendererFadeDuration = 0.2f;
[Tooltip("float2: seconds over which the dissolve _Fade is driven from 1 to 0 once the fade begins. " +
"When this completes, the track's fragments are reclaimed asynchronously (collected one-by-one).")]
[SerializeField] private float fractureFadeDuration = 1f;
[Tooltip("The _Fade value written when the fracture dissolve begins.")]
[SerializeField] private float fractureFadeStartValue = 0f;
[Tooltip("The _Fade value written when the fracture dissolve completes.")]
[SerializeField] private float fractureFadeEndValue = 1f;
[Header("Start Z Move")]
[SerializeField] private bool playStartZMove = true;
[SerializeField] private float startLocalZ = 0f;
[SerializeField] private float endLocalZ = 0.286f;
[SerializeField] private float moveDuration = 0.35f;
[Header("Debug")]
[SerializeField] private bool enableDebugKeyTrigger = true;
[SerializeField] private bool debugLogs = true;
private readonly FractureAndDrift[] fractureDrivers = new FractureAndDrift[TrackCount];
private readonly Renderer[] trackRenderers = new Renderer[TrackCount];
private readonly Material[] runtimeDissolveMaterials = new Material[TrackCount];
// Original _Fade of each ASSIGNED material asset, captured before we touch it, so we can restore it
// on teardown. Needed only when NOT cloning: we drive the asset directly, and Unity persists those
// writes back to the .mat, so the value would otherwise stick after exiting play mode. NaN = not captured.
private readonly float[] originalFadeValues = new float[TrackCount];
private Coroutine startMoveRoutine;
private readonly Coroutine[] fadeRoutines = new Coroutine[TrackCount];
private void Awake()
{
CacheTrackReferences();
}
private void Start()
{
CacheTrackReferences();
ApplyRuntimeDissolveMaterials();
SetAllTrackLocalZ(startLocalZ);
if (playStartZMove)
{
startMoveRoutine = StartCoroutine(AnimateTracksToEndZ());
}
}
private void Update()
{
if (!enableDebugKeyTrigger)
{
return;
}
if (Input.GetKeyDown(KeyCode.Alpha1) || Input.GetKeyDown(KeyCode.Keypad1))
{
if (debugLogs) Debug.Log("[trackFractureController] Debug key 1 pressed.", this);
TriggerTrackFracture(0);
}
else if (Input.GetKeyDown(KeyCode.Alpha2) || Input.GetKeyDown(KeyCode.Keypad2))
{
if (debugLogs) Debug.Log("[trackFractureController] Debug key 2 pressed.", this);
TriggerTrackFracture(1);
}
else if (Input.GetKeyDown(KeyCode.Alpha3) || Input.GetKeyDown(KeyCode.Keypad3))
{
if (debugLogs) Debug.Log("[trackFractureController] Debug key 3 pressed.", this);
TriggerTrackFracture(2);
}
else if (Input.GetKeyDown(KeyCode.Alpha4) || Input.GetKeyDown(KeyCode.Keypad4))
{
if (debugLogs) Debug.Log("[trackFractureController] Debug key 4 pressed.", this);
TriggerTrackFracture(3);
}
else if (Input.GetKeyDown(KeyCode.Alpha5) || Input.GetKeyDown(KeyCode.Keypad5))
{
if (debugLogs) Debug.Log("[trackFractureController] Debug key 5 pressed.", this);
TriggerTrackFracture(4);
}
}
private void OnValidate()
{
EnsureArraySizes();
if (!Application.isPlaying)
{
CacheTrackReferences();
}
}
private void OnDestroy()
{
// When not cloning, we drove the assigned .mat asset directly and Unity persists those writes.
// Restore each asset's original _Fade so the value doesn't stick after play mode ends.
RestoreOriginalFadeValues();
for (int i = 0; i < runtimeDissolveMaterials.Length; i++)
{
if (runtimeDissolveMaterials[i] != null)
{
Destroy(runtimeDissolveMaterials[i]);
runtimeDissolveMaterials[i] = null;
}
}
}
private void RestoreOriginalFadeValues()
{
for (int i = 0; i < TrackCount; i++)
{
// Only restore assets we drove directly (clones are thrown away, no restore needed).
if (runtimeDissolveMaterials[i] != null)
{
continue;
}
Material sourceMaterial = dissolveMaterials[i];
if (sourceMaterial == null || float.IsNaN(originalFadeValues[i]) || !sourceMaterial.HasProperty(FadeId))
{
continue;
}
sourceMaterial.SetFloat(FadeId, originalFadeValues[i]);
originalFadeValues[i] = float.NaN;
}
}
public void TriggerTrackFractureByNumber(int trackNumber)
{
TriggerTrackFracture(trackNumber - 1);
}
public void TriggerTrackFracture(int trackIndex)
{
if (!IsValidTrackIndex(trackIndex))
{
Debug.LogWarning($"[trackFractureController] Invalid track index: {trackIndex}", this);
return;
}
FractureAndDrift fractureDriver = fractureDrivers[trackIndex];
if (fractureDriver == null)
{
Debug.LogWarning($"[trackFractureController] Missing FractureAndDrift on track {trackIndex + 1}.", this);
return;
}
fractureDriver.Shatter();
StartFractureFade(trackIndex);
}
private void StartFractureFade(int trackIndex)
{
Material targetMaterial = GetFadeTargetMaterial(trackIndex);
if (targetMaterial == null || !targetMaterial.HasProperty(FadeId))
{
return;
}
if (fadeRoutines[trackIndex] != null)
{
StopCoroutine(fadeRoutines[trackIndex]);
}
fadeRoutines[trackIndex] = StartCoroutine(FadeTrackOut(trackIndex, targetMaterial));
}
private IEnumerator FadeTrackOut(int trackIndex, Material targetMaterial)
{
TrackSpriteFadeEntry spriteFadeEntry = GetSpriteFadeEntry(trackIndex);
SetSpriteFadeEntryAlpha(spriteFadeEntry, 1f);
if (spriteRendererFadeDuration > 0f)
{
float spriteFadeElapsed = 0f;
while (spriteFadeElapsed < spriteRendererFadeDuration)
{
spriteFadeElapsed += Time.deltaTime;
float t = Mathf.Clamp01(spriteFadeElapsed / spriteRendererFadeDuration);
SetSpriteFadeEntryAlpha(spriteFadeEntry, Mathf.Lerp(1f, 0f, t));
yield return null;
}
}
else
{
SetSpriteFadeEntryAlpha(spriteFadeEntry, 0f);
}
SetSpriteFadeEntryAlpha(spriteFadeEntry, 0f);
// float1: hold before the dissolve begins. Fragments have already started drifting during this time.
if (fractureFadeDelay > 0f)
{
yield return new WaitForSeconds(fractureFadeDelay);
}
// float2: drive _Fade from the configured start value to the configured end value over this duration.
float duration = Mathf.Max(0.0001f, fractureFadeDuration);
float startFade = Mathf.Clamp01(fractureFadeStartValue);
float endFade = Mathf.Clamp01(fractureFadeEndValue);
float elapsed = 0f;
targetMaterial.SetFloat(FadeId, startFade);
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = Mathf.Clamp01(elapsed / duration);
targetMaterial.SetFloat(FadeId, Mathf.Lerp(startFade, endFade, t));
yield return null;
}
targetMaterial.SetFloat(FadeId, endFade);
fadeRoutines[trackIndex] = null;
// Fade finished: asynchronously reclaim (collect) this track's fragments rather than destroying
// them all at once.
FractureAndDrift fractureDriver = fractureDrivers[trackIndex];
if (fractureDriver != null)
{
fractureDriver.ReclaimFragments();
}
}
public void SetTrackFadeByNumber(int trackNumber, float fadeValue)
{
SetTrackFade(trackNumber - 1, fadeValue);
}
public void SetTrackFade(int trackIndex, float fadeValue)
{
if (!IsValidTrackIndex(trackIndex))
{
Debug.LogWarning($"[trackFractureController] Invalid track index: {trackIndex}", this);
return;
}
Material targetMaterial = GetFadeTargetMaterial(trackIndex);
if (targetMaterial == null)
{
Debug.LogWarning($"[trackFractureController] Missing dissolve material on track {trackIndex + 1}.", this);
return;
}
if (!targetMaterial.HasProperty(FadeId))
{
Debug.LogWarning($"[trackFractureController] Material on track {trackIndex + 1} has no _Fade property.", targetMaterial);
return;
}
targetMaterial.SetFloat(FadeId, Mathf.Clamp01(fadeValue));
}
public float GetTrackFade(int trackIndex)
{
if (!IsValidTrackIndex(trackIndex))
{
return 0f;
}
Material targetMaterial = GetFadeTargetMaterial(trackIndex);
if (targetMaterial == null || !targetMaterial.HasProperty(FadeId))
{
return 0f;
}
return targetMaterial.GetFloat(FadeId);
}
public void SetAllTrackFade(float fadeValue)
{
for (int i = 0; i < TrackCount; i++)
{
SetTrackFade(i, fadeValue);
}
}
public void RestartStartZMove()
{
SetAllTrackLocalZ(startLocalZ);
if (startMoveRoutine != null)
{
StopCoroutine(startMoveRoutine);
}
startMoveRoutine = StartCoroutine(AnimateTracksToEndZ());
}
public void TriggerTrack1Fracture() => TriggerTrackFracture(0);
public void TriggerTrack2Fracture() => TriggerTrackFracture(1);
public void TriggerTrack3Fracture() => TriggerTrackFracture(2);
public void TriggerTrack4Fracture() => TriggerTrackFracture(3);
public void TriggerTrack5Fracture() => TriggerTrackFracture(4);
private void CacheTrackReferences()
{
EnsureArraySizes();
for (int i = 0; i < TrackCount; i++)
{
GameObject trackObject = trackObjects[i];
fractureDrivers[i] = null;
trackRenderers[i] = null;
if (trackObject == null)
{
continue;
}
if (autoFetchFractureAndDrift)
{
fractureDrivers[i] = trackObject.GetComponent<FractureAndDrift>();
if (fractureDrivers[i] == null)
{
fractureDrivers[i] = trackObject.GetComponentInChildren<FractureAndDrift>(true);
}
}
trackRenderers[i] = trackObject.GetComponent<Renderer>();
if (trackRenderers[i] == null)
{
trackRenderers[i] = trackObject.GetComponentInChildren<Renderer>(true);
}
}
}
private void ApplyRuntimeDissolveMaterials()
{
for (int i = 0; i < TrackCount; i++)
{
if (runtimeDissolveMaterials[i] != null)
{
Destroy(runtimeDissolveMaterials[i]);
runtimeDissolveMaterials[i] = null;
}
originalFadeValues[i] = float.NaN;
Material sourceMaterial = dissolveMaterials[i];
Renderer trackRenderer = trackRenderers[i];
if (sourceMaterial == null)
{
continue;
}
// The material actually used on the renderer AND driven for fade. When cloning is off this is the
// assigned asset itself, so external control of that asset's _Fade drives the dissolve directly.
Material appliedMaterial = sourceMaterial;
if (cloneDissolveMaterialAtRuntime)
{
appliedMaterial = new Material(sourceMaterial) { name = sourceMaterial.name + "_Runtime" };
runtimeDissolveMaterials[i] = appliedMaterial;
}
else if (sourceMaterial.HasProperty(FadeId))
{
// Not cloning: we mutate the asset directly, so remember its original _Fade to restore later.
originalFadeValues[i] = sourceMaterial.GetFloat(FadeId);
}
if (autoApplyDissolveMaterialOnStart && trackRenderer != null)
{
Material[] materials = trackRenderer.sharedMaterials;
if (materials == null || materials.Length == 0)
{
trackRenderer.sharedMaterial = appliedMaterial;
}
else
{
bool replaced = false;
for (int j = 0; j < materials.Length; j++)
{
if (materials[j] == sourceMaterial)
{
materials[j] = appliedMaterial;
replaced = true;
}
}
if (!replaced)
{
materials[0] = appliedMaterial;
}
trackRenderer.sharedMaterials = materials;
}
}
}
}
private IEnumerator AnimateTracksToEndZ()
{
float duration = Mathf.Max(0.0001f, moveDuration);
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = Mathf.Clamp01(elapsed / duration);
float z = Mathf.Lerp(startLocalZ, endLocalZ, t);
SetAllTrackLocalZ(z);
yield return null;
}
SetAllTrackLocalZ(endLocalZ);
startMoveRoutine = null;
}
private void SetAllTrackLocalZ(float zValue)
{
for (int i = 0; i < TrackCount; i++)
{
GameObject trackObject = trackObjects[i];
if (trackObject == null)
{
continue;
}
Transform targetTransform = trackObject.transform;
Vector3 localPosition = targetTransform.localPosition;
localPosition.z = zValue;
targetTransform.localPosition = localPosition;
}
}
private Material GetFadeTargetMaterial(int trackIndex)
{
if (!IsValidTrackIndex(trackIndex))
{
return null;
}
// When cloning is on, drive the runtime clone. Otherwise drive the assigned asset directly - which is
// also the material now used on the renderer, so controlling that asset's _Fade drives the dissolve.
return runtimeDissolveMaterials[trackIndex] != null
? runtimeDissolveMaterials[trackIndex]
: dissolveMaterials[trackIndex];
}
private void EnsureArraySizes()
{
if (trackObjects == null || trackObjects.Length != TrackCount)
{
System.Array.Resize(ref trackObjects, TrackCount);
}
if (dissolveMaterials == null || dissolveMaterials.Length != TrackCount)
{
System.Array.Resize(ref dissolveMaterials, TrackCount);
}
if (trackSpriteFadeEntries == null || trackSpriteFadeEntries.Length != TrackCount)
{
System.Array.Resize(ref trackSpriteFadeEntries, TrackCount);
}
}
private static bool IsValidTrackIndex(int trackIndex)
{
return trackIndex >= 0 && trackIndex < TrackCount;
}
private TrackSpriteFadeEntry GetSpriteFadeEntry(int trackIndex)
{
if (!IsValidTrackIndex(trackIndex) || trackSpriteFadeEntries == null || trackIndex >= trackSpriteFadeEntries.Length)
{
return null;
}
return trackSpriteFadeEntries[trackIndex];
}
private static void SetSpriteFadeEntryAlpha(TrackSpriteFadeEntry entry, float alpha)
{
if (entry == null)
{
return;
}
SetSpriteRendererAlpha(entry.spriteRendererA, alpha);
SetSpriteRendererAlpha(entry.spriteRendererB, alpha);
}
private static void SetSpriteRendererAlpha(SpriteRenderer spriteRenderer, float alpha)
{
if (spriteRenderer == null)
{
return;
}
Color color = spriteRenderer.color;
color.a = Mathf.Clamp01(alpha);
spriteRenderer.color = color;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1f43164d05a229940a9b89f7c35236ae
@@ -6,6 +6,7 @@ using UnityEngine.UI;
using TMPro;
using System.Collections.Generic;
using System.Collections;
using DG.Tweening;
#if UNITY_EDITOR
using UnityEditor;
#endif
@@ -840,6 +841,12 @@ public class teamUIController : MonoBehaviour
public TextMeshProUGUI allSum_pmScore;
[Header("Inspector")]
public TextMeshProUGUI allSum_idolScore;
[Header("Inspector")]
public Text recentPlusAmountText;
[Header("Inspector")]
public Image currentScoreRankImage;
[Header("Inspector")]
public RankConfig currentScoreRankConfig;
// new: runtime enemy instance and UI sync fields
private EnemyCombatant enemyCombatantInstance;
@@ -872,6 +879,7 @@ public class teamUIController : MonoBehaviour
private int combo = 0;
public int CurrentCombo => combo;
private Tween recentPlusAmountFadeTween;
public enum ComboJudgeType
{
@@ -1173,6 +1181,19 @@ public class teamUIController : MonoBehaviour
{
SpawnNextEnemy();
}
if (recentPlusAmountText != null)
{
recentPlusAmountText.text = "+0";
Color color = recentPlusAmountText.color;
color.a = 0f;
recentPlusAmountText.color = color;
}
if (ScoreManager.Instance != null)
{
ScoreManager.Instance.RefreshAllScoreUi();
}
}
// Update is called once per frame
@@ -1233,10 +1254,12 @@ public class teamUIController : MonoBehaviour
if (IsCombo(result))
{
combo++;
ResetRecentPlusAmountVisual();
}
else
{
combo = 0;
FadeOutRecentPlusAmount();
}
if (comboCounter != null)
{
@@ -1260,6 +1283,42 @@ public class teamUIController : MonoBehaviour
}
}
public void ResetRecentPlusAmountVisual()
{
if (recentPlusAmountText == null)
{
return;
}
if (recentPlusAmountFadeTween != null)
{
recentPlusAmountFadeTween.Kill();
recentPlusAmountFadeTween = null;
}
Color color = recentPlusAmountText.color;
color.a = 1f;
recentPlusAmountText.color = color;
}
public void FadeOutRecentPlusAmount()
{
if (recentPlusAmountText == null)
{
return;
}
if (recentPlusAmountFadeTween != null)
{
recentPlusAmountFadeTween.Kill();
}
recentPlusAmountFadeTween = recentPlusAmountText
.DOFade(0f, 0.25f)
.SetUpdate(true)
.OnComplete(() => recentPlusAmountFadeTween = null);
}
/// <summary>
/// Documentation text normalized.
/// </summary>
@@ -0,0 +1,132 @@
#if UNITY_EDITOR
using System.IO;
using UnityEditor;
using UnityEngine;
// Bakes a Unity Gradient into a 1D ramp texture (width x 1) that can be assigned
// to the Dissolve URP material's "Gradient Ramp" (_GradientTex) slot. The dissolve
// edge value samples this ramp along its U axis, so the left of the gradient maps to
// the inner edge and the right maps to the outer edge of the dissolve.
public class DissolveGradientRampBaker : EditorWindow
{
[SerializeField] private Gradient gradient = CreateDefaultGradient();
[SerializeField] private int width = 256;
[SerializeField] private bool hdr = true;
[SerializeField] private string outputFolder = "Assets/Rainbow-Cats-Unity-Dissolve-HDR-Shaders-main/Materials";
[SerializeField] private string fileName = "DissolveGradientRamp";
private SerializedObject serialized;
[MenuItem("Bansonic/Rendering/Dissolve Gradient Ramp Baker")]
public static void Open()
{
GetWindow<DissolveGradientRampBaker>("Gradient Ramp Baker").minSize = new Vector2(360f, 260f);
}
private void OnEnable()
{
serialized = new SerializedObject(this);
}
private void OnGUI()
{
serialized.Update();
EditorGUILayout.LabelField("Bake a Gradient into a ramp texture", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"Assign the baked texture to the material's 'Gradient Ramp' slot. " +
"The dissolve edge samples it left-to-right.",
MessageType.Info);
EditorGUILayout.PropertyField(serialized.FindProperty(nameof(gradient)));
EditorGUILayout.PropertyField(serialized.FindProperty(nameof(width)));
EditorGUILayout.PropertyField(serialized.FindProperty(nameof(hdr)),
new GUIContent("HDR (float texture)"));
EditorGUILayout.PropertyField(serialized.FindProperty(nameof(outputFolder)));
EditorGUILayout.PropertyField(serialized.FindProperty(nameof(fileName)));
serialized.ApplyModifiedProperties();
EditorGUILayout.Space();
if (GUILayout.Button("Bake Ramp Texture", GUILayout.Height(32f)))
Bake();
}
private void Bake()
{
int w = Mathf.Clamp(width, 2, 4096);
var format = hdr ? TextureFormat.RGBAFloat : TextureFormat.RGBA32;
var tex = new Texture2D(w, 1, format, false, true)
{
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Bilinear
};
var pixels = new Color[w];
for (int x = 0; x < w; x++)
{
float t = w == 1 ? 0f : (float)x / (w - 1);
pixels[x] = gradient.Evaluate(t);
}
tex.SetPixels(pixels);
tex.Apply(false, false);
if (!AssetDatabase.IsValidFolder(outputFolder))
{
Debug.LogError($"[DissolveGradientRampBaker] Output folder does not exist: {outputFolder}");
Object.DestroyImmediate(tex);
return;
}
string extension = hdr ? "exr" : "png";
byte[] bytes = hdr
? tex.EncodeToEXR(Texture2D.EXRFlags.OutputAsFloat)
: tex.EncodeToPNG();
string assetPath = $"{outputFolder}/{fileName}.{extension}";
assetPath = AssetDatabase.GenerateUniqueAssetPath(assetPath);
File.WriteAllBytes(assetPath, bytes);
Object.DestroyImmediate(tex);
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
ConfigureImporter(assetPath);
var imported = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath);
EditorGUIUtility.PingObject(imported);
Selection.activeObject = imported;
Debug.Log($"[DissolveGradientRampBaker] Baked ramp to {assetPath}");
}
private void ConfigureImporter(string assetPath)
{
if (AssetImporter.GetAtPath(assetPath) is not TextureImporter importer)
return;
importer.textureType = TextureImporterType.Default;
importer.wrapMode = TextureWrapMode.Clamp;
importer.filterMode = FilterMode.Bilinear;
importer.mipmapEnabled = false;
importer.sRGBTexture = !hdr;
importer.alphaSource = TextureImporterAlphaSource.FromInput;
importer.alphaIsTransparency = false;
importer.SaveAndReimport();
}
private static Gradient CreateDefaultGradient()
{
var g = new Gradient();
g.SetKeys(
new[]
{
new GradientColorKey(new Color(1f, 0.35f, 0.1f), 0f),
new GradientColorKey(new Color(1f, 0.9f, 0.3f), 0.5f),
new GradientColorKey(new Color(0.3f, 0.7f, 1f), 1f)
},
new[]
{
new GradientAlphaKey(1f, 0f),
new GradientAlphaKey(1f, 1f)
});
return g;
}
}
#endif
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9821637dad915c344b817ce763d891ae
@@ -0,0 +1,314 @@
using System;
using System.Collections;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
using UnityEngine.Rendering;
// Compiles the FX / sprite / particle shaders used by gameplay during the preload
// screen so the first note (or first VFX / UI blur) does not pay the variant compile
// cost mid-gameplay.
//
// Progress: WarmupRoutine drives compilation across frames and reports a localized
// progress string ("正在编译着色器 xx%") through the onProgress callback so the caller
// (preload) can surface it on the loading overlay. When nothing needs compiling
// (cache valid) it reports the idle default ("加载中...").
//
// Persistence: after compiling, a validation manifest is written to a project-local
// ShaderCache folder (never Application.persistentDataPath / the C: AppData path). On
// the next load the manifest is compared against the current shader sources + GPU +
// build. If everything matches, compilation is skipped; if any shader source changed
// (or the GPU / build changed, invalidating previously compiled variants) the shaders
// are recompiled and the manifest refreshed.
public static class GlobalFxShaderWarmer
{
public const string IdleLoadingText = "加载中...";
private const string CompilingTextFormat = "正在编译着色器 {0}%";
[Serializable]
private class ShaderEntry
{
public string name;
public string sourceHash;
}
[Serializable]
private class WarmManifest
{
public string unityVersion;
public string appVersion;
public string graphicsDevice;
public string graphicsDeviceType;
public ShaderEntry[] shaders;
}
// A shader to warm plus its source path relative to Application.dataPath (the
// Assets folder). sourcePath may be null for engine/built-in shaders that have no
// file under Assets; those are still gated by the Unity/GPU/build fingerprint.
private readonly struct WarmTarget
{
public readonly string ShaderName;
public readonly string SourcePath;
public WarmTarget(string shaderName, string sourcePath)
{
ShaderName = shaderName;
SourcePath = sourcePath;
}
}
// Order is stable and used directly for manifest comparison.
private static readonly WarmTarget[] Targets =
{
// Global full-screen FX blit shaders (noteFunction effects).
new WarmTarget("Hidden/Bansonic/GlobalGlitch", "Shaders/GlobalGlitch.shader"),
new WarmTarget("Hidden/Bansonic/GlobalMonochrome", "Shaders/GlobalMonochrome.shader"),
new WarmTarget("Hidden/Bansonic/GlobalDistortion", "Shaders/GlobalDistortion.shader"),
new WarmTarget("Hidden/Bansonic/GlobalAfterimage", "Shaders/GlobalAfterimage.shader"),
// Explicitly requested particle shaders.
new WarmTarget("Cartoon FX/Remaster/Particle Ubershader",
"JMO Assets/Cartoon FX Remaster/CFXR Assets/Shaders/CFXR Particle Ubershader.cfxrshader"),
new WarmTarget("Mobile/Particles/Additive", null),
// Other CFXR particle shaders used across gameplay VFX.
new WarmTarget("Cartoon FX/Remaster/Particle Procedural Glow",
"JMO Assets/Cartoon FX Remaster/CFXR Assets/Shaders/CFXR Particle Glow.cfxrshader"),
new WarmTarget("Cartoon FX/Remaster/Particle Screen Distortion",
"JMO Assets/Cartoon FX Remaster/CFXR Assets/Shaders/CFXR Particle Distortion.cfxrshader"),
new WarmTarget("Cartoon FX/Remaster/Particle Procedural Ring",
"JMO Assets/Cartoon FX Remaster/CFXR Assets/Shaders/CFXR Particle Procedural Ring.cfxrshader"),
// Legacy Cartoon FX particle shaders still referenced by prefabs.
new WarmTarget("Cartoon FX/Legacy/Particles Additive Alpha8",
"JMO Assets/Cartoon FX (legacy)/Shaders/CFXM_MobileParticleAdd_Alpha8.shader"),
new WarmTarget("Cartoon FX/Legacy/Particle Multiply Colored",
"JMO Assets/Cartoon FX (legacy)/Shaders/CFX3 Multiply Color.shader"),
// Custom sprite/UI shaders used by gameplay materials.
new WarmTarget("Bansonic/Sprite Glow", "Shaders/BansonicSpriteGlow.shader"),
new WarmTarget("Bansonic/Sprite Light Curtain", "Shaders/BansonicSpriteLightCurtain.shader"),
new WarmTarget("Bansonic/Sprite Vertical Glow Only", "Shaders/BansonicSpriteVerticalGlowOnly.shader"),
new WarmTarget("Sprites/Outline", "SpriteGlow/Resources/SpriteGlow/Shaders/SpriteOutline.shader"),
new WarmTarget("Custom/SphereGradientShader", "sphereShaders/SphereGradient.shader"),
new WarmTarget("UI/Soft Shadow", "Shaders/UI/UIShadow.shader"),
new WarmTarget("UI/UIGaussianBlur", "Shaders/UI/UIGaussianBlur.shader"),
new WarmTarget("UI/Bansonic/Blur Behind", "Shaders/UI/BansonicUIBlurBehind.shader")
};
private const string ManifestFolder = "ShaderCache";
private const string ManifestFileName = "fx_shader_warm_manifest.json";
private static bool s_warmed;
// Coroutine entry point: warms shaders across frames and reports progress. Safe to
// call once per process; subsequent calls are no-ops (guarded by s_warmed).
public static IEnumerator WarmupRoutine(Action<string> onProgress)
{
if (s_warmed)
{
onProgress?.Invoke(IdleLoadingText);
yield break;
}
s_warmed = true;
WarmManifest current = BuildCurrentManifest();
WarmManifest cached = TryLoadManifest();
if (cached != null && ManifestsMatch(cached, current))
{
// Shaders (and GPU/build) unchanged: the driver's persistent pipeline cache
// still holds valid binaries, so skip the redundant compile this process.
Debug.Log("[GlobalFxShaderWarmer] Cached warm manifest valid; skipping shader recompile.");
onProgress?.Invoke(IdleLoadingText);
yield break;
}
RenderTexture temp = null;
RenderTexture previousActive = RenderTexture.active;
int count = Targets.Length;
try
{
temp = RenderTexture.GetTemporary(4, 4, 0);
for (int i = 0; i < count; i++)
{
int percent = Mathf.Clamp(Mathf.RoundToInt((float)i / count * 100f), 0, 100);
onProgress?.Invoke(string.Format(CompilingTextFormat, percent));
CompileOne(Targets[i].ShaderName, temp);
// Yield so the compile spikes are spread over frames and the progress
// text can refresh instead of blocking on one long frame.
yield return null;
}
}
finally
{
if (temp != null)
RenderTexture.ReleaseTemporary(temp);
RenderTexture.active = previousActive;
}
SaveManifest(current);
onProgress?.Invoke(IdleLoadingText);
Debug.Log(cached == null
? "[GlobalFxShaderWarmer] No cache found; compiled shaders and wrote manifest."
: "[GlobalFxShaderWarmer] Shader source or device changed; recompiled shaders and refreshed manifest.");
}
private static void CompileOne(string shaderName, RenderTexture temp)
{
Shader shader = Shader.Find(shaderName);
if (shader == null)
{
Debug.LogWarning("[GlobalFxShaderWarmer] Shader not found: " + shaderName);
return;
}
Material material = CoreUtils.CreateEngineMaterial(shader);
if (material == null)
return;
try
{
// Blit pass 0 to force the shader's variant to compile. Even for sprite /
// particle shaders this triggers program compilation; the throwaway 4x4
// render is discarded. Use a real source texture so URP's Blit does not
// warn about a null source.
Graphics.Blit(Texture2D.blackTexture, temp, material, 0);
}
catch (Exception ex)
{
Debug.LogWarning("[GlobalFxShaderWarmer] Warmup blit failed for " + shaderName + ": " + ex.Message);
}
finally
{
CoreUtils.Destroy(material);
}
}
private static WarmManifest BuildCurrentManifest()
{
var entries = new ShaderEntry[Targets.Length];
for (int i = 0; i < Targets.Length; i++)
{
entries[i] = new ShaderEntry
{
name = Targets[i].ShaderName,
sourceHash = ComputeSourceHash(Targets[i].SourcePath)
};
}
return new WarmManifest
{
unityVersion = Application.unityVersion,
appVersion = Application.version,
graphicsDevice = SystemInfo.graphicsDeviceName,
graphicsDeviceType = SystemInfo.graphicsDeviceType.ToString(),
shaders = entries
};
}
// Hashes the .shader source so an edit invalidates the cache. Engine/built-in
// shaders (sourcePath == null) or a stripped player where the file is absent hash
// to empty; validity there is gated by the build version + GPU fingerprint.
private static string ComputeSourceHash(string relativePath)
{
if (string.IsNullOrEmpty(relativePath))
return string.Empty;
try
{
string full = Path.Combine(Application.dataPath, relativePath);
if (!File.Exists(full))
return string.Empty;
byte[] bytes = File.ReadAllBytes(full);
using (var sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(bytes);
var sb = new StringBuilder(hash.Length * 2);
foreach (byte b in hash)
sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
catch (Exception ex)
{
Debug.LogWarning("[GlobalFxShaderWarmer] Failed to hash shader source '" + relativePath + "': " + ex.Message);
return string.Empty;
}
}
private static bool ManifestsMatch(WarmManifest a, WarmManifest b)
{
if (a == null || b == null)
return false;
if (a.unityVersion != b.unityVersion
|| a.appVersion != b.appVersion
|| a.graphicsDevice != b.graphicsDevice
|| a.graphicsDeviceType != b.graphicsDeviceType)
return false;
if (a.shaders == null || b.shaders == null || a.shaders.Length != b.shaders.Length)
return false;
for (int i = 0; i < a.shaders.Length; i++)
{
ShaderEntry ea = a.shaders[i];
ShaderEntry eb = b.shaders[i];
if (ea == null || eb == null)
return false;
if (ea.name != eb.name || ea.sourceHash != eb.sourceHash)
return false;
}
return true;
}
private static string GetManifestPath()
{
// Project-local folder only. Deliberately not Application.persistentDataPath
// (that lives under C:/Users/.../AppData) per the requirement to stay off C:.
string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
string dir = Path.Combine(projectRoot, ManifestFolder);
Directory.CreateDirectory(dir);
return Path.Combine(dir, ManifestFileName);
}
private static WarmManifest TryLoadManifest()
{
try
{
string path = GetManifestPath();
if (!File.Exists(path))
return null;
string json = File.ReadAllText(path);
if (string.IsNullOrEmpty(json))
return null;
return JsonUtility.FromJson<WarmManifest>(json);
}
catch (Exception ex)
{
Debug.LogWarning("[GlobalFxShaderWarmer] Failed to read warm manifest: " + ex.Message);
return null;
}
}
private static void SaveManifest(WarmManifest manifest)
{
try
{
string path = GetManifestPath();
string json = JsonUtility.ToJson(manifest, true);
File.WriteAllText(path, json);
}
catch (Exception ex)
{
Debug.LogWarning("[GlobalFxShaderWarmer] Failed to write warm manifest: " + ex.Message);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b6ba0a08c1b219f4e9eb2af1707a5510
@@ -94,6 +94,38 @@ public class playerMessagePrefab : MonoBehaviour
await TryGetAvatarSpriteAsync(steamId, normalizedAvatarUrl);
}
public static bool TryGetCachedAvatarSprite(string steamId, string avatarUrl, out Sprite sprite)
{
sprite = null;
string normalizedAvatarUrl = GameServer.Client.NetworkManager.NormalizeAvatarUrlForClient(avatarUrl);
if (!string.IsNullOrWhiteSpace(normalizedAvatarUrl)
&& AvatarCache.TryGetValue(normalizedAvatarUrl, out Sprite cachedByUrl)
&& cachedByUrl != null)
{
sprite = cachedByUrl;
return true;
}
if (!string.IsNullOrWhiteSpace(steamId)
&& AvatarCache.TryGetValue(steamId, out Sprite cachedBySteamId)
&& cachedBySteamId != null)
{
sprite = cachedBySteamId;
return true;
}
Sprite diskSprite = TryLoadAvatarSpriteFromDisk(steamId, normalizedAvatarUrl);
if (diskSprite != null)
{
CacheAvatarSpriteInMemory(steamId, normalizedAvatarUrl, diskSprite);
sprite = diskSprite;
return true;
}
return false;
}
private async Task RefreshAvatarAsync(string steamId, string avatarUrl)
{
const int maxAttempts = 20;
+30 -22
View File
@@ -667,7 +667,7 @@ RectTransform:
m_GameObject: {fileID: 1226732915180594430}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1.0000305, y: 1.0000305, z: 1.0000305}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 2346837952888000687}
@@ -675,7 +675,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -0.00024414062, y: -0.00024414062}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 3840, y: 2160}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &2740858507354457569
@@ -2004,8 +2004,8 @@ MonoBehaviour:
m_TargetGraphic: {fileID: 6197548626279452246}
m_HandleRect: {fileID: 3080131863592265227}
m_Direction: 0
m_Value: 1
m_Size: 0.9999999
m_Value: 0
m_Size: 1
m_NumberOfSteps: 0
m_OnValueChanged:
m_PersistentCalls:
@@ -2246,8 +2246,8 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 435.98, y: -18.199}
m_SizeDelta: {x: 333.95, y: 494.397}
m_AnchoredPosition: {x: 479.1093, y: -18.199}
m_SizeDelta: {x: 420.2086, y: 494.397}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &6546604333839282624
CanvasRenderer:
@@ -2894,8 +2894,8 @@ MonoBehaviour:
m_TargetGraphic: {fileID: 6407104958731660754}
m_HandleRect: {fileID: 2464122768890705763}
m_Direction: 0
m_Value: 1
m_Size: 1
m_Value: 0
m_Size: 0.99999976
m_NumberOfSteps: 0
m_OnValueChanged:
m_PersistentCalls:
@@ -3831,7 +3831,7 @@ MonoBehaviour:
m_TargetGraphic: {fileID: 1040773132025281584}
m_HandleRect: {fileID: 5614210503018957314}
m_Direction: 2
m_Value: 1
m_Value: 0
m_Size: 1
m_NumberOfSteps: 0
m_OnValueChanged:
@@ -3954,7 +3954,7 @@ RectTransform:
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 1300, y: 800}
m_SizeDelta: {x: 1500, y: 800}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &7214062317850356192
CanvasRenderer:
@@ -4295,7 +4295,7 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 6249172551117239467, guid: 6ad36126be99a574285929280ea8fac7, type: 3}
propertyPath: m_AnchorMax.y
value: 0
value: 1
objectReference: {fileID: 0}
- target: {fileID: 6249172551117239467, guid: 6ad36126be99a574285929280ea8fac7, type: 3}
propertyPath: m_AnchorMin.x
@@ -4303,7 +4303,7 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 6249172551117239467, guid: 6ad36126be99a574285929280ea8fac7, type: 3}
propertyPath: m_AnchorMin.y
value: 0
value: 1
objectReference: {fileID: 0}
- target: {fileID: 6249172551117239467, guid: 6ad36126be99a574285929280ea8fac7, type: 3}
propertyPath: m_SizeDelta.x
@@ -4311,7 +4311,7 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 6249172551117239467, guid: 6ad36126be99a574285929280ea8fac7, type: 3}
propertyPath: m_SizeDelta.y
value: 30
value: 0
objectReference: {fileID: 0}
- target: {fileID: 6249172551117239467, guid: 6ad36126be99a574285929280ea8fac7, type: 3}
propertyPath: m_LocalPosition.x
@@ -4343,11 +4343,11 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 6249172551117239467, guid: 6ad36126be99a574285929280ea8fac7, type: 3}
propertyPath: m_AnchoredPosition.x
value: 0
value: 169.875
objectReference: {fileID: 0}
- target: {fileID: 6249172551117239467, guid: 6ad36126be99a574285929280ea8fac7, type: 3}
propertyPath: m_AnchoredPosition.y
value: 0
value: -100.5
objectReference: {fileID: 0}
- target: {fileID: 6249172551117239467, guid: 6ad36126be99a574285929280ea8fac7, type: 3}
propertyPath: m_LocalEulerAnglesHint.x
@@ -4365,6 +4365,14 @@ PrefabInstance:
propertyPath: m_SizeDelta.x
value: 0
objectReference: {fileID: 0}
- target: {fileID: 6563396645772216654, guid: 6ad36126be99a574285929280ea8fac7, type: 3}
propertyPath: m_SizeDelta.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 8888888888888888801, guid: 6ad36126be99a574285929280ea8fac7, type: 3}
propertyPath: m_PreferredHeight
value: 0
objectReference: {fileID: 0}
m_RemovedComponents: []
m_RemovedGameObjects: []
m_AddedGameObjects: []
@@ -4527,7 +4535,7 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 752414209697581781, guid: f3c648c5e98a3d64fb64514c03aaf7a3, type: 3}
propertyPath: m_AnchoredPosition.y
value: -53
value: -62.5
objectReference: {fileID: 0}
- target: {fileID: 3794995701616312333, guid: f3c648c5e98a3d64fb64514c03aaf7a3, type: 3}
propertyPath: m_Name
@@ -4551,11 +4559,11 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 4696069065802349809, guid: f3c648c5e98a3d64fb64514c03aaf7a3, type: 3}
propertyPath: m_AnchoredPosition.y
value: 53
value: 62.500004
objectReference: {fileID: 0}
- target: {fileID: 4706209777741000001, guid: f3c648c5e98a3d64fb64514c03aaf7a3, type: 3}
propertyPath: m_PreferredHeight
value: 53
value: 62.500004
objectReference: {fileID: 0}
- target: {fileID: 5207749702406868266, guid: f3c648c5e98a3d64fb64514c03aaf7a3, type: 3}
propertyPath: m_AnchorMax.y
@@ -4591,7 +4599,7 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 5792101653971418633, guid: f3c648c5e98a3d64fb64514c03aaf7a3, type: 3}
propertyPath: m_SizeDelta.x
value: 256
value: 683
objectReference: {fileID: 0}
- target: {fileID: 5792101653971418633, guid: f3c648c5e98a3d64fb64514c03aaf7a3, type: 3}
propertyPath: m_SizeDelta.y
@@ -4599,11 +4607,11 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 5792101653971418633, guid: f3c648c5e98a3d64fb64514c03aaf7a3, type: 3}
propertyPath: m_AnchoredPosition.x
value: 41
value: 49
objectReference: {fileID: 0}
- target: {fileID: 5792101653971418633, guid: f3c648c5e98a3d64fb64514c03aaf7a3, type: 3}
propertyPath: m_AnchoredPosition.y
value: 20
value: 15
objectReference: {fileID: 0}
- target: {fileID: 7157313819124811504, guid: f3c648c5e98a3d64fb64514c03aaf7a3, type: 3}
propertyPath: m_Pivot.x
@@ -4635,7 +4643,7 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 7157313819124811504, guid: f3c648c5e98a3d64fb64514c03aaf7a3, type: 3}
propertyPath: m_SizeDelta.y
value: 53
value: 62.500004
objectReference: {fileID: 0}
- target: {fileID: 7157313819124811504, guid: f3c648c5e98a3d64fb64514c03aaf7a3, type: 3}
propertyPath: m_LocalPosition.x
@@ -0,0 +1,98 @@
using System;
using GameServer.Client;
using UnityEngine;
public sealed class StartupSettingsApplier : MonoBehaviour
{
private const string BootstrapObjectName = "[StartupSettingsApplier]";
private static StartupSettingsApplier _instance;
private bool _started;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Bootstrap()
{
if (_instance != null)
{
return;
}
GameObject go = new GameObject(BootstrapObjectName);
_instance = go.AddComponent<StartupSettingsApplier>();
DontDestroyOnLoad(go);
}
private void Start()
{
if (_started)
{
return;
}
_started = true;
ApplyStartupSettings();
StartCoroutine(SyncLeaderboardConsentRoutine());
}
private void ApplyStartupSettings()
{
graphicSettings.ApplySavedDisplaySettingsAtStartup(this);
ApplySavedAudioSettings();
}
private void ApplySavedAudioSettings()
{
audioSettingsPreloader[] preloaders = FindObjectsByType<audioSettingsPreloader>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < preloaders.Length; i++)
{
audioSettingsPreloader preloader = preloaders[i];
if (preloader == null)
{
continue;
}
try
{
preloader.ApplyAudioSettings();
}
catch (Exception ex)
{
Debug.LogWarning($"[StartupSettingsApplier] Failed to apply audio settings via scene preloader: {ex.Message}");
}
}
}
private System.Collections.IEnumerator SyncLeaderboardConsentRoutine()
{
float timeoutAt = Time.realtimeSinceStartup + 8f;
while (NetworkManager.Instance == null && Time.realtimeSinceStartup < timeoutAt)
{
yield return null;
}
NetworkManager networkManager = NetworkManager.Instance;
if (networkManager == null)
{
yield break;
}
bool isJoined = LeaderboardConsentUtility.HasLeaderboardConsent();
var task = networkManager.UpdateLeaderboardMembership(isJoined);
while (!task.IsCompleted)
{
yield return null;
}
if (task.IsFaulted)
{
Exception ex = task.Exception;
Debug.LogWarning($"[StartupSettingsApplier] Failed to sync leaderboard consent on startup: {ex?.GetBaseException().Message}");
yield break;
}
LeaderboardMembershipResponse response = task.Result;
if (response != null)
{
LeaderboardConsentUtility.SetLeaderboardConsent(response.is_joined);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4e9ad3d519677da4bb6487b008a1eddb
+17 -4
View File
@@ -37,6 +37,7 @@ public class controllerSettings : MonoBehaviour
private const float NoteSpeedMin = 0.5f;
private const float NoteSpeedMax = 2f;
private const string NoteSpeedPrefKey = "noteSpeedMultiplier";
private const float NoteSpeedDefault = 1.0f;
// continuous change
private Coroutine continuousChangeCoroutine = null;
@@ -77,7 +78,8 @@ public class controllerSettings : MonoBehaviour
{
noteSpeedMultipler_slider.minValue = NoteSpeedMin;
noteSpeedMultipler_slider.maxValue = NoteSpeedMax;
float saved = PlayerPrefs.GetFloat(NoteSpeedPrefKey, 1.0f);
EnsureDefaultNoteSpeedPreference();
float saved = PlayerPrefs.GetFloat(NoteSpeedPrefKey, NoteSpeedDefault);
saved = Mathf.Clamp(saved, NoteSpeedMin, NoteSpeedMax);
noteSpeedMultipler_slider.value = saved;
UpdateNoteSpeedText(saved);
@@ -494,9 +496,20 @@ public class controllerSettings : MonoBehaviour
private void ResetNoteSpeed()
{
if (noteSpeedMultipler_slider == null) return;
noteSpeedMultipler_slider.value = 1.0f; // triggers change and save
SaveNoteSpeedValue(1.0f);
UpdateNoteSpeedText(1.0f);
noteSpeedMultipler_slider.value = NoteSpeedDefault; // triggers change and save
SaveNoteSpeedValue(NoteSpeedDefault);
UpdateNoteSpeedText(NoteSpeedDefault);
}
private static void EnsureDefaultNoteSpeedPreference()
{
if (PlayerPrefs.HasKey(NoteSpeedPrefKey))
{
return;
}
PlayerPrefs.SetFloat(NoteSpeedPrefKey, NoteSpeedDefault);
PlayerPrefs.Save();
}
private void AddButtonContinuousEvents(Button btn, float delta)
+195 -18
View File
@@ -1,32 +1,205 @@
using System;
using Bansonic;
using GameServer.Client;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class gameSettings : MonoBehaviour
{
[Header("multiNotesSigns")]
public Toggle multiNoteNoticeToggle;
public Text multiNoteNoticeText; // Using Legacy Text object
public Text multiNoteNoticeText;
private const string PrefKey_EnableSyncNotePrefab = "EnableSyncNotePrefab";
[Header("joinRankingList")]
public Toggle user_accept_joinRankingList;
public Text jrlWarningText;
void Start()
private const string PrefKeyEnableSyncNotePrefab = "EnableSyncNotePrefab";
private bool _isUpdatingJoinRankingToggle;
private void Awake()
{
if (multiNoteNoticeToggle != null)
{
// Initialize toggle state from PlayerPrefs, default to 1 (true)
bool isEnabled = PlayerPrefs.GetInt(PrefKey_EnableSyncNotePrefab, 1) == 1;
multiNoteNoticeToggle.isOn = isEnabled;
multiNoteNoticeToggle.onValueChanged.RemoveListener(HandleMultiNoteNoticeToggleChanged);
multiNoteNoticeToggle.onValueChanged.AddListener(HandleMultiNoteNoticeToggleChanged);
}
// Initialize text based on current toggle value
UpdateText(isEnabled);
if (user_accept_joinRankingList != null)
{
user_accept_joinRankingList.onValueChanged.RemoveListener(HandleJoinRankingListToggleChanged);
user_accept_joinRankingList.onValueChanged.AddListener(HandleJoinRankingListToggleChanged);
}
}
// Add listener to save value and update text when changed
multiNoteNoticeToggle.onValueChanged.AddListener((isOn) =>
private void OnEnable()
{
RefreshMultiNoteNoticeState();
_ = RefreshJoinRankingStateFromServerAsync();
}
private void OnDestroy()
{
if (multiNoteNoticeToggle != null)
{
multiNoteNoticeToggle.onValueChanged.RemoveListener(HandleMultiNoteNoticeToggleChanged);
}
if (user_accept_joinRankingList != null)
{
user_accept_joinRankingList.onValueChanged.RemoveListener(HandleJoinRankingListToggleChanged);
}
}
private void RefreshMultiNoteNoticeState()
{
if (multiNoteNoticeToggle == null)
{
return;
}
bool isEnabled = PlayerPrefs.GetInt(PrefKeyEnableSyncNotePrefab, 1) == 1;
multiNoteNoticeToggle.SetIsOnWithoutNotify(isEnabled);
UpdateText(isEnabled);
}
private async Awaitable RefreshJoinRankingStateFromServerAsync()
{
if (user_accept_joinRankingList == null)
{
return;
}
try
{
NetworkManager nm = NetworkManager.Instance;
if (nm == null)
{
PlayerPrefs.SetInt(PrefKey_EnableSyncNotePrefab, isOn ? 1 : 0);
PlayerPrefs.Save();
UpdateText(isOn);
});
RefreshJoinRankingListStateFromLocal();
await RefreshJoinRankingWarningTextAsync(null);
return;
}
LeaderboardMembershipResponse resp = await nm.GetLeaderboardMembership();
bool isJoined = resp != null ? resp.is_joined : LeaderboardConsentUtility.HasLeaderboardConsent();
LeaderboardConsentUtility.SetLeaderboardConsent(isJoined);
_isUpdatingJoinRankingToggle = true;
user_accept_joinRankingList.SetIsOnWithoutNotify(isJoined);
_isUpdatingJoinRankingToggle = false;
await RefreshJoinRankingWarningTextAsync(resp);
}
catch (Exception ex)
{
Debug.LogWarning($"[gameSettings] Failed to refresh leaderboard membership state: {ex.Message}");
RefreshJoinRankingListStateFromLocal();
await RefreshJoinRankingWarningTextAsync(null);
}
}
private void RefreshJoinRankingListStateFromLocal()
{
if (user_accept_joinRankingList == null)
{
return;
}
_isUpdatingJoinRankingToggle = true;
user_accept_joinRankingList.SetIsOnWithoutNotify(LeaderboardConsentUtility.HasLeaderboardConsent());
_isUpdatingJoinRankingToggle = false;
}
private async Awaitable RefreshJoinRankingWarningTextAsync(LeaderboardMembershipResponse membership)
{
if (jrlWarningText == null)
{
return;
}
if (membership != null && membership.changed_today && !string.IsNullOrWhiteSpace(membership.next_change_at))
{
jrlWarningText.text = BuildChangeCooldownText(membership.next_change_at);
return;
}
jrlWarningText.text = "下次排行榜更新时间:正在获取...";
try
{
jrlWarningText.text = membership != null && !string.IsNullOrWhiteSpace(membership.next_settle_at)
? "下次排行榜更新时间:" + membership.next_settle_at
: await LeaderboardConsentUtility.BuildNextSettleWarningTextAsync();
}
catch (Exception ex)
{
Debug.LogWarning($"[gameSettings] Failed to refresh ranking warning text: {ex.Message}");
jrlWarningText.text = "下次排行榜更新时间:--";
}
}
private static string BuildChangeCooldownText(string nextChangeAt)
{
if (DateTime.TryParse(nextChangeAt, out DateTime parsed))
{
return $"{parsed.Hour}时{parsed.Minute:D2}分后可切换";
}
return "今日已切换";
}
private void HandleMultiNoteNoticeToggleChanged(bool isOn)
{
PlayerPrefs.SetInt(PrefKeyEnableSyncNotePrefab, isOn ? 1 : 0);
PlayerPrefs.Save();
UpdateText(isOn);
}
private async void HandleJoinRankingListToggleChanged(bool isOn)
{
if (_isUpdatingJoinRankingToggle)
{
return;
}
try
{
NetworkManager nm = NetworkManager.Instance;
if (nm == null)
{
LeaderboardConsentUtility.SetLeaderboardConsent(isOn);
await RefreshJoinRankingWarningTextAsync(null);
return;
}
LeaderboardMembershipResponse resp = await nm.UpdateLeaderboardMembership(isOn);
bool finalJoined = resp != null ? resp.is_joined : isOn;
LeaderboardConsentUtility.SetLeaderboardConsent(finalJoined);
_isUpdatingJoinRankingToggle = true;
user_accept_joinRankingList.SetIsOnWithoutNotify(finalJoined);
_isUpdatingJoinRankingToggle = false;
if (resp != null && !resp.success)
{
if (!string.IsNullOrWhiteSpace(resp.next_change_at))
{
gNotice.warning.display(BuildChangeCooldownText(resp.next_change_at));
}
else if (!string.IsNullOrWhiteSpace(resp.message))
{
gNotice.warning.display(resp.message);
}
}
await RefreshJoinRankingWarningTextAsync(resp);
}
catch (Exception ex)
{
Debug.LogWarning($"[gameSettings] Failed to update leaderboard membership: {ex.Message}");
gNotice.error.display("排行榜加入状态更新失败");
RefreshJoinRankingListStateFromLocal();
await RefreshJoinRankingWarningTextAsync(null);
}
}
@@ -34,14 +207,18 @@ public class gameSettings : MonoBehaviour
{
if (multiNoteNoticeText != null)
{
multiNoteNoticeText.text = isOn ? "已启用多押指示器" : "已禁用多押指示器";
multiNoteNoticeText.text = isOn ? "已开启多押提示音符同步显示" : "已关闭多押提示音符同步显示";
}
if (multiNoteNoticeToggle == null)
{
return;
}
// Also update the Toggle's own label text if it exists (usually a child object)
Text toggleLabel = multiNoteNoticeToggle.GetComponentInChildren<Text>();
if (toggleLabel != null)
{
toggleLabel.text = isOn ? "启" : "禁用";
toggleLabel.text = isOn ? "已开启" : "已关闭";
}
}
}
+183 -92
View File
@@ -8,6 +8,11 @@ using System.Runtime.InteropServices;
public class graphicSettings : MonoBehaviour
{
private const string PrefKeyScreenMode = "screenMode";
private const string PrefKeyResolutionIndex = "resolutionIndex";
private const string PrefKeyCustomResW = "customResW";
private const string PrefKeyCustomResH = "customResH";
public Dropdown screenMode_Dropdown;
public Dropdown resolution_Dropdown;
public Dropdown frameRate_Dropdown;
@@ -30,6 +35,22 @@ public class graphicSettings : MonoBehaviour
lastScreenSize = new Vector2Int(Screen.width, Screen.height);
}
public static void ApplySavedDisplaySettingsAtStartup(MonoBehaviour coroutineRunner)
{
int screenMode = PlayerPrefs.GetInt(PrefKeyScreenMode, 0);
screenMode = Mathf.Clamp(screenMode, 0, 2);
try
{
ApplyScreenModeStatic(screenMode, coroutineRunner);
ApplySavedResolutionStatic();
}
catch (Exception ex)
{
Debug.LogWarning($"[graphicSettings] Failed to apply saved display settings at startup: {ex}");
}
}
void OnDestroy()
{
if (screenMode_Dropdown != null)
@@ -99,7 +120,7 @@ public class graphicSettings : MonoBehaviour
int current = MapFullScreenModeToIndex(Screen.fullScreenMode);
if (PlayerPrefs.HasKey("screenMode"))
{
int saved = PlayerPrefs.GetInt("screenMode", current);
int saved = PlayerPrefs.GetInt(PrefKeyScreenMode, current);
saved = Mathf.Clamp(saved, 0, options.Count - 1);
current = saved;
ApplyScreenMode(current);
@@ -129,7 +150,7 @@ public class graphicSettings : MonoBehaviour
private void OnScreenModeChanged(int index)
{
ApplyScreenMode(index);
PlayerPrefs.SetInt("screenMode", index);
PlayerPrefs.SetInt(PrefKeyScreenMode, index);
PlayerPrefs.Save();
bool isWindowed = (index == 0);
@@ -141,96 +162,17 @@ public class graphicSettings : MonoBehaviour
private void ApplyScreenMode(int index)
{
try
{
if (index == 0)
{
Screen.fullScreenMode = FullScreenMode.Windowed;
Screen.fullScreen = false;
StopAllCoroutines();
StartCoroutine(ApplyWindowStyleNextFrame(true));
Screen.SetResolution(Screen.width, Screen.height, FullScreenMode.Windowed, Screen.currentResolution.refreshRateRatio);
}
else if (index == 1)
{
Screen.fullScreenMode = FullScreenMode.ExclusiveFullScreen;
Screen.fullScreen = true;
Resolution res = Screen.currentResolution;
Screen.SetResolution(res.width, res.height, FullScreenMode.ExclusiveFullScreen, res.refreshRateRatio);
}
else if (index == 2)
{
Screen.fullScreenMode = FullScreenMode.FullScreenWindow;
Screen.fullScreen = true;
Resolution res = Screen.currentResolution;
Screen.SetResolution(res.width, res.height, FullScreenMode.FullScreenWindow, res.refreshRateRatio);
StopAllCoroutines();
StartCoroutine(ApplyWindowStyleNextFrame(false));
}
}
catch (System.Exception ex)
{
Debug.LogWarning($"ApplyScreenMode failed: {ex}");
}
ApplyScreenModeStatic(index, this);
}
private System.Collections.IEnumerator ApplyWindowStyleNextFrame(bool enable)
{
yield return null;
yield return new WaitForSeconds(0.05f);
EnableWindowedModeResizable(enable);
yield return ApplyWindowStyleNextFrameStatic(enable);
}
private void EnableWindowedModeResizable(bool enable)
{
#if UNITY_STANDALONE_WIN && !UNITY_EDITOR
try
{
IntPtr hWnd = GetForegroundWindow();
if (hWnd == IntPtr.Zero) return;
const int GWL_STYLE = -16;
const int WS_OVERLAPPEDWINDOW = unchecked((int)0x00CF0000);
const int WS_POPUP = unchecked((int)0x80000000);
if (IntPtr.Size == 8)
{
long style = GetWindowLongPtr64(hWnd, GWL_STYLE);
if (enable)
{
style &= ~((long)WS_POPUP);
style |= WS_OVERLAPPEDWINDOW;
}
else
{
style &= ~((long)WS_OVERLAPPEDWINDOW);
style |= (long)WS_POPUP;
}
SetWindowLongPtr64(hWnd, GWL_STYLE, style);
SetWindowPos(hWnd, IntPtr.Zero, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
}
else
{
int style = GetWindowLong32(hWnd, GWL_STYLE);
if (enable)
{
style &= ~WS_POPUP;
style |= WS_OVERLAPPEDWINDOW;
}
else
{
style &= ~((int)WS_OVERLAPPEDWINDOW);
style |= (int)WS_POPUP;
}
SetWindowLong32(hWnd, GWL_STYLE, style);
SetWindowPos(hWnd, IntPtr.Zero, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
}
}
catch (Exception e)
{
Debug.LogWarning($"EnableWindowedModeResizable failed: {e}");
}
#endif
EnableWindowedModeResizableStatic(enable);
}
private void InitResolutionDropdown()
@@ -295,7 +237,7 @@ public class graphicSettings : MonoBehaviour
}
}
int savedResIndex = PlayerPrefs.GetInt("resolutionIndex", -2);
int savedResIndex = PlayerPrefs.GetInt(PrefKeyResolutionIndex, -2);
if (savedResIndex >= 0 && savedResIndex < availableResolutions.Count)
{
resolution_Dropdown.SetValueWithoutNotify(savedResIndex);
@@ -305,8 +247,8 @@ public class graphicSettings : MonoBehaviour
}
else if (savedResIndex == -1)
{
int cw = PlayerPrefs.GetInt("customResW", -1);
int ch = PlayerPrefs.GetInt("customResH", -1);
int cw = PlayerPrefs.GetInt(PrefKeyCustomResW, -1);
int ch = PlayerPrefs.GetInt(PrefKeyCustomResH, -1);
if (cw > 0 && ch > 0)
{
Screen.SetResolution(cw, ch, Screen.fullScreenMode, Screen.currentResolution.refreshRateRatio);
@@ -325,7 +267,7 @@ public class graphicSettings : MonoBehaviour
}
resolution_Dropdown.onValueChanged.AddListener(OnResolutionChanged);
int screenMode = PlayerPrefs.GetInt("screenMode", MapFullScreenModeToIndex(Screen.fullScreenMode));
int screenMode = PlayerPrefs.GetInt(PrefKeyScreenMode, MapFullScreenModeToIndex(Screen.fullScreenMode));
bool isWindowed = (screenMode == 0) || (Screen.fullScreenMode == FullScreenMode.Windowed);
if (resolutionLock_Image != null)
resolutionLock_Image.gameObject.SetActive(!isWindowed);
@@ -354,9 +296,9 @@ public class graphicSettings : MonoBehaviour
if (index < 0) return;
if (index >= availableResolutions.Count)
{
PlayerPrefs.SetInt("resolutionIndex", -1);
PlayerPrefs.SetInt("customResW", Screen.width);
PlayerPrefs.SetInt("customResH", Screen.height);
PlayerPrefs.SetInt(PrefKeyResolutionIndex, -1);
PlayerPrefs.SetInt(PrefKeyCustomResW, Screen.width);
PlayerPrefs.SetInt(PrefKeyCustomResH, Screen.height);
PlayerPrefs.Save();
return;
}
@@ -364,7 +306,7 @@ public class graphicSettings : MonoBehaviour
var r = availableResolutions[index];
FullScreenMode mode = Screen.fullScreenMode;
Screen.SetResolution(r.x, r.y, mode, Screen.currentResolution.refreshRateRatio);
PlayerPrefs.SetInt("resolutionIndex", index);
PlayerPrefs.SetInt(PrefKeyResolutionIndex, index);
PlayerPrefs.Save();
RemoveTempFreeOptionIfExists();
@@ -484,6 +426,155 @@ public class graphicSettings : MonoBehaviour
}
}
private static void ApplyScreenModeStatic(int index, MonoBehaviour coroutineRunner)
{
try
{
if (index == 0)
{
Screen.fullScreenMode = FullScreenMode.Windowed;
Screen.fullScreen = false;
Screen.SetResolution(Screen.width, Screen.height, FullScreenMode.Windowed, Screen.currentResolution.refreshRateRatio);
if (coroutineRunner != null)
{
coroutineRunner.StartCoroutine(ApplyWindowStyleNextFrameStatic(true));
}
}
else if (index == 1)
{
Screen.fullScreenMode = FullScreenMode.ExclusiveFullScreen;
Screen.fullScreen = true;
Resolution res = Screen.currentResolution;
Screen.SetResolution(res.width, res.height, FullScreenMode.ExclusiveFullScreen, res.refreshRateRatio);
}
else if (index == 2)
{
Screen.fullScreenMode = FullScreenMode.FullScreenWindow;
Screen.fullScreen = true;
Resolution res = Screen.currentResolution;
Screen.SetResolution(res.width, res.height, FullScreenMode.FullScreenWindow, res.refreshRateRatio);
if (coroutineRunner != null)
{
coroutineRunner.StartCoroutine(ApplyWindowStyleNextFrameStatic(false));
}
}
}
catch (Exception ex)
{
Debug.LogWarning($"ApplyScreenMode failed: {ex}");
}
}
private static void ApplySavedResolutionStatic()
{
int savedResIndex = PlayerPrefs.GetInt(PrefKeyResolutionIndex, -2);
if (savedResIndex == -1)
{
int customWidth = PlayerPrefs.GetInt(PrefKeyCustomResW, -1);
int customHeight = PlayerPrefs.GetInt(PrefKeyCustomResH, -1);
if (customWidth > 0 && customHeight > 0)
{
Screen.SetResolution(customWidth, customHeight, Screen.fullScreenMode, Screen.currentResolution.refreshRateRatio);
}
return;
}
if (savedResIndex < 0)
{
return;
}
Vector2Int[] candidates =
{
new Vector2Int(1024, 576),
new Vector2Int(1152, 648),
new Vector2Int(1280, 720),
new Vector2Int(1366, 768),
new Vector2Int(1600, 900),
new Vector2Int(1920, 1080),
new Vector2Int(2560, 1440),
new Vector2Int(3440, 1440),
new Vector2Int(3200, 1800),
new Vector2Int(3840, 2160),
new Vector2Int(5120, 2880),
new Vector2Int(7680, 4320),
new Vector2Int(15360, 8640)
};
if (savedResIndex >= candidates.Length)
{
return;
}
Vector2Int resolution = candidates[savedResIndex];
Resolution currentResolution = Screen.currentResolution;
if (resolution.x > currentResolution.width || resolution.y > currentResolution.height)
{
return;
}
Screen.SetResolution(resolution.x, resolution.y, Screen.fullScreenMode, currentResolution.refreshRateRatio);
}
private static System.Collections.IEnumerator ApplyWindowStyleNextFrameStatic(bool enable)
{
yield return null;
yield return new WaitForSeconds(0.05f);
EnableWindowedModeResizableStatic(enable);
}
private static void EnableWindowedModeResizableStatic(bool enable)
{
#if UNITY_STANDALONE_WIN && !UNITY_EDITOR
try
{
IntPtr hWnd = GetForegroundWindow();
if (hWnd == IntPtr.Zero) return;
const int GWL_STYLE = -16;
const int WS_OVERLAPPEDWINDOW = unchecked((int)0x00CF0000);
const int WS_POPUP = unchecked((int)0x80000000);
if (IntPtr.Size == 8)
{
long style = GetWindowLongPtr64(hWnd, GWL_STYLE);
if (enable)
{
style &= ~((long)WS_POPUP);
style |= WS_OVERLAPPEDWINDOW;
}
else
{
style &= ~((long)WS_OVERLAPPEDWINDOW);
style |= (long)WS_POPUP;
}
SetWindowLongPtr64(hWnd, GWL_STYLE, style);
SetWindowPos(hWnd, IntPtr.Zero, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
}
else
{
int style = GetWindowLong32(hWnd, GWL_STYLE);
if (enable)
{
style &= ~WS_POPUP;
style |= WS_OVERLAPPEDWINDOW;
}
else
{
style &= ~((int)WS_OVERLAPPEDWINDOW);
style |= (int)WS_POPUP;
}
SetWindowLong32(hWnd, GWL_STYLE, style);
SetWindowPos(hWnd, IntPtr.Zero, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
}
}
catch (Exception e)
{
Debug.LogWarning($"EnableWindowedModeResizable failed: {e}");
}
#endif
}
#if UNITY_STANDALONE_WIN && !UNITY_EDITOR
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
+254 -8
View File
@@ -3,11 +3,17 @@ using UnityEngine.UI;
using System.IO;
using System.Text.RegularExpressions;
using System;
using System.Collections.Generic;
using System.Text;
using System.Runtime.InteropServices;
public class readDeviceInfo : MonoBehaviour
{
private const string LabelColorHex = "#2457c6";
private const string ValueColorHex = "#6b8acd";
private const float ColumnPaddingPixels = 32f;
private const int MinimumGapSpaces = 2;
public Button reread_button;
public Button export_button;
public Text deviceInfo_text;
@@ -25,7 +31,9 @@ public class readDeviceInfo : MonoBehaviour
}
if (deviceInfo_text != null)
deviceInfo_text.supportRichText = true;
{
ConfigureDeviceInfoText();
}
read_deviceInfo();
}
@@ -45,7 +53,8 @@ public class readDeviceInfo : MonoBehaviour
private void read_deviceInfo()
{
StringBuilder sb = new StringBuilder();
ConfigureDeviceInfoText();
var entries = new System.Collections.Generic.List<KeyValuePair<string, string>>();
void AddEntry(string title, object value)
{
@@ -54,9 +63,7 @@ public class readDeviceInfo : MonoBehaviour
else if (value is bool b) str = b ? "是" : "否";
else str = value.ToString();
sb.AppendLine($"<b>{title}</b>");
sb.AppendLine(str);
sb.AppendLine();
entries.Add(new KeyValuePair<string, string>(title ?? string.Empty, str ?? string.Empty));
}
AddEntry("平台", Application.platform.ToString());
@@ -101,7 +108,7 @@ public class readDeviceInfo : MonoBehaviour
AddEntry("启用后台运行", Application.runInBackground);
AddEntry("启用多线程渲染", SystemInfo.graphicsMultiThreaded);
string output = sb.ToString();
string output = BuildTwoColumnOutput(entries);
if (deviceInfo_text != null)
{
@@ -125,8 +132,7 @@ public class readDeviceInfo : MonoBehaviour
try
{
string raw = deviceInfo_text.text;
// Remove <b> tags and add colon after title
string cleaned = Regex.Replace(raw, "<b>(.*?)</b>", "$1:");
string cleaned = Regex.Replace(raw, "<.*?>", string.Empty);
File.WriteAllText(path, cleaned);
}
catch (System.Exception)
@@ -134,6 +140,246 @@ public class readDeviceInfo : MonoBehaviour
}
}
private string BuildTwoColumnOutput(System.Collections.Generic.IReadOnlyList<KeyValuePair<string, string>> entries)
{
if (entries == null || entries.Count == 0)
{
return string.Empty;
}
float maxLabelWidth = 0f;
for (int i = 0; i < entries.Count; i++)
{
maxLabelWidth = Mathf.Max(maxLabelWidth, MeasurePlainTextWidth(entries[i].Key));
}
float availableWidth = GetAvailableTextWidth();
float targetLabelWidth = maxLabelWidth + ColumnPaddingPixels;
if (availableWidth > 0f)
{
targetLabelWidth = Mathf.Min(targetLabelWidth, availableWidth * 0.38f);
}
float spaceWidth = Mathf.Max(1f, MeasurePlainTextWidth(" "));
int continuationIndentSpaces = Mathf.Max(MinimumGapSpaces, Mathf.CeilToInt(targetLabelWidth / spaceWidth) + MinimumGapSpaces);
string continuationIndent = new string(' ', continuationIndentSpaces);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < entries.Count; i++)
{
string label = entries[i].Key ?? string.Empty;
string value = entries[i].Value ?? string.Empty;
int gapSpaces = Mathf.Max(
MinimumGapSpaces,
Mathf.CeilToInt((targetLabelWidth - MeasurePlainTextWidth(label)) / spaceWidth) + MinimumGapSpaces);
string padding = new string(' ', gapSpaces);
float firstLineValueWidth = availableWidth > 0f
? Mathf.Max(32f, availableWidth - MeasurePlainTextWidth(label) - MeasurePlainTextWidth(padding))
: 0f;
float continuationValueWidth = availableWidth > 0f
? Mathf.Max(32f, availableWidth - MeasurePlainTextWidth(continuationIndent))
: 0f;
List<string> wrappedLines = WrapValueLines(value, firstLineValueWidth, continuationValueWidth);
sb.Append("<color=").Append(LabelColorHex).Append("><b>")
.Append(EscapeRichText(label))
.Append("</b></color>")
.Append(padding)
.Append("<color=").Append(ValueColorHex).Append(">")
.Append(EscapeRichText(wrappedLines[0]))
.Append("</color>");
for (int lineIndex = 1; lineIndex < wrappedLines.Count; lineIndex++)
{
sb.AppendLine();
sb.Append(continuationIndent)
.Append("<color=").Append(ValueColorHex).Append(">")
.Append(EscapeRichText(wrappedLines[lineIndex]))
.Append("</color>");
}
if (i < entries.Count - 1)
{
sb.AppendLine();
}
}
return sb.ToString();
}
private void ConfigureDeviceInfoText()
{
if (deviceInfo_text == null)
{
return;
}
deviceInfo_text.supportRichText = true;
deviceInfo_text.alignment = TextAnchor.UpperLeft;
deviceInfo_text.horizontalOverflow = HorizontalWrapMode.Wrap;
deviceInfo_text.verticalOverflow = VerticalWrapMode.Overflow;
deviceInfo_text.resizeTextForBestFit = false;
}
private float GetAvailableTextWidth()
{
if (deviceInfo_text == null)
{
return 0f;
}
RectTransform rectTransform = deviceInfo_text.rectTransform;
if (rectTransform == null)
{
return 0f;
}
float width = rectTransform.rect.width;
return width > 0f ? width : 0f;
}
private float MeasurePlainTextWidth(string text)
{
if (deviceInfo_text == null || string.IsNullOrEmpty(text))
{
return 0f;
}
TextGenerationSettings settings = deviceInfo_text.GetGenerationSettings(Vector2.zero);
settings.richText = false;
settings.generateOutOfBounds = true;
TextGenerator generator = new TextGenerator();
return generator.GetPreferredWidth(text, settings) / deviceInfo_text.pixelsPerUnit;
}
private List<string> WrapValueLines(string value, float firstLineWidth, float continuationLineWidth)
{
string normalized = NormalizeLineBreaks(value);
if (string.IsNullOrEmpty(normalized))
{
return new List<string> { string.Empty };
}
string[] rawLines = normalized.Split('\n');
List<string> wrapped = new List<string>();
for (int i = 0; i < rawLines.Length; i++)
{
string rawLine = rawLines[i];
List<string> lineParts = WrapSingleLine(rawLine, wrapped.Count == 0 ? firstLineWidth : continuationLineWidth);
if (lineParts.Count == 0)
{
lineParts.Add(string.Empty);
}
wrapped.Add(lineParts[0]);
for (int j = 1; j < lineParts.Count; j++)
{
wrapped.Add(lineParts[j]);
}
}
return wrapped.Count > 0 ? wrapped : new List<string> { string.Empty };
}
private List<string> WrapSingleLine(string text, float maxWidth)
{
List<string> lines = new List<string>();
if (string.IsNullOrEmpty(text) || maxWidth <= 0f)
{
lines.Add(text ?? string.Empty);
return lines;
}
string remaining = text;
while (!string.IsNullOrEmpty(remaining))
{
if (MeasurePlainTextWidth(remaining) <= maxWidth)
{
lines.Add(remaining);
break;
}
int splitIndex = FindSplitIndex(remaining, maxWidth);
if (splitIndex <= 0 || splitIndex >= remaining.Length)
{
lines.Add(remaining);
break;
}
string current = remaining.Substring(0, splitIndex).TrimEnd();
if (current.Length == 0)
{
current = remaining.Substring(0, Mathf.Min(1, remaining.Length));
splitIndex = current.Length;
}
lines.Add(current);
remaining = remaining.Substring(splitIndex).TrimStart();
}
return lines;
}
private int FindSplitIndex(string text, float maxWidth)
{
int lastBreakableIndex = -1;
int fallbackIndex = -1;
for (int i = 1; i <= text.Length; i++)
{
string candidate = text.Substring(0, i);
if (MeasurePlainTextWidth(candidate) <= maxWidth)
{
fallbackIndex = i;
if (IsBreakableCharacter(text[i - 1]))
{
lastBreakableIndex = i;
}
continue;
}
break;
}
if (lastBreakableIndex > 0)
{
return lastBreakableIndex;
}
return fallbackIndex;
}
private static bool IsBreakableCharacter(char c)
{
return char.IsWhiteSpace(c) || c == '/' || c == '\\' || c == '_' || c == '-' || c == '.' || c == ':' || c == ')' || c == ']';
}
private static string NormalizeLineBreaks(string input)
{
if (string.IsNullOrEmpty(input))
{
return string.Empty;
}
return input.Replace("\r\n", "\n").Replace('\r', '\n');
}
private static string EscapeRichText(string input)
{
if (string.IsNullOrEmpty(input))
{
return string.Empty;
}
return input.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;");
}
string ShowSaveFileDialog()
{
#if UNITY_EDITOR
+15 -1
View File
@@ -238,7 +238,15 @@ public class userSettings : MonoBehaviour
index = 0;
}
LocalizationService.SetLanguage(_availableLanguageCodes[index]);
string targetLanguageCode = _availableLanguageCodes[index];
if (IsTemporarilyUnsupportedLanguage(targetLanguageCode))
{
gNotice.error.display(LocalizationService.LocalizeLiteral("暂不支持该语言"));
SyncLanguageDropdownValue();
return;
}
LocalizationService.SetLanguage(targetLanguageCode);
}
private void HandleLanguageChanged(string languageCode)
@@ -247,6 +255,12 @@ public class userSettings : MonoBehaviour
UpdateUserInfoDisplay();
}
private static bool IsTemporarilyUnsupportedLanguage(string languageCode)
{
return !string.IsNullOrWhiteSpace(languageCode) &&
languageCode.StartsWith("en", System.StringComparison.OrdinalIgnoreCase);
}
private void OnClearupSaveDataClicked()
{
if (clearupClickCount > 0 && Time.unscaledTime > clearupExpireTime)