using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json.Linq; using UnityEngine; namespace HotUpdate.Gameplay { /// Independent review player for a C++ generated immutable event trace. Never computes damage or rewards. public sealed class SkillSlicePlayer : MonoBehaviour { [Serializable] public sealed class View { public int id; public Transform root, muzzle; public Animator animator; public TextMesh label; [Range(0, 1)] public float impact = .5f; [NonSerialized] public int hp, maxHp, shield, cast, start, release, end, lastMove; [NonSerialized] public bool casting, dead, moving; [NonSerialized] public float idleClock; } [Serializable] public sealed class Pool { public ParticleSystem[] instances; [NonSerialized] public int next; } [SerializeField] TextAsset _trace; [SerializeField] View[] _views; // Charge, projectile, fire impact, stone impact, shield. Authored in a prefab, no runtime construction. [SerializeField] Pool[] _effects; [SerializeField] AudioClip[] _clips; [SerializeField] AudioSource[] _voices; [SerializeField] EnemyCombatPresentation _enemyFeedback; [SerializeField] TextMesh _status; [SerializeField] bool _autoPlay = true; sealed class Lease { public ParticleSystem ps; public int generation; } sealed class Shot { public Lease lease; public View target; public Vector3 start; public float born, arrival; } readonly Dictionary _charges = new Dictionary(); readonly Dictionary _shields = new Dictionary(); readonly Dictionary _generations = new Dictionary(); readonly List _shots = new List(); JArray _events; JObject _document; int _cursor, _generation, _voice; int[] _voiceCasts; float _clock, _speed = 1; bool _paused, _loaded; public bool Finished { get; private set; } public int ConsumedEvents => _cursor; public int EventCount => _events?.Count ?? 0; public int PlayedSounds { get; private set; } public int ActiveShots => _shots.Count; public int ActiveCharges => _charges.Count; public int ActiveShields => _shields.Count; public float Clock => _clock; public View[] Views => _views; public bool Won => (bool)_document["won"]; public bool FinalStateMatches => _document != null && ((JArray)_document["final"]).All(t => { var v=Find((int)t["id"]); return v.hp==(int)t["hp"] && v.shield==(int)t["shield"] && Mathf.Abs(v.root.localPosition.z-(int)t["z"]/1000f)<.0001f && v.dead==((int)t["hp"]<=0||((bool?)t["exited"]??false)); }); View Find(int id) => Array.Find(_views, v => v.id == id) ?? throw new InvalidOperationException("Missing trace entity " + id); void OnEnable() { PrototypeClientPrefs.AudioPreferencesChanged += AudioChanged; if (_autoPlay) Restart(); } void OnDisable() { PrototypeClientPrefs.AudioPreferencesChanged -= AudioChanged; Clear(); _loaded=false; } void AudioChanged() { if (!PrototypeClientPrefs.Sfx) foreach (var v in _voices) v.Stop(); } public void Restart() { Clear(); _document=JObject.Parse(_trace.text); if ((int)_document["schema"]!=1 || (string)_document["kind"]!="isolated_skill_slice" || (int)_document["tickMs"]!=50) throw new InvalidOperationException("Unsupported skill trace"); _events=(JArray)_document["events"]; int lastTick=-1; for(int i=0;i<_events.Count;i++) {int tick=(int)_events[i]["tick"];if((int)_events[i]["seq"]!=i||tick lease!=null && _generations.TryGetValue(lease.ps,out int n) && n==lease.generation; void Stop(Lease lease) {if(Current(lease)){lease.ps.Stop(true,ParticleSystemStopBehavior.StopEmittingAndClear);_generations.Remove(lease.ps);}} Lease Effect(int kind,Vector3 position) { var pool=_effects[kind];var ps=pool.instances[pool.next++%pool.instances.Length];ps.Stop(true,ParticleSystemStopBehavior.StopEmittingAndClear);ps.transform.position=position; foreach(var child in ps.GetComponentsInChildren()){var main=child.main;main.simulationSpeed=_speed;} var lease=new Lease{ps=ps,generation=++_generation};_generations[ps]=lease.generation;ps.Play(true); foreach(var child in ps.GetComponentsInChildren())if(child.main.loop)child.Emit(1); return lease; } void Sound(int kind,int cast=0) { if(!PrototypeClientPrefs.Sfx||_paused)return; int index=_voice++%_voices.Length;var source=_voices[index];source.Stop();source.clip=_clips[kind];source.pitch=1;source.volume=.7f/_voices.Length;_voiceCasts[index]=cast;source.Play();PlayedSounds++; } void EndCharge(int cast) { if(_charges.TryGetValue(cast,out var lease)){Stop(lease);_charges.Remove(cast);} for(int i=0;i<_voices.Length;i++)if(_voiceCasts[i]==cast&&_voices[i].clip==_clips[0])_voices[i].Stop(); } public void SetPlayback(bool paused,float speed) { if(speed!=1&&speed!=2)throw new ArgumentOutOfRangeException(nameof(speed)); if(_paused==paused&&_speed==speed)return; foreach(var pool in _effects)foreach(var ps in pool.instances) { foreach(var child in ps.GetComponentsInChildren()){var main=child.main;main.simulationSpeed=speed;} if(paused&&!_paused)ps.Pause(true);else if(!paused&&_paused&&ps.IsAlive(true))ps.Play(true); } if(paused!=_paused)foreach(var source in _voices){if(paused)source.Pause();else source.UnPause();} _enemyFeedback?.SetPlayback(paused,speed);_paused=paused;_speed=speed;if(_loaded)Render(); } void Update() { if(_loaded)Advance(Time.unscaledDeltaTime); } /// Also permits deterministic low-frame-rate replay in verification. public void Advance(float seconds) { if(seconds<0||float.IsNaN(seconds)||float.IsInfinity(seconds))throw new ArgumentOutOfRangeException(nameof(seconds)); if(!_loaded||_paused||Finished)return; _clock+=seconds*_speed;Consume();Render(); } void Consume() { while(_cursor<_events.Count&&(int)_events[_cursor]["tick"]*.05f<=_clock+.00001f) { var e=(JObject)_events[_cursor++];string kind=(string)e["kind"],skill=(string)e["skill"]??"";int cast=(int?)e["cast"]??0; var source=e["source"]==null?null:Find((int)e["source"]);var target=e["target"]==null?null:Find((int)e["target"]); float eventTime=(int)e["tick"]*.05f;bool fresh=_clock-eventTime<.15f; if(kind=="cast_start") { source.cast=cast;source.start=(int)e["tick"];source.release=(int)e["release"];source.end=(int)e["end"];source.casting=true; if(source.id==1&&fresh){_charges[cast]=Effect(0,source.muzzle.position);Sound(0,cast);} } else if(kind=="release") {EndCharge(cast);if(source.id==1&&fresh)Sound(1);} else if(kind=="cancel"||kind=="cast_end") {EndCharge(cast);if(source.cast==cast)source.casting=false;} else if(kind=="move") {var p=target.root.localPosition;p.z=(int)e["z"]/1000f;target.root.localPosition=p;target.moving=true;target.lastMove=(int)e["tick"];} else if(kind=="projectile") { float arrival=(int)e["arrival"]*.05f; if(arrival>_clock){var start=source.muzzle.position;_shots.Add(new Shot{lease=Effect(1,start),target=target,start=start,born=eventTime,arrival=arrival});} } else if(kind=="hit") { target.hp=(int)e["hp"];target.shield=(int)e["shield"]; if(fresh) { if(source.id==1){Effect(2,target.muzzle.position);Sound(2);} else if(source.id==0){Effect(3,target.muzzle.position);Sound(3);} else _enemyFeedback?.Play(new PrototypeBattle.CombatCue(PrototypeBattle.CueKind.EnemyAttack,0,transform.InverseTransformPoint(target.muzzle.position),Vector3.back,(int)e["amount"]==0,transform.InverseTransformPoint(source.muzzle.position))); } if(target.shield<=0&&_shields.TryGetValue(target.id,out var shield)){Stop(shield);_shields.Remove(target.id);} } else if(kind=="shield") { target.shield=(int)e["shield"];if(_shields.TryGetValue(target.id,out var old))Stop(old); if((int)e["expires"]*.05f>_clock){_shields[target.id]=Effect(4,target.muzzle.position);if(fresh)Sound(4);} } else if(kind=="shield_expired") {target.shield=0;if(_shields.TryGetValue(target.id,out var shield)){Stop(shield);_shields.Remove(target.id);}} else if(kind=="death"||kind=="exit") {target.dead=true;if(kind=="death")target.hp=0;target.casting=false;EndCharge(target.cast);if(_shields.TryGetValue(target.id,out var shield)){Stop(shield);_shields.Remove(target.id);}} else if(kind=="battle_end") {Finished=true;Clear();} } } void Render() { foreach(var v in _views) { v.root.gameObject.SetActive(!v.dead); if(v.label!=null)v.label.text=(v.id==0?"RockBall":v.id==1?"FireCat":"Hornlet")+" "+Mathf.CeilToInt(v.hp/1000f)+"/"+Mathf.CeilToInt(v.maxHp/1000f)+(v.shield>0?" Shield "+Mathf.CeilToInt(v.shield/1000f):""); if(v.animator==null||v.dead)continue; float tick=_clock*20,normalized; if(v.casting&&tick=100&&v.moving&&tick-v.lastMove<2;v.animator.SetInteger("Motion",walk?1:0);v.animator.Play(walk?"Walk":"Idle",0,_clock/2%1);} v.animator.Update(0); } foreach(var pair in _charges)if(Current(pair.Value)){var v=_views.FirstOrDefault(x=>x.cast==pair.Key);if(v!=null)pair.Value.ps.transform.position=v.muzzle.position;} foreach(var pair in _shields)if(Current(pair.Value))pair.Value.ps.transform.position=Find(pair.Key).muzzle.position; for(int i=_shots.Count-1;i>=0;i--) { var shot=_shots[i];if(_clock>=shot.arrival||shot.target.dead||!Current(shot.lease)){Stop(shot.lease);_shots.RemoveAt(i);continue;} float progress=Mathf.InverseLerp(shot.born,shot.arrival,_clock); shot.lease.ps.transform.position=Vector3.Lerp(shot.start,shot.target.muzzle.position,progress)+Vector3.up*Mathf.Sin(progress*Mathf.PI); } if(_status!=null)_status.text="Skill slice "+(_paused?"Paused":Finished?Won?"Victory":"Finished":_speed+"x")+" "+_clock.ToString("F2")+"s\n"+"C++ trace / "+_cursor+" events"; } } }