多轮审查和修复

This commit is contained in:
2026-05-12 15:34:08 +08:00
parent f55d2a57c3
commit ebbbb7332e
805 changed files with 838724 additions and 1905 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1321d24ceb7521347a8f3db80c553902
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,89 @@
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.Support.Accessibility
{
/// <summary>
/// 无障碍功能管理器(架构 16_SupportingModules §6.1)。
/// 响应设置变更,广播色盲模式事件;提供 CanPlayScreenShake() 供 FeedbackSystem 查询。
/// </summary>
public class AccessibilityManager : MonoBehaviour
{
[Header("设置资产")]
[SerializeField] private AccessibilitySettingsSO _settings;
[Header("事件频道(输出)")]
[SerializeField] private ColorblindModeEventChannelSO _onColorblindModeChanged;
[SerializeField] private BoolEventChannelSO _onScreenShakeChanged;
private static AccessibilityManager _instance;
// ── 静态查询接口(供 FeedbackSystem 使用) ───────────────────────────────
public static bool CanPlayScreenShake()
=> _instance == null || (_instance._settings != null && _instance._settings.ScreenShake);
private void Awake()
{
if (_instance != null && _instance != this)
{
Debug.LogWarning("[AccessibilityManager] 已存在实例,请确保本组件仅放置在 Persistent 场景中。", this);
Destroy(this);
return;
}
_instance = this;
if (_settings != null)
_settings.Load();
}
private void Start()
{
// 初始化时广播当前色盲模式
if (_settings != null)
_onColorblindModeChanged?.Raise(_settings.ColorblindMode);
}
private void OnDestroy()
{
if (_instance == this) _instance = null;
}
/// <summary>应用新的设置并持久化。</summary>
public void Apply(AccessibilitySettingsSO newSettings)
{
_settings.ScreenShake = newSettings.ScreenShake;
_settings.ReducedFlash = newSettings.ReducedFlash;
_settings.LargeText = newSettings.LargeText;
_settings.HighContrast = newSettings.HighContrast;
_settings.UIScale = newSettings.UIScale;
bool colorblindChanged = _settings.ColorblindMode != newSettings.ColorblindMode;
_settings.ColorblindMode = newSettings.ColorblindMode;
_settings.Save();
_onScreenShakeChanged?.Raise(_settings.ScreenShake);
if (colorblindChanged)
_onColorblindModeChanged?.Raise(_settings.ColorblindMode);
}
/// <summary>直接设置色盲模式。</summary>
public void SetColorblindMode(ColorblindMode mode)
{
if (_settings == null) return;
_settings.ColorblindMode = mode;
_settings.Save();
_onColorblindModeChanged?.Raise(mode);
}
/// <summary>直接设置屏幕抖动开关。</summary>
public void SetScreenShake(bool enabled)
{
if (_settings == null) return;
_settings.ScreenShake = enabled;
_settings.Save();
_onScreenShakeChanged?.Raise(enabled);
}
public AccessibilitySettingsSO Settings => _settings;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: db1f332f741502f4385b7d639582a8cf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,46 @@
using UnityEngine;
using BaseGames.Core.Events;
/// <summary>
/// 无障碍设置数据 SO架构 16_SupportingModules §6
/// 包含屏幕抖动、闪光减弱、色盲模式等辅助功能开关。
/// </summary>
[CreateAssetMenu(menuName = "Settings/AccessibilitySettings", fileName = "SET_Accessibility")]
public class AccessibilitySettingsSO : ScriptableObject
{
private const string PrefsPrefix = "accessibility_";
[Header("屏幕体感")]
public bool ScreenShake = true;
public bool ReducedFlash = false;
[Header("色盲模式")]
public ColorblindMode ColorblindMode = ColorblindMode.None;
[Header("文字与 UI")]
public bool LargeText = false;
public bool HighContrast = false;
public float UIScale = 1f;
// ── 持久化 ──────────────────────────────────────────────────────────────
public void Save()
{
PlayerPrefs.SetInt (PrefsPrefix + "ScreenShake", ScreenShake ? 1 : 0);
PlayerPrefs.SetInt (PrefsPrefix + "ReducedFlash", ReducedFlash ? 1 : 0);
PlayerPrefs.SetInt (PrefsPrefix + "ColorblindMode",(int)ColorblindMode);
PlayerPrefs.SetInt (PrefsPrefix + "LargeText", LargeText ? 1 : 0);
PlayerPrefs.SetInt (PrefsPrefix + "HighContrast", HighContrast ? 1 : 0);
PlayerPrefs.SetFloat (PrefsPrefix + "UIScale", UIScale);
PlayerPrefs.Save();
}
public void Load()
{
ScreenShake = PlayerPrefs.GetInt (PrefsPrefix + "ScreenShake", 1) == 1;
ReducedFlash = PlayerPrefs.GetInt (PrefsPrefix + "ReducedFlash", 0) == 1;
ColorblindMode = (ColorblindMode)PlayerPrefs.GetInt(PrefsPrefix + "ColorblindMode", 0);
LargeText = PlayerPrefs.GetInt (PrefsPrefix + "LargeText", 0) == 1;
HighContrast = PlayerPrefs.GetInt (PrefsPrefix + "HighContrast", 0) == 1;
UIScale = PlayerPrefs.GetFloat(PrefsPrefix + "UIScale", 1f);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a1cbca9ad14985d469becb19e074f8a5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,137 @@
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using BaseGames.Core.Events;
/// <summary>
/// 色盲滤镜 URP Renderer Feature架构 16_SupportingModules §6.2)。
/// 通过颜色矩阵将画面转换到对应色盲友好的色彩空间。
/// 配置:在 URP Renderer Asset 的 Renderer Features 列表中添加此 Feature。
/// 订阅 EVT_ColorBlindModeChanged 事件切换模式。
/// </summary>
public class ColorBlindFilter : ScriptableRendererFeature
{
[System.Serializable]
public class Settings
{
public ColorblindMode Mode = ColorblindMode.None;
public float Strength = 1f;
public RenderPassEvent PassEvent = RenderPassEvent.AfterRenderingPostProcessing;
}
[SerializeField] public Settings settings = new();
[Header("事件频道")]
[SerializeField] private ColorblindModeEventChannelSO _onColorblindModeChanged;
private ColorBlindRenderPass _pass;
private readonly CompositeDisposable _subs = new();
public override void Create()
{
_pass = new ColorBlindRenderPass(settings);
_pass.renderPassEvent = settings.PassEvent;
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
if (settings.Mode == ColorblindMode.None) return;
renderer.EnqueuePass(_pass);
}
private void OnEnable() => _onColorblindModeChanged?.Subscribe(SetMode).AddTo(_subs);
private void OnDisable() => _subs.Clear();
/// <summary>切换色盲模式(由 AccessibilityManager 通过事件调用)。</summary>
public void SetMode(ColorblindMode mode)
{
settings.Mode = mode;
_pass?.UpdateSettings(settings);
}
// ── 内部 RenderPass ───────────────────────────────────────────────────────
private class ColorBlindRenderPass : ScriptableRenderPass
{
private Settings _settings;
private Material _material;
private static readonly int ColorMatrixId = Shader.PropertyToID("_ColorMatrix");
public ColorBlindRenderPass(Settings settings)
{
_settings = settings;
_material = CoreUtils.CreateEngineMaterial(Shader.Find("Hidden/ColorBlindFilter"));
}
public void UpdateSettings(Settings s)
{
_settings = s;
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
if (_material == null || _settings.Mode == ColorblindMode.None) return;
var matrix = GetColorMatrix(_settings.Mode, _settings.Strength);
_material.SetMatrix(ColorMatrixId, matrix);
CommandBuffer cmd = CommandBufferPool.Get("ColorBlindFilter");
// Blit with material applies the color matrix shader
Blit(cmd, ref renderingData, _material);
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
/// <summary>返回指定色盲类型的颜色变换矩阵。</summary>
private static Matrix4x4 GetColorMatrix(ColorblindMode mode, float strength)
{
// 基于 Brettel/Viénot 等人的色盲模拟矩阵
Matrix4x4 identity = Matrix4x4.identity;
Matrix4x4 sim = mode switch
{
ColorblindMode.Protanopia => Protanopia,
ColorblindMode.Deuteranopia => Deuteranopia,
ColorblindMode.Tritanopia => Tritanopia,
ColorblindMode.Achromatopsia=> Achromatopsia,
_ => identity,
};
// 按强度插值
if (Mathf.Approximately(strength, 1f)) return sim;
return Lerp(identity, sim, strength);
}
private static Matrix4x4 Lerp(Matrix4x4 a, Matrix4x4 b, float t)
{
var result = new Matrix4x4();
for (int i = 0; i < 16; i++)
result[i] = Mathf.Lerp(a[i], b[i], t);
return result;
}
// ── 预定义色盲矩阵 ───────────────────────────────────────────────────
private static readonly Matrix4x4 Protanopia = new(
new Vector4(0.567f, 0.433f, 0.000f, 0),
new Vector4(0.558f, 0.442f, 0.000f, 0),
new Vector4(0.000f, 0.242f, 0.758f, 0),
new Vector4(0, 0, 0, 1));
private static readonly Matrix4x4 Deuteranopia = new(
new Vector4(0.625f, 0.375f, 0.000f, 0),
new Vector4(0.700f, 0.300f, 0.000f, 0),
new Vector4(0.000f, 0.300f, 0.700f, 0),
new Vector4(0, 0, 0, 1));
private static readonly Matrix4x4 Tritanopia = new(
new Vector4(0.950f, 0.050f, 0.000f, 0),
new Vector4(0.000f, 0.433f, 0.567f, 0),
new Vector4(0.000f, 0.475f, 0.525f, 0),
new Vector4(0, 0, 0, 1));
private static readonly Matrix4x4 Achromatopsia = new(
new Vector4(0.299f, 0.587f, 0.114f, 0),
new Vector4(0.299f, 0.587f, 0.114f, 0),
new Vector4(0.299f, 0.587f, 0.114f, 0),
new Vector4(0, 0, 0, 1));
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9fff57f1963a7e74e8000da706ddafce
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 2f18a04f598295c4ca6e9c3f2ea80b58
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,134 @@
using System;
using System.IO;
using System.Collections.Generic;
using UnityEngine;
using Newtonsoft.Json;
using BaseGames.Core;
namespace BaseGames.Support.Analytics
{
/// <summary>
/// 游戏玩法数据分析收集器(架构 16_SupportingModules §7
/// 在本地记录结构化事件日志,供后续分析或上传。
/// 注不收集任何个人身份信息PII
/// </summary>
public class AnalyticsManager : MonoBehaviour, IAnalyticsService
{
[Header("配置")]
[Tooltip("是否启用分析收集")]
[SerializeField] private bool _enabled = true;
[Tooltip("超过此条数时自动将缓存刷写到磁盘")]
[SerializeField] private int _flushThreshold = 50;
private readonly List<AnalyticsEvent> _buffer = new();
private string _logPath;
private void Awake()
{
#if !UNITY_EDITOR && !DEVELOPMENT_BUILD
if (!_enabled) { enabled = false; return; }
#endif
if (ServiceLocator.GetOrDefault<IAnalyticsService>() != null) { Destroy(gameObject); return; }
ServiceLocator.Register<IAnalyticsService>(this);
_logPath = Path.Combine(Application.persistentDataPath, "analytics.json");
}
private void OnDestroy()
{
Flush();
ServiceLocator.Unregister<IAnalyticsService>(this);
}
private void OnApplicationQuit() => Flush();
// ── 实例方法IAnalyticsService 实现)────────────────────────────────────
/// <summary>记录一个自定义分析事件。</summary>
public void Track(string eventName, Dictionary<string, object> parameters = null)
{
if (!_enabled) return;
Enqueue(eventName, parameters);
}
// ── 预定义事件 ────────────────────────────────────────────────────────────
public void TrackBossKill(string bossId, float duration, int deathCount)
=> Track("boss_kill", new Dictionary<string, object>
{
{ "boss_id", bossId },
{ "duration", duration },
{ "death_count", deathCount },
});
public void TrackDeath(string cause, string sceneId, Vector2 position)
=> Track("player_death", new Dictionary<string, object>
{
{ "cause", cause },
{ "scene", sceneId },
{ "pos_x", Mathf.RoundToInt(position.x) },
{ "pos_y", Mathf.RoundToInt(position.y) },
});
public void TrackAbilityUnlock(string abilityId)
=> Track("ability_unlock", new Dictionary<string, object>
{
{ "ability", abilityId }
});
public void TrackSessionStart(int slotIndex)
=> Track("session_start", new Dictionary<string, object>
{
{ "slot", slotIndex },
});
public void TrackSessionEnd(float playtimeSeconds)
=> Track("session_end", new Dictionary<string, object>
{
{ "playtime", playtimeSeconds },
});
// ── 内部实现 ──────────────────────────────────────────────────────────────
private void Enqueue(string name, Dictionary<string, object> parms)
{
_buffer.Add(new AnalyticsEvent
{
EventName = name,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
Parameters = parms ?? new Dictionary<string, object>(),
});
if (_buffer.Count >= _flushThreshold)
Flush();
}
private void Flush()
{
if (_buffer.Count == 0) return;
try
{
var existing = new List<AnalyticsEvent>();
if (File.Exists(_logPath))
{
string json = File.ReadAllText(_logPath);
var loaded = JsonConvert.DeserializeObject<List<AnalyticsEvent>>(json);
if (loaded != null) existing.AddRange(loaded);
}
existing.AddRange(_buffer);
File.WriteAllText(_logPath, JsonConvert.SerializeObject(existing, Formatting.Indented));
_buffer.Clear();
}
catch (Exception ex)
{
Debug.LogWarning($"[Analytics] 写入失败: {ex.Message}");
}
}
[Serializable]
private class AnalyticsEvent
{
public string EventName;
public long Timestamp;
public Dictionary<string, object> Parameters;
}
}
} // namespace BaseGames.Support.Analytics

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 89c0c8e7bdf40b6489a4967bd85852c8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,21 @@
using System.Collections.Generic;
using UnityEngine;
namespace BaseGames.Support.Analytics
{
/// <summary>
/// 分析收集服务接口。通过 ServiceLocator 注册,供各系统上报事件使用。
/// 注不收集任何个人身份信息PII
/// </summary>
public interface IAnalyticsService
{
/// <summary>记录一个自定义分析事件。</summary>
void Track(string eventName, Dictionary<string, object> parameters = null);
void TrackBossKill(string bossId, float duration, int deathCount);
void TrackDeath(string cause, string sceneId, Vector2 position);
void TrackAbilityUnlock(string abilityId);
void TrackSessionStart(int slotIndex);
void TrackSessionEnd(float playtimeSeconds);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c32b212b5daca7b4198e1d37aeb891a6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 44f3dd440d1cbc4428c5d80f3bf40263
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,140 @@
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Input;
using BaseGames.Player;
using BaseGames.Player.States;
using BaseGames.Progression;
namespace BaseGames.Support.AntiSoftlock
{
/// <summary>
/// 防软锁系统(架构 16_SupportingModules §5
/// 检测玩家长时间静止且无法移动的情况,提供逃脱选项。
/// </summary>
public class AntiSoftlockSystem : MonoBehaviour
{
[Header("配置")]
[Tooltip("玩家静止超过此秒数后显示逃脱提示")]
[SerializeField] private float _stuckThresholdSeconds = 30f;
[Tooltip("移动速度低于此值视为静止")]
[SerializeField] private float _minVelocityThreshold = 0.1f;
[Header("逃脱路径列表(按优先级降序排列)")]
[SerializeField] private RoomEscapeInfoSO[] _escapeOptions;
[Header("事件频道")]
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
[SerializeField] private VoidEventChannelSO _onShowEscapeUI;
[SerializeField] private VoidEventChannelSO _onHideEscapeUI;
/// <summary>
/// 玩家生成事件频道PlayerController.Start() 广播)。
/// 替代 FindFirstObjectByType 的全场景扫描。
/// </summary>
[SerializeField] private TransformEventChannelSO _onPlayerSpawned;
[Header("引用")]
[SerializeField] private InputReaderSO _input;
private PlayerController _player;
private Rigidbody2D _playerRb;
private float _stuckTimer;
private bool _escapeUIVisible;
private Vector2 _lastPos;
private readonly BaseGames.Core.Events.CompositeDisposable _subs = new();
private void OnEnable()
{
if (_onPlayerSpawned != null)
_onPlayerSpawned.Subscribe(OnPlayerSpawned).AddTo(_subs);
}
private void OnDisable() => _subs.Clear();
private void OnPlayerSpawned(Transform playerTransform)
{
if (playerTransform == null) return;
_player = playerTransform.GetComponent<PlayerController>();
_playerRb = playerTransform.GetComponent<Rigidbody2D>();
_lastPos = playerTransform.position;
_stuckTimer = 0f;
}
private void Update()
{
if (_player == null || !_player.Stats.IsAlive) return;
Vector2 pos = _player.transform.position;
float vel = _playerRb != null ? _playerRb.linearVelocity.magnitude : Vector2.Distance(pos, _lastPos) / Time.deltaTime;
if (vel > _minVelocityThreshold)
{
_stuckTimer = 0f;
_lastPos = pos;
if (_escapeUIVisible) HideEscapeUI();
return;
}
_lastPos = pos;
_stuckTimer += Time.deltaTime;
if (_stuckTimer >= _stuckThresholdSeconds && !_escapeUIVisible)
ShowEscapeUI();
}
private void ShowEscapeUI()
{
_escapeUIVisible = true;
_onShowEscapeUI?.Raise();
}
private void HideEscapeUI()
{
_escapeUIVisible = false;
_onHideEscapeUI?.Raise();
}
/// <summary>执行最优先的逃脱路径(由 UI 按钮调用)。</summary>
public void ExecuteEscape()
{
var escape = ChooseBestEscape();
HideEscapeUI();
var sm = ServiceLocator.GetOrDefault<ISaveService>();
string scene = string.IsNullOrEmpty(escape?.targetScene)
? sm?.LastCheckpointScene
: escape.targetScene;
string spawn = string.IsNullOrEmpty(escape?.spawnId)
? sm?.LastCheckpointSpawnId
: escape.spawnId;
_onSceneLoadRequest?.Raise(new SceneLoadRequest
{
SceneName = scene,
EntryId = spawn,
ShowLoadingScreen = true,
IsRespawn = true,
});
}
private RoomEscapeInfoSO ChooseBestEscape()
{
if (_escapeOptions == null || _escapeOptions.Length == 0) return null;
RoomEscapeInfoSO best = null;
int bestPriority = int.MinValue;
foreach (var opt in _escapeOptions)
{
if (opt == null) continue;
if (opt.requiredAbility != BaseGames.Player.AbilityType.None
&& (_player?.Stats.HasAbility(opt.requiredAbility) == false)) continue;
if (opt.priority > bestPriority)
{
bestPriority = opt.priority;
best = opt;
}
}
return best;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 00954cb28f3eb9e4aaf388feb35565dd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,36 @@
using UnityEngine;
using BaseGames.World;
using BaseGames.Player;
using BaseGames.Core.Save;
namespace BaseGames.Progression
{
/// <summary>
/// 硬性能力门禁(架构 16_SupportingModules §5.3)。
/// 在 AbilityGate 的基础上增加次级验证:检查玩家是否真正拾取了物理道具。
/// 当 _requirePhysicalValidation = true 时,还需要通过 _physicalPickupSwitchKey 确认。
/// </summary>
public class HardAbilityGate : AbilityGate
{
[Header("HardAbilityGate 设置")]
[Tooltip("是否还要求物理拾取验证(需要 World.Switches 中对应 Key = true")]
[SerializeField] private bool _requirePhysicalValidation = false;
[Tooltip("物理拾取验证 Switch Key需 SaveManager.Data.World.Switches[key] == true")]
[SerializeField] private string _physicalPickupSwitchKey;
[Header("引用")]
[SerializeField] private SaveManager _saveManager;
protected override bool EvaluateAccess()
{
if (!base.EvaluateAccess()) return false;
if (!_requirePhysicalValidation) return true;
// 次级检查:物理拾取确认
if (string.IsNullOrEmpty(_physicalPickupSwitchKey)) return true;
var save = _saveManager != null ? _saveManager.Data : null;
if (save?.World?.Switches == null) return false;
return save.World.Switches.TryGetValue(_physicalPickupSwitchKey, out bool val) && val;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f7ae44a614ccc104aa8ad89cf2eebf54
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,25 @@
using UnityEngine;
using BaseGames.Player;
namespace BaseGames.Progression
{
/// <summary>
/// 房间逃脱信息 SO架构 16_SupportingModules §5.2)。
/// 当 AntiSoftlockSystem 检测到玩家卡关时,提供重置目标场景和生成点。
/// </summary>
[CreateAssetMenu(menuName = "AntiSoftlock/RoomEscapeInfo", fileName = "ESC_")]
public class RoomEscapeInfoSO : ScriptableObject
{
[Tooltip("逃脱目标场景名称(留空 = 最近检查点)")]
public string targetScene;
[Tooltip("目标场景中的生成点 ID留空 = 默认生成点)")]
public string spawnId;
[Tooltip("需要玩家拥有此能力才触发此逃脱路径None = 无要求)")]
public AbilityType requiredAbility = AbilityType.None;
[Tooltip("此逃脱路径的优先级(越高越优先选择)")]
public int priority = 0;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 89e0ac3b73daf45479939b0d29070cf0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,34 @@
{
"name": "BaseGames.Support",
"rootNamespace": "",
"references": [
"BaseGames.Core",
"BaseGames.Core.Events",
"BaseGames.Core.Save",
"BaseGames.Input",
"BaseGames.Player",
"BaseGames.Player.States",
"BaseGames.Enemies",
"BaseGames.Combat",
"BaseGames.World",
"BaseGames.Progression",
"BaseGames.Platform",
"Unity.TextMeshPro",
"Unity.RenderPipelines.Universal.Runtime"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [
{
"name": "com.unity.nuget.newtonsoft-json",
"expression": "",
"define": "NEWTONSOFT_JSON"
}
],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 7cf751d9b386aae42ac8948c0e9a91be
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b0034d19093a1314586b9bd7d497a085
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,155 @@
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Player;
using BaseGames.Player.States;
using BaseGames.Enemies;
using BaseGames.Combat;
/// <summary>
/// 调试作弊控制台(仅 UNITY_EDITOR 或 DEVELOPMENT_BUILD 下编译)。
/// 按反引号键(`)呼出/隐藏输入框,输入指令后按 Enter 执行。
/// 指令格式:指令名 [参数1] [参数2] ...
/// </summary>
public class DebugCheatSystem : MonoBehaviour
{
[Header("UI 引用")]
[SerializeField] private GameObject _consoleRoot;
[SerializeField] private TMP_InputField _inputField;
[SerializeField] private TMP_Text _outputText;
[Header("事件频道")]
[SerializeField] private VoidEventChannelSO _onPlayerRevive;
private bool _visible;
private void Update()
{
if (UnityEngine.Input.GetKeyDown(KeyCode.BackQuote))
Toggle();
if (_visible && _inputField != null && UnityEngine.Input.GetKeyDown(KeyCode.Return))
ExecuteCommand(_inputField.text);
}
private void Toggle()
{
_visible = !_visible;
if (_consoleRoot != null) _consoleRoot.SetActive(_visible);
if (_visible && _inputField != null)
{
_inputField.text = string.Empty;
_inputField.Select();
_inputField.ActivateInputField();
}
}
private void ExecuteCommand(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return;
_inputField.text = string.Empty;
_inputField.ActivateInputField();
string[] parts = raw.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0) return;
string cmd = parts[0].ToLowerInvariant();
string result;
try
{
result = cmd switch
{
"help" => "指令: heal | addgeo [n] | godmode | ungodmode | unlock [ability] | killall | scene [name] | revive",
"heal" => CmdHeal(),
"addgeo" => CmdAddGeo(parts),
"godmode" => CmdGodMode(true),
"ungodmode" => CmdGodMode(false),
"unlock" => CmdUnlock(parts),
"killall" => CmdKillAll(),
"scene" => CmdLoadScene(parts),
"revive" => CmdRevive(),
_ => $"未知指令: {cmd}",
};
}
catch (Exception ex)
{
result = $"[错误] {ex.Message}";
}
Log(result);
}
// ── 指令实现 ──────────────────────────────────────────────────────────────
private string CmdHeal()
{
var player = FindFirstObjectByType<PlayerController>();
if (player == null) return "找不到 PlayerController";
player.Stats.FullHeal();
return "HP 已满";
}
private string CmdAddGeo(string[] parts)
{
int amount = parts.Length > 1 && int.TryParse(parts[1], out int v) ? v : 100;
var player = FindFirstObjectByType<PlayerController>();
if (player == null) return "找不到 PlayerController";
player.Stats.AddGeo(amount);
return $"增加 Geo: {amount}";
}
private string CmdGodMode(bool on)
{
var player = FindFirstObjectByType<PlayerController>();
if (player == null) return "找不到 PlayerController";
player.Stats.SetGodMode(on);
return on ? "无敌模式 ON" : "无敌模式 OFF";
}
private string CmdUnlock(string[] parts)
{
if (parts.Length < 2) return "用法: unlock [AbilityType]";
if (!Enum.TryParse<AbilityType>(parts[1], true, out var abilityType))
return $"未知能力: {parts[1]}(可用:{string.Join(", ", Enum.GetNames(typeof(AbilityType)))}";
var player = FindFirstObjectByType<PlayerController>();
if (player == null) return "找不到 PlayerController";
player.Stats.UnlockAbility(abilityType);
return $"解锁能力: {abilityType}";
}
private string CmdKillAll()
{
var enemies = FindObjectsByType<EnemyBase>(FindObjectsSortMode.None);
var dmg = new DamageInfo.Builder().SetRaw(999999).Build();
foreach (var e in enemies) e.TakeDamage(dmg);
return $"已击杀 {enemies.Length} 个敌人";
}
private string CmdLoadScene(string[] parts)
{
if (parts.Length < 2) return "用法: scene [SceneName]";
SceneManager.LoadScene(parts[1]);
return $"加载场景: {parts[1]}";
}
private string CmdRevive()
{
_onPlayerRevive?.Raise();
return "已触发玩家复活事件";
}
private void Log(string msg)
{
Debug.Log($"[Cheat] {msg}");
if (_outputText != null)
{
_outputText.text = $"> {msg}";
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7213e4687db174a4181966eeaf9d8a81
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: acf94e71d5d85064ba0a9bbbf4536b01
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,51 @@
using UnityEngine;
namespace BaseGames.Localization
{
/// <summary>
/// 语言设置持久化 SO架构 16_SupportingModules §4.1)。
/// 支持的语言列表由设计师填写;当前语言持久化到 PlayerPrefs。
/// </summary>
[CreateAssetMenu(menuName = "Settings/LanguageManager", fileName = "SET_LanguageManager")]
public class LanguageManagerSO : ScriptableObject
{
private const string PrefsKey = "game_language";
[Tooltip("游戏支持的语言 locale code 列表(如 zh-CN、en-US")]
public string[] supportedLocales = { "zh-CN", "en-US" };
[Tooltip("默认语言 locale code")]
public string defaultLocale = "zh-CN";
public string CurrentLocale { get; private set; }
private void OnEnable()
{
CurrentLocale = PlayerPrefs.GetString(PrefsKey, defaultLocale);
}
/// <summary>设置当前语言并持久化到 PlayerPrefs同时通知 Unity Localization若已安装。</summary>
public void SetLocale(string localeCode)
{
CurrentLocale = localeCode;
PlayerPrefs.SetString(PrefsKey, localeCode);
PlayerPrefs.Save();
ApplyLocale(localeCode);
}
/// <summary>初始化时应用已保存语言。</summary>
public void ApplySaved() => ApplyLocale(CurrentLocale);
private static void ApplyLocale(string localeCode)
{
#if UNITY_LOCALIZATION
var locales = UnityEngine.Localization.Settings.LocalizationSettings.AvailableLocales;
var locale = locales?.GetLocale(new UnityEngine.Localization.LocaleIdentifier(localeCode));
if (locale != null)
UnityEngine.Localization.Settings.LocalizationSettings.SelectedLocale = locale;
#else
Debug.Log($"[LanguageManager] 语言设为 {localeCode}Unity Localization 包未安装,仅记录 PlayerPrefs。");
#endif
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0305b3bda1379324883e51f0fb0d5cb4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,59 @@
using System;
using UnityEngine;
namespace BaseGames.Localization
{
/// <summary>
/// 本地化字符串访问工具类(架构 16_SupportingModules §4
/// 当 Unity Localization 包com.unity.localization安装后使用 LocalizationSettings 实现完整功能;
/// 未安装时退回到 Key 直传(返回 entryKey 本身)。
/// </summary>
public static class LocalizationManager
{
#if UNITY_LOCALIZATION
private const string DefaultTable = "UI";
/// <summary>从指定表中获取本地化字符串。</summary>
public static string Get(string entryKey, string tableName = DefaultTable)
{
try
{
var op = UnityEngine.Localization.Settings.LocalizationSettings
.StringDatabase.GetLocalizedStringAsync(tableName, entryKey);
return op.IsDone ? op.Result : entryKey;
}
catch (Exception e)
{
Debug.LogWarning($"[Localization] Key '{entryKey}' in table '{tableName}' 读取失败: {e.Message}");
return entryKey;
}
}
/// <summary>带格式化参数的本地化字符串。</summary>
public static string GetFormat(string entryKey, string tableName, params object[] args)
{
string template = Get(entryKey, tableName);
try { return string.Format(template, args); }
catch (Exception e)
{
Debug.LogWarning($"[Localization] GetFormat '{entryKey}' 格式化失败: {e.Message}");
return template;
}
}
#else
/// <summary>Unity Localization 包未安装:直接返回 Key。</summary>
public static string Get(string entryKey, string tableName = "UI")
=> entryKey;
public static string GetFormat(string entryKey, string tableName, params object[] args)
{
try { return string.Format(entryKey, args); }
catch (Exception e)
{
Debug.LogWarning($"[Localization] GetFormat '{entryKey}' 格式化失败: {e.Message}");
return entryKey;
}
}
#endif
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 46d9a88adb6ede743a783e306209d4e2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 70825e06cc8c90543a0dbe0ee05ac579
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,17 @@
{
"name": "BaseGames.Platform",
"rootNamespace": "BaseGames.Platform",
"references": [
"BaseGames.Core",
"BaseGames.Core.Events"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3b86aa8ca428d1f4e945cb6c1a9a60e5
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,54 @@
using System.Threading.Tasks;
namespace BaseGames.Platform
{
/// <summary>
/// 平台服务抽象接口(架构 16_SupportingModules §3
/// 解耦游戏逻辑与平台 SDKSteam / Console / Mobile
/// </summary>
public interface IPlatformService
{
// ── 初始化 / 生命周期 ──────────────────────────────────────────────────
bool IsInitialized { get; }
Task<bool> InitializeAsync();
/// <summary>每帧调用(由 PlatformBootstrap.Update 驱动),处理 SDK 回调。</summary>
void RunCallbacks();
void Shutdown();
// ── 成就 ──────────────────────────────────────────────────────────────
void UnlockAchievement(string achievementId);
void ClearAchievement(string achievementId);
Task<bool> IsAchievementUnlocked(string achievementId);
// ── 统计数据Steam 成就进度跟踪)────────────────────────────────────
void SetStat(string statId, int value);
void IncrementStat(string statId, int increment = 1);
int GetStat(string statId);
// ── 云存档 ────────────────────────────────────────────────────────────
bool IsCloudAvailable { get; }
Task<bool> CloudSaveAsync(string fileName, byte[] data);
Task<byte[]> CloudLoadAsync(string fileName);
// ── Rich Presence ─────────────────────────────────────────────────────
void SetRichPresence(string key, string value);
void ClearRichPresence();
// ── 排行榜 ────────────────────────────────────────────────────────────
void SubmitLeaderboardScore(string boardId, long score);
Task<LeaderboardEntry[]> GetLeaderboardEntries(string boardId, int maxCount);
// ── DLC / 商城 ────────────────────────────────────────────────────────
bool IsDLCOwned(string dlcId);
// ── 覆盖层Steam Overlay 等)─────────────────────────────────────────
void ShowOverlay(string dialog);
}
public struct LeaderboardEntry
{
public string PlayerName;
public long Score;
public int Rank;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c8e96f654546bef488e8e5e27eb280da
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,46 @@
using System.Threading.Tasks;
using UnityEngine;
namespace BaseGames.Platform
{
/// <summary>
/// IPlatformService 的空实现,无平台 SDK 时作为默认服务使用。
/// 所有操作均为无操作no-op或返回安全默认值。
/// </summary>
public class NullPlatformService : IPlatformService
{
public bool IsInitialized => true;
public bool IsCloudAvailable => false;
public Task<bool> InitializeAsync() => Task.FromResult(true);
public void RunCallbacks() { }
public void Shutdown() { }
public void UnlockAchievement(string achievementId)
=> Debug.Log($"[NullPlatform] UnlockAchievement: {achievementId}");
public void ClearAchievement(string achievementId) { }
public Task<bool> IsAchievementUnlocked(string achievementId)
=> Task.FromResult(false);
public void SetStat(string statId, int value) { }
public void IncrementStat(string statId, int increment = 1) { }
public int GetStat(string statId) => 0;
public Task<bool> CloudSaveAsync(string fileName, byte[] data) => Task.FromResult(false);
public Task<byte[]> CloudLoadAsync(string fileName) => Task.FromResult<byte[]>(null);
public void SetRichPresence(string key, string value) { }
public void ClearRichPresence() { }
public void SubmitLeaderboardScore(string boardId, long score)
=> Debug.Log($"[NullPlatform] SubmitLeaderboardScore: {boardId} = {score}");
public Task<LeaderboardEntry[]> GetLeaderboardEntries(string boardId, int maxCount)
=> Task.FromResult(System.Array.Empty<LeaderboardEntry>());
public bool IsDLCOwned(string dlcId) => false;
public void ShowOverlay(string dialog) { }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 452af73acae0f8d40840f96095a34b9a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,42 @@
using UnityEngine;
using BaseGames.Core;
namespace BaseGames.Platform
{
/// <summary>
/// 平台服务引导组件(架构 16_SupportingModules §3.1)。
/// 在场景加载最早阶段DefaultExecutionOrder = -200检测平台并向 ServiceLocator 注入实现。
/// ⚠️ 使用 PlatformBootstrap MonoBehaviour + ServiceLocator 模式,非静态 PlatformManager。
/// </summary>
[DefaultExecutionOrder(-200)]
public class PlatformBootstrap : MonoBehaviour
{
private IPlatformService _platform;
private async void Awake()
{
IPlatformService service;
#if UNITY_STANDALONE && STEAMWORKS_NET
service = new SteamPlatformService();
#else
service = new NullPlatformService();
#endif
bool ok = await service.InitializeAsync();
if (!ok)
{
Debug.LogWarning("[PlatformBootstrap] 平台服务初始化失败,退回到 NullPlatformService。");
service = new NullPlatformService();
}
ServiceLocator.Register<IPlatformService>(service);
_platform = service;
Debug.Log($"[PlatformBootstrap] 已注册 {service.GetType().Name}。");
}
private void Update() => _platform?.RunCallbacks();
private void OnApplicationQuit() => _platform?.Shutdown();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 276b59f1a8979c14cbeac97645248a57
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,149 @@
#if UNITY_STANDALONE && STEAMWORKS_NET
using System.Threading.Tasks;
using UnityEngine;
using Steamworks;
namespace BaseGames.Platform
{
/// <summary>
/// 基于 Steamworks.NET 的平台服务实现(仅 UNITY_STANDALONE + STEAMWORKS_NET 时编译)。
/// </summary>
public class SteamPlatformService : IPlatformService
{
public bool IsInitialized { get; private set; }
public bool IsCloudAvailable => IsInitialized && SteamRemoteStorage.IsCloudEnabledForApp();
// ── 初始化 / 生命周期 ──────────────────────────────────────────────────
public async Task<bool> InitializeAsync()
{
try
{
if (!SteamAPI.Init())
{
Debug.LogWarning("[SteamPlatform] SteamAPI.Init() 失败,请确认 Steam 客户端已运行。");
IsInitialized = false;
return false;
}
IsInitialized = true;
Debug.Log("[SteamPlatform] Steam 初始化成功。");
return true;
}
catch (System.Exception ex)
{
Debug.LogError($"[SteamPlatform] 初始化异常: {ex.Message}");
IsInitialized = false;
return false;
}
}
public void RunCallbacks()
{
if (IsInitialized) SteamAPI.RunCallbacks();
}
public void Shutdown()
{
if (IsInitialized) SteamAPI.Shutdown();
}
// ── 成就 ──────────────────────────────────────────────────────────────
public void UnlockAchievement(string achievementId)
{
if (!IsInitialized) return;
SteamUserStats.SetAchievement(achievementId);
SteamUserStats.StoreStats();
}
public void ClearAchievement(string achievementId)
{
if (!IsInitialized) return;
SteamUserStats.ClearAchievement(achievementId);
SteamUserStats.StoreStats();
}
public async Task<bool> IsAchievementUnlocked(string achievementId)
{
if (!IsInitialized) return false;
SteamUserStats.GetAchievement(achievementId, out bool achieved);
return achieved;
}
// ── 统计数据 ──────────────────────────────────────────────────────────
public void SetStat(string statId, int value)
{
if (!IsInitialized) return;
SteamUserStats.SetStat(statId, value);
SteamUserStats.StoreStats();
}
public void IncrementStat(string statId, int increment = 1)
{
if (!IsInitialized) return;
SteamUserStats.GetStat(statId, out int current);
SteamUserStats.SetStat(statId, current + increment);
SteamUserStats.StoreStats();
}
public int GetStat(string statId)
{
if (!IsInitialized) return 0;
SteamUserStats.GetStat(statId, out int value);
return value;
}
// ── 云存档 ────────────────────────────────────────────────────────────
public async Task<bool> CloudSaveAsync(string fileName, byte[] data)
{
if (!IsCloudAvailable) return false;
return await Task.Run(() =>
SteamRemoteStorage.FileWrite(fileName, data, data.Length));
}
public async Task<byte[]> CloudLoadAsync(string fileName)
{
if (!IsCloudAvailable || !SteamRemoteStorage.FileExists(fileName))
return null;
int size = SteamRemoteStorage.GetFileSize(fileName);
var buf = new byte[size];
await Task.Run(() => SteamRemoteStorage.FileRead(fileName, buf, size));
return buf;
}
// ── Rich Presence ─────────────────────────────────────────────────────
public void SetRichPresence(string key, string value)
{
if (IsInitialized) SteamFriends.SetRichPresence(key, value);
}
public void ClearRichPresence()
{
if (IsInitialized) SteamFriends.ClearRichPresence();
}
// ── 排行榜 ────────────────────────────────────────────────────────────
public void SubmitLeaderboardScore(string boardId, long score)
{
// Steamworks 排行榜提交为异步,此处为 fire-and-forget 简化实现
Debug.Log($"[SteamPlatform] SubmitLeaderboardScore: {boardId} = {score}");
}
public Task<LeaderboardEntry[]> GetLeaderboardEntries(string boardId, int maxCount)
{
return Task.FromResult(System.Array.Empty<LeaderboardEntry>());
}
// ── DLC ───────────────────────────────────────────────────────────────
public bool IsDLCOwned(string dlcId)
{
if (!IsInitialized || !uint.TryParse(dlcId, out uint appId)) return false;
return SteamApps.BIsDlcInstalled((AppId_t)appId);
}
// ── 覆盖层 ────────────────────────────────────────────────────────────
public void ShowOverlay(string dialog)
{
if (IsInitialized) SteamFriends.ActivateGameOverlay(dialog);
}
}
}
#endif

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fdef5bc72bba5b74cb7d6498cb27395f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d58be49899c8d04498da53d0f12815e3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,104 @@
using UnityEngine;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
namespace BaseGames.Support.Speedrun
{
/// <summary>
/// 速通计时器(架构 16_SupportingModules §8
/// 使用 Time.unscaledDeltaTime 以免被 HitStoptimeScale &lt; 1拉慢游戏暂停时须由外部调用 PauseTimer()。
/// 实现 ISaveable计时持久化到 <see cref="StatsSaveData.SpeedrunTime"/>。
/// </summary>
public class SpeedrunTimer : MonoBehaviour, ISaveable
{
[Header("显示设置")]
[SerializeField] private TMP_Text _display;
[SerializeField] private GlobalSettingsSO _settings;
[Header("事件频道")]
[SerializeField] private VoidEventChannelSO _onTimerReset;
[SerializeField] private BoolEventChannelSO _onTimerVisibilityChanged;
public float ElapsedSeconds { get; private set; }
public bool IsRunning { get; private set; }
private bool _paused;
/// <summary>上一帧已显示的整秒值,展示内容未变化时跳过字符串重建。</summary>
private int _lastDisplayedSecond = -1;
private void Start()
{
bool show = _settings != null && _settings.ShowSpeedrunTimer;
SetVisible(show);
if (show) StartTimer();
}
private void Update()
{
if (!IsRunning || _paused) return;
ElapsedSeconds += Time.unscaledDeltaTime;
// 仅当整秒数发生变化时才重建展示字符串,避免每帧分配
int currentSecond = (int)ElapsedSeconds;
if (currentSecond != _lastDisplayedSecond)
{
_lastDisplayedSecond = currentSecond;
UpdateDisplay();
}
}
// ── 控制接口 ──────────────────────────────────────────────────────────────
public void StartTimer()
{
IsRunning = true;
_paused = false;
}
public void StopTimer() => IsRunning = false;
public void PauseTimer() => _paused = true;
public void ResumeTimer() => _paused = false;
public void ResetTimer()
{
ElapsedSeconds = 0f;
UpdateDisplay();
_onTimerReset?.Raise();
}
public void SetVisible(bool visible)
{
if (_display != null) _display.gameObject.SetActive(visible);
_onTimerVisibilityChanged?.Raise(visible);
}
// ── ISaveable ─────────────────────────────────────────────────────────────
public void OnSave(SaveData saveData)
{
if (saveData?.Stats != null)
saveData.Stats.SpeedrunTime = ElapsedSeconds;
}
public void OnLoad(SaveData saveData)
{
if (saveData?.Stats != null)
ElapsedSeconds = saveData.Stats.SpeedrunTime;
UpdateDisplay();
}
// ── 内部 ──────────────────────────────────────────────────────────────────
private void UpdateDisplay()
{
if (_display == null) return;
int hours = (int)(ElapsedSeconds / 3600);
int minutes = (int)(ElapsedSeconds % 3600 / 60);
int seconds = (int)(ElapsedSeconds % 60);
int ms = (int)(ElapsedSeconds * 100 % 100);
_display.text = hours > 0
? $"{hours:00}:{minutes:00}:{seconds:00}.{ms:00}"
: $"{minutes:00}:{seconds:00}.{ms:00}";
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e2bd7f531212a924b9300bd3a8b7610a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: