using System; using System.Collections.Generic; using System.IO; using Mirror; using UnityEngine; /// /// Server-only match recorder. Samples poses every FixedUpdate; flushes JSON to disk on stop. /// Admin playback must interpolate by time (Hermite using velocity) — never snap one FixedUpdate sample per render frame. /// public class ReplayRecorder : MonoBehaviour { public static ReplayRecorder Instance { get; private set; } public static bool IsRecording => Instance != null && Instance._recording && !Instance._flushed; bool _recording; bool _flushed; int _matchId; bool _isPractice; float _startFixedTime; float _duration; readonly List _entities = new List(); readonly List _bodies = new List(); readonly Dictionary _instanceIdToEntityId = new Dictionary(); readonly List _frames = new List(); readonly List _events = new List(); /// True if recording started (or already recording this session). public static bool StartRecording(int matchId, GameManager gm) { if (!NetworkServer.active || gm == null) return false; if (Instance == null) { var go = new GameObject("ReplayRecorder"); DontDestroyOnLoad(go); Instance = go.AddComponent(); } Instance.Begin(matchId, gm); return true; } public static void StopAndFlush() { if (Instance != null) Instance.Flush(); } public static void RecordHit(string kind, float vol, Component source) { if (!IsRecording) return; Instance.AddHit(kind, vol, source); } public static void RecordGoal(Team concedingTeam, int redScore, int blueScore, bool kickoff) { if (!IsRecording) return; Instance.AddEvent(new ReplayEvent { t = Instance.CurrentT(), type = "goal", kind = "", vol = 0f, id = -1, team = concedingTeam.ToString(), redScore = redScore, blueScore = blueScore, kickoff = kickoff }); } public static void RecordReset(bool kickoff) { if (!IsRecording) return; Instance.AddEvent(new ReplayEvent { t = Instance.CurrentT(), type = "reset", kind = "", vol = 0f, id = -1, team = "", redScore = 0, blueScore = 0, kickoff = kickoff }); } public static bool TryGetEntityId(Component source, out int entityId) { entityId = -1; if (Instance == null || source == null) return false; return Instance._instanceIdToEntityId.TryGetValue(source.gameObject.GetInstanceID(), out entityId); } void Begin(int matchId, GameManager gm) { if (_recording && !_flushed) Flush(); // Practice / tutorial host uses DedicatedMatchId 0 — mint a unique id so files don't overwrite. _isPractice = matchId <= 0 || GameTutorialManager.tutorialModeEnabled; if (matchId <= 0) matchId = GeneratePracticeMatchId(); _matchId = matchId; _recording = true; _flushed = false; _startFixedTime = Time.fixedTime; _duration = 0f; _entities.Clear(); _bodies.Clear(); _instanceIdToEntityId.Clear(); _frames.Clear(); _events.Clear(); // Keep log + replay siblings: Logs/{matchId}.txt and Logs/{matchId}.json if (_isPractice) Logger.SetFileName(_matchId.ToString()); BuildEntities(gm); string mode = _isPractice ? "practice" : "match"; Logger.Log($"ReplayRecorder started ({mode}) id {_matchId} with {_entities.Count} entities"); } /// Negative unix timestamp so practice ids never collide with positive matchmaker ids. static int GeneratePracticeMatchId() { long unix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (unix > int.MaxValue) unix %= int.MaxValue; int id = -(int)unix; return id == 0 ? -1 : id; } void BuildEntities(GameManager gm) { if (gm.ball != null) { RegisterEntity(gm.ball.gameObject, gm.ball, "ball", ""); } var sorted = new List(gm.pucks); sorted.Sort((a, b) => { int teamCmp = a.team.CompareTo(b.team); if (teamCmp != 0) return teamCmp; int xCmp = a.transform.position.x.CompareTo(b.transform.position.x); if (xCmp != 0) return xCmp; return a.transform.position.y.CompareTo(b.transform.position.y); }); foreach (Puck puck in sorted) { if (puck == null || puck.rb == null) continue; RegisterEntity(puck.gameObject, puck.rb, "puck", puck.team.ToString()); } } void RegisterEntity(GameObject go, Rigidbody2D rb, string type, string team) { int id = _entities.Count; _entities.Add(new ReplayEntity { id = id, type = type, team = team ?? "" }); _bodies.Add(rb); _instanceIdToEntityId[go.GetInstanceID()] = id; } float CurrentT() => Time.fixedTime - _startFixedTime; void FixedUpdate() { if (!_recording || _flushed || !NetworkServer.active) return; int n = _bodies.Count; if (n == 0) return; var frame = new ReplayFrame { t = CurrentT(), x = new float[n], y = new float[n], vx = new float[n], vy = new float[n] }; for (int i = 0; i < n; i++) { Rigidbody2D rb = _bodies[i]; if (rb == null) continue; Vector2 pos = rb.position; Vector2 vel = rb.linearVelocity; frame.x[i] = pos.x; frame.y[i] = pos.y; frame.vx[i] = vel.x; frame.vy[i] = vel.y; } _duration = frame.t; _frames.Add(frame); } void AddHit(string kind, float vol, Component source) { int entityId = -1; if (source != null) _instanceIdToEntityId.TryGetValue(source.gameObject.GetInstanceID(), out entityId); AddEvent(new ReplayEvent { t = CurrentT(), type = "hit", kind = kind ?? "", vol = vol, id = entityId, team = "", redScore = 0, blueScore = 0, kickoff = false }); } void AddEvent(ReplayEvent ev) { _events.Add(ev); } void Flush() { if (!_recording || _flushed) return; _flushed = true; _recording = false; _duration = Mathf.Max(_duration, CurrentT()); var file = new ReplayFile { version = 1, matchId = _matchId, isPractice = _isPractice, fixedDeltaTime = Time.fixedDeltaTime, duration = _duration, entities = _entities.ToArray(), frames = _frames.ToArray(), events = _events.ToArray() }; try { string path = ResolveReplayFilePath(); string json = JsonUtility.ToJson(file); File.WriteAllText(path, json); Logger.Log($"ReplayRecorder wrote {path} ({_frames.Count} frames, {_events.Count} events, duration {_duration:F2}s)"); } catch (Exception e) { Logger.Log("ReplayRecorder flush failed: " + e.Message); } } /// /// Same directory and basename as the match log, extension .json /// (e.g. Logs/228.txtLogs/228.json). /// string ResolveReplayFilePath() { string expectedName = _matchId.ToString(); string logPath = Logger.instance != null ? Logger.instance.LogFilePath : null; if (!string.IsNullOrEmpty(logPath)) { string logBase = Path.GetFileNameWithoutExtension(logPath); if (logBase == expectedName) return Path.ChangeExtension(logPath, ".json"); } return Path.Combine(Logger.GetLogsDirectory(), expectedName + ".json"); } void OnDestroy() { if (Instance == this) { Flush(); Instance = null; } } }