// Portfolio reconstruction, 2026. Not original production source. // Based on the Notion parachute-attraction CSV ghost replay description. using System; using System.Globalization; using System.Collections.Generic; using UnityEngine; public sealed class GhostReplay : MonoBehaviour { [Serializable] public struct Sample { public float time; public Vector3 position; public Quaternion rotation; } [SerializeField] TextAsset csv; // time,x,y,z,qx,qy,qz,qw; header required [SerializeField] Transform player; [SerializeField, Min(1)] float catchUpDistance = 40; [SerializeField, Min(0)] float speed = 1; readonly List samples = new List(); float clock; int cursor; void Start() { try { Load(csv == null ? null : csv.text); } catch (Exception error) { Debug.LogError(error.Message, this); enabled = false; } } public void Load(string text) { if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("CSV is empty."); var parsed = new List(); var rows = text.Split('\n'); for (int i = 1; i < rows.Length; i++) { if (string.IsNullOrWhiteSpace(rows[i])) continue; var cells = rows[i].Trim().Split(','); if (cells.Length != 8) throw new FormatException("Expected 8 columns at row " + (i + 1)); float[] v = new float[8]; for (int j = 0; j < v.Length; j++) if (!float.TryParse(cells[j], NumberStyles.Float, CultureInfo.InvariantCulture, out v[j]) || float.IsNaN(v[j]) || float.IsInfinity(v[j])) throw new FormatException("Invalid finite number at row " + (i + 1)); if (v[0] < 0 || (parsed.Count > 0 && v[0] <= parsed[parsed.Count - 1].time)) throw new FormatException("Timestamps must increase strictly."); var q = new Quaternion(v[4], v[5], v[6], v[7]); if (Quaternion.Dot(q, q) < 0.000001f) throw new FormatException("Zero quaternion."); parsed.Add(new Sample { time = v[0], position = new Vector3(v[1], v[2], v[3]), rotation = q.normalized }); } if (parsed.Count < 2) throw new FormatException("At least two samples are required."); samples.Clear(); samples.AddRange(parsed); cursor = 0; clock = samples[0].time; Apply(); } void Update() { if (samples.Count < 2) return; clock = Mathf.Min(clock + Time.deltaTime * Mathf.Max(0, speed), samples[samples.Count - 1].time); Apply(); if (player != null && (transform.position - player.position).sqrMagnitude > catchUpDistance * catchUpDistance) SeekNearest(player.position); } public void SeekNearest(Vector3 position) { if (samples.Count == 0) return; int nearest = 0; float best = float.PositiveInfinity; for (int i = 0; i < samples.Count; i++) { float distance = (samples[i].position - position).sqrMagnitude; if (distance < best) { best = distance; nearest = i; } } clock = samples[nearest].time; cursor = Mathf.Min(nearest, samples.Count - 2); Apply(); } void Apply() { while (cursor < samples.Count - 2 && samples[cursor + 1].time < clock) cursor++; Sample a = samples[cursor], b = samples[cursor + 1]; float t = Mathf.InverseLerp(a.time, b.time, clock); transform.SetPositionAndRotation(Vector3.Lerp(a.position, b.position, t), Quaternion.Slerp(a.rotation, b.rotation, t)); } }