using System; using System.Collections.Generic; using System.IO; using System.Reflection; using Harmony; using HutongGames.PlayMaker; using MSCLoader; using UnityEngine; namespace MSCO { public struct FsmEventMsg { public string Path; public string FsmName; public string EventName; public string State; public Vector3 Position; public byte[] Serialize() { FsmEventMsg m = this; return NetSession.Build(w => { w.Write(m.Path); w.Write(m.FsmName); w.Write(m.EventName); w.Write(m.State); w.Write(m.Position.x); w.Write(m.Position.y); w.Write(m.Position.z); }); } public static FsmEventMsg Deserialize(BinaryReader r) { return new FsmEventMsg { Path = r.ReadString(), FsmName = r.ReadString(), EventName = r.ReadString(), State = r.ReadString(), Position = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()) }; } } /// /// Überträgt Spieler-Aktionen, die in PlayMaker-FSMs ablaufen: Schrauben anziehen/lösen, /// Teile anbauen/abbauen. Dazu wird Fsm.Event per Harmony abgefangen; beim anderen /// Spieler wird dieselbe FSM in denselben Zielzustand geschaltet. /// public static class FsmSync { // Events, die direkt durch den Spieler ausgelöst werden static readonly string[] SyncedEvents = { "TIGHTEN", "UNTIGHTEN", "ASSEMBLE", "DISASSEMBLE", "REMOVE", "INSTALL", "UNINSTALL" }; // Folge-Events einer Aktion laufen auf beiden Seiten von selbst ab und dürfen nicht nochmal gesendet werden const float ChainWindow = 0.3f; public static bool Enabled; public static bool Debug; public static readonly List Outbox = new List(); static bool patched; static int applying; static float lastApplyTime = -100f; static float lastSendTime = -100f; static Fsm lastSendFsm; static readonly MethodInfo switchState = typeof(Fsm).GetMethod("SwitchState", BindingFlags.NonPublic | BindingFlags.Instance); // "GameObject-Name|FSM-Name" -> FSMs, zum Wiederfinden beim Empfänger static Dictionary> fsmCache; static float lastCacheBuild = -100f; public static void Init() { if (patched) return; patched = true; try { MethodInfo original = typeof(Fsm).GetMethod("Event", new[] { typeof(FsmEvent) }); MethodInfo prefix = typeof(FsmSync).GetMethod("EventPrefix", BindingFlags.NonPublic | BindingFlags.Static); HarmonyInstance harmony = HarmonyInstance.Create("mbm.msco.fsmsync"); harmony.Patch(original, new HarmonyMethod(prefix), null); // Klick-Interaktionen (Lichtschalter, Knöpfe, Radio ...) über Zustandswechsel erkennen MethodInfo switchPrefix = typeof(FsmSync).GetMethod("SwitchStatePrefix", BindingFlags.NonPublic | BindingFlags.Static); harmony.Patch(switchState, new HarmonyMethod(switchPrefix), null); MscoLog.Info("[MSCO] FSM sync ready"); } catch (Exception e) { MscoLog.Error("[MSCO] FSM sync patch failed: " + e); } } public static void Reset() { Outbox.Clear(); fsmCache = null; lastSendFsm = null; clickCandidates.Clear(); clickActive = false; } // ---------------------------------------------------------------- Klick-Interaktionen // Nach einem Klick wird gesammelt, welche FSMs in der Nähe den Zustand wechseln. // Das Objekt, auf das der Spieler zielt, gewinnt; gesendet wird der erste Zustand, // der nicht selbst auf Maus/Tasten/Raycast prüft (das würde beim Empfänger scheitern). const float ClickWindow = 0.4f; const float ClickRange = 4f; static readonly string[] InputActionWords = { "Mouse", "Raycast", "GetKey", "GetButton", "Input", "Pick", "Axis" }; class ClickCandidate { public Fsm Fsm; public float AimDistance; public readonly List States = new List(); } static readonly Dictionary clickCandidates = new Dictionary(); static bool clickActive; static float clickTime; static int lastClickCheckFrame = -1; static bool useKeyAvailable = true; static bool ClickThisFrame() { if (Input.GetMouseButtonDown(0) || Input.GetMouseButtonDown(1)) return true; if (!useKeyAvailable) return false; try { return cInput.GetKeyDown("Use"); } catch { useKeyAvailable = false; return false; } } static void SwitchStatePrefix(Fsm __instance, FsmState toState) { if (!Enabled || applying > 0 || toState == null) return; try { float now = Time.realtimeSinceStartup; if (now - lastApplyTime < ChainWindow) return; if (!clickActive && lastClickCheckFrame != Time.frameCount) { lastClickCheckFrame = Time.frameCount; if (ClickThisFrame()) { clickActive = true; clickTime = now; clickCandidates.Clear(); } } if (!clickActive) return; ClickCandidate c; if (!clickCandidates.TryGetValue(__instance, out c)) { float aim; if (!IsAimedAt(__instance, out aim)) return; c = new ClickCandidate { Fsm = __instance, AimDistance = aim }; clickCandidates[__instance] = c; } c.States.Add(toState); } catch (Exception e) { MscoLog.Error("[MSCO] Click capture error: " + e.Message); } } static bool IsAimedAt(Fsm fsm, out float aimDistance) { aimDistance = float.MaxValue; GameObject go = fsm.GameObject; Camera cam = Camera.main; if (go == null || cam == null) return false; string root = go.transform.root.name; if (root == "PLAYER" || root.StartsWith("MSCO_")) return false; Vector3 toObject = go.transform.position - cam.transform.position; if (toObject.magnitude > ClickRange || Vector3.Dot(toObject, cam.transform.forward) < 0f) return false; aimDistance = Vector3.Cross(cam.transform.forward, toObject).magnitude; // Abstand zur Blickachse return true; } /// Jedes Frame vom Manager aufrufen: abgeschlossene Klicks auswerten. public static void Tick() { if (!clickActive || Time.realtimeSinceStartup - clickTime < ClickWindow) return; clickActive = false; // Schrauben/Montage wurden schon über die Events gesendet if (Time.realtimeSinceStartup - lastSendTime < ClickWindow + ChainWindow) { clickCandidates.Clear(); return; } ClickCandidate best = null; foreach (ClickCandidate c in clickCandidates.Values) if (best == null || c.AimDistance < best.AimDistance) best = c; clickCandidates.Clear(); if (best == null || best.Fsm.GameObject == null) return; FsmState target = null; foreach (FsmState s in best.States) if (!ChecksInput(s)) { target = s; break; } if (target == null) return; GameObject go = best.Fsm.GameObject; var msg = new FsmEventMsg { Path = BuildPath(go.transform), FsmName = best.Fsm.Name, EventName = "CLICK", State = target.Name, Position = go.transform.position }; Outbox.Add(msg); if (Debug) MscoLog.Info("[MSCO] >> CLICK " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]"); } /// Prüft der Zustand nur Maus/Tasten/Raycast (z.B. "Maus zeigt auf den Griff")? public static bool ChecksInputPublic(FsmState state) { return ChecksInput(state); } static bool ChecksInput(FsmState state) { if (!state.ActionsLoaded) state.LoadActions(); if (state.Actions == null) return false; foreach (FsmStateAction action in state.Actions) { if (action == null) continue; string type = action.GetType().Name; foreach (string word in InputActionWords) if (type.Contains(word)) return true; } return false; } // ---------------------------------------------------------------- Senden static void EventPrefix(Fsm __instance, FsmEvent fsmEvent) { if (!Enabled || applying > 0 || fsmEvent == null) return; if (Array.IndexOf(SyncedEvents, fsmEvent.Name) < 0) return; try { float now = Time.realtimeSinceStartup; if (now - lastApplyTime < ChainWindow) return; // Folge einer Remote-Aktion if (__instance != lastSendFsm && now - lastSendTime < ChainWindow) return; // Folge einer eigenen Aktion GameObject go = __instance.GameObject; if (go == null || go.transform.root.name == "PLAYER") return; string target = FindTargetState(__instance, fsmEvent.Name); if (target == null) return; // Event bewirkt im aktuellen Zustand nichts lastSendTime = now; lastSendFsm = __instance; var msg = new FsmEventMsg { Path = BuildPath(go.transform), FsmName = __instance.Name, EventName = fsmEvent.Name, State = target, Position = go.transform.position }; Outbox.Add(msg); if (Debug) MscoLog.Info("[MSCO] >> " + msg.EventName + " " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]"); } catch (Exception e) { MscoLog.Error("[MSCO] FSM capture error: " + e.Message); } } static string FindTargetState(Fsm fsm, string eventName) { FsmState active = fsm.ActiveState; if (active != null) foreach (FsmTransition t in active.Transitions) if (t.EventName == eventName) return t.ToState; foreach (FsmTransition t in fsm.GlobalTransitions) if (t.EventName == eventName) return t.ToState; return null; } // ---------------------------------------------------------------- Empfangen public static void Apply(FsmEventMsg msg) { PlayMakerFSM fsm = Resolve(msg); if (fsm == null) { if (Debug) MscoLog.Warning("[MSCO] << not found: " + msg.Path + " [" + msg.FsmName + "]"); return; } FsmState state = fsm.Fsm.GetState(msg.State); if (state == null || switchState == null) return; if (Debug) MscoLog.Info("[MSCO] << " + msg.EventName + " " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]"); applying++; lastApplyTime = Time.realtimeSinceStartup; try { switchState.Invoke(fsm.Fsm, new object[] { state }); } catch (Exception e) { MscoLog.Error("[MSCO] FSM apply error: " + e.Message); } finally { applying--; } } /// FSM in einen Zustand schalten, ohne dass das als eigene Aktion erkannt und gesendet wird. public static void SwitchStateSilently(PlayMakerFSM fsm, FsmState state) { if (switchState == null) return; applying++; lastApplyTime = Time.realtimeSinceStartup; try { switchState.Invoke(fsm.Fsm, new object[] { state }); } catch (Exception e) { MscoLog.Error("[MSCO] FSM switch error: " + e.Message); } finally { applying--; } } /// Pfad eines Objekts, auf allen Rechnern gleich (siehe BuildPath). public static string PathOf(Transform t) { return BuildPath(t); } static PlayMakerFSM Resolve(FsmEventMsg msg) { string goName = LastSegmentName(msg.Path); PlayMakerFSM found = FindInCache(goName, msg); if (found == null && Time.realtimeSinceStartup - lastCacheBuild > 1f) { BuildCache(); // neu aktivierte Objekte (z.B. Schrauben eines gerade montierten Teils) found = FindInCache(goName, msg); } return found; } static PlayMakerFSM FindInCache(string goName, FsmEventMsg msg) { if (fsmCache == null) return null; List list; if (!fsmCache.TryGetValue(goName + "|" + msg.FsmName, out list)) return null; // Gleicher Pfad; bei mehreren Treffern das nächstgelegene Objekt PlayMakerFSM best = null; float bestDist = float.MaxValue; foreach (PlayMakerFSM fsm in list) { if (fsm == null || BuildPath(fsm.transform) != msg.Path) continue; float d = (fsm.transform.position - msg.Position).sqrMagnitude; if (d < bestDist) { bestDist = d; best = fsm; } } return best; } static void BuildCache() { lastCacheBuild = Time.realtimeSinceStartup; fsmCache = new Dictionary>(); foreach (PlayMakerFSM fsm in SceneCache.Fsms(1f)) { if (fsm == null) continue; string key = fsm.gameObject.name + "|" + fsm.FsmName; List list; if (!fsmCache.TryGetValue(key, out list)) fsmCache[key] = list = new List(); list.Add(fsm); } } // ---------------------------------------------------------------- Pfade /// /// "SATSUMA(557kg, 248)|Body#0|alternator(Clone)#0|Bolts#0|bolt#2" - Name plus Nummer unter /// gleichnamigen Geschwistern. Beim Wurzelobjekt nur der Name, Mehrdeutigkeit löst die Position. /// static string BuildPath(Transform t) { var segments = new List(); for (; t != null; t = t.parent) { if (t.parent == null) { segments.Add(t.name); break; } int ordinal = 0; Transform parent = t.parent; for (int i = 0; i < parent.childCount; i++) { Transform sibling = parent.GetChild(i); if (sibling == t) break; if (sibling.name == t.name) ordinal++; } segments.Add(t.name + "#" + ordinal); } segments.Reverse(); return string.Join("|", segments.ToArray()); } static string LastSegmentName(string path) { string last = path.Substring(path.LastIndexOf('|') + 1); int hash = last.LastIndexOf('#'); return hash >= 0 && path.Contains("|") ? last.Substring(0, hash) : last; } } }