From 097d1e87de64e42b331a22ca03ea3ab99cdb1c1c Mon Sep 17 00:00:00 2001 From: MrMaho Date: Sun, 27 Sep 2026 16:51:39 +0200 Subject: [PATCH] Sync vehicle controls (ignition, engine, lights ...), stop forced starter sound, auto-reconnect after server restart or lost connection Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++- ControlSync.cs | 167 ++++++++++++++++++++++++++++++++++++++++++ MSCO.csproj | 1 + MultiplayerManager.cs | 28 ++++++- NetSession.cs | 3 +- VehicleFx.cs | 29 +++++++- 6 files changed, 229 insertions(+), 8 deletions(-) create mode 100644 ControlSync.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ce25f..15cce75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Server und Mod müssen beide 2.8 sein (Server: `git pull && docker compose up -d - **Räder** drehen sich bei Autos, die ein anderer Spieler fährt - **Unfallschäden**: verbeulte Karosserie wird an alle übertragen - **Jobs, Aufträge und Laden**: Fortschritt (Auftrag angenommen, erledigt, bestellt ...) wird synchronisiert +- **Zündung, Motor, Licht, Choke, Blinker, Wischer**: die Schalterstellungen werden übertragen – beim anderen laufen + Motor und Scheinwerfer dadurch wirklich (nicht nur das Geräusch) - **Autotüren, Motorhaube, Kofferraum**: stehen bei allen im selben Winkel offen - **Verkehr**: NPC-Autos fahren bei allen an derselben Stelle (der erste Spieler simuliert, die anderen folgen) - `vehicles` in der F7-Konsole zeigt, wie viele Karosserie-Meshes, Job-Werte und Verkehrsautos synchronisiert werden @@ -23,10 +25,13 @@ Server und Mod müssen beide 2.8 sein (Server: `git pull && docker compose up -d **Trennen & Neu-Verbinden** - Gekickt, gebannt, Server beendet, Verbindung verloren: eigenes Fenster in der Bildmitte mit **Grund** und OK (im Spiel wird der Grund vorher 2,5 s groß angezeigt) -- `reset-world` / `reload-save`: Fenster „Reconnecting“ – verbindet automatisch neu, bis zu 5 Versuche (falls der - Server gerade neu startet), mit CANCEL-Knopf +- `reset-world` / `reload-save` / **Server-Neustart** (z.B. Update per `docker compose up`) / **Verbindung verloren**: + Fenster „Reconnecting“ – verbindet automatisch neu, bis zu 10 Versuche (ca. 1 Minute), mit CANCEL-Knopf. + Nach Kick, Ban oder eigenem Trennen wird nicht neu verbunden **Behoben** +- Beim anderen Spieler „orgelte“ der Anlasser endlos: erzwungene Geräusche/Lichter werden losgelassen, sobald keine + Daten mehr vom Fahrer kommen - Beim Neuladen der Welt wurden herumliegende Objekte als „verbraucht“ gemeldet und bei allen gelöscht. Jetzt werden nur einzelne echte Items gemeldet, Fahrzeuge/Teile nie gelöscht, und der Server nimmt höchstens 20 pro Minute an diff --git a/ControlSync.cs b/ControlSync.cs new file mode 100644 index 0000000..97334df --- /dev/null +++ b/ControlSync.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using HutongGames.PlayMaker; +using UnityEngine; + +namespace MSCO +{ + /// + /// Bedienelemente der Fahrzeuge: Zündschloss, Anlasser, Motor, Lichtschalter, Choke, Blinker, Scheibenwischer ... + /// Das Spiel schaltet damit Motor und Licht über PlayMaker-FSMs (Zustände wie "Off" / "Lights" / "Running"). + /// Übertragen wird der aktive Zustand; beim anderen Spieler wird dieselbe FSM in denselben Zustand geschaltet, + /// sodass sein Spiel Motor, Geräusch, Scheinwerfer und Armaturen selbst richtig anschaltet. + /// Wer sendet: der Fahrer; ist das Fahrzeug frei, wer daneben steht bzw. drin sitzt (Schalter betätigt). + /// FSMs, die ständig den Zustand wechseln (interne Schleifen), werden nach kurzer Zeit nicht mehr übertragen. + /// + public class ControlSync + { + // Objekt- oder FSM-Name enthält eines davon + static readonly string[] Keywords = + { + "ignition", "starter", "engine", "light", "choke", "wiper", "hazard", "indicator", "turnsignal", "blinker", + "horn", "heater", "fan", "radio", "key", "switch", "knob", "button" + }; + // Montage/Speichern/Schrauben - läuft über den Objekt- bzw. FSM-Sync, hier nicht anfassen + static readonly string[] Excluded = + { + "removal", "assembly", "assemble", "bolt", "screw", "data", "save", "load", "paint", "detach", "attach", + "install", "trigger", "tighten", "wear", "damage", "oil", "coolant", "fuel", "inspection" + }; + + const float ScanInterval = 10f; + const float CheckInterval = 0.1f; + const float NearRange = 5f; + const int FlipLimit = 12; // so viele Zustandswechsel ... + const float FlipWindow = 10f; // ... in dieser Zeit = interne Schleife, nicht übertragen + const int MaxPerMessage = 100; + + class Control + { + public int Key; + public string Root; + public PlayMakerFSM Fsm; + public string LastState; + public float WindowStart; + public int Flips; + public bool Muted; + } + + readonly Dictionary controls = new Dictionary(); + readonly HashSet scanned = new HashSet(); + readonly List outgoing = new List(); + float nextScan, nextCheck; + + public int Count { get { return controls.Count; } } + public int MutedCount { get { int n = 0; foreach (Control c in controls.Values) if (c.Muted) n++; return n; } } + + public void Reset() + { + controls.Clear(); + scanned.Clear(); + nextScan = 0f; + } + + public void Update(ObjectSync objects, int localId, Vector3 playerPos, Action send) + { + if (Time.time >= nextScan) + { + nextScan = Time.time + ScanInterval; + Scan(); + } + if (Time.time < nextCheck) return; + nextCheck = Time.time + CheckInterval; + + outgoing.Clear(); + foreach (Control c in controls.Values) + { + if (c.Fsm == null || c.Muted) continue; + string state = c.Fsm.ActiveStateName; + if (state == c.LastState || string.IsNullOrEmpty(state)) continue; + c.LastState = state; + + if (Time.time - c.WindowStart > FlipWindow) { c.WindowStart = Time.time; c.Flips = 0; } + if (++c.Flips > FlipLimit) + { + c.Muted = true; + MscoLog.Info("[MSCO] Control not synced (changes all the time): " + c.Fsm.gameObject.name + " [" + c.Fsm.FsmName + "]"); + continue; + } + + int owner = objects.VehicleOwner(c.Root); + if (owner != SyncedBody.NoOwner && owner != localId) continue; // der Fahrer gibt vor + if (owner == SyncedBody.NoOwner && (c.Fsm.transform.position - playerPos).sqrMagnitude > NearRange * NearRange) continue; + outgoing.Add(c); + } + + for (int start = 0; start < outgoing.Count; start += MaxPerMessage) + { + int from = start, count = Math.Min(MaxPerMessage, outgoing.Count - start); + send(MsgType.VehicleControls, NetSession.Build(w => + { + w.Write(count); + for (int i = from; i < from + count; i++) { w.Write(outgoing[i].Key); w.Write(outgoing[i].LastState); } + })); + } + } + + public void Handle(BinaryReader r, ObjectSync objects, int localId) + { + int count = r.ReadInt32(); + for (int i = 0; i < count; i++) + { + int key = r.ReadInt32(); + string state = r.ReadString(); + Control c; + if (!controls.TryGetValue(key, out c) || c.Fsm == null) { nextScan = 0f; continue; } + if (objects.VehicleOwner(c.Root) == localId) continue; // ich fahre: meine Schalter gelten + if (c.Fsm.ActiveStateName == state) { c.LastState = state; continue; } + FsmState target = c.Fsm.Fsm.GetState(state); + if (target == null) continue; + c.LastState = state; // nicht als eigene Änderung zurückschicken + FsmSync.SwitchStateSilently(c.Fsm, target); + c.LastState = c.Fsm.ActiveStateName; // FSM kann sofort weiterschalten (z.B. "Start" -> "Running") + } + } + + void Scan() + { + int before = controls.Count; + foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f)) + { + if (fsm == null || !scanned.Add(fsm)) continue; + string root = fsm.transform.root.name; + if (!IsVehicle(root) || !Wanted(fsm.gameObject.name, fsm.FsmName)) continue; + int key = Hash(FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName); + if (controls.ContainsKey(key)) continue; + controls[key] = new Control { Key = key, Root = root, Fsm = fsm, LastState = fsm.ActiveStateName, WindowStart = Time.time }; + } + if (controls.Count != before) MscoLog.Info("[MSCO] Vehicle controls (ignition, lights, engine ...): " + controls.Count + " synced"); + } + + static bool Wanted(string objectName, string fsmName) + { + string o = objectName.ToLower(), f = fsmName.ToLower(); + foreach (string x in Excluded) if (o.Contains(x) || f.Contains(x)) return false; + foreach (string k in Keywords) if (o.Contains(k) || f.Contains(k)) return true; + return false; + } + + static bool IsVehicle(string root) + { + foreach (string v in ObjectSync.VehicleRoots) if (root.StartsWith(v)) return true; + return false; + } + + static int Hash(string s) + { + unchecked + { + uint h = 2166136261; + foreach (byte c in Encoding.UTF8.GetBytes(s)) { h ^= c; h *= 16777619; } + return (int)h; + } + } + } +} diff --git a/MSCO.csproj b/MSCO.csproj index 30c8d7d..e121b68 100644 --- a/MSCO.csproj +++ b/MSCO.csproj @@ -78,6 +78,7 @@ + diff --git a/MultiplayerManager.cs b/MultiplayerManager.cs index 61dc8d9..0895c22 100644 --- a/MultiplayerManager.cs +++ b/MultiplayerManager.cs @@ -64,6 +64,7 @@ namespace MSCO readonly WorldVarSync worldVars = new WorldVarSync(); readonly TrafficSync traffic = new TrafficSync(); readonly HingeSync hinges = new HingeSync(); + readonly ControlSync controls = new ControlSync(); readonly Overlays overlays = new Overlays(); bool isAdmin; // Server hat uns als Admin gemeldet readonly HashSet bannedNames = new HashSet(); // P2P-Host: für diese Sitzung gebannt @@ -209,6 +210,7 @@ namespace MSCO lines.Add("synced vehicle values (fuel, oil, bolts, wear ...): " + vehicleState.Count); lines.Add("synced body meshes (crash damage): " + damageSync.Count); lines.Add("synced car doors/hoods/trunks: " + hinges.Count); + lines.Add("synced vehicle controls (ignition, engine, lights ...): " + controls.Count + " (" + controls.MutedCount + " muted)"); lines.Add("synced job/shop values: " + worldVars.Count); lines.Add("synced traffic cars: " + traffic.Count + (IsWorldAuthority ? " (simulated here)" : " (following)")); return lines; @@ -607,7 +609,7 @@ namespace MSCO float centerMessageUntil; float reconnectAt; int reconnectAttempts; - const int MaxReconnectAttempts = 5; + const int MaxReconnectAttempts = 10; // ~1 Minute: reicht für einen Server-Neustart const float ReconnectRetrySeconds = 6f; void ConnectToServer(string host, int port, string displayName, int maxPlayers) @@ -848,6 +850,8 @@ namespace MSCO { reconnectAttempts++; reconnectAt = Time.realtimeSinceStartup + ReconnectRetrySeconds; + MscoLog.Info("[MSCO] Reconnecting to " + lastServerHost + ":" + lastServerPort + + " (attempt " + reconnectAttempts + "/" + MaxReconnectAttempts + ")"); JoinServer(lastServerHost, lastServerPort, lastServerName, lastServerMax); } } @@ -990,6 +994,15 @@ namespace MSCO void OnSessionEnded(string reason) { + // Verbindung zum Server unerwartet weg (Absturz, Timeout, Netz) - nicht gekickt/gebannt, nicht selbst + // getrennt: automatisch neu verbinden, als hätte der Server einen Neustart angekündigt + if (reconnectMessage == null && serverReason == null && reason != "Disconnected" && dedicated && handshakeDone + && lastServerHost != null) + { + reconnectMessage = "Lost the connection to the server - reconnecting..."; + reconnectAttempts = 0; + MscoLog.Warning("[MSCO] Connection lost (" + reason + ") - will reconnect"); + } // Hinweis fürs Hauptmenü - außer man hat selbst auf DISCONNECT gedrückt oder verbindet gleich neu if (reconnectMessage != null) { @@ -1022,7 +1035,7 @@ namespace MSCO worldSync.Reset(); moneySync.Reset(); doorSync.Reset(); - vehicleState.Reset(); damageSync.Reset(); worldVars.Reset(); traffic.Reset(); hinges.Reset(); + vehicleState.Reset(); damageSync.Reset(); worldVars.Reset(); traffic.Reset(); hinges.Reset(); controls.Reset(); isAdmin = false; if (overlays.AdminOpen || overlays.ChatOpen) overlays.CloseAll(); FsmSync.Enabled = false; @@ -1051,7 +1064,8 @@ namespace MSCO || t == MsgType.ObjRelease || t == MsgType.FsmEvent || t == MsgType.VehicleFx || t == MsgType.WorldTime || t == MsgType.MoneyDelta || t == MsgType.DoorState || t == MsgType.Chat || t == MsgType.VarSync || t == MsgType.SpawnItem || t == MsgType.ItemAppeared || t == MsgType.ObjDestroy || t == MsgType.VehicleDamage - || t == MsgType.TrafficStates || t == MsgType.WorldVars || t == MsgType.HingeStates; + || t == MsgType.TrafficStates || t == MsgType.WorldVars || t == MsgType.HingeStates + || t == MsgType.VehicleControls; } void OnMessage(int peerId, Message m) @@ -1366,6 +1380,9 @@ namespace MSCO case MsgType.HingeStates: hinges.Handle(r); break; + case MsgType.VehicleControls: + controls.Handle(r, objects, localId); + break; case MsgType.TrafficStates: traffic.Handle(r, IsWorldAuthority); break; @@ -1486,7 +1503,7 @@ namespace MSCO passenger.Reset(); localPlayer = null; objects.OnGameLoaded(); - vehicleState.Reset(); damageSync.Reset(); worldVars.Reset(); traffic.Reset(); hinges.Reset(); + vehicleState.Reset(); damageSync.Reset(); worldVars.Reset(); traffic.Reset(); hinges.Reset(); controls.Reset(); vehicleFx.Reset(); wheelSpin.Reset(); worldSync.Reset(); moneySync.Reset(); @@ -1610,6 +1627,9 @@ namespace MSCO // Unfallschäden (verbeulte Karosserie) try { if (objects.ScanComplete) damageSync.Update(objects, localId, SendGame); } catch (System.Exception e) { ReportError("crash damage sync", e); } + // Zündung, Motor, Licht, Choke ... der Fahrzeuge + try { if (objects.ScanComplete && localPlayer != null) controls.Update(objects, localId, localPlayer.position, SendGame); } + catch (System.Exception e) { ReportError("vehicle controls sync", e); } // Türen, Motorhaube, Kofferraum der Fahrzeuge try { if (objects.ScanComplete && localPlayer != null) hinges.Update(objects, localId, localPlayer.position, Time.deltaTime, SendGame); } catch (System.Exception e) { ReportError("car door sync", e); } diff --git a/NetSession.cs b/NetSession.cs index 5a2560c..19ec529 100644 --- a/NetSession.cs +++ b/NetSession.cs @@ -50,7 +50,8 @@ namespace MSCO VehicleDamage = 39, // Unfallschaden: int Mesh-Schlüssel, int Eckpunkte gesamt, int n, je [int Index, 3 float] TrafficStates = 40, // Verkehr (nur Welt-Autorität): int n, je [int Schlüssel, 3 float Pos, 4 float Rot, 3 float Geschw.] WorldVars = 41, // Jobs/Laden: int n, je [int Schlüssel, byte Typ (2 int, 3 bool), Wert] - HingeStates = 42 // Autotüren/Haube/Kofferraum: int n, je [int Schlüssel, 3 float Pos, 4 float Rot relativ zum Auto] + HingeStates = 42, // Autotüren/Haube/Kofferraum: int n, je [int Schlüssel, 3 float Pos, 4 float Rot relativ zum Auto] + VehicleControls = 43 // Zündung/Motor/Licht ...: int n, je [int FSM-Schlüssel, string Zustand] } public struct Message diff --git a/VehicleFx.cs b/VehicleFx.cs index e6ea003..ec9030b 100644 --- a/VehicleFx.cs +++ b/VehicleFx.cs @@ -46,6 +46,7 @@ namespace MSCO class Snapshot { public float ReceivedAt; + public bool Released; // nach dem Timeout einmal freigegeben public readonly Dictionary Lights = new Dictionary(); public readonly Dictionary Audio = new Dictionary(); public readonly Dictionary Dials = new Dictionary(); @@ -161,11 +162,17 @@ namespace MSCO { foreach (KeyValuePair kv in incoming) { - if (Time.time - kv.Value.ReceivedAt > ApplyTimeout) continue; SyncedBody b = objects.Get(kv.Key); if (b == null || objects.IsLocalBody(b)) continue; // eigene Fahrzeuge simulieren wir selbst Components c = GetComponents(b); if (c == null) continue; + if (Time.time - kv.Value.ReceivedAt > ApplyTimeout) + { + // Keine Daten mehr (Fahrer ausgestiegen o.ä.): einmal alles loslassen, was wir erzwungen haben - + // sonst läuft z.B. das Anlasser-Geräusch endlos weiter ("orgelt") + if (!kv.Value.Released) Release(kv.Value, c); + continue; + } foreach (KeyValuePair l in kv.Value.Lights) { @@ -192,6 +199,26 @@ namespace MSCO } } + static void Release(Snapshot s, Components c) + { + s.Released = true; + foreach (KeyValuePair a in s.Audio) + { + AudioSource src; + if (a.Value.Playing && c.Audio.TryGetValue(a.Key, out src) && src != null && src.isPlaying) src.Stop(); + } + foreach (KeyValuePair p in s.Particles) + { + ParticleSystem ps; + if (p.Value && c.Particles.TryGetValue(p.Key, out ps) && ps != null && ps.isPlaying) ps.Stop(); + } + foreach (KeyValuePair l in s.Lights) + { + Light light; + if (l.Value && c.Lights.TryGetValue(l.Key, out light) && light != null) light.enabled = false; + } + } + static void SetLight(Light light, bool on, Transform root) { if (!on)