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 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 16:51:39 +02:00
co-authored by Claude Opus 5.5
parent bddc1efe76
commit 097d1e87de
6 changed files with 229 additions and 8 deletions
+7 -2
View File
@@ -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 - **Räder** drehen sich bei Autos, die ein anderer Spieler fährt
- **Unfallschäden**: verbeulte Karosserie wird an alle übertragen - **Unfallschäden**: verbeulte Karosserie wird an alle übertragen
- **Jobs, Aufträge und Laden**: Fortschritt (Auftrag angenommen, erledigt, bestellt ...) wird synchronisiert - **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 - **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) - **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 - `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** **Trennen & Neu-Verbinden**
- Gekickt, gebannt, Server beendet, Verbindung verloren: eigenes Fenster in der Bildmitte mit **Grund** und OK - 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) (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 - `reset-world` / `reload-save` / **Server-Neustart** (z.B. Update per `docker compose up`) / **Verbindung verloren**:
Server gerade neu startet), mit CANCEL-Knopf 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** **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 - 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 nur einzelne echte Items gemeldet, Fahrzeuge/Teile nie gelöscht, und der Server nimmt höchstens 20 pro Minute an
+167
View File
@@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// 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.
/// </summary>
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<int, Control> controls = new Dictionary<int, Control>();
readonly HashSet<PlayMakerFSM> scanned = new HashSet<PlayMakerFSM>();
readonly List<Control> outgoing = new List<Control>();
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<MsgType, byte[]> 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;
}
}
}
}
+1
View File
@@ -78,6 +78,7 @@
<Compile Include="WorldVarSync.cs" /> <Compile Include="WorldVarSync.cs" />
<Compile Include="TrafficSync.cs" /> <Compile Include="TrafficSync.cs" />
<Compile Include="HingeSync.cs" /> <Compile Include="HingeSync.cs" />
<Compile Include="ControlSync.cs" />
<Compile Include="MpCommand.cs" /> <Compile Include="MpCommand.cs" />
<Compile Include="Commands.cs" /> <Compile Include="Commands.cs" />
<Compile Include="MscoLog.cs" /> <Compile Include="MscoLog.cs" />
+24 -4
View File
@@ -64,6 +64,7 @@ namespace MSCO
readonly WorldVarSync worldVars = new WorldVarSync(); readonly WorldVarSync worldVars = new WorldVarSync();
readonly TrafficSync traffic = new TrafficSync(); readonly TrafficSync traffic = new TrafficSync();
readonly HingeSync hinges = new HingeSync(); readonly HingeSync hinges = new HingeSync();
readonly ControlSync controls = new ControlSync();
readonly Overlays overlays = new Overlays(); readonly Overlays overlays = new Overlays();
bool isAdmin; // Server hat uns als Admin gemeldet bool isAdmin; // Server hat uns als Admin gemeldet
readonly HashSet<string> bannedNames = new HashSet<string>(); // P2P-Host: für diese Sitzung gebannt readonly HashSet<string> bannedNames = new HashSet<string>(); // 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 vehicle values (fuel, oil, bolts, wear ...): " + vehicleState.Count);
lines.Add("synced body meshes (crash damage): " + damageSync.Count); lines.Add("synced body meshes (crash damage): " + damageSync.Count);
lines.Add("synced car doors/hoods/trunks: " + hinges.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 job/shop values: " + worldVars.Count);
lines.Add("synced traffic cars: " + traffic.Count + (IsWorldAuthority ? " (simulated here)" : " (following)")); lines.Add("synced traffic cars: " + traffic.Count + (IsWorldAuthority ? " (simulated here)" : " (following)"));
return lines; return lines;
@@ -607,7 +609,7 @@ namespace MSCO
float centerMessageUntil; float centerMessageUntil;
float reconnectAt; float reconnectAt;
int reconnectAttempts; int reconnectAttempts;
const int MaxReconnectAttempts = 5; const int MaxReconnectAttempts = 10; // ~1 Minute: reicht für einen Server-Neustart
const float ReconnectRetrySeconds = 6f; const float ReconnectRetrySeconds = 6f;
void ConnectToServer(string host, int port, string displayName, int maxPlayers) void ConnectToServer(string host, int port, string displayName, int maxPlayers)
@@ -848,6 +850,8 @@ namespace MSCO
{ {
reconnectAttempts++; reconnectAttempts++;
reconnectAt = Time.realtimeSinceStartup + ReconnectRetrySeconds; reconnectAt = Time.realtimeSinceStartup + ReconnectRetrySeconds;
MscoLog.Info("[MSCO] Reconnecting to " + lastServerHost + ":" + lastServerPort +
" (attempt " + reconnectAttempts + "/" + MaxReconnectAttempts + ")");
JoinServer(lastServerHost, lastServerPort, lastServerName, lastServerMax); JoinServer(lastServerHost, lastServerPort, lastServerName, lastServerMax);
} }
} }
@@ -990,6 +994,15 @@ namespace MSCO
void OnSessionEnded(string reason) 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 // Hinweis fürs Hauptmenü - außer man hat selbst auf DISCONNECT gedrückt oder verbindet gleich neu
if (reconnectMessage != null) if (reconnectMessage != null)
{ {
@@ -1022,7 +1035,7 @@ namespace MSCO
worldSync.Reset(); worldSync.Reset();
moneySync.Reset(); moneySync.Reset();
doorSync.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; isAdmin = false;
if (overlays.AdminOpen || overlays.ChatOpen) overlays.CloseAll(); if (overlays.AdminOpen || overlays.ChatOpen) overlays.CloseAll();
FsmSync.Enabled = false; 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.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.Chat || t == MsgType.VarSync || t == MsgType.SpawnItem
|| t == MsgType.ItemAppeared || t == MsgType.ObjDestroy || t == MsgType.VehicleDamage || 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) void OnMessage(int peerId, Message m)
@@ -1366,6 +1380,9 @@ namespace MSCO
case MsgType.HingeStates: case MsgType.HingeStates:
hinges.Handle(r); hinges.Handle(r);
break; break;
case MsgType.VehicleControls:
controls.Handle(r, objects, localId);
break;
case MsgType.TrafficStates: case MsgType.TrafficStates:
traffic.Handle(r, IsWorldAuthority); traffic.Handle(r, IsWorldAuthority);
break; break;
@@ -1486,7 +1503,7 @@ namespace MSCO
passenger.Reset(); passenger.Reset();
localPlayer = null; localPlayer = null;
objects.OnGameLoaded(); 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(); vehicleFx.Reset(); wheelSpin.Reset();
worldSync.Reset(); worldSync.Reset();
moneySync.Reset(); moneySync.Reset();
@@ -1610,6 +1627,9 @@ namespace MSCO
// Unfallschäden (verbeulte Karosserie) // Unfallschäden (verbeulte Karosserie)
try { if (objects.ScanComplete) damageSync.Update(objects, localId, SendGame); } try { if (objects.ScanComplete) damageSync.Update(objects, localId, SendGame); }
catch (System.Exception e) { ReportError("crash damage sync", e); } 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 // Türen, Motorhaube, Kofferraum der Fahrzeuge
try { if (objects.ScanComplete && localPlayer != null) hinges.Update(objects, localId, localPlayer.position, Time.deltaTime, SendGame); } try { if (objects.ScanComplete && localPlayer != null) hinges.Update(objects, localId, localPlayer.position, Time.deltaTime, SendGame); }
catch (System.Exception e) { ReportError("car door sync", e); } catch (System.Exception e) { ReportError("car door sync", e); }
+2 -1
View File
@@ -50,7 +50,8 @@ namespace MSCO
VehicleDamage = 39, // Unfallschaden: int Mesh-Schlüssel, int Eckpunkte gesamt, int n, je [int Index, 3 float] 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.] 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] 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 public struct Message
+28 -1
View File
@@ -46,6 +46,7 @@ namespace MSCO
class Snapshot class Snapshot
{ {
public float ReceivedAt; public float ReceivedAt;
public bool Released; // nach dem Timeout einmal freigegeben
public readonly Dictionary<int, bool> Lights = new Dictionary<int, bool>(); public readonly Dictionary<int, bool> Lights = new Dictionary<int, bool>();
public readonly Dictionary<int, AudioState> Audio = new Dictionary<int, AudioState>(); public readonly Dictionary<int, AudioState> Audio = new Dictionary<int, AudioState>();
public readonly Dictionary<int, Quaternion> Dials = new Dictionary<int, Quaternion>(); public readonly Dictionary<int, Quaternion> Dials = new Dictionary<int, Quaternion>();
@@ -161,11 +162,17 @@ namespace MSCO
{ {
foreach (KeyValuePair<int, Snapshot> kv in incoming) foreach (KeyValuePair<int, Snapshot> kv in incoming)
{ {
if (Time.time - kv.Value.ReceivedAt > ApplyTimeout) continue;
SyncedBody b = objects.Get(kv.Key); SyncedBody b = objects.Get(kv.Key);
if (b == null || objects.IsLocalBody(b)) continue; // eigene Fahrzeuge simulieren wir selbst if (b == null || objects.IsLocalBody(b)) continue; // eigene Fahrzeuge simulieren wir selbst
Components c = GetComponents(b); Components c = GetComponents(b);
if (c == null) continue; 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<int, bool> l in kv.Value.Lights) foreach (KeyValuePair<int, bool> l in kv.Value.Lights)
{ {
@@ -192,6 +199,26 @@ namespace MSCO
} }
} }
static void Release(Snapshot s, Components c)
{
s.Released = true;
foreach (KeyValuePair<int, AudioState> 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<int, bool> p in s.Particles)
{
ParticleSystem ps;
if (p.Value && c.Particles.TryGetValue(p.Key, out ps) && ps != null && ps.isPlaying) ps.Stop();
}
foreach (KeyValuePair<int, bool> 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) static void SetLight(Light light, bool on, Transform root)
{ {
if (!on) if (!on)