diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d2c29a..249b7bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## 2.8
+
+Server und Mod müssen beide 2.8 sein (Server: `git pull && docker compose up -d --build`).
+
+**Mehr Sync**
+- **Einkäufe**: gekaufte/ausgepackte Items tauchen bei allen auf (auch bei später Beitretenden)
+- **Verbrauchte Items**: Gegessenes, Getrunkenes und Verbrauchtes verschwindet bei allen
+- **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
+- **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
+
## 2.7
Server und Mod müssen beide 2.7 sein (Server: `git pull && docker compose up -d --build`).
diff --git a/DamageSync.cs b/DamageSync.cs
new file mode 100644
index 0000000..7a802cf
--- /dev/null
+++ b/DamageSync.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Unfallschäden (verbeulte Karosserie): Das Spiel verformt die Meshes der Fahrzeuge bei Kollisionen
+ /// (CarDamage, CarDamage2, Deformable). Das lässt sich nicht nachspielen, also übertragen wir das Ergebnis:
+ /// Wer das Fahrzeug fährt (oder bei freien Fahrzeugen: bei wem es passiert), schickt die geänderten Eckpunkte.
+ ///
+ public class DamageSync
+ {
+ const float ScanInterval = 10f;
+ const float CheckInterval = 1f;
+ const float MinMove = 0.002f;
+ const int MaxPerMessage = 4000;
+
+ static readonly string[] DamageTypes = { "CarDamage", "CarDamage2", "Deformable" };
+
+ class DamageMesh
+ {
+ public int Key;
+ public string Root;
+ public MeshFilter Filter;
+ public Vector3[] Known; // zuletzt gesendet/empfangen
+ }
+
+ readonly Dictionary meshes = new Dictionary();
+ readonly HashSet seen = new HashSet();
+ readonly List changed = new List();
+ float nextScan, nextCheck;
+ static List types;
+
+ public int Count { get { return meshes.Count; } }
+
+ public void Reset()
+ {
+ meshes.Clear();
+ seen.Clear();
+ nextScan = 0f;
+ }
+
+ public void Update(ObjectSync objects, int localId, Action send)
+ {
+ if (Time.time >= nextScan)
+ {
+ nextScan = Time.time + ScanInterval;
+ Scan();
+ }
+ if (Time.time < nextCheck) return;
+ nextCheck = Time.time + CheckInterval;
+
+ foreach (DamageMesh m in meshes.Values)
+ {
+ if (m.Filter == null) continue;
+ int owner = objects.VehicleOwner(m.Root);
+ if (owner != SyncedBody.NoOwner && owner != localId) continue; // der Fahrer schickt die Schäden
+
+ Mesh mesh = m.Filter.sharedMesh;
+ if (mesh == null) continue;
+ Vector3[] now = mesh.vertices;
+ if (now.Length != m.Known.Length) { m.Known = now; continue; }
+
+ changed.Clear();
+ for (int i = 0; i < now.Length; i++)
+ if ((now[i] - m.Known[i]).sqrMagnitude > MinMove * MinMove) changed.Add(i);
+ if (changed.Count == 0) continue;
+
+ for (int start = 0; start < changed.Count; start += MaxPerMessage)
+ {
+ int from = start, count = Math.Min(MaxPerMessage, changed.Count - start);
+ int key = m.Key, total = now.Length;
+ send(MsgType.VehicleDamage, NetSession.Build(w =>
+ {
+ w.Write(key);
+ w.Write(total);
+ w.Write(count);
+ for (int i = from; i < from + count; i++)
+ {
+ int idx = changed[i];
+ Vector3 v = now[idx];
+ w.Write(idx); w.Write(v.x); w.Write(v.y); w.Write(v.z);
+ }
+ }));
+ }
+ m.Known = now;
+ }
+ }
+
+ public void Handle(BinaryReader r)
+ {
+ int key = r.ReadInt32();
+ int total = r.ReadInt32();
+ int count = r.ReadInt32();
+ DamageMesh m;
+ if (!meshes.TryGetValue(key, out m) || m.Filter == null) { nextScan = 0f; return; }
+ Mesh mesh = m.Filter.mesh; // eigene Kopie, falls das Mesh (noch) mit anderen geteilt ist
+ if (mesh == null) return;
+ Vector3[] verts = mesh.vertices;
+ if (verts.Length != total) return; // anderes Mesh (anderes Teil verbaut)
+
+ for (int i = 0; i < count; i++)
+ {
+ int idx = r.ReadInt32();
+ var v = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
+ if (idx < 0 || idx >= verts.Length || float.IsNaN(v.x) || float.IsNaN(v.y) || float.IsNaN(v.z)) continue;
+ if (v.sqrMagnitude > 1e6f) continue; // Unsinn nie übernehmen
+ verts[idx] = v;
+ }
+ mesh.vertices = verts;
+ mesh.RecalculateNormals();
+ mesh.RecalculateBounds();
+ m.Known = verts;
+ }
+
+ void Scan()
+ {
+ if (types == null)
+ {
+ types = new List();
+ foreach (Assembly a in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ if (a.GetName().Name != "Assembly-CSharp") continue;
+ foreach (string n in DamageTypes)
+ {
+ Type t = a.GetType(n);
+ if (t != null) types.Add(t);
+ }
+ }
+ }
+
+ int before = meshes.Count;
+ foreach (Type t in types)
+ {
+ foreach (UnityEngine.Object o in UnityEngine.Object.FindObjectsOfType(t))
+ {
+ var c = o as Component;
+ if (c == null || !IsVehicle(c.transform.root.name)) continue;
+ foreach (FieldInfo f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
+ {
+ if (f.FieldType == typeof(MeshFilter)) Register(f.GetValue(c) as MeshFilter);
+ else if (f.FieldType == typeof(MeshFilter[]))
+ {
+ var arr = f.GetValue(c) as MeshFilter[];
+ if (arr != null) foreach (MeshFilter mf in arr) Register(mf);
+ }
+ }
+ }
+ }
+ if (meshes.Count != before) MscoLog.Info("[MSCO] Crash damage: " + meshes.Count + " body meshes synced");
+ }
+
+ void Register(MeshFilter mf)
+ {
+ if (mf == null || !seen.Add(mf)) return;
+ Mesh mesh = mf.sharedMesh;
+ if (mesh == null || mesh.vertexCount == 0 || mesh.vertexCount > 65000) return;
+ int key = Hash(FsmSync.PathOf(mf.transform));
+ if (meshes.ContainsKey(key)) return; // nicht eindeutig
+ meshes[key] = new DamageMesh { Key = key, Root = mf.transform.root.name, Filter = mf, Known = mesh.vertices };
+ }
+
+ 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 (char c in s) { h ^= c; h *= 16777619; }
+ return (int)h;
+ }
+ }
+ }
+}
diff --git a/ItemSpawner.cs b/ItemSpawner.cs
index dc1b8a3..36fdcf5 100644
--- a/ItemSpawner.cs
+++ b/ItemSpawner.cs
@@ -47,12 +47,13 @@ namespace MSCO
return objectNames.TryGetValue(item, out name) ? name : item + "(itemx)";
}
- static GameObject FindTemplate(string item)
+ static GameObject FindTemplate(string item) { return FindTemplateByObjectName(ObjectName(item)); }
+
+ static GameObject FindTemplateByObjectName(string name)
{
GameObject cached;
- if (templates.TryGetValue(item, out cached) && cached != null) return cached;
+ if (templates.TryGetValue(name, out cached) && cached != null) return cached;
- string name = ObjectName(item);
GameObject best = null;
foreach (Object o in Resources.FindObjectsOfTypeAll(typeof(GameObject)))
{
@@ -62,20 +63,36 @@ namespace MSCO
if (go.transform.parent != null && go.transform.parent.GetComponentInParent() != null) continue;
if (best == null || (go.activeInHierarchy && !best.activeInHierarchy)) best = go; // am liebsten eins aus der Welt
}
- templates[item] = best;
+ templates[name] = best;
return best;
}
+ /// Zuletzt erzeugtes Objekt (damit der Objekt-Sync es nicht als "neu gekauft" meldet).
+ public static GameObject LastSpawned { get; private set; }
+
+ /// Item nach seinem Objektnamen erzeugen (z.B. "beer case(itemx)") - für Einkäufe anderer Spieler.
+ public static bool SpawnObjectNamed(string objectName, Vector3 position, Quaternion rotation)
+ {
+ if (string.IsNullOrEmpty(objectName) || !objectName.EndsWith("(itemx)") || objectName.Length > 100) return false;
+ return SpawnTemplate(FindTemplateByObjectName(objectName), objectName, position, rotation);
+ }
+
/// Item erzeugen. Gibt false zurück, wenn es im Spiel kein solches Item gibt.
public static bool Spawn(string item, Vector3 position, Quaternion rotation)
{
- GameObject template = FindTemplate(item);
+ return SpawnTemplate(FindTemplate(item), item, position, rotation);
+ }
+
+ static bool SpawnTemplate(GameObject template, string item, Vector3 position, Quaternion rotation)
+ {
+ LastSpawned = null;
if (template == null)
{
MscoLog.Warning("Cannot spawn '" + item + "' - no such item in this game");
return false;
}
var clone = (GameObject)Object.Instantiate(template, position, rotation);
+ LastSpawned = clone;
clone.name = template.name;
clone.transform.SetParent(null, true);
clone.SetActive(true);
diff --git a/MSCO.cs b/MSCO.cs
index 86f34c8..34307d4 100644
--- a/MSCO.cs
+++ b/MSCO.cs
@@ -5,7 +5,7 @@ namespace MSCO
{
public class MSCO : Mod
{
- public const string ModVersion = "2.7";
+ public const string ModVersion = "2.8";
public override string ID => "MSCO"; // Your (unique) mod ID
public override string Name => "MSCO"; // Your mod name
diff --git a/MSCO.csproj b/MSCO.csproj
index 35c6fc1..ae071d0 100644
--- a/MSCO.csproj
+++ b/MSCO.csproj
@@ -73,6 +73,10 @@
+
+
+
+
diff --git a/MultiplayerManager.cs b/MultiplayerManager.cs
index 0a17ce8..e9a350f 100644
--- a/MultiplayerManager.cs
+++ b/MultiplayerManager.cs
@@ -52,6 +52,7 @@ namespace MSCO
readonly LobbyUI lobby = new LobbyUI();
readonly ObjectSync objects = new ObjectSync();
readonly VehicleFx vehicleFx = new VehicleFx();
+ readonly WheelSpin wheelSpin = new WheelSpin();
readonly WorldSync worldSync = new WorldSync();
readonly DiscordPresence discord = new DiscordPresence();
readonly MoneySync moneySync = new MoneySync();
@@ -59,6 +60,9 @@ namespace MSCO
readonly Passenger passenger = new Passenger();
readonly SteamLobby steam = new SteamLobby();
readonly VehicleStateSync vehicleState = new VehicleStateSync();
+ readonly DamageSync damageSync = new DamageSync();
+ readonly WorldVarSync worldVars = new WorldVarSync();
+ readonly TrafficSync traffic = new TrafficSync();
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
@@ -96,6 +100,26 @@ namespace MSCO
Application.runInBackground = true;
playerName = PlayerPrefs.GetString(PrefName, System.Environment.UserName);
objects.Send = SendGame;
+ // Einkäufe / verbrauchte Items bei uns: allen melden
+ objects.ItemAppeared = b =>
+ {
+ Vector3 pos = b.Rb.position;
+ Quaternion rot = b.Rb.rotation;
+ string name = b.Go.name;
+ int id = b.Id;
+ SendGame(MsgType.ItemAppeared, NetSession.Build(w =>
+ {
+ w.Write(id);
+ w.Write(name);
+ w.Write(pos.x); w.Write(pos.y); w.Write(pos.z);
+ w.Write(rot.x); w.Write(rot.y); w.Write(rot.z); w.Write(rot.w);
+ }));
+ };
+ objects.ItemDestroyed = b =>
+ {
+ int id = b.Id;
+ SendGame(MsgType.ObjDestroy, NetSession.Build(w => w.Write(id)));
+ };
FsmSync.Init();
SafetyPatches.Apply(); // verhindert den Absturz durch Kreis in der Objekt-Hierarchie
discord.Start();
@@ -182,6 +206,9 @@ namespace MSCO
var lines = objects.DescribeVehicles();
lines.Add("tracked doors/gates/hoods: " + doorSync.Count);
lines.Add("synced vehicle values (fuel, oil, bolts, wear ...): " + vehicleState.Count);
+ lines.Add("synced body meshes (crash damage): " + damageSync.Count);
+ lines.Add("synced job/shop values: " + worldVars.Count);
+ lines.Add("synced traffic cars: " + traffic.Count + (IsWorldAuthority ? " (simulated here)" : " (following)"));
return lines;
}
@@ -357,6 +384,7 @@ namespace MSCO
Vector3 pos = head.position + forward * 1.2f;
Quaternion rot = Quaternion.LookRotation(forward, Vector3.up);
if (!ItemSpawner.Spawn(item, pos, rot)) { overlays.Log("No '" + item + "' in this game"); return; }
+ objects.MarkNetworkCreated(ItemSpawner.LastSpawned); // wird per SpawnItem gemeldet, nicht als "gekauft"
rescanSoon = true;
SendGame(MsgType.SpawnItem, NetSession.Build(w =>
{
@@ -374,7 +402,22 @@ namespace MSCO
var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
var rot = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
if (!ItemSpawner.IsKnown(item) || item == ItemSpawner.RatchetSet) return false; // nur bekannte Gegenstände
- return ItemSpawner.Spawn(item, pos, rot);
+ bool ok = ItemSpawner.Spawn(item, pos, rot);
+ if (ok && Instance != null) Instance.objects.MarkNetworkCreated(ItemSpawner.LastSpawned);
+ return ok;
+ }
+
+ /// Bei einem anderen Spieler ist ein Item aufgetaucht (Einkauf ...): hier auch erzeugen.
+ bool ApplyAppeared(BinaryReader r)
+ {
+ int id = r.ReadInt32();
+ string name = r.ReadString();
+ var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
+ var rot = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
+ if (objects.HasNear(id, pos, 5f)) return false; // haben wir schon (z.B. war nur kurz ausgeblendet)
+ if (!ItemSpawner.SpawnObjectNamed(name, pos, rot)) return false;
+ objects.MarkNetworkCreated(ItemSpawner.LastSpawned);
+ return true;
}
/// Admin: Bann aufheben (Server: SteamID oder name:..., P2P: Name).
@@ -926,11 +969,11 @@ namespace MSCO
foreach (PlayerInfo p in players.Values) DestroyAvatar(p);
players.Clear();
objects.Reset();
- vehicleFx.Reset();
+ vehicleFx.Reset(); wheelSpin.Reset();
worldSync.Reset();
moneySync.Reset();
doorSync.Reset();
- vehicleState.Reset();
+ vehicleState.Reset(); damageSync.Reset(); worldVars.Reset(); traffic.Reset();
isAdmin = false;
if (overlays.AdminOpen || overlays.ChatOpen) overlays.CloseAll();
FsmSync.Enabled = false;
@@ -957,7 +1000,9 @@ namespace MSCO
{
return t == MsgType.Player || t == MsgType.ObjStates || t == MsgType.ObjOwn
|| 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.TrafficStates || t == MsgType.WorldVars;
}
void OnMessage(int peerId, Message m)
@@ -1245,9 +1290,24 @@ namespace MSCO
case MsgType.VarSync:
vehicleState.Handle(from, r, objects, localId);
break;
+ case MsgType.VehicleDamage:
+ damageSync.Handle(r);
+ break;
+ case MsgType.WorldVars:
+ worldVars.Handle(r);
+ break;
+ case MsgType.TrafficStates:
+ traffic.Handle(r, IsWorldAuthority);
+ break;
case MsgType.SpawnItem:
if (ApplySpawn(r)) rescanSoon = true; // neues Item gleich in den Objekt-Sync aufnehmen
break;
+ case MsgType.ItemAppeared:
+ if (ApplyAppeared(r)) rescanSoon = true;
+ break;
+ case MsgType.ObjDestroy:
+ objects.DestroyRemote(r.ReadInt32());
+ break;
}
}
@@ -1356,8 +1416,8 @@ namespace MSCO
passenger.Reset();
localPlayer = null;
objects.OnGameLoaded();
- vehicleState.Reset();
- vehicleFx.Reset();
+ vehicleState.Reset(); damageSync.Reset(); worldVars.Reset(); traffic.Reset();
+ vehicleFx.Reset(); wheelSpin.Reset();
worldSync.Reset();
moneySync.Reset();
doorSync.Reset();
@@ -1377,7 +1437,7 @@ namespace MSCO
passenger.Reset();
localPlayer = null;
objects.Reset();
- vehicleFx.Reset();
+ vehicleFx.Reset(); wheelSpin.Reset();
worldSync.Reset();
moneySync.Reset();
doorSync.Reset();
@@ -1477,6 +1537,15 @@ namespace MSCO
// Gesamtzustand der Fahrzeuge (Tank, Öl, Verschleiß ...)
try { if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame); }
catch (System.Exception e) { ReportError("vehicle state sync", e); }
+ // Unfallschäden (verbeulte Karosserie)
+ try { if (objects.ScanComplete) damageSync.Update(objects, localId, SendGame); }
+ catch (System.Exception e) { ReportError("crash damage sync", e); }
+ // Jobs, Aufträge, Laden
+ try { if (objects.ScanComplete) worldVars.Update(SendGame); }
+ catch (System.Exception e) { ReportError("jobs/shop sync", e); }
+ // Verkehr: die Welt-Autorität simuliert, alle anderen fahren nach
+ try { if (objects.ScanComplete) traffic.Update(IsWorldAuthority, Time.deltaTime, SendGame); }
+ catch (System.Exception e) { ReportError("traffic sync", e); }
}
foreach (PlayerInfo p in players.Values)
@@ -1523,6 +1592,8 @@ namespace MSCO
if (!IsPeerReady || !MenuHelper.InGame) return;
try { objects.FixedUpdate(); }
catch (System.Exception e) { ReportError("object movement", e); }
+ try { traffic.FixedUpdate(); }
+ catch (System.Exception e) { ReportError("traffic movement", e); }
}
void LateUpdate()
@@ -1531,6 +1602,8 @@ namespace MSCO
// Nach den Spiel-FSMs, damit Lichter/Motorgeräusch der Fahrzeuge anderer gewinnen
try { if (IsPeerReady && MenuHelper.InGame) vehicleFx.Apply(objects); }
catch (System.Exception e) { ReportError("vehicle lights/sound", e); }
+ try { if (IsPeerReady && MenuHelper.InGame) wheelSpin.Apply(objects, Time.deltaTime); }
+ catch (System.Exception e) { ReportError("wheels", e); }
// Beifahrer nach der Fahrzeugbewegung auf den Sitz setzen
try { if (localPlayer != null && MenuHelper.InGame) passenger.LateUpdate(localPlayer, playerCamera, objects); }
catch (System.Exception e) { ReportError("passenger", e); }
@@ -1620,9 +1693,28 @@ namespace MSCO
}
}
+ // Seit dem letzten Spielstand gekaufte bzw. verbrauchte Items
+ var destroyedIds = new List();
+ if (r.BaseStream.Length - r.BaseStream.Position >= 4)
+ {
+ int appeared = r.ReadInt32();
+ for (int i = 0; i < appeared; i++)
+ {
+ byte[] payload = r.ReadBytes(r.ReadInt32());
+ try { ApplyAppeared(new BinaryReader(new MemoryStream(payload))); }
+ catch (System.Exception e) { MscoLog.Warning("Could not restore a bought item: " + e.Message); }
+ }
+ }
+ if (r.BaseStream.Length - r.BaseStream.Position >= 4)
+ {
+ int destroyed = r.ReadInt32();
+ for (int i = 0; i < destroyed; i++) destroyedIds.Add(r.ReadInt32());
+ }
+
// Abgebaute Teile und gespawnte Items sind jetzt eigene Objekte: erfassen, dann alle Positionen setzen
yield return null;
objects.ScanNow();
+ foreach (int id in destroyedIds) objects.DestroyRemote(id);
int applied = 0;
foreach (KeyValuePair p in poses)
if (objects.ApplySnapshotPose(p.Key, p.Value.Position, p.Value.Rotation)) applied++;
diff --git a/NetSession.cs b/NetSession.cs
index 2852425..5ebb2bf 100644
--- a/NetSession.cs
+++ b/NetSession.cs
@@ -44,7 +44,12 @@ namespace MSCO
Reconnect = 33, // Server -> Client: string Meldung; ins Menü und neu verbinden (z.B. Welt zurückgesetzt)
SaveRequest = 34, // Server -> Client: int Anfrage; im Spiel speichern und den Spielstand hochladen
SaveUploadPart = 35, // Client -> Server: int Anfrage, string Datei, int Gesamtgröße, int Offset, int Länge, bytes
- SaveUploadDone = 36 // Client -> Server: int Anfrage, int Anzahl Dateien
+ SaveUploadDone = 36, // Client -> Server: int Anfrage, int Anzahl Dateien
+ ItemAppeared = 37, // neues Item bei einem Spieler (Einkauf, Tüte ausgepackt ...): int Objekt-ID, string Objektname, 3 float Pos, 4 float Rot
+ ObjDestroy = 38, // Item wurde verbraucht/zerstört (gegessen, getrunken ...): int Objekt-ID
+ 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]
}
public struct Message
diff --git a/ObjectSync.cs b/ObjectSync.cs
index 92f58bc..09f16c8 100644
--- a/ObjectSync.cs
+++ b/ObjectSync.cs
@@ -16,6 +16,8 @@ namespace MSCO
public int Id;
public string Path;
public Rigidbody Rb;
+ public GameObject Go; // zum Unterscheiden: nur Rigidbody weg (angebaut) oder ganzes Objekt weg (verbraucht)
+ public Vector3 LastPos;
public bool IsVehicle;
public string RootName;
@@ -89,6 +91,32 @@ namespace MSCO
public int LocalId;
/// Senden an alle anderen Spieler (setzt der MultiplayerManager).
public Action Send;
+ /// Neues Item bei uns aufgetaucht (Einkauf ...) bzw. bei uns verbraucht - der Manager meldet es allen.
+ public Action ItemAppeared, ItemDestroyed;
+
+ // Übers Netz erzeugte Items (nicht nochmal als "neu" melden)
+ readonly HashSet networkCreated = new HashSet();
+ Vector3 lastPlayerPos;
+ const float LocalEventRange = 8f; // Aufgetaucht/verbraucht in dieser Nähe = wir haben es ausgelöst
+
+ public void MarkNetworkCreated(GameObject go) { if (go != null) networkCreated.Add(go); }
+
+ /// Gibt es dieses Objekt bei uns schon an dieser Stelle? (dann nicht nochmal erzeugen)
+ public bool HasNear(int id, Vector3 pos, float range)
+ {
+ SyncedBody b;
+ return byId.TryGetValue(id, out b) && b.Rb != null && (b.Rb.position - pos).sqrMagnitude < range * range;
+ }
+
+ /// Ein anderer Spieler hat dieses Item verbraucht: bei uns auch entfernen.
+ public bool DestroyRemote(int id)
+ {
+ SyncedBody b;
+ if (!byId.TryGetValue(id, out b)) return false;
+ if (b.Go != null) UnityEngine.Object.Destroy(b.Go);
+ Unregister(b);
+ return true;
+ }
readonly Dictionary byId = new Dictionary();
readonly Dictionary byRb = new Dictionary();
@@ -207,6 +235,7 @@ namespace MSCO
byId.Clear();
byRb.Clear();
rejected.Clear();
+ networkCreated.Clear();
drivenName = null;
drivenCache = null;
ScanComplete = false;
@@ -252,6 +281,7 @@ namespace MSCO
public void Update(Transform player, float dt)
{
+ if (player != null) lastPlayerPos = player.position;
if (Time.time >= nextScan)
{
nextScan = Time.time + ScanInterval;
@@ -268,7 +298,17 @@ namespace MSCO
removeList.Clear();
foreach (SyncedBody b in byId.Values)
{
- if (b.Rb == null) { removeList.Add(b); continue; } // z.B. Teil wurde montiert
+ if (b.Rb == null)
+ {
+ // Ganzes Objekt weg (gegessen, getrunken, verbraucht) - nicht nur der Rigidbody (Teil angebaut).
+ // Nur melden, wenn es bei uns passiert ist: wir besaßen es oder standen daneben.
+ if (b.Go == null && !b.IsVehicle && ItemDestroyed != null
+ && (IsLocal(b) || (b.LastPos - lastPlayerPos).sqrMagnitude < LocalEventRange * LocalEventRange))
+ ItemDestroyed(b);
+ removeList.Add(b);
+ continue;
+ }
+ b.LastPos = b.Rb.position;
if (!b.Rb.gameObject.activeInHierarchy) continue;
if (UpdateAttached(b)) continue; // angebaut: bewegt sich mit dem Auto
@@ -847,12 +887,21 @@ namespace MSCO
Id = id,
Path = path,
Rb = c.Key,
+ Go = c.Key.gameObject,
+ LastPos = c.Key.position,
IsVehicle = c.Value,
RootName = c.Key.transform.root.name
};
byId[id] = b;
byRb[c.Key] = b;
added++;
+
+ // Neues Item nach dem ersten Suchlauf, bei uns in der Nähe und nicht übers Netz entstanden:
+ // wahrscheinlich gekauft / ausgepackt / geliefert - den anderen melden
+ bool isItem = !b.IsVehicle && b.Go.name.EndsWith("(itemx)");
+ if (ScanComplete && isItem && ItemAppeared != null && !networkCreated.Contains(b.Go)
+ && (b.LastPos - lastPlayerPos).sqrMagnitude < LocalEventRange * LocalEventRange)
+ ItemAppeared(b);
}
if (added > 0) MscoLog.Info("[MSCO] Syncing " + byId.Count + " objects (+" + added + ")");
}
diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs
index f5784f5..24d1bf4 100644
--- a/Properties/AssemblyInfo.cs
+++ b/Properties/AssemblyInfo.cs
@@ -32,5 +32,5 @@ using System.Runtime.InteropServices;
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("2.7.0.0")]
-[assembly: AssemblyFileVersion("2.7.0.0")]
+[assembly: AssemblyVersion("2.8.0.0")]
+[assembly: AssemblyFileVersion("2.8.0.0")]
diff --git a/TrafficSync.cs b/TrafficSync.cs
new file mode 100644
index 0000000..c57d959
--- /dev/null
+++ b/TrafficSync.cs
@@ -0,0 +1,163 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Verkehr (NPC-Autos): Der Spieler mit der kleinsten ID (Welt-Autorität, gibt auch Uhrzeit/Wetter vor)
+ /// simuliert den Verkehr und schickt die Positionen. Bei allen anderen fahren die Autos dann nur noch nach
+ /// (kinematisch) - so stehen sie überall an derselben Stelle und man kann sich gemeinsam darüber ärgern.
+ ///
+ public class TrafficSync
+ {
+ static readonly string[] Roots = { "TRAFFIC", "NPC_CARS" };
+
+ const float ScanInterval = 10f;
+ const float SendInterval = 0.1f;
+ const float Stale = 1.5f; // so lange ohne Daten: wieder selbst simulieren
+ const float Smoothing = 12f;
+ const float MaxExtrapolation = 0.3f;
+
+ class Car
+ {
+ public int Key;
+ public Rigidbody Rb;
+ public bool WasKinematic;
+ public bool Following;
+ public Vector3 TargetPos, TargetVel;
+ public Quaternion TargetRot;
+ public float TargetTime = -100f;
+ }
+
+ readonly Dictionary cars = new Dictionary();
+ readonly HashSet seen = new HashSet();
+ float nextScan, sendTimer;
+
+ public int Count { get { return cars.Count; } }
+
+ public void Reset()
+ {
+ foreach (Car c in cars.Values) StopFollowing(c);
+ cars.Clear();
+ seen.Clear();
+ nextScan = 0f;
+ }
+
+ public void Update(bool authority, float dt, Action send)
+ {
+ if (Time.time >= nextScan)
+ {
+ nextScan = Time.time + ScanInterval;
+ Scan();
+ }
+
+ if (!authority)
+ {
+ foreach (Car c in cars.Values)
+ if (c.Following && Time.time - c.TargetTime > Stale) StopFollowing(c);
+ return;
+ }
+
+ // Wir simulieren: eventuell noch nachfahrende Autos freigeben, Positionen senden
+ foreach (Car c in cars.Values) if (c.Following) StopFollowing(c);
+ sendTimer += dt;
+ if (sendTimer < SendInterval) return;
+ sendTimer = 0f;
+
+ var list = new List();
+ foreach (Car c in cars.Values)
+ if (c.Rb != null && c.Rb.gameObject.activeInHierarchy) list.Add(c);
+ if (list.Count == 0) return;
+ send(MsgType.TrafficStates, NetSession.Build(w =>
+ {
+ w.Write(list.Count);
+ foreach (Car c in list)
+ {
+ Vector3 p = c.Rb.position, v = c.Rb.velocity;
+ Quaternion q = c.Rb.rotation;
+ w.Write(c.Key);
+ w.Write(p.x); w.Write(p.y); w.Write(p.z);
+ w.Write(q.x); w.Write(q.y); w.Write(q.z); w.Write(q.w);
+ w.Write(v.x); w.Write(v.y); w.Write(v.z);
+ }
+ }));
+ }
+
+ public void Handle(BinaryReader r, bool authority)
+ {
+ int count = r.ReadInt32();
+ for (int i = 0; i < count; i++)
+ {
+ int key = r.ReadInt32();
+ var p = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
+ var q = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
+ var v = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
+ Car c;
+ if (authority || !cars.TryGetValue(key, out c) || c.Rb == null) continue;
+ if (float.IsNaN(p.x) || float.IsNaN(q.w) || p.sqrMagnitude > 1e8f || v.sqrMagnitude > 1e4f) continue;
+ c.TargetPos = p;
+ c.TargetRot = q;
+ c.TargetVel = v;
+ c.TargetTime = Time.time;
+ if (!c.Following)
+ {
+ c.WasKinematic = c.Rb.isKinematic;
+ c.Rb.isKinematic = true;
+ c.Rb.position = p;
+ c.Rb.rotation = q;
+ c.Following = true;
+ }
+ }
+ }
+
+ public void FixedUpdate()
+ {
+ foreach (Car c in cars.Values)
+ {
+ if (!c.Following || c.Rb == null) continue;
+ float ahead = Mathf.Min(Time.time - c.TargetTime, MaxExtrapolation);
+ Vector3 goal = c.TargetPos + c.TargetVel * ahead;
+ float t = Mathf.Clamp01(Time.fixedDeltaTime * Smoothing);
+ if ((c.Rb.position - goal).sqrMagnitude > 25f) { c.Rb.position = goal; c.Rb.rotation = c.TargetRot; continue; }
+ c.Rb.MovePosition(Vector3.Lerp(c.Rb.position, goal, t));
+ c.Rb.MoveRotation(Quaternion.Slerp(c.Rb.rotation, c.TargetRot, t));
+ }
+ }
+
+ static void StopFollowing(Car c)
+ {
+ if (!c.Following) return;
+ c.Following = false;
+ if (c.Rb != null) c.Rb.isKinematic = c.WasKinematic;
+ }
+
+ void Scan()
+ {
+ int before = cars.Count;
+ foreach (Rigidbody rb in UnityEngine.Object.FindObjectsOfType())
+ {
+ if (rb == null || !seen.Add(rb)) continue;
+ if (Array.IndexOf(Roots, rb.transform.root.name) < 0) continue;
+ // nur das oberste Rigidbody je Auto (keine Räder/Türen)
+ Transform parent = rb.transform.parent;
+ if (parent != null && parent.GetComponentInParent() != null) continue;
+ int key = Hash(FsmSync.PathOf(rb.transform));
+ if (cars.ContainsKey(key)) continue;
+ cars[key] = new Car { Key = key, Rb = rb };
+ }
+ if (cars.Count != before) MscoLog.Info("[MSCO] Traffic: " + cars.Count + " cars synced");
+ }
+
+ static int Hash(string s)
+ {
+ unchecked
+ {
+ uint h = 2166136261;
+ foreach (char c in s) { h ^= c; h *= 16777619; }
+ return (int)h;
+ }
+ }
+ }
+}
diff --git a/WheelSpin.cs b/WheelSpin.cs
new file mode 100644
index 0000000..b2e8322
--- /dev/null
+++ b/WheelSpin.cs
@@ -0,0 +1,102 @@
+using System.Collections.Generic;
+using System.Reflection;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Räder an Fahrzeugen, die ein anderer Spieler fährt, drehen sich mit. Bei uns wird so ein Auto nur verschoben
+ /// (kinematisch), die Rad-Physik des Spiels (CarDynamics "Wheel") dreht die Räder dann nicht. Wir drehen das
+ /// Rad-Modell passend zur Geschwindigkeit um seine Achse - nach dem Spiel (LateUpdate), ohne dessen Werte zu ändern.
+ ///
+ public class WheelSpin
+ {
+ const float CacheRefresh = 5f; // Räder können ab-/angebaut werden
+
+ class WheelInfo
+ {
+ public Transform Model;
+ public Transform Hub; // Transform der Wheel-Komponente (Fahrtrichtung)
+ public float Radius;
+ public float Angle;
+ public Quaternion Base; // Drehung ohne unsere Rollbewegung
+ public Quaternion Written; // zuletzt von uns gesetzt
+ public bool HasWritten;
+ }
+
+ class Vehicle
+ {
+ public float BuiltAt;
+ public readonly List Wheels = new List();
+ }
+
+ readonly Dictionary vehicles = new Dictionary();
+ static System.Type wheelType;
+ static FieldInfo modelField, radiusField;
+ static bool lookedUp;
+
+ public void Reset() { vehicles.Clear(); }
+
+ public void Apply(ObjectSync objects, float dt)
+ {
+ if (!Lookup()) return;
+ List driven = objects.RemoteDrivenVehicles();
+ if (driven.Count == 0) { if (vehicles.Count > 0) vehicles.Clear(); return; }
+
+ foreach (SyncedBody b in driven)
+ {
+ Vehicle v;
+ if (!vehicles.TryGetValue(b.Id, out v) || Time.time - v.BuiltAt > CacheRefresh)
+ {
+ v = Build(b.Rb.transform);
+ vehicles[b.Id] = v;
+ }
+
+ Vector3 vel = b.TargetVel;
+ foreach (WheelInfo w in v.Wheels)
+ {
+ if (w.Model == null || w.Hub == null) continue;
+ float speed = Vector3.Dot(vel, w.Hub.forward);
+ w.Angle = Mathf.Repeat(w.Angle + speed / w.Radius * Mathf.Rad2Deg * dt, 360f);
+
+ // Hat das Spiel das Modell in diesem Frame selbst gedreht (z.B. Lenkung), ist das die neue Basis
+ Quaternion current = w.Model.localRotation;
+ if (!w.HasWritten || current != w.Written) w.Base = current;
+ w.Written = w.Base * Quaternion.Euler(w.Angle, 0f, 0f);
+ w.Model.localRotation = w.Written;
+ w.HasWritten = true;
+ }
+ }
+ }
+
+ static Vehicle Build(Transform root)
+ {
+ var v = new Vehicle { BuiltAt = Time.time };
+ foreach (Component c in root.GetComponentsInChildren(wheelType, true))
+ {
+ var model = modelField.GetValue(c) as GameObject;
+ float radius = radiusField != null ? (float)radiusField.GetValue(c) : 0.3f;
+ if (model == null || radius < 0.05f || radius > 2f) continue;
+ v.Wheels.Add(new WheelInfo { Model = model.transform, Hub = c.transform, Radius = radius });
+ }
+ return v;
+ }
+
+ static bool Lookup()
+ {
+ if (lookedUp) return wheelType != null;
+ lookedUp = true;
+ foreach (Assembly a in System.AppDomain.CurrentDomain.GetAssemblies())
+ {
+ if (a.GetName().Name != "Assembly-CSharp") continue;
+ wheelType = a.GetType("Wheel");
+ break;
+ }
+ if (wheelType == null) { MscoLog.Warning("[MSCO] Wheel type not found - remote wheels will not spin"); return false; }
+ modelField = wheelType.GetField("model", BindingFlags.Public | BindingFlags.Instance);
+ radiusField = wheelType.GetField("radius", BindingFlags.Public | BindingFlags.Instance);
+ if (modelField == null) { wheelType = null; return false; }
+ return true;
+ }
+ }
+}
diff --git a/WorldVarSync.cs b/WorldVarSync.cs
new file mode 100644
index 0000000..f84ec36
--- /dev/null
+++ b/WorldVarSync.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using HutongGames.PlayMaker;
+using UnityEngine;
+
+namespace MSCO
+{
+ ///
+ /// Jobs, Aufträge und Laden: deren Fortschritt steht in PlayMaker-FSMs (z.B. "Auftrag angenommen", Anzahl
+ /// geleerter Klärgruben, bestellte Teile). Übertragen werden nur Ganzzahlen und Schalter - keine Kommazahlen
+ /// (Timer, Positionen), die jeder selbst weiterzählt. Wer etwas ändert, schickt es; Werte, die ständig hin und her
+ /// springen (interne Zähler), werden nach kurzer Zeit nicht mehr übertragen.
+ ///
+ public class WorldVarSync
+ {
+ /// Wurzelobjekte mit Jobs/Aufträgen/Laden.
+ public static readonly string[] Roots = { "JOBS", "STORE", "REPAIRSHOP", "INSPECTION", "LANDFILL", "DANCEHALL", "COTTAGE", "RALLY" };
+
+ const float ScanInterval = 15f;
+ const float CheckInterval = 0.5f;
+ const float MinResend = 2f; // höchstens so oft pro Wert
+ const int FlipLimit = 6; // so viele Änderungen ...
+ const float FlipWindow = 30f; // ... in dieser Zeit = interner Zähler, nicht mehr übertragen
+ const int MaxPerMessage = 200;
+
+ class Var
+ {
+ public int Key;
+ public NamedVariable Variable;
+ public byte Type; // 2 int, 3 bool
+ public int LastI;
+ public bool LastB;
+ public float LastSent = -100f;
+ public float WindowStart;
+ public int Flips;
+ public bool Muted;
+ }
+
+ readonly Dictionary vars = new Dictionary();
+ readonly HashSet scanned = new HashSet();
+ readonly List outgoing = new List();
+ float nextScan, nextCheck;
+
+ public int Count { get { return vars.Count; } }
+
+ public void Reset()
+ {
+ vars.Clear();
+ scanned.Clear();
+ nextScan = 0f;
+ }
+
+ public void Update(Action send)
+ {
+ if (Time.time >= nextScan)
+ {
+ nextScan = Time.time + ScanInterval;
+ Scan();
+ }
+ if (Time.time < nextCheck) return;
+ nextCheck = Time.time + CheckInterval;
+
+ outgoing.Clear();
+ foreach (Var v in vars.Values)
+ {
+ if (v.Muted || !Changed(v)) continue;
+ if (Time.time - v.LastSent < MinResend) continue; // später nochmal prüfen
+ Remember(v);
+ if (Time.time - v.WindowStart > FlipWindow) { v.WindowStart = Time.time; v.Flips = 0; }
+ if (++v.Flips > FlipLimit) { v.Muted = true; continue; }
+ v.LastSent = Time.time;
+ outgoing.Add(v);
+ }
+
+ for (int start = 0; start < outgoing.Count; start += MaxPerMessage)
+ {
+ int from = start, count = Math.Min(MaxPerMessage, outgoing.Count - start);
+ send(MsgType.WorldVars, NetSession.Build(w =>
+ {
+ w.Write(count);
+ for (int i = from; i < from + count; i++)
+ {
+ Var v = outgoing[i];
+ w.Write(v.Key);
+ w.Write(v.Type);
+ if (v.Type == 2) w.Write(v.LastI); else w.Write(v.LastB);
+ }
+ }));
+ }
+ }
+
+ public void Handle(BinaryReader r)
+ {
+ int count = r.ReadInt32();
+ for (int i = 0; i < count; i++)
+ {
+ int key = r.ReadInt32();
+ byte type = r.ReadByte();
+ int iv = 0; bool bv = false;
+ if (type == 2) iv = r.ReadInt32(); else if (type == 3) bv = r.ReadBoolean(); else return;
+ Var v;
+ if (!vars.TryGetValue(key, out v) || v.Type != type) continue;
+ if (type == 2) ((FsmInt)v.Variable).Value = iv; else ((FsmBool)v.Variable).Value = bv;
+ Remember(v); // nicht zurückschicken
+ }
+ }
+
+ void Scan()
+ {
+ int before = vars.Count;
+ foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
+ {
+ if (fsm == null || !scanned.Add(fsm)) continue;
+ if (Array.IndexOf(Roots, fsm.transform.root.name) < 0) continue;
+ string prefix = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName + "|";
+ FsmVariables fv = fsm.FsmVariables;
+ foreach (FsmInt n in fv.IntVariables) Register(prefix, n, 2);
+ foreach (FsmBool b in fv.BoolVariables) Register(prefix, b, 3);
+ }
+ if (vars.Count != before) MscoLog.Info("[MSCO] Jobs/shop: " + vars.Count + " values synced");
+ }
+
+ void Register(string prefix, NamedVariable variable, byte type)
+ {
+ if (variable == null || string.IsNullOrEmpty(variable.Name)) return;
+ int key = Hash(prefix + variable.Name);
+ if (vars.ContainsKey(key)) return;
+ var v = new Var { Key = key, Variable = variable, Type = type };
+ Remember(v);
+ vars[key] = v;
+ }
+
+ static bool Changed(Var v)
+ {
+ return v.Type == 2 ? ((FsmInt)v.Variable).Value != v.LastI : ((FsmBool)v.Variable).Value != v.LastB;
+ }
+
+ static void Remember(Var v)
+ {
+ if (v.Type == 2) v.LastI = ((FsmInt)v.Variable).Value; else v.LastB = ((FsmBool)v.Variable).Value;
+ }
+
+ static int Hash(string s)
+ {
+ unchecked
+ {
+ uint h = 2166136261;
+ foreach (byte c in Encoding.UTF8.GetBytes(s)) { h ^= c; h *= 16777619; }
+ return (int)h;
+ }
+ }
+ }
+}