first push!
This commit is contained in:
8
Assets/Scripts/Debug.meta
Normal file
8
Assets/Scripts/Debug.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7792fdd9cc2c4791b509bfed728822b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
42
Assets/Scripts/Debug/ForceCurveDataLogger.cs
Normal file
42
Assets/Scripts/Debug/ForceCurveDataLogger.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using UnityEngine;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
public class ForceCurveDataLogger : MonoBehaviour
|
||||
{
|
||||
private List<float> _strokeBuffer = new List<float>();
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (PerformanceMonitorManager.Instance != null)
|
||||
PerformanceMonitorManager.Instance.OnForceCurveUpdated += HandleData;
|
||||
}
|
||||
|
||||
void HandleData(List<float> points)
|
||||
{
|
||||
// 1. SIGNAL: Manager sends empty list when a NEW stroke starts
|
||||
if (points.Count == 0)
|
||||
{
|
||||
if (_strokeBuffer.Count > 5)
|
||||
{
|
||||
LogFinishedStroke();
|
||||
}
|
||||
_strokeBuffer.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
// 2. Accumulate the cumulative data
|
||||
_strokeBuffer = new List<float>(points);
|
||||
}
|
||||
}
|
||||
|
||||
void LogFinishedStroke()
|
||||
{
|
||||
float maxForce = _strokeBuffer.Max();
|
||||
float totalImpulse = _strokeBuffer.Sum(); // Area under the curve
|
||||
string csv = string.Join(",", _strokeBuffer.Select(p => p.ToString("0")));
|
||||
|
||||
Debug.Log($"<color=#FF7F00><b>[STROKE RECORDED]</b></color> Impulse: {totalImpulse:N0} | Pts: {_strokeBuffer.Count} | Max: {maxForce}");
|
||||
Debug.Log("CSV:" + csv);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Debug/ForceCurveDataLogger.cs.meta
Normal file
11
Assets/Scripts/Debug/ForceCurveDataLogger.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a905dc9499e83430980b57cbc956dd70
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
86
Assets/Scripts/Debug/ForceCurveHistoryDebugger.cs
Normal file
86
Assets/Scripts/Debug/ForceCurveHistoryDebugger.cs
Normal file
@@ -0,0 +1,86 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
[RequireComponent(typeof(LineRenderer))]
|
||||
public class ForceCurveHistoryDebugger : MonoBehaviour
|
||||
{
|
||||
private LineRenderer _lr;
|
||||
private List<float> _historyPoints = new List<float>();
|
||||
|
||||
[Header("Settings")]
|
||||
public float xScale = 0.05f; // Distance between points
|
||||
public float yScale = 0.05f; // Height multiplier
|
||||
public int maxPoints = 1000; // Keep last 1000 points (approx 20-30 seconds)
|
||||
|
||||
private int _lastPointsCount = 0;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_lr = GetComponent<LineRenderer>();
|
||||
_lr.positionCount = 0;
|
||||
_lr.startWidth = 0.05f;
|
||||
_lr.endWidth = 0.05f;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (PerformanceMonitorManager.Instance != null)
|
||||
{
|
||||
PerformanceMonitorManager.Instance.OnForceCurveUpdated += OnCurveData;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCurveData(List<float> fullCurve)
|
||||
{
|
||||
// Calculate how many NEW points were added since last update
|
||||
// (Since the Manager sends the whole cumulative curve of the current stroke)
|
||||
int newPointsCount = fullCurve.Count - _lastPointsCount;
|
||||
|
||||
// If count dropped (new stroke started), we take the whole new list
|
||||
if (newPointsCount < 0)
|
||||
{
|
||||
newPointsCount = fullCurve.Count;
|
||||
AddPoints(fullCurve); // Add the whole new stroke start
|
||||
}
|
||||
else if (newPointsCount > 0)
|
||||
{
|
||||
// Just add the tail (the newest packet)
|
||||
List<float> newSegment = fullCurve.GetRange(_lastPointsCount, newPointsCount);
|
||||
AddPoints(newSegment);
|
||||
}
|
||||
|
||||
_lastPointsCount = fullCurve.Count;
|
||||
}
|
||||
|
||||
private void AddPoints(List<float> newPoints)
|
||||
{
|
||||
UnityMainThreadDispatcher.Instance().Enqueue(() => {
|
||||
|
||||
_historyPoints.AddRange(newPoints);
|
||||
|
||||
// Trim history if too long
|
||||
if (_historyPoints.Count > maxPoints)
|
||||
{
|
||||
_historyPoints.RemoveRange(0, _historyPoints.Count - maxPoints);
|
||||
}
|
||||
|
||||
DrawGraph();
|
||||
});
|
||||
}
|
||||
|
||||
private void DrawGraph()
|
||||
{
|
||||
_lr.positionCount = _historyPoints.Count;
|
||||
|
||||
for (int i = 0; i < _historyPoints.Count; i++)
|
||||
{
|
||||
float x = i * xScale;
|
||||
float y = _historyPoints[i] * yScale;
|
||||
|
||||
// Shift x so the graph scrolls to the left
|
||||
// (Optional: Keep it static and let it grow to the right)
|
||||
|
||||
_lr.SetPosition(i, new Vector3(x, y, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Debug/ForceCurveHistoryDebugger.cs.meta
Normal file
11
Assets/Scripts/Debug/ForceCurveHistoryDebugger.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 68892aba1c1c94fd7959380b4718584a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
71
Assets/Scripts/Debug/ForceCurveVisualizer.cs
Normal file
71
Assets/Scripts/Debug/ForceCurveVisualizer.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
[RequireComponent(typeof(LineRenderer))]
|
||||
public class ForceCurveVisualizer : MonoBehaviour
|
||||
{
|
||||
private LineRenderer _lr;
|
||||
|
||||
[Header("Visual Settings")]
|
||||
public float xScale = 0.1f;
|
||||
public float yScale = 0.05f; // Raw force is usually 0-200. 0.05 scales it to 0-10 height.
|
||||
public float lineWidth = 0.1f;
|
||||
public Color lineColor = Color.cyan;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_lr = GetComponent<LineRenderer>();
|
||||
|
||||
// Setup LineRenderer programmatically if not set in Inspector
|
||||
_lr.positionCount = 0;
|
||||
_lr.startWidth = lineWidth;
|
||||
_lr.endWidth = lineWidth;
|
||||
_lr.useWorldSpace = false; // Important for UI/HUD
|
||||
_lr.material = new Material(Shader.Find("Sprites/Default")); // Basic white material
|
||||
_lr.startColor = lineColor;
|
||||
_lr.endColor = lineColor;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (PerformanceMonitorManager.Instance != null)
|
||||
{
|
||||
PerformanceMonitorManager.Instance.OnForceCurveUpdated += UpdateGraph;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateGraph(List<float> points)
|
||||
{
|
||||
UnityMainThreadDispatcher.Instance().Enqueue(() => {
|
||||
if (points.Count < 2)
|
||||
{
|
||||
_lr.positionCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
_lr.positionCount = points.Count;
|
||||
|
||||
// Draw
|
||||
for (int i = 0; i < points.Count; i++)
|
||||
{
|
||||
float x = i * xScale;
|
||||
float y = points[i] * yScale;
|
||||
|
||||
// Simple 3-point smoothing
|
||||
if (i > 0 && i < points.Count - 1)
|
||||
{
|
||||
float prev = points[i-1] * yScale;
|
||||
float next = points[i+1] * yScale;
|
||||
y = (prev + y + next) / 3f;
|
||||
}
|
||||
|
||||
_lr.SetPosition(i, new Vector3(x, y, 0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// JUICE TIP: Use this to check for "Good Form"
|
||||
// A good stroke is a smooth bell curve.
|
||||
// A bad stroke has a "dip" in the middle (two peaks).
|
||||
// You can analyze 'points' here to trigger the "sputtering engine" sound.
|
||||
}
|
||||
11
Assets/Scripts/Debug/ForceCurveVisualizer.cs.meta
Normal file
11
Assets/Scripts/Debug/ForceCurveVisualizer.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a6d1a8e151ddc4bae990fe2e86149e70
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
84
Assets/Scripts/Debug/PM5TestUI.cs
Normal file
84
Assets/Scripts/Debug/PM5TestUI.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using UnityEngine;
|
||||
using TMPro;
|
||||
using UnityEngine.UI;
|
||||
|
||||
public class PM5TestUI : MonoBehaviour
|
||||
{
|
||||
[Header("UI References")]
|
||||
[SerializeField] private TMP_Text wattsText;
|
||||
[SerializeField] private TMP_Text spmText;
|
||||
[SerializeField] private TMP_Text distanceText;
|
||||
[SerializeField] private TMP_Text hrText; // NEW
|
||||
[SerializeField] private TMP_Text caloriesText; // NEW
|
||||
[SerializeField] private TMP_Text statusText;
|
||||
[SerializeField] private Image statusIndicator;
|
||||
[SerializeField] private Button connectButton;
|
||||
|
||||
[Header("Logging")]
|
||||
[SerializeField] private Transform logContent;
|
||||
[SerializeField] private GameObject logItemPrefab;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (PerformanceMonitorManager.Instance != null)
|
||||
{
|
||||
PerformanceMonitorManager.Instance.OnLog += AddLog;
|
||||
PerformanceMonitorManager.Instance.OnConnectionStateChanged += UpdateStatusUI;
|
||||
PerformanceMonitorManager.Instance.OnStatsUpdated += UpdateDashboard;
|
||||
}
|
||||
|
||||
connectButton.onClick.AddListener(() => {
|
||||
PerformanceMonitorManager.Instance.StartScan();
|
||||
});
|
||||
|
||||
UpdateStatusUI(false);
|
||||
}
|
||||
|
||||
private void UpdateDashboard(PerformanceMonitorManager.RowingStats stats)
|
||||
{
|
||||
UnityMainThreadDispatcher.Instance().Enqueue(() => {
|
||||
if (wattsText) wattsText.text = $"{stats.Watts} W";
|
||||
if (spmText) spmText.text = $"{stats.SPM} s/m";
|
||||
if (distanceText) distanceText.text = $"{stats.Distance:F0} m";
|
||||
|
||||
if (hrText) hrText.text = stats.HeartRate > 0 ? $"{stats.HeartRate} bpm" : "--";
|
||||
if (caloriesText) caloriesText.text = $"{stats.Calories} cal";
|
||||
});
|
||||
}
|
||||
|
||||
private void UpdateStatusUI(bool isConnected)
|
||||
{
|
||||
UnityMainThreadDispatcher.Instance().Enqueue(() => {
|
||||
if (statusText)
|
||||
{
|
||||
statusText.text = isConnected ? "CONNECTED" : "DISCONNECTED";
|
||||
statusText.color = isConnected ? Color.green : Color.red;
|
||||
}
|
||||
|
||||
if (statusIndicator)
|
||||
statusIndicator.color = isConnected ? Color.green : Color.red;
|
||||
|
||||
if (connectButton)
|
||||
connectButton.interactable = !isConnected;
|
||||
});
|
||||
}
|
||||
|
||||
private void AddLog(string message)
|
||||
{
|
||||
UnityMainThreadDispatcher.Instance().Enqueue(() => {
|
||||
if (logContent == null || logItemPrefab == null) return;
|
||||
|
||||
// Optional: Limit log size to prevent lag
|
||||
if (logContent.childCount > 20)
|
||||
Destroy(logContent.GetChild(0).gameObject);
|
||||
|
||||
GameObject newItem = Instantiate(logItemPrefab, logContent);
|
||||
TMP_Text txt = newItem.GetComponent<TMP_Text>();
|
||||
if (txt) txt.text = $"[{System.DateTime.Now:HH:mm:ss}] {message}";
|
||||
|
||||
Canvas.ForceUpdateCanvases();
|
||||
ScrollRect sr = logContent.GetComponentInParent<ScrollRect>();
|
||||
if (sr) sr.verticalNormalizedPosition = 0f;
|
||||
});
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Debug/PM5TestUI.cs.meta
Normal file
11
Assets/Scripts/Debug/PM5TestUI.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 039ab1d0f5dab403db97489b34b3aa0e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Scripts/Input.meta
Normal file
8
Assets/Scripts/Input.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b1180c7df0fe547399159fad47c82773
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
92
Assets/Scripts/Input/RowingDataRecorder.cs
Normal file
92
Assets/Scripts/Input/RowingDataRecorder.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
public class RowingDataRecorder : MonoBehaviour
|
||||
{
|
||||
[Header("Recording Setup")]
|
||||
public bool isRecording = false;
|
||||
public string saveFileName = "Zone2_Session";
|
||||
|
||||
private RowingSessionData _currentSession;
|
||||
private float _recordingStartTime;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (PerformanceMonitorManager.Instance != null)
|
||||
{
|
||||
// We listen to the same events the InputManager uses
|
||||
PerformanceMonitorManager.Instance.OnStatsUpdated += RecordStats;
|
||||
PerformanceMonitorManager.Instance.OnForceCurveUpdated += RecordForceCurve;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (Input.GetKeyDown(KeyCode.R)) ToggleRecording();
|
||||
}
|
||||
|
||||
private void ToggleRecording()
|
||||
{
|
||||
isRecording = !isRecording;
|
||||
if (isRecording)
|
||||
{
|
||||
_currentSession = ScriptableObject.CreateInstance<RowingSessionData>();
|
||||
_currentSession.sessionName = saveFileName;
|
||||
_recordingStartTime = Time.time;
|
||||
Debug.Log($"[Recorder] Started recording: {saveFileName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
SaveSessionAsset();
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordStats(PerformanceMonitorManager.RowingStats stats)
|
||||
{
|
||||
if (!isRecording) return;
|
||||
|
||||
RowingFrame frame = new RowingFrame
|
||||
{
|
||||
timestamp = Time.time - _recordingStartTime,
|
||||
watts = stats.Watts,
|
||||
spm = stats.SPM,
|
||||
heartRate = stats.HeartRate,
|
||||
forceCurve = new List<float>() // Empty for normal stat updates
|
||||
};
|
||||
_currentSession.frames.Add(frame);
|
||||
}
|
||||
|
||||
private void RecordForceCurve(List<float> curve)
|
||||
{
|
||||
if (!isRecording || curve == null || curve.Count == 0) return;
|
||||
|
||||
// We create a specific frame just for the force curve completion
|
||||
RowingFrame frame = new RowingFrame
|
||||
{
|
||||
timestamp = Time.time - _recordingStartTime,
|
||||
watts = PerformanceMonitorManager.Instance.Stats.Watts,
|
||||
spm = PerformanceMonitorManager.Instance.Stats.SPM,
|
||||
heartRate = PerformanceMonitorManager.Instance.Stats.HeartRate,
|
||||
forceCurve = new List<float>(curve) // Clone the list!
|
||||
};
|
||||
_currentSession.frames.Add(frame);
|
||||
}
|
||||
|
||||
private void SaveSessionAsset()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (_currentSession == null || _currentSession.frames.Count == 0) return;
|
||||
|
||||
// Create the Asset in your Project folder
|
||||
string path = $"Assets/{saveFileName}.asset";
|
||||
AssetDatabase.CreateAsset(_currentSession, AssetDatabase.GenerateUniqueAssetPath(path));
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
Debug.Log($"[Recorder] Session saved to {path} with {_currentSession.frames.Count} frames.");
|
||||
_currentSession = null;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Input/RowingDataRecorder.cs.meta
Normal file
11
Assets/Scripts/Input/RowingDataRecorder.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a737e255a43774e5a8e57cb70c7f9f01
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
234
Assets/Scripts/Input/RowingInputManager.cs
Normal file
234
Assets/Scripts/Input/RowingInputManager.cs
Normal file
@@ -0,0 +1,234 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
public class RowingInputManager : MonoBehaviour
|
||||
{
|
||||
public static RowingInputManager Instance;
|
||||
|
||||
public enum InputSource { LivePM5, Recorded, Simulated }
|
||||
|
||||
[Header("Data Source")]
|
||||
public InputSource currentInputSource = InputSource.LivePM5;
|
||||
|
||||
[Header("Recorded Mode Controls")]
|
||||
public List<RowingSessionData> recordedSessions;
|
||||
public int currentSessionIndex = 0;
|
||||
[Range(0.1f, 5f)] public float playbackSpeed = 1f;
|
||||
public bool loopSimulation = true;
|
||||
|
||||
[Header("Simulated Mode (Button Mashing)")]
|
||||
public KeyCode simulateStrokeKey = KeyCode.Space;
|
||||
public float simulateWattJump = 50f; // Lowered slightly so you have to work for it
|
||||
public float simulateWattDecayRate = 85f; // Increased so the "flywheel" slows down faster
|
||||
public float simulateMaxWatts = 300f; // The new hard cap!
|
||||
|
||||
private float _currentSimulatedWatts = 0f;
|
||||
private float _lastSimStrokeTime = 0f;
|
||||
|
||||
[Header("Game Physics Settings")]
|
||||
public float maxGameSpeed = 50f;
|
||||
public float wattsToSpeedMultiplier = 0.1f;
|
||||
public float speedSmoothTime = 0.75f;
|
||||
public int userMaxHR = 190;
|
||||
|
||||
[Header("Live Output (Read Only)")]
|
||||
public float CurrentSmoothedVelocity;
|
||||
public float LastStrokeImpulse;
|
||||
public bool IsLastStrokeSmooth;
|
||||
public int MaxPowerOutput;
|
||||
public int CurrentHRZone;
|
||||
public float LastStrokeRatio;
|
||||
|
||||
public Action<float> OnVelocityChanged;
|
||||
public Action<float, bool, float> OnStrokeCompleted;
|
||||
public Action<int> OnZoneChanged;
|
||||
|
||||
private float _targetVelocity;
|
||||
private float _velocityVelocity;
|
||||
|
||||
// Recorded State
|
||||
private float _playbackTimer = 0f;
|
||||
private int _playbackFrameIndex = 0;
|
||||
|
||||
// Stroke Tracking State Machine
|
||||
private enum StrokeState { Idle, Drive, Recovery }
|
||||
private StrokeState _currentState = StrokeState.Idle;
|
||||
private float _driveTimer = 0f;
|
||||
private float _recoveryTimer = 0f;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null) Instance = this;
|
||||
else Destroy(gameObject);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (PerformanceMonitorManager.Instance != null)
|
||||
{
|
||||
PerformanceMonitorManager.Instance.OnStatsUpdated += (stats) => { if (currentInputSource == InputSource.LivePM5) ProcessStats(stats.Watts, stats.SPM, stats.HeartRate); };
|
||||
PerformanceMonitorManager.Instance.OnForceCurveUpdated += (curve) => { if (currentInputSource == InputSource.LivePM5) ProcessForceCurve(curve); };
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
HandleKeyboardControls();
|
||||
|
||||
if (currentInputSource == InputSource.Recorded)
|
||||
{
|
||||
RunRecordedSession();
|
||||
}
|
||||
else if (currentInputSource == InputSource.Simulated)
|
||||
{
|
||||
RunManualSimulation();
|
||||
}
|
||||
|
||||
// Apply Smoothing
|
||||
CurrentSmoothedVelocity = Mathf.SmoothDamp(CurrentSmoothedVelocity, _targetVelocity, ref _velocityVelocity, speedSmoothTime);
|
||||
OnVelocityChanged?.Invoke(CurrentSmoothedVelocity);
|
||||
|
||||
// Timers
|
||||
if (_currentState == StrokeState.Drive) _driveTimer += Time.deltaTime;
|
||||
else if (_currentState == StrokeState.Recovery) _recoveryTimer += Time.deltaTime;
|
||||
}
|
||||
|
||||
private void RunManualSimulation()
|
||||
{
|
||||
// 1. Decay the watts over time (Simulating the flywheel slowing down)
|
||||
_currentSimulatedWatts = Mathf.Max(0f, _currentSimulatedWatts - (simulateWattDecayRate * Time.deltaTime));
|
||||
|
||||
// 2. Handle the "Pull"
|
||||
if (Input.GetKeyDown(simulateStrokeKey))
|
||||
{
|
||||
// Add power, but firmly cap it so we don't break the sound barrier
|
||||
_currentSimulatedWatts = Mathf.Min(_currentSimulatedWatts + simulateWattJump, simulateMaxWatts);
|
||||
|
||||
// Calculate a fake SPM based on how fast you are mashing
|
||||
float timeSinceLast = Time.time - _lastSimStrokeTime;
|
||||
int fakeSPM = timeSinceLast > 0 ? Mathf.RoundToInt(60f / timeSinceLast) : 0;
|
||||
_lastSimStrokeTime = Time.time;
|
||||
|
||||
// Fake HR that scales up gently with your power output
|
||||
int fakeHR = Mathf.Clamp(100 + Mathf.RoundToInt(_currentSimulatedWatts / 4f), 70, 200);
|
||||
|
||||
ProcessStats(Mathf.RoundToInt(_currentSimulatedWatts), fakeSPM, fakeHR);
|
||||
|
||||
// Generate a fake, perfectly smooth force curve (a bell shape)
|
||||
List<float> fakeCurve = new List<float> { 20f, 60f, 120f, 150f, 120f, 60f, 20f };
|
||||
ProcessForceCurve(fakeCurve);
|
||||
|
||||
// Immediately send an empty list to signify the drive ended and trigger recovery
|
||||
ProcessForceCurve(new List<float>());
|
||||
}
|
||||
else
|
||||
{
|
||||
// If not pulling, just update the decaying watts
|
||||
ProcessStats(Mathf.RoundToInt(_currentSimulatedWatts), 0, 100);
|
||||
}
|
||||
}
|
||||
|
||||
private void RunRecordedSession()
|
||||
{
|
||||
if (recordedSessions == null || recordedSessions.Count == 0 || recordedSessions[currentSessionIndex] == null) return;
|
||||
|
||||
var session = recordedSessions[currentSessionIndex];
|
||||
|
||||
_playbackTimer += Time.deltaTime * playbackSpeed;
|
||||
|
||||
while (_playbackFrameIndex < session.frames.Count && _playbackTimer >= session.frames[_playbackFrameIndex].timestamp)
|
||||
{
|
||||
RowingFrame frame = session.frames[_playbackFrameIndex];
|
||||
|
||||
ProcessStats(frame.watts, frame.spm, frame.heartRate);
|
||||
|
||||
if (frame.forceCurve != null && frame.forceCurve.Count > 0)
|
||||
{
|
||||
ProcessForceCurve(frame.forceCurve);
|
||||
}
|
||||
else if (frame.watts == 0 && frame.spm == 0)
|
||||
{
|
||||
ProcessForceCurve(new List<float>());
|
||||
}
|
||||
|
||||
_playbackFrameIndex++;
|
||||
}
|
||||
|
||||
if (_playbackFrameIndex >= session.frames.Count)
|
||||
{
|
||||
if (loopSimulation)
|
||||
{
|
||||
_playbackTimer = 0f;
|
||||
_playbackFrameIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleKeyboardControls()
|
||||
{
|
||||
if (currentInputSource == InputSource.Recorded)
|
||||
{
|
||||
if (Input.GetKeyDown(KeyCode.UpArrow)) playbackSpeed += 0.25f;
|
||||
if (Input.GetKeyDown(KeyCode.DownArrow)) playbackSpeed = Mathf.Max(0.25f, playbackSpeed - 0.25f);
|
||||
|
||||
for (int i = 0; i < recordedSessions.Count; i++)
|
||||
{
|
||||
if (Input.GetKeyDown(KeyCode.Alpha1 + i))
|
||||
{
|
||||
currentSessionIndex = i;
|
||||
_playbackTimer = 0f;
|
||||
_playbackFrameIndex = 0;
|
||||
Debug.Log($"Switched to Session: {recordedSessions[i].sessionName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Core Logic (Shared by Live, Recorded, and Simulated) ---
|
||||
|
||||
private void ProcessStats(int watts, int spm, int hr)
|
||||
{
|
||||
_targetVelocity = Mathf.Clamp(watts * wattsToSpeedMultiplier, 0, maxGameSpeed);
|
||||
if (watts > MaxPowerOutput) MaxPowerOutput = watts;
|
||||
|
||||
int newZone = CalculateHRZone(hr);
|
||||
if (newZone != CurrentHRZone)
|
||||
{
|
||||
CurrentHRZone = newZone;
|
||||
OnZoneChanged?.Invoke(CurrentHRZone);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessForceCurve(List<float> points)
|
||||
{
|
||||
if (points == null || points.Count == 0)
|
||||
{
|
||||
if (_currentState == StrokeState.Drive)
|
||||
{
|
||||
_currentState = StrokeState.Recovery;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentState == StrokeState.Recovery || _currentState == StrokeState.Idle)
|
||||
{
|
||||
if (_recoveryTimer > 0)
|
||||
{
|
||||
LastStrokeRatio = _driveTimer / _recoveryTimer;
|
||||
|
||||
LastStrokeImpulse = CalculateImpulse(points);
|
||||
IsLastStrokeSmooth = AnalyzeSmoothness(points);
|
||||
|
||||
OnStrokeCompleted?.Invoke(LastStrokeImpulse, IsLastStrokeSmooth, LastStrokeRatio);
|
||||
}
|
||||
_driveTimer = 0f;
|
||||
_recoveryTimer = 0f;
|
||||
_currentState = StrokeState.Drive;
|
||||
}
|
||||
}
|
||||
|
||||
private int CalculateHRZone(int currentHR) { return 1; /* Replace with your actual HR math from previous step */ }
|
||||
private float CalculateImpulse(List<float> curve) { return 1f; /* Replace with your actual Impulse math */ }
|
||||
private bool AnalyzeSmoothness(List<float> curve) { return true; /* Replace with your actual Smoothness math */ }
|
||||
}
|
||||
11
Assets/Scripts/Input/RowingInputManager.cs.meta
Normal file
11
Assets/Scripts/Input/RowingInputManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2ac4117e608824ebfb7e8f80008e8ca0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
19
Assets/Scripts/Input/RowingSessionData.cs
Normal file
19
Assets/Scripts/Input/RowingSessionData.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
[System.Serializable]
|
||||
public struct RowingFrame
|
||||
{
|
||||
public float timestamp; // Time since the recording started
|
||||
public int watts;
|
||||
public int spm;
|
||||
public int heartRate;
|
||||
public List<float> forceCurve; // Will be empty unless a stroke just finished
|
||||
}
|
||||
|
||||
[CreateAssetMenu(fileName = "NewRowingSession", menuName = "Rowing/Session Data")]
|
||||
public class RowingSessionData : ScriptableObject
|
||||
{
|
||||
public string sessionName = "New Session";
|
||||
public List<RowingFrame> frames = new List<RowingFrame>();
|
||||
}
|
||||
11
Assets/Scripts/Input/RowingSessionData.cs.meta
Normal file
11
Assets/Scripts/Input/RowingSessionData.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ecde514822d9e4bdc89dcb697d725f65
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Scripts/PM5.meta
Normal file
8
Assets/Scripts/PM5.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5865157d44c834e7fb35c80be377143f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
381
Assets/Scripts/PM5/CSAFEProtocol.cs
Normal file
381
Assets/Scripts/PM5/CSAFEProtocol.cs
Normal file
@@ -0,0 +1,381 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
|
||||
|
||||
public static class CSAFEDictionary
|
||||
{
|
||||
public static readonly byte ExtendedFrameStartFlag = 0xF0;
|
||||
public static readonly byte StandardFrameStartFlag = 0xF1;
|
||||
public static readonly byte StopFrameFlag = 0xF2;
|
||||
public static readonly byte ByteStuffingFlag = 0xF3;
|
||||
|
||||
public static readonly Dictionary<string, List<object>> Cmd = new Dictionary<string, List<object>>
|
||||
{
|
||||
// Standard Short Commands
|
||||
{ "CSAFE_GETSTATUS_CMD", new List<object> { 0x80, new int[] { } } },
|
||||
{ "CSAFE_RESET_CMD", new List<object> { 0x81, new int[] { } } },
|
||||
{ "CSAFE_GOIDLE_CMD", new List<object> { 0x82, new int[] { } } },
|
||||
{ "CSAFE_GOHAVEID_CMD", new List<object> { 0x83, new int[] { } } },
|
||||
{ "CSAFE_GOINUSE_CMD", new List<object> { 0x85, new int[] { } } },
|
||||
{ "CSAFE_GOFINISHED_CMD", new List<object> { 0x86, new int[] { } } },
|
||||
{ "CSAFE_GOREADY_CMD", new List<object> { 0x87, new int[] { } } },
|
||||
{ "CSAFE_BADID_CMD", new List<object> { 0x88, new int[] { } } },
|
||||
{ "CSAFE_GETVERSION_CMD", new List<object> { 0x91, new int[] { } } },
|
||||
{ "CSAFE_GETID_CMD", new List<object> { 0x92, new int[] { } } },
|
||||
{ "CSAFE_GETUNITS_CMD", new List<object> { 0x93, new int[] { } } },
|
||||
{ "CSAFE_GETSERIAL_CMD", new List<object> { 0x94, new int[] { } } },
|
||||
{ "CSAFE_GETODOMETER_CMD", new List<object> { 0x9B, new int[] { } } },
|
||||
{ "CSAFE_GETERRORCODE_CMD", new List<object> { 0x9C, new int[] { } } },
|
||||
{ "CSAFE_GETTWORK_CMD", new List<object> { 0xA0, new int[] { } } },
|
||||
{ "CSAFE_GETHORIZONTAL_CMD", new List<object> { 0xA1, new int[] { } } },
|
||||
{ "CSAFE_GETCALORIES_CMD", new List<object> { 0xA3, new int[] { } } },
|
||||
{ "CSAFE_GETPROGRAM_CMD", new List<object> { 0xA4, new int[] { } } },
|
||||
{ "CSAFE_GETPACE_CMD", new List<object> { 0xA6, new int[] { } } },
|
||||
{ "CSAFE_GETCADENCE_CMD", new List<object> { 0xA7, new int[] { } } },
|
||||
{ "CSAFE_GETUSERINFO_CMD", new List<object> { 0xAB, new int[] { } } },
|
||||
{ "CSAFE_GETHRCUR_CMD", new List<object> { 0xB0, new int[] { } } },
|
||||
{ "CSAFE_GETPOWER_CMD", new List<object> { 0xB4, new int[] { } } },
|
||||
|
||||
// Configuration / Set Commands
|
||||
{ "CSAFE_AUTOUPLOAD_CMD", new List<object> { 0x01, new int[] { 1 } } },
|
||||
{ "CSAFE_IDDIGITS_CMD", new List<object> { 0x10, new int[] { 1 } } },
|
||||
{ "CSAFE_SETTIME_CMD", new List<object> { 0x11, new int[] { 1, 1, 1 } } },
|
||||
{ "CSAFE_SETDATE_CMD", new List<object> { 0x12, new int[] { 1, 1, 1 } } },
|
||||
{ "CSAFE_SETTIMEOUT_CMD", new List<object> { 0x13, new int[] { 1 } } },
|
||||
{ "CSAFE_SETUSERCFG1_CMD", new List<object> { 0x1A, new int[] { 0 } } },
|
||||
{ "CSAFE_SETTWORK_CMD", new List<object> { 0x20, new int[] { 1, 1, 1 } } },
|
||||
{ "CSAFE_SETHORIZONTAL_CMD", new List<object> { 0x21, new int[] { 2, 1 } } },
|
||||
{ "CSAFE_SETCALORIES_CMD", new List<object> { 0x23, new int[] { 2 } } },
|
||||
{ "CSAFE_SETPROGRAM_CMD", new List<object> { 0x24, new int[] { 1, 1 } } },
|
||||
{ "CSAFE_SETPOWER_CMD", new List<object> { 0x34, new int[] { 2, 1 } } },
|
||||
{ "CSAFE_GETCAPS_CMD", new List<object> { 0x70, new int[] { 1 } } },
|
||||
|
||||
// PM5 Specific Long Commands (Wrapper 0x1A)
|
||||
{ "CSAFE_PM_GET_WORKOUTTYPE", new List<object> { 0x89, new int[] { }, 0x1A } },
|
||||
{ "CSAFE_PM_GET_DRAGFACTOR", new List<object> { 0xC1, new int[] { }, 0x1A } },
|
||||
{ "CSAFE_PM_GET_STROKESTATE", new List<object> { 0xBF, new int[] { }, 0x1A } },
|
||||
{ "CSAFE_PM_GET_WORKTIME", new List<object> { 0xA0, new int[] { }, 0x1A } },
|
||||
{ "CSAFE_PM_GET_WORKDISTANCE", new List<object> { 0xA3, new int[] { }, 0x1A } },
|
||||
{ "CSAFE_PM_GET_ERRORVALUE", new List<object> { 0xC9, new int[] { }, 0x1A } },
|
||||
{ "CSAFE_PM_GET_WORKOUTSTATE", new List<object> { 0x8D, new int[] { }, 0x1A } },
|
||||
{ "CSAFE_PM_GET_WORKOUTINTERVALCOUNT", new List<object> { 0x9F, new int[] { }, 0x1A } },
|
||||
{ "CSAFE_PM_GET_INTERVALTYPE", new List<object> { 0x8E, new int[] { }, 0x1A } },
|
||||
{ "CSAFE_PM_GET_RESTTIME", new List<object> { 0xCF, new int[] { }, 0x1A } },
|
||||
{ "CSAFE_PM_SET_SPLITDURATION", new List<object> { 0x05, new int[] { 1, 4 }, 0x1A } }, // Fixed ID
|
||||
{ "CSAFE_PM_GET_FORCEPLOTDATA", new List<object> { 0x6B, new int[] { 1 }, 0x1A } }, // Fixed to 0x6B, 1 arg (block size)
|
||||
{ "CSAFE_PM_SET_SCREENERRORMODE", new List<object> { 0x27, new int[] { 1 }, 0x1A } },
|
||||
{ "CSAFE_PM_GET_HEARTBEATDATA", new List<object> { 0x6C, new int[] { }, 0x1A } } // FIXED: No arguments required
|
||||
};
|
||||
|
||||
public static readonly Dictionary<int, List<object>> Resp = new Dictionary<int, List<object>>
|
||||
{
|
||||
{ 0x80, new List<object> { "CSAFE_GETSTATUS_CMD", new int[] { 0 } } },
|
||||
{ 0x81, new List<object> { "CSAFE_RESET_CMD", new int[] { 0 } } },
|
||||
{ 0x82, new List<object> { "CSAFE_GOIDLE_CMD", new int[] { 0 } } },
|
||||
{ 0x83, new List<object> { "CSAFE_GOHAVEID_CMD", new int[] { 0 } } },
|
||||
{ 0x85, new List<object> { "CSAFE_GOINUSE_CMD", new int[] { 0 } } },
|
||||
{ 0x86, new List<object> { "CSAFE_GOFINISHED_CMD", new int[] { 0 } } },
|
||||
{ 0x87, new List<object> { "CSAFE_GOREADY_CMD", new int[] { 0 } } },
|
||||
{ 0x88, new List<object> { "CSAFE_BADID_CMD", new int[] { 0 } } },
|
||||
{ 0x91, new List<object> { "CSAFE_GETVERSION_CMD", new int[] { 1, 1, 1, 2, 2 } } },
|
||||
{ 0x92, new List<object> { "CSAFE_GETID_CMD", new int[] { -5 } } },
|
||||
{ 0x93, new List<object> { "CSAFE_GETUNITS_CMD", new int[] { 1 } } },
|
||||
{ 0x94, new List<object> { "CSAFE_GETSERIAL_CMD", new int[] { -9 } } },
|
||||
{ 0x9B, new List<object> { "CSAFE_GETODOMETER_CMD", new int[] { 4, 1 } } },
|
||||
{ 0x9C, new List<object> { "CSAFE_GETERRORCODE_CMD", new int[] { 3 } } },
|
||||
{ 0xA0, new List<object> { "CSAFE_GETTWORK_CMD", new int[] { 1, 1, 1 } } },
|
||||
{ 0xA1, new List<object> { "CSAFE_GETHORIZONTAL_CMD", new int[] { 2, 1 } } },
|
||||
{ 0xA3, new List<object> { "CSAFE_GETCALORIES_CMD", new int[] { 2 } } },
|
||||
{ 0xA4, new List<object> { "CSAFE_GETPROGRAM_CMD", new int[] { 1 } } },
|
||||
{ 0xA6, new List<object> { "CSAFE_GETPACE_CMD", new int[] { 2, 1 } } },
|
||||
{ 0xA7, new List<object> { "CSAFE_GETCADENCE_CMD", new int[] { 2, 1 } } },
|
||||
{ 0xAB, new List<object> { "CSAFE_GETUSERINFO_CMD", new int[] { 2, 1, 1, 1 } } },
|
||||
{ 0xB0, new List<object> { "CSAFE_GETHRCUR_CMD", new int[] { 1 } } },
|
||||
{ 0xB4, new List<object> { "CSAFE_GETPOWER_CMD", new int[] { 2, 1 } } },
|
||||
{ 0x01, new List<object> { "CSAFE_AUTOUPLOAD_CMD", new int[] { 0 } } },
|
||||
{ 0x10, new List<object> { "CSAFE_IDDIGITS_CMD", new int[] { 0 } } },
|
||||
{ 0x11, new List<object> { "CSAFE_SETTIME_CMD", new int[] { 0 } } },
|
||||
{ 0x12, new List<object> { "CSAFE_SETDATE_CMD", new int[] { 0 } } },
|
||||
{ 0x13, new List<object> { "CSAFE_SETTIMEOUT_CMD", new int[] { 0 } } },
|
||||
{ 0x1A, new List<object> { "CSAFE_SETUSERCFG1_CMD", new int[] { 0 } } },
|
||||
{ 0x20, new List<object> { "CSAFE_SETTWORK_CMD", new int[] { 0 } } },
|
||||
{ 0x21, new List<object> { "CSAFE_SETHORIZONTAL_CMD", new int[] { 0 } } },
|
||||
{ 0x23, new List<object> { "CSAFE_SETCALORIES_CMD", new int[] { 0 } } },
|
||||
{ 0x24, new List<object> { "CSAFE_SETPROGRAM_CMD", new int[] { 0 } } },
|
||||
{ 0x34, new List<object> { "CSAFE_SETPOWER_CMD", new int[] { 0 } } },
|
||||
{ 0x70, new List<object> { "CSAFE_GETCAPS_CMD", new int[] { 11 } } },
|
||||
|
||||
{ 0x1A89, new List<object> { "CSAFE_PM_GET_WORKOUTTYPE", new int[] { 1 } } },
|
||||
{ 0x1AC1, new List<object> { "CSAFE_PM_GET_DRAGFACTOR", new int[] { 1 } } },
|
||||
{ 0x1ABF, new List<object> { "CSAFE_PM_GET_STROKESTATE", new int[] { 1 } } },
|
||||
{ 0x1AA0, new List<object> { "CSAFE_PM_GET_WORKTIME", new int[] { 4, 1 } } },
|
||||
{ 0x1AA3, new List<object> { "CSAFE_PM_GET_WORKDISTANCE", new int[] { 4, 1 } } },
|
||||
{ 0x1AC9, new List<object> { "CSAFE_PM_GET_ERRORVALUE", new int[] { 2 } } },
|
||||
{ 0x1A8D, new List<object> { "CSAFE_PM_GET_WORKOUTSTATE", new int[] { 1 } } },
|
||||
{ 0x1A9F, new List<object> { "CSAFE_PM_GET_WORKOUTINTERVALCOUNT", new int[] { 1 } } },
|
||||
{ 0x1A8E, new List<object> { "CSAFE_PM_GET_INTERVALTYPE", new int[] { 1 } } },
|
||||
{ 0x1ACF, new List<object> { "CSAFE_PM_GET_RESTTIME", new int[] { 2 } } },
|
||||
{ 0x1A05, new List<object> { "CSAFE_PM_SET_SPLITDURATION", new int[] { 0 } } },
|
||||
{ 0x1A6B, new List<object> { "CSAFE_PM_GET_FORCEPLOTDATA", new int[] { -1 } } },
|
||||
{ 0x1A27, new List<object> { "CSAFE_PM_SET_SCREENERRORMODE", new int[] { 0 } } },
|
||||
{ 0x1A6C, new List<object> { "CSAFE_PM_GET_HEARTBEATDATA", new int[] { -1 } } }
|
||||
};
|
||||
}
|
||||
|
||||
public static class CSAFECommand
|
||||
{
|
||||
public static List<byte> Write(string[] arguments)
|
||||
{
|
||||
int i = 0;
|
||||
List<byte> message = new List<byte>();
|
||||
List<byte> wrapper = new List<byte>();
|
||||
int wrapped = 0;
|
||||
|
||||
while (i < arguments.Length)
|
||||
{
|
||||
string argument = arguments[i];
|
||||
int commandPropertiesIdInt = Convert.ToInt32(CSAFEDictionary.Cmd[argument][0]);
|
||||
int[] cmdProps = CSAFEDictionary.Cmd[argument][1] as int[];
|
||||
|
||||
int wrapperInt = 0;
|
||||
if (CSAFEDictionary.Cmd[argument].Count > 2)
|
||||
wrapperInt = Convert.ToInt32(CSAFEDictionary.Cmd[argument][2]);
|
||||
|
||||
List<byte> command = new List<byte>();
|
||||
|
||||
if (cmdProps.Length != 0)
|
||||
{
|
||||
for (int bytes = 0; bytes < cmdProps.Length; bytes++)
|
||||
{
|
||||
i++;
|
||||
int intValue = int.Parse(arguments[i]);
|
||||
byte[] value = IntToBytes(intValue, cmdProps[bytes]);
|
||||
command.AddRange(value);
|
||||
}
|
||||
command.Insert(0, Convert.ToByte(command.Count));
|
||||
}
|
||||
|
||||
command.Insert(0, Convert.ToByte(commandPropertiesIdInt));
|
||||
|
||||
if (wrapperInt == 0x1A)
|
||||
{
|
||||
if (wrapped == 0)
|
||||
{
|
||||
wrapper.Insert(0, Convert.ToByte(wrapperInt));
|
||||
wrapped = 1;
|
||||
}
|
||||
wrapper.AddRange(command);
|
||||
}
|
||||
else
|
||||
{
|
||||
message.AddRange(command);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (wrapped == 1)
|
||||
{
|
||||
wrapper.Insert(1, Convert.ToByte(wrapper.Count - 1));
|
||||
message.AddRange(wrapper);
|
||||
}
|
||||
|
||||
int checksum = 0;
|
||||
for (int j = 0; j < message.Count; j++)
|
||||
{
|
||||
checksum ^= message[j];
|
||||
}
|
||||
|
||||
int k = 0;
|
||||
while (k < message.Count)
|
||||
{
|
||||
if (0xF0 <= message[k] && message[k] <= 0xF3)
|
||||
{
|
||||
byte stuffValue = Convert.ToByte(message[k] & 0x03);
|
||||
message[k] = CSAFEDictionary.ByteStuffingFlag;
|
||||
message.Insert(k + 1, stuffValue);
|
||||
k++;
|
||||
}
|
||||
k++;
|
||||
}
|
||||
|
||||
if (0xF0 <= checksum && checksum <= 0xF3)
|
||||
{
|
||||
message.Add(CSAFEDictionary.ByteStuffingFlag);
|
||||
message.Add(Convert.ToByte(checksum & 0x03));
|
||||
}
|
||||
else
|
||||
{
|
||||
message.Add(Convert.ToByte(checksum));
|
||||
}
|
||||
|
||||
message.Insert(0, CSAFEDictionary.StandardFrameStartFlag);
|
||||
message.Add(CSAFEDictionary.StopFrameFlag);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
public static Dictionary<string, List<string>> Read(byte[] transmission)
|
||||
{
|
||||
Dictionary<string, List<string>> response = new Dictionary<string, List<string>>();
|
||||
List<byte> message = transmission.ToList();
|
||||
|
||||
int startFlagIndex = message.IndexOf(CSAFEDictionary.StandardFrameStartFlag);
|
||||
int extStartFlagIndex = message.IndexOf(CSAFEDictionary.ExtendedFrameStartFlag);
|
||||
int startIndex = Math.Max(startFlagIndex, extStartFlagIndex);
|
||||
int stopFlagIndex = message.IndexOf(CSAFEDictionary.StopFrameFlag);
|
||||
|
||||
if (startIndex == -1 || stopFlagIndex == -1 || stopFlagIndex < startIndex)
|
||||
return response;
|
||||
|
||||
message = message.GetRange(startIndex + 1, stopFlagIndex - startIndex - 1);
|
||||
message = CheckMessage(message);
|
||||
|
||||
if (message == null || message.Count == 0) return response;
|
||||
|
||||
int k = 0;
|
||||
int msgState = message[k];
|
||||
response["CSAFE_GETSTATUS_CMD"] = new List<string> { msgState.ToString() };
|
||||
k++;
|
||||
|
||||
// NEW: Track the end index of the PM5 wrapper block
|
||||
int wrapperEndIndex = -1;
|
||||
|
||||
while (k < message.Count)
|
||||
{
|
||||
try
|
||||
{
|
||||
int k_before = k; // Track where this specific command started
|
||||
int responseId = message[k];
|
||||
k++;
|
||||
|
||||
// Wrapper Check
|
||||
if (responseId == 0x1A)
|
||||
{
|
||||
if (k >= message.Count) break;
|
||||
int wrapperLength = message[k];
|
||||
k++;
|
||||
|
||||
if (wrapperLength > 0)
|
||||
{
|
||||
wrapperEndIndex = k + wrapperLength; // Mark the boundary of wrapped data
|
||||
|
||||
if (k < message.Count)
|
||||
{
|
||||
responseId = message[k];
|
||||
responseId = responseId | (0x1A << 8);
|
||||
k++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// NEW: If we are still reading bytes that fall inside the wrapper length,
|
||||
// automatically apply the 0x1A PM5 prefix!
|
||||
else if (k_before < wrapperEndIndex)
|
||||
{
|
||||
responseId = responseId | (0x1A << 8);
|
||||
}
|
||||
|
||||
if (!CSAFEDictionary.Resp.ContainsKey(responseId))
|
||||
{
|
||||
if (k < message.Count) k += message[k] + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
List<object> commandResponse = CSAFEDictionary.Resp[responseId];
|
||||
string commandName = commandResponse[0] as string;
|
||||
int[] commandByteCountArray = commandResponse[1] as int[];
|
||||
|
||||
List<string> result = new List<string>();
|
||||
|
||||
// SAFEGUARD: Prevent out of bounds
|
||||
if (k >= message.Count) break;
|
||||
int payloadLength = message[k];
|
||||
k++;
|
||||
|
||||
int bytesReadForCmd = 0;
|
||||
|
||||
foreach (int commandByteCount in commandByteCountArray)
|
||||
{
|
||||
if (bytesReadForCmd >= payloadLength) break;
|
||||
|
||||
int bytesToRead = commandByteCount >= 0 ? commandByteCount : (payloadLength - bytesReadForCmd);
|
||||
|
||||
if (k + bytesToRead > message.Count) break;
|
||||
|
||||
byte[] rawBytes = message.GetRange(k, bytesToRead).ToArray();
|
||||
string value = "";
|
||||
|
||||
if (commandByteCount >= 0)
|
||||
{
|
||||
int intValue = 0;
|
||||
for (int b = 0; b < rawBytes.Length; b++)
|
||||
{
|
||||
intValue |= (rawBytes[b] << (8 * b));
|
||||
}
|
||||
value = intValue.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
value = string.Join(",", rawBytes.Select(b => b.ToString()));
|
||||
}
|
||||
|
||||
result.Add(value);
|
||||
k += bytesToRead;
|
||||
bytesReadForCmd += bytesToRead;
|
||||
}
|
||||
|
||||
if (bytesReadForCmd < payloadLength)
|
||||
{
|
||||
k += (payloadLength - bytesReadForCmd);
|
||||
}
|
||||
|
||||
response[commandName] = result;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static List<byte> CheckMessage(List<byte> message)
|
||||
{
|
||||
int i = 0;
|
||||
int checksum = 0;
|
||||
|
||||
while (i < message.Count)
|
||||
{
|
||||
if (message[i] == CSAFEDictionary.ByteStuffingFlag)
|
||||
{
|
||||
if (i + 1 >= message.Count) break;
|
||||
byte stuffValue = message[i + 1];
|
||||
message.RemoveAt(i + 1);
|
||||
message[i] = Convert.ToByte(0xF0 | stuffValue);
|
||||
}
|
||||
|
||||
checksum ^= message[i];
|
||||
i++;
|
||||
}
|
||||
|
||||
if (checksum != 0) return new List<byte>();
|
||||
|
||||
if (message.Count > 0) message.RemoveAt(message.Count - 1);
|
||||
return message;
|
||||
}
|
||||
|
||||
private static byte[] IntToBytes(int value, int length)
|
||||
{
|
||||
byte[] result = new byte[length];
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
result[i] = (byte)((value >> (i * 8)) & 0xFF);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/PM5/CSAFEProtocol.cs.meta
Normal file
11
Assets/Scripts/PM5/CSAFEProtocol.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fcecce28b8a4248ac86b498638edf8ca
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
21
Assets/Scripts/PM5/PerfomanceMonitorDictionary.cs
Normal file
21
Assets/Scripts/PM5/PerfomanceMonitorDictionary.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
public static class PerformanceMonitorDictionary
|
||||
{
|
||||
// Concept2 PM5 UUIDs
|
||||
public const string DeviceUUID = "ce060000-43e5-11e4-916c-0800200c9a66";
|
||||
|
||||
// Services
|
||||
public const string DeviceInfoService = "ce060010-43e5-11e4-916c-0800200c9a66";
|
||||
public const string ControlService = "ce060020-43e5-11e4-916c-0800200c9a66";
|
||||
public const string RowingService = "ce060030-43e5-11e4-916c-0800200c9a66";
|
||||
|
||||
// Characteristics - Rowing Service
|
||||
// 0x31: Row Status (Distance, Time)
|
||||
// 0x32: Extra Status (Split, Interval)
|
||||
// 0x35: Stroke Data (Watts, SPM, Drive Info)
|
||||
// 0x3D: Multiplexed (The main firehose of data)
|
||||
public const string MultiplexedCharacteristic = "ce060080-43e5-11e4-916c-0800200c9a66";
|
||||
|
||||
// Characteristics - Control Service (CSAFE)
|
||||
public const string ControlReceiveChar = "ce060021-43e5-11e4-916c-0800200c9a66"; // Write here
|
||||
public const string ControlTransmitChar = "ce060022-43e5-11e4-916c-0800200c9a66"; // Read here
|
||||
}
|
||||
11
Assets/Scripts/PM5/PerfomanceMonitorDictionary.cs.meta
Normal file
11
Assets/Scripts/PM5/PerfomanceMonitorDictionary.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad857e139c72d41edb392b5719f88390
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
288
Assets/Scripts/PM5/PerformanceMonitorManager.cs
Normal file
288
Assets/Scripts/PM5/PerformanceMonitorManager.cs
Normal file
@@ -0,0 +1,288 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using System.Linq;
|
||||
|
||||
public class PerformanceMonitorManager : MonoBehaviour
|
||||
{
|
||||
public static PerformanceMonitorManager Instance;
|
||||
|
||||
public Action<RowingStats> OnStatsUpdated;
|
||||
public Action<List<float>> OnForceCurveUpdated;
|
||||
public Action<bool> OnConnectionStateChanged;
|
||||
public Action<string> OnLog;
|
||||
|
||||
[Serializable]
|
||||
public class RowingStats
|
||||
{
|
||||
public bool IsConnected;
|
||||
public float Distance;
|
||||
public float ElapsedTime;
|
||||
public int Watts;
|
||||
public int SPM;
|
||||
public int HeartRate;
|
||||
public int Calories;
|
||||
public List<float> CurrentForceCurve = new List<float>();
|
||||
}
|
||||
|
||||
public RowingStats Stats = new RowingStats();
|
||||
|
||||
[Header("Settings")]
|
||||
[Tooltip("10Hz is the sweet spot for BLE. Faster may cause packet collisions.")]
|
||||
[SerializeField] private float pollRate = 0.10f;
|
||||
[SerializeField] private float activityTimeout = 3.5f; // Time before we consider the user "stopped"
|
||||
private string _deviceAddress;
|
||||
private bool _isSubscribedToControl = false;
|
||||
private List<byte> _csafeBuffer = new List<byte>();
|
||||
|
||||
// Watchdog
|
||||
private float _lastDataTime = 0f;
|
||||
private int _lastStrokeState = -1;
|
||||
|
||||
public static class UUIDs
|
||||
{
|
||||
public const string Svc_Rowing = "ce060030-43e5-11e4-916c-0800200c9a66";
|
||||
public const string Char_Multiplexed = "ce060080-43e5-11e4-916c-0800200c9a66";
|
||||
public const string Svc_Control = "ce060020-43e5-11e4-916c-0800200c9a66";
|
||||
public const string Char_ControlWrite = "ce060021-43e5-11e4-916c-0800200c9a66";
|
||||
public const string Char_ControlRead = "ce060022-43e5-11e4-916c-0800200c9a66";
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null) Instance = this;
|
||||
else Destroy(gameObject);
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
InitializeBLE();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (Stats.IsConnected)
|
||||
{
|
||||
// Instead of checking SPM == 0, we check how long it's been since we got ANY multiplexed data.
|
||||
// This prevents Watts dropping to 0 during a long, slow recovery phase (like in Stone Giant mode).
|
||||
if (Time.time - _lastDataTime > activityTimeout)
|
||||
{
|
||||
if (Stats.Watts != 0 || Stats.SPM != 0)
|
||||
{
|
||||
Stats.Watts = 0;
|
||||
Stats.SPM = 0;
|
||||
OnStatsUpdated?.Invoke(Stats);
|
||||
Log("Rowing stopped (Timeout). Resetting active stats.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void InitializeBLE()
|
||||
{
|
||||
BluetoothLEHardwareInterface.Initialize(true, false, () => Log("BLE Init"), (err) => Log("Error: " + err));
|
||||
}
|
||||
|
||||
public void StartScan()
|
||||
{
|
||||
if (Stats.IsConnected) return;
|
||||
Log("Scanning...");
|
||||
BluetoothLEHardwareInterface.ScanForPeripheralsWithServices(null, (address, name) => {
|
||||
if (!string.IsNullOrEmpty(name) && (name.Contains("Concept2") || name.Contains("PM5")))
|
||||
{
|
||||
Log($"Found {name}");
|
||||
_deviceAddress = address;
|
||||
BluetoothLEHardwareInterface.StopScan();
|
||||
StartCoroutine(ConnectSequence());
|
||||
}
|
||||
}, null);
|
||||
}
|
||||
|
||||
private IEnumerator ConnectSequence()
|
||||
{
|
||||
yield return new WaitForSeconds(0.5f);
|
||||
Connect();
|
||||
}
|
||||
|
||||
private void Connect()
|
||||
{
|
||||
BluetoothLEHardwareInterface.ConnectToPeripheral(_deviceAddress, (addr) => {
|
||||
Stats.IsConnected = true;
|
||||
OnConnectionStateChanged?.Invoke(true);
|
||||
Log("Connected!");
|
||||
}, null, (addr, svc, chr) => {
|
||||
|
||||
if (svc.ToUpper().Contains("0030") && chr.ToUpper().Contains("0080"))
|
||||
BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress(_deviceAddress, svc, chr, null, OnMultiplexedDataReceived);
|
||||
|
||||
if (svc.ToUpper().Contains("0020") && chr.ToUpper().Contains("0022"))
|
||||
{
|
||||
if (!_isSubscribedToControl)
|
||||
{
|
||||
BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress(_deviceAddress, svc, chr, null, OnControlDataReceived);
|
||||
_isSubscribedToControl = true;
|
||||
StartCoroutine(CSAFEPollLoop());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private IEnumerator CSAFEPollLoop()
|
||||
{
|
||||
while (_isSubscribedToControl && Stats.IsConnected)
|
||||
{
|
||||
// 1. Get Stroke State
|
||||
// 2. Get Force Plot Data (Command 0x6B) with Block Size 32 (0x20)
|
||||
// 3. Get Power
|
||||
string[] commands = new string[]
|
||||
{
|
||||
"CSAFE_PM_GET_STROKESTATE",
|
||||
"CSAFE_PM_GET_FORCEPLOTDATA", "32",
|
||||
"CSAFE_GETPOWER_CMD",
|
||||
"CSAFE_GETHRCUR_CMD"
|
||||
};
|
||||
|
||||
byte[] payload = CSAFECommand.Write(commands).ToArray();
|
||||
|
||||
BluetoothLEHardwareInterface.WriteCharacteristic(
|
||||
_deviceAddress,
|
||||
UUIDs.Svc_Control,
|
||||
UUIDs.Char_ControlWrite,
|
||||
payload,
|
||||
payload.Length,
|
||||
true,
|
||||
(x) => {}
|
||||
);
|
||||
|
||||
yield return new WaitForSeconds(pollRate);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMultiplexedDataReceived(string addr, string chr, byte[] data)
|
||||
{
|
||||
if (data == null || data.Length < 1) return;
|
||||
_lastDataTime = Time.time;
|
||||
|
||||
int packetID = data[0];
|
||||
switch (packetID)
|
||||
{
|
||||
case 0x31:
|
||||
if (data.Length >= 7) {
|
||||
Stats.ElapsedTime = (data[1] | (data[2] << 8) | (data[3] << 16)) * 0.01f;
|
||||
Stats.Distance = (data[4] | (data[5] << 8) | (data[6] << 16)) * 0.1f;
|
||||
}
|
||||
break;
|
||||
case 0x32:
|
||||
if (data.Length >= 7) {
|
||||
if (data[6] != 255) Stats.SPM = data[6];
|
||||
}
|
||||
break;
|
||||
case 0x33:
|
||||
if (data.Length >= 7) {
|
||||
Stats.Calories = (data[5] | (data[6] << 8));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnControlDataReceived(string addr, string chr, byte[] data)
|
||||
{
|
||||
_csafeBuffer.AddRange(data);
|
||||
while (_csafeBuffer.Contains(0xF2))
|
||||
{
|
||||
int stopIdx = _csafeBuffer.IndexOf(0xF2);
|
||||
byte[] completeFrame = _csafeBuffer.GetRange(0, stopIdx + 1).ToArray();
|
||||
_csafeBuffer.RemoveRange(0, stopIdx + 1);
|
||||
ParseCSAFE(completeFrame);
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseCSAFE(byte[] frame)
|
||||
{
|
||||
try
|
||||
{
|
||||
var res = CSAFECommand.Read(frame);
|
||||
|
||||
// --- 1. STROKE STATE ---
|
||||
if (res.ContainsKey("CSAFE_PM_GET_STROKESTATE"))
|
||||
{
|
||||
// We keep this variable for debugging, but no longer use it as the curve trigger
|
||||
_lastStrokeState = int.Parse(res["CSAFE_PM_GET_STROKESTATE"][0]);
|
||||
}
|
||||
|
||||
// --- 2. FORCE CURVE PARSING ---
|
||||
if (res.ContainsKey("CSAFE_PM_GET_FORCEPLOTDATA"))
|
||||
{
|
||||
string rawCsvData = res["CSAFE_PM_GET_FORCEPLOTDATA"][0];
|
||||
string[] byteStrings = rawCsvData.Split(',');
|
||||
|
||||
if (byteStrings.Length > 0)
|
||||
{
|
||||
int bytesRead = int.Parse(byteStrings[0]);
|
||||
|
||||
// NEW BULLETPROOF TRIGGER: The PM5 sends 0 bytes during the recovery phase.
|
||||
// If bytesRead is 0, but we have data accumulated, the drive JUST finished.
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
if (Stats.CurrentForceCurve.Count > 0)
|
||||
{
|
||||
// 1. Emit an empty list to signal ForceCurveDataLogger to print the completed stroke
|
||||
OnForceCurveUpdated?.Invoke(new List<float>());
|
||||
|
||||
// 2. Clear our internal buffer to prep for the next stroke
|
||||
Stats.CurrentForceCurve.Clear();
|
||||
}
|
||||
}
|
||||
else if (bytesRead > 0 && byteStrings.Length >= bytesRead + 1)
|
||||
{
|
||||
List<float> newPoints = new List<float>();
|
||||
|
||||
// Start at index 1. Each point consists of 2 bytes.
|
||||
for (int i = 1; i < bytesRead; i += 2)
|
||||
{
|
||||
if (i + 1 >= byteStrings.Length) break;
|
||||
|
||||
// ENDIANNESS FIX: Page 91 of CSAFE docs explicitly shows LSB first, then MSB.
|
||||
int lsb = int.Parse(byteStrings[i]); // Byte 1: LSB
|
||||
int msb = int.Parse(byteStrings[i + 1]); // Byte 2: MSB
|
||||
|
||||
// Combine MSB and LSB correctly (Little Endian)
|
||||
int forceValue = (msb << 8) | lsb;
|
||||
|
||||
newPoints.Add(forceValue);
|
||||
}
|
||||
|
||||
if (newPoints.Count > 0)
|
||||
{
|
||||
Stats.CurrentForceCurve.AddRange(newPoints);
|
||||
OnForceCurveUpdated?.Invoke(Stats.CurrentForceCurve); // Emit accumulated list
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. POWER & HR ---
|
||||
if (res.ContainsKey("CSAFE_GETPOWER_CMD"))
|
||||
{
|
||||
if (int.TryParse(res["CSAFE_GETPOWER_CMD"][0], out int watts))
|
||||
Stats.Watts = watts;
|
||||
}
|
||||
|
||||
if (res.ContainsKey("CSAFE_GETHRCUR_CMD"))
|
||||
{
|
||||
if (int.TryParse(res["CSAFE_GETHRCUR_CMD"][0], out int hr))
|
||||
Stats.HeartRate = hr;
|
||||
}
|
||||
|
||||
// Emit the overall stats update
|
||||
OnStatsUpdated?.Invoke(Stats);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.Log("CSAFE Parse Error: " + e.Message);
|
||||
}
|
||||
}
|
||||
private void Log(string m) { Debug.Log("[PM5] " + m); OnLog?.Invoke(m); }
|
||||
}
|
||||
11
Assets/Scripts/PM5/PerformanceMonitorManager.cs.meta
Normal file
11
Assets/Scripts/PM5/PerformanceMonitorManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 598ae3859dc5b48b68087a6740b22b00
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Scripts/Utils.meta
Normal file
8
Assets/Scripts/Utils.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8508e66c93823466485cba7d42c1db82
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
51
Assets/Scripts/Utils/TPPCameraFollow.cs
Normal file
51
Assets/Scripts/Utils/TPPCameraFollow.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using UnityEngine;
|
||||
|
||||
public class TPPCameraFollow : MonoBehaviour
|
||||
{
|
||||
[Header("Target Tracking")]
|
||||
public Transform target; // Drag your Moving Cube here
|
||||
public Vector3 offset = new Vector3(0f, 3f, -6f); // Behind and slightly above
|
||||
|
||||
[Header("Dynamics")]
|
||||
public float smoothTime = 0.3f;
|
||||
public float dynamicFOVSpeedMultiplier = 0.5f; // How much the FOV stretches based on speed
|
||||
public float baseFOV = 60f;
|
||||
|
||||
private Vector3 _velocity = Vector3.zero;
|
||||
private Camera _cam;
|
||||
private float _currentSpeed;
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_cam = GetComponent<Camera>();
|
||||
|
||||
if (RowingInputManager.Instance != null)
|
||||
{
|
||||
RowingInputManager.Instance.OnVelocityChanged += UpdateDynamicFOV;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDynamicFOV(float speed)
|
||||
{
|
||||
_currentSpeed = speed;
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (target == null) return;
|
||||
|
||||
// 1. Smoothly follow the target's position
|
||||
Vector3 targetPosition = target.position + target.TransformDirection(offset);
|
||||
transform.position = Vector3.SmoothDamp(transform.position, targetPosition, ref _velocity, smoothTime);
|
||||
|
||||
// 2. Look at the target
|
||||
transform.LookAt(target.position + Vector3.up * 1.5f); // Look slightly above the base of the cube
|
||||
|
||||
// 3. The "Juice": Dynamically widen the FOV as you row faster (Speed Sense)
|
||||
if (_cam != null)
|
||||
{
|
||||
float targetFOV = baseFOV + (_currentSpeed * dynamicFOVSpeedMultiplier);
|
||||
_cam.fieldOfView = Mathf.Lerp(_cam.fieldOfView, targetFOV, Time.deltaTime * 2f);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Utils/TPPCameraFollow.cs.meta
Normal file
11
Assets/Scripts/Utils/TPPCameraFollow.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 80f384e7a225c4f98850a2f01f2b68ae
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
97
Assets/Scripts/Utils/UnityMainThreadDispatcher.cs
Normal file
97
Assets/Scripts/Utils/UnityMainThreadDispatcher.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using UnityEngine;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
public class UnityMainThreadDispatcher : MonoBehaviour {
|
||||
|
||||
private static readonly Queue<Action> _executionQueue = new Queue<Action>();
|
||||
|
||||
public void Update() {
|
||||
lock(_executionQueue) {
|
||||
while (_executionQueue.Count > 0) {
|
||||
_executionQueue.Dequeue().Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locks the queue and adds the IEnumerator to the queue
|
||||
/// </summary>
|
||||
/// <param name="action">IEnumerator function that will be executed from the main thread.</param>
|
||||
public void Enqueue(IEnumerator action) {
|
||||
lock (_executionQueue) {
|
||||
_executionQueue.Enqueue (() => {
|
||||
StartCoroutine (action);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locks the queue and adds the Action to the queue
|
||||
/// </summary>
|
||||
/// <param name="action">function that will be executed from the main thread.</param>
|
||||
public void Enqueue(Action action)
|
||||
{
|
||||
Enqueue(ActionWrapper(action));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locks the queue and adds the Action to the queue, returning a Task which is completed when the action completes
|
||||
/// </summary>
|
||||
/// <param name="action">function that will be executed from the main thread.</param>
|
||||
/// <returns>A Task that can be awaited until the action completes</returns>
|
||||
public Task EnqueueAsync(Action action)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
|
||||
void WrappedAction() {
|
||||
try
|
||||
{
|
||||
action();
|
||||
tcs.TrySetResult(true);
|
||||
} catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
Enqueue(ActionWrapper(WrappedAction));
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
|
||||
IEnumerator ActionWrapper(Action a)
|
||||
{
|
||||
a();
|
||||
yield return null;
|
||||
}
|
||||
|
||||
|
||||
private static UnityMainThreadDispatcher _instance = null;
|
||||
|
||||
public static bool Exists() {
|
||||
return _instance != null;
|
||||
}
|
||||
|
||||
public static UnityMainThreadDispatcher Instance() {
|
||||
if (!Exists ()) {
|
||||
throw new Exception ("UnityMainThreadDispatcher could not find the UnityMainThreadDispatcher object. Please ensure you have added the MainThreadExecutor Prefab to your scene.");
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
|
||||
void Awake() {
|
||||
if (_instance == null) {
|
||||
_instance = this;
|
||||
DontDestroyOnLoad(this.gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
void OnDestroy() {
|
||||
_instance = null;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
11
Assets/Scripts/Utils/UnityMainThreadDispatcher.cs.meta
Normal file
11
Assets/Scripts/Utils/UnityMainThreadDispatcher.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2ed9d3282e8634c038764ee594f764f8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user