- VEHICLE: INSTALL for parts that are not installed - runs the game's own assembly (trigger "Assembly" like on load), so the part sits exactly on its pivot; synced to other players via FsmSync (MSCO_INSTALL) - WORLD: time buttons (2-hour steps via the sun FSM's load path) and CLEAR / RAIN / THUNDERSTORM (weather front moved above the admin); sent to everyone as mod message "msco.world" (no server update needed) - JOIN from the server browser/website connects right away in the main menu; server mods still ask before downloading Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
399 lines
16 KiB
C#
399 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Reflection;
|
|
using Harmony;
|
|
using HutongGames.PlayMaker;
|
|
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())
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ü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.
|
|
/// </summary>
|
|
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<FsmEventMsg> Outbox = new List<FsmEventMsg>();
|
|
|
|
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<string, List<PlayMakerFSM>> 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<FsmState> States = new List<FsmState>();
|
|
}
|
|
|
|
static readonly Dictionary<Fsm, ClickCandidate> clickCandidates = new Dictionary<Fsm, ClickCandidate>();
|
|
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)
|
|
{
|
|
PerfStats.SwitchStateCalls++;
|
|
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;
|
|
}
|
|
|
|
/// <summary>Jedes Frame vom Manager aufrufen: abgeschlossene Klicks auswerten.</summary>
|
|
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 + "]");
|
|
}
|
|
|
|
/// <summary>Prüft der Zustand nur Maus/Tasten/Raycast (z.B. "Maus zeigt auf den Griff")?</summary>
|
|
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)
|
|
{
|
|
PerfStats.EventCalls++;
|
|
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;
|
|
}
|
|
if (msg.EventName == VehicleAdmin.InstallEvent) { VehicleAdmin.InstallRemote(fsm); return; } // Admin hat ein Teil eingebaut
|
|
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--; }
|
|
}
|
|
|
|
/// <summary>FSM in einen Zustand schalten, ohne dass das als eigene Aktion erkannt und gesendet wird.</summary>
|
|
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--; }
|
|
}
|
|
|
|
/// <summary>Pfad eines Objekts, auf allen Rechnern gleich (siehe BuildPath).</summary>
|
|
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<PlayMakerFSM> 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<string, List<PlayMakerFSM>>();
|
|
foreach (PlayMakerFSM fsm in SceneCache.Fsms(1f))
|
|
{
|
|
if (fsm == null) continue;
|
|
string key = fsm.gameObject.name + "|" + fsm.FsmName;
|
|
List<PlayMakerFSM> list;
|
|
if (!fsmCache.TryGetValue(key, out list)) fsmCache[key] = list = new List<PlayMakerFSM>();
|
|
list.Add(fsm);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Pfade
|
|
|
|
/// <summary>
|
|
/// "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.
|
|
/// </summary>
|
|
static string BuildPath(Transform t)
|
|
{
|
|
var segments = new List<string>();
|
|
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;
|
|
}
|
|
}
|
|
}
|