Satsuma: sync part state from CARPARTS/Database, paint, late-installed parts, more controls

- VehicleStateSync also tracks the CARPARTS and Database roots (wear, damaged
  parts, tire wear, oilpan oil, battery charge, carb/ignition settings,
  suspension, odometer); the Satsuma's driver is authoritative for them
- Paint colors (FsmColor) as four float channels (compatible with old servers)
  and applied to the part's material
- FSMs are re-examined when a part is installed/removed (path changes);
  before, anything not on the car at the first scan was never synced
- ControlSync: handbrake, sun visors, seat recline, seat belt, CD player,
  interior light; pending states for controls not found yet
- Late joiners: job/shop values and switch states from the server snapshot;
  P2P host resends all switch states every 15 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 14:10:42 +02:00
co-authored by Claude Opus 5.5
parent 0b2834282d
commit 3b83644ce9
6 changed files with 221 additions and 34 deletions
+120 -19
View File
@@ -34,9 +34,17 @@ namespace MSCO
static readonly string[] Excluded =
{
"install", "bolt", "tight", "detach", "attach", "rpm", "speed", "steer", "throttle", "gear", "raycast", "distance",
"leak", "rate"
"leak", "rate",
// Spieler-Werte (Database/PlayerDatabase: Schmutz, Verbrennungen ...) sind pro Spieler; Fluch-Geräusche,
// Ton-Verzerrung und Anzeige-Hilfswerte (GUI) rechnet jeder selbst
"player", "swear", "distortion", "gui"
};
// Wurzeln außer den Fahrzeugen: Satsuma-Teile (lose und angebaut) und ihre Datenbank (Verschleiß, beschädigt,
// Lack, Füllstände ...). Maßgeblich ist dort wie beim Satsuma selbst, wer den Satsuma gerade fährt.
static readonly string[] PartRoots = { "CARPARTS", "Database" };
const string PartOwnerPrefix = "SATSUMA";
const float ScanInterval = 10f;
const float CheckInterval = 0.5f;
const int MaxPerMessage = 200;
@@ -47,7 +55,9 @@ namespace MSCO
public string Name; // Pfad|FSM|Variable (für die Diagnose "vars")
public string Root;
public NamedVariable Variable;
public byte Type; // 1 float, 2 int, 3 bool
public byte Type; // 1 float, 2 int, 3 bool (übertragen); Farben als 4 einzelne Kanäle vom Typ 1
public int Channel = -1; // FsmColor: 0-3 = r/g/b/a
public PlayMakerFSM Fsm;
public float LastF;
public int LastI;
public bool LastB;
@@ -56,7 +66,12 @@ namespace MSCO
readonly Dictionary<int, Var> vars = new Dictionary<int, Var>();
readonly Dictionary<int, byte[]> pending = new Dictionary<int, byte[]>(); // noch nicht gefundene Werte
readonly List<Var> outgoing = new List<Var>();
readonly HashSet<PlayMakerFSM> scanned = new HashSet<PlayMakerFSM>();
// Erfasste FSMs mit dem Elternobjekt beim Erfassen: wird ein Teil an-/abgebaut, ändert sich sein Pfad (und damit
// die Schlüssel) - dann neu erfassen. FSMs außerhalb der Fahrzeuge/Teile werden bei jedem Suchlauf neu geprüft
// (vorher einmal als "nicht Fahrzeug" gemerkt und nie wieder angesehen: später angebaute Teile fehlten)
readonly Dictionary<PlayMakerFSM, Transform> scanned = new Dictionary<PlayMakerFSM, Transform>();
readonly Dictionary<PlayMakerFSM, List<int>> keysByFsm = new Dictionary<PlayMakerFSM, List<int>>();
string satsumaRoot;
float nextScan, nextCheck;
public int Count { get { return vars.Count; } }
@@ -69,7 +84,7 @@ namespace MSCO
foreach (Var v in vars.Values)
{
if (f.Length > 0 && (v.Name == null || !v.Name.ToLower().Contains(f))) continue;
string value = v.Type == 1 ? ((FsmFloat)v.Variable).Value.ToString("0.###")
string value = v.Type == 1 ? GetFloat(v).ToString("0.###")
: v.Type == 2 ? ((FsmInt)v.Variable).Value.ToString()
: ((FsmBool)v.Variable).Value.ToString();
lines.Add(" " + v.Name + " = " + value);
@@ -84,6 +99,8 @@ namespace MSCO
vars.Clear();
pending.Clear();
scanned.Clear();
keysByFsm.Clear();
satsumaRoot = null;
nextScan = -1f; // erster Suchlauf verteilt, siehe Update
}
@@ -99,10 +116,11 @@ namespace MSCO
nextCheck = Time.time + CheckInterval;
outgoing.Clear();
ResolveSatsuma(objects);
foreach (Var v in vars.Values)
{
// Fährt/schiebt ein anderer das Fahrzeug, ist seine Version maßgeblich - nichts senden
int owner = objects.VehicleOwner(v.Root);
int owner = objects.VehicleOwner(OwnerRoot(v.Root));
if (owner != SyncedBody.NoOwner && owner != localId) { if (Changed(v, false)) Remember(v); continue; }
// Eigenes Fahrzeug: jede Änderung. Freies Fahrzeug: nur deutliche Sprünge (Öl nachfüllen, tanken ...) -
@@ -134,7 +152,7 @@ namespace MSCO
byte[] value = r.ReadBytes(type == 3 ? 1 : 4);
Var v;
if (!vars.TryGetValue(key, out v)) { pending[key] = Combine(type, value); continue; }
if (from >= 0 && objects.VehicleOwner(v.Root) == localId) continue; // ich fahre gerade: meine Werte gelten
if (from >= 0 && objects.VehicleOwner(OwnerRoot(v.Root)) == localId) continue; // ich fahre gerade: meine Werte gelten
Apply(v, type, value);
}
}
@@ -161,18 +179,29 @@ namespace MSCO
int before = vars.Count;
foreach (PlayMakerFSM fsm in SceneCache.Fsms(20f))
{
// Schon erfasste FSMs nicht jedes Mal neu durchgehen (Pfad + Hash kosten)
if (fsm == null || !scanned.Add(fsm)) continue;
if (fsm == null) continue;
// Schon erfasst und nicht umgehängt (an-/abgebaut): nichts zu tun (Pfad + Hash kosten)
Transform parent;
if (scanned.TryGetValue(fsm, out parent) && parent == fsm.transform.parent) continue;
string root = fsm.transform.root.name;
if (!IsVehicle(root)) continue;
if (!IsTracked(root)) { Forget(fsm); continue; }
scanned[fsm] = fsm.transform.parent;
Forget(fsm);
// FSMs, die Größen/Positionen setzen (z.B. Füllstands-Anzeigen), NICHT anfassen: ein übertragener Wert
// kann dort eine Größe 0 ergeben - Unity stürzt dann in der Physik ab ("cleaning the mesh failed")
if (TouchesTransform(fsm)) continue;
if (fsm.transform.root.name == "Database" && FsmSync.PathOf(fsm.transform).Contains("PlayerDatabase")) continue;
string prefix = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName + "|";
FsmVariables fv = fsm.FsmVariables;
foreach (FsmFloat f in fv.FloatVariables) Register(prefix, root, f, 1);
foreach (FsmInt n in fv.IntVariables) Register(prefix, root, n, 2);
foreach (FsmBool b in fv.BoolVariables) Register(prefix, root, b, 3);
foreach (FsmFloat f in fv.FloatVariables) Register(fsm, prefix, root, f, 1, -1);
foreach (FsmInt n in fv.IntVariables) Register(fsm, prefix, root, n, 2, -1);
foreach (FsmBool b in fv.BoolVariables) Register(fsm, prefix, root, b, 3, -1);
// Lack-/Farbwerte (FsmColor) als vier Zahlen: alte Server/Mods kennen nur float/int/bool
foreach (FsmColor c in fv.ColorVariables)
{
if (c == null || !IsColorName(c.Name)) continue;
for (int ch = 0; ch < 4; ch++) Register(fsm, prefix, root, c, 1, ch);
}
}
if (vars.Count != before) MscoLog.Info("[MSCO] Vehicle state: " + vars.Count + " values synced");
}
@@ -200,15 +229,20 @@ namespace MSCO
return false;
}
void Register(string prefix, string root, NamedVariable variable, byte type)
void Register(PlayMakerFSM fsm, string prefix, string root, NamedVariable variable, byte type, int channel)
{
if (variable == null || string.IsNullOrEmpty(variable.Name) || !Wanted(variable.Name)) return;
int key = Hash(prefix + variable.Name);
if (variable == null || string.IsNullOrEmpty(variable.Name)) return;
if (channel < 0 && !Wanted(variable.Name)) return;
string name = prefix + variable.Name + (channel >= 0 ? "#" + "rgba"[channel] : "");
int key = Hash(name);
Var existing;
if (vars.TryGetValue(key, out existing) && existing.Variable == variable) return;
var v = new Var { Key = key, Name = prefix + variable.Name, Root = root, Variable = variable, Type = type };
var v = new Var { Key = key, Name = name, Root = root, Variable = variable, Type = type, Channel = channel, Fsm = fsm };
Remember(v);
vars[key] = v;
List<int> keys;
if (!keysByFsm.TryGetValue(fsm, out keys)) keysByFsm[fsm] = keys = new List<int>();
keys.Add(key);
byte[] stored;
if (pending.TryGetValue(key, out stored))
@@ -234,13 +268,80 @@ namespace MSCO
return false;
}
static bool IsTracked(string root)
{
return IsVehicle(root) || Array.IndexOf(PartRoots, root) >= 0;
}
static bool IsColorName(string name)
{
string n = name.ToLower();
return n.Contains("paint") || n.Contains("color") || n.Contains("colour");
}
/// <summary>Werte einer umgehängten/verschwundenen FSM vergessen (Pfad und damit Schlüssel ändern sich).</summary>
void Forget(PlayMakerFSM fsm)
{
List<int> keys;
if (!keysByFsm.TryGetValue(fsm, out keys)) return;
foreach (int k in keys)
{
Var v;
if (vars.TryGetValue(k, out v) && v.Fsm == fsm) vars.Remove(k);
}
keysByFsm.Remove(fsm);
}
/// <summary>Satsuma-Teile gehören dem, der den Satsuma fährt.</summary>
string OwnerRoot(string root)
{
return Array.IndexOf(PartRoots, root) >= 0 ? satsumaRoot : root;
}
void ResolveSatsuma(ObjectSync objects)
{
if (satsumaRoot != null) return;
foreach (SyncedBody b in objects.AllVehicles())
if (b.RootName != null && b.RootName.StartsWith(PartOwnerPrefix)) { satsumaRoot = b.RootName; return; }
}
static float GetFloat(Var v)
{
if (v.Channel < 0) return ((FsmFloat)v.Variable).Value;
Color c = ((FsmColor)v.Variable).Value;
return c[v.Channel];
}
static void SetFloat(Var v, float f)
{
if (v.Channel < 0) { ((FsmFloat)v.Variable).Value = f; return; }
var cv = (FsmColor)v.Variable;
Color c = cv.Value;
c[v.Channel] = Mathf.Clamp(f, 0f, 4f);
cv.Value = c;
ShowPaint(v, c);
}
/// <summary>
/// Lack sichtbar machen: Die Lackier-FSM eines Teils setzt beim Lackieren die Materialfarbe ihres eigenen Meshes.
/// Beim Mitspieler kommt nur der Farbwert an - also dieselbe Farbe aufs Mesh des Teils legen.
/// </summary>
static void ShowPaint(Var v, Color c)
{
if (v.Fsm == null || v.Fsm.FsmName.IndexOf("paint", StringComparison.OrdinalIgnoreCase) < 0) return;
Renderer r = v.Fsm.GetComponent<Renderer>();
if (r == null || r.material == null) return;
r.material.color = c;
}
static bool Changed(Var v, bool bigOnly)
{
switch (v.Type)
{
case 1:
float f = ((FsmFloat)v.Variable).Value;
float f = GetFloat(v);
float delta = Mathf.Abs(f - v.LastF);
if (v.Channel >= 0) return delta > 0.002f; // Farben: jede Änderung (Lackieren)
return bigOnly
? delta > Mathf.Max(0.5f, Mathf.Abs(v.LastF) * 0.05f)
: delta > Mathf.Max(0.01f, Mathf.Abs(v.LastF) * 0.002f);
@@ -253,7 +354,7 @@ namespace MSCO
{
switch (v.Type)
{
case 1: v.LastF = ((FsmFloat)v.Variable).Value; break;
case 1: v.LastF = GetFloat(v); break;
case 2: v.LastI = ((FsmInt)v.Variable).Value; break;
default: v.LastB = ((FsmBool)v.Variable).Value; break;
}
@@ -267,7 +368,7 @@ namespace MSCO
case 1:
float f = BitConverter.ToSingle(value, 0);
if (float.IsNaN(f) || float.IsInfinity(f)) return; // nie ungültige Werte übernehmen
((FsmFloat)v.Variable).Value = f;
SetFloat(v, f);
break;
case 2: ((FsmInt)v.Variable).Value = BitConverter.ToInt32(value, 0); break;
default: ((FsmBool)v.Variable).Value = value[0] != 0; break;