2.8: sync bought/consumed items, wheel spin, crash damage, jobs/shop, traffic

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 14:58:38 +02:00
co-authored by Claude Opus 5.5
parent 96e8790f0b
commit 2eb15f2166
12 changed files with 799 additions and 17 deletions
+13
View File
@@ -1,5 +1,18 @@
# Changelog # 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 ## 2.7
Server und Mod müssen beide 2.7 sein (Server: `git pull && docker compose up -d --build`). Server und Mod müssen beide 2.7 sein (Server: `git pull && docker compose up -d --build`).
+182
View File
@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// 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.
/// </summary>
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<int, DamageMesh> meshes = new Dictionary<int, DamageMesh>();
readonly HashSet<MeshFilter> seen = new HashSet<MeshFilter>();
readonly List<int> changed = new List<int>();
float nextScan, nextCheck;
static List<Type> 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<MsgType, byte[]> 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<Type>();
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;
}
}
}
}
+22 -5
View File
@@ -47,12 +47,13 @@ namespace MSCO
return objectNames.TryGetValue(item, out name) ? name : item + "(itemx)"; 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; 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; GameObject best = null;
foreach (Object o in Resources.FindObjectsOfTypeAll(typeof(GameObject))) foreach (Object o in Resources.FindObjectsOfTypeAll(typeof(GameObject)))
{ {
@@ -62,20 +63,36 @@ namespace MSCO
if (go.transform.parent != null && go.transform.parent.GetComponentInParent<Rigidbody>() != null) continue; if (go.transform.parent != null && go.transform.parent.GetComponentInParent<Rigidbody>() != null) continue;
if (best == null || (go.activeInHierarchy && !best.activeInHierarchy)) best = go; // am liebsten eins aus der Welt if (best == null || (go.activeInHierarchy && !best.activeInHierarchy)) best = go; // am liebsten eins aus der Welt
} }
templates[item] = best; templates[name] = best;
return best; return best;
} }
/// <summary>Zuletzt erzeugtes Objekt (damit der Objekt-Sync es nicht als "neu gekauft" meldet).</summary>
public static GameObject LastSpawned { get; private set; }
/// <summary>Item nach seinem Objektnamen erzeugen (z.B. "beer case(itemx)") - für Einkäufe anderer Spieler.</summary>
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);
}
/// <summary>Item erzeugen. Gibt false zurück, wenn es im Spiel kein solches Item gibt.</summary> /// <summary>Item erzeugen. Gibt false zurück, wenn es im Spiel kein solches Item gibt.</summary>
public static bool Spawn(string item, Vector3 position, Quaternion rotation) 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) if (template == null)
{ {
MscoLog.Warning("Cannot spawn '" + item + "' - no such item in this game"); MscoLog.Warning("Cannot spawn '" + item + "' - no such item in this game");
return false; return false;
} }
var clone = (GameObject)Object.Instantiate(template, position, rotation); var clone = (GameObject)Object.Instantiate(template, position, rotation);
LastSpawned = clone;
clone.name = template.name; clone.name = template.name;
clone.transform.SetParent(null, true); clone.transform.SetParent(null, true);
clone.SetActive(true); clone.SetActive(true);
+1 -1
View File
@@ -5,7 +5,7 @@ namespace MSCO
{ {
public class MSCO : Mod 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 ID => "MSCO"; // Your (unique) mod ID
public override string Name => "MSCO"; // Your mod name public override string Name => "MSCO"; // Your mod name
+4
View File
@@ -73,6 +73,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="MoneySync.cs" /> <Compile Include="MoneySync.cs" />
<Compile Include="WheelSpin.cs" />
<Compile Include="DamageSync.cs" />
<Compile Include="WorldVarSync.cs" />
<Compile Include="TrafficSync.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" />
+99 -7
View File
@@ -52,6 +52,7 @@ namespace MSCO
readonly LobbyUI lobby = new LobbyUI(); readonly LobbyUI lobby = new LobbyUI();
readonly ObjectSync objects = new ObjectSync(); readonly ObjectSync objects = new ObjectSync();
readonly VehicleFx vehicleFx = new VehicleFx(); readonly VehicleFx vehicleFx = new VehicleFx();
readonly WheelSpin wheelSpin = new WheelSpin();
readonly WorldSync worldSync = new WorldSync(); readonly WorldSync worldSync = new WorldSync();
readonly DiscordPresence discord = new DiscordPresence(); readonly DiscordPresence discord = new DiscordPresence();
readonly MoneySync moneySync = new MoneySync(); readonly MoneySync moneySync = new MoneySync();
@@ -59,6 +60,9 @@ namespace MSCO
readonly Passenger passenger = new Passenger(); readonly Passenger passenger = new Passenger();
readonly SteamLobby steam = new SteamLobby(); readonly SteamLobby steam = new SteamLobby();
readonly VehicleStateSync vehicleState = new VehicleStateSync(); 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(); 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
@@ -96,6 +100,26 @@ namespace MSCO
Application.runInBackground = true; Application.runInBackground = true;
playerName = PlayerPrefs.GetString(PrefName, System.Environment.UserName); playerName = PlayerPrefs.GetString(PrefName, System.Environment.UserName);
objects.Send = SendGame; 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(); FsmSync.Init();
SafetyPatches.Apply(); // verhindert den Absturz durch Kreis in der Objekt-Hierarchie SafetyPatches.Apply(); // verhindert den Absturz durch Kreis in der Objekt-Hierarchie
discord.Start(); discord.Start();
@@ -182,6 +206,9 @@ namespace MSCO
var lines = objects.DescribeVehicles(); var lines = objects.DescribeVehicles();
lines.Add("tracked doors/gates/hoods: " + doorSync.Count); lines.Add("tracked doors/gates/hoods: " + doorSync.Count);
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 job/shop values: " + worldVars.Count);
lines.Add("synced traffic cars: " + traffic.Count + (IsWorldAuthority ? " (simulated here)" : " (following)"));
return lines; return lines;
} }
@@ -357,6 +384,7 @@ namespace MSCO
Vector3 pos = head.position + forward * 1.2f; Vector3 pos = head.position + forward * 1.2f;
Quaternion rot = Quaternion.LookRotation(forward, Vector3.up); Quaternion rot = Quaternion.LookRotation(forward, Vector3.up);
if (!ItemSpawner.Spawn(item, pos, rot)) { overlays.Log("No '" + item + "' in this game"); return; } 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; rescanSoon = true;
SendGame(MsgType.SpawnItem, NetSession.Build(w => SendGame(MsgType.SpawnItem, NetSession.Build(w =>
{ {
@@ -374,7 +402,22 @@ namespace MSCO
var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
var rot = new Quaternion(r.ReadSingle(), 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 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;
}
/// <summary>Bei einem anderen Spieler ist ein Item aufgetaucht (Einkauf ...): hier auch erzeugen.</summary>
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;
} }
/// <summary>Admin: Bann aufheben (Server: SteamID oder name:..., P2P: Name).</summary> /// <summary>Admin: Bann aufheben (Server: SteamID oder name:..., P2P: Name).</summary>
@@ -926,11 +969,11 @@ namespace MSCO
foreach (PlayerInfo p in players.Values) DestroyAvatar(p); foreach (PlayerInfo p in players.Values) DestroyAvatar(p);
players.Clear(); players.Clear();
objects.Reset(); objects.Reset();
vehicleFx.Reset(); vehicleFx.Reset(); wheelSpin.Reset();
worldSync.Reset(); worldSync.Reset();
moneySync.Reset(); moneySync.Reset();
doorSync.Reset(); doorSync.Reset();
vehicleState.Reset(); vehicleState.Reset(); damageSync.Reset(); worldVars.Reset(); traffic.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;
@@ -957,7 +1000,9 @@ namespace MSCO
{ {
return t == MsgType.Player || t == MsgType.ObjStates || t == MsgType.ObjOwn 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.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) void OnMessage(int peerId, Message m)
@@ -1245,9 +1290,24 @@ namespace MSCO
case MsgType.VarSync: case MsgType.VarSync:
vehicleState.Handle(from, r, objects, localId); vehicleState.Handle(from, r, objects, localId);
break; 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: case MsgType.SpawnItem:
if (ApplySpawn(r)) rescanSoon = true; // neues Item gleich in den Objekt-Sync aufnehmen if (ApplySpawn(r)) rescanSoon = true; // neues Item gleich in den Objekt-Sync aufnehmen
break; 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(); passenger.Reset();
localPlayer = null; localPlayer = null;
objects.OnGameLoaded(); objects.OnGameLoaded();
vehicleState.Reset(); vehicleState.Reset(); damageSync.Reset(); worldVars.Reset(); traffic.Reset();
vehicleFx.Reset(); vehicleFx.Reset(); wheelSpin.Reset();
worldSync.Reset(); worldSync.Reset();
moneySync.Reset(); moneySync.Reset();
doorSync.Reset(); doorSync.Reset();
@@ -1377,7 +1437,7 @@ namespace MSCO
passenger.Reset(); passenger.Reset();
localPlayer = null; localPlayer = null;
objects.Reset(); objects.Reset();
vehicleFx.Reset(); vehicleFx.Reset(); wheelSpin.Reset();
worldSync.Reset(); worldSync.Reset();
moneySync.Reset(); moneySync.Reset();
doorSync.Reset(); doorSync.Reset();
@@ -1477,6 +1537,15 @@ namespace MSCO
// Gesamtzustand der Fahrzeuge (Tank, Öl, Verschleiß ...) // Gesamtzustand der Fahrzeuge (Tank, Öl, Verschleiß ...)
try { if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame); } try { if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame); }
catch (System.Exception e) { ReportError("vehicle state sync", e); } 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) foreach (PlayerInfo p in players.Values)
@@ -1523,6 +1592,8 @@ namespace MSCO
if (!IsPeerReady || !MenuHelper.InGame) return; if (!IsPeerReady || !MenuHelper.InGame) return;
try { objects.FixedUpdate(); } try { objects.FixedUpdate(); }
catch (System.Exception e) { ReportError("object movement", e); } catch (System.Exception e) { ReportError("object movement", e); }
try { traffic.FixedUpdate(); }
catch (System.Exception e) { ReportError("traffic movement", e); }
} }
void LateUpdate() void LateUpdate()
@@ -1531,6 +1602,8 @@ namespace MSCO
// Nach den Spiel-FSMs, damit Lichter/Motorgeräusch der Fahrzeuge anderer gewinnen // Nach den Spiel-FSMs, damit Lichter/Motorgeräusch der Fahrzeuge anderer gewinnen
try { if (IsPeerReady && MenuHelper.InGame) vehicleFx.Apply(objects); } try { if (IsPeerReady && MenuHelper.InGame) vehicleFx.Apply(objects); }
catch (System.Exception e) { ReportError("vehicle lights/sound", e); } 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 // Beifahrer nach der Fahrzeugbewegung auf den Sitz setzen
try { if (localPlayer != null && MenuHelper.InGame) passenger.LateUpdate(localPlayer, playerCamera, objects); } try { if (localPlayer != null && MenuHelper.InGame) passenger.LateUpdate(localPlayer, playerCamera, objects); }
catch (System.Exception e) { ReportError("passenger", e); } 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<int>();
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 // Abgebaute Teile und gespawnte Items sind jetzt eigene Objekte: erfassen, dann alle Positionen setzen
yield return null; yield return null;
objects.ScanNow(); objects.ScanNow();
foreach (int id in destroyedIds) objects.DestroyRemote(id);
int applied = 0; int applied = 0;
foreach (KeyValuePair<int, Pose> p in poses) foreach (KeyValuePair<int, Pose> p in poses)
if (objects.ApplySnapshotPose(p.Key, p.Value.Position, p.Value.Rotation)) applied++; if (objects.ApplySnapshotPose(p.Key, p.Value.Position, p.Value.Rotation)) applied++;
+6 -1
View File
@@ -44,7 +44,12 @@ namespace MSCO
Reconnect = 33, // Server -> Client: string Meldung; ins Menü und neu verbinden (z.B. Welt zurückgesetzt) 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 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 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 public struct Message
+50 -1
View File
@@ -16,6 +16,8 @@ namespace MSCO
public int Id; public int Id;
public string Path; public string Path;
public Rigidbody Rb; public Rigidbody Rb;
public GameObject Go; // zum Unterscheiden: nur Rigidbody weg (angebaut) oder ganzes Objekt weg (verbraucht)
public Vector3 LastPos;
public bool IsVehicle; public bool IsVehicle;
public string RootName; public string RootName;
@@ -89,6 +91,32 @@ namespace MSCO
public int LocalId; public int LocalId;
/// <summary>Senden an alle anderen Spieler (setzt der MultiplayerManager).</summary> /// <summary>Senden an alle anderen Spieler (setzt der MultiplayerManager).</summary>
public Action<MsgType, byte[]> Send; public Action<MsgType, byte[]> Send;
/// <summary>Neues Item bei uns aufgetaucht (Einkauf ...) bzw. bei uns verbraucht - der Manager meldet es allen.</summary>
public Action<SyncedBody> ItemAppeared, ItemDestroyed;
// Übers Netz erzeugte Items (nicht nochmal als "neu" melden)
readonly HashSet<GameObject> networkCreated = new HashSet<GameObject>();
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); }
/// <summary>Gibt es dieses Objekt bei uns schon an dieser Stelle? (dann nicht nochmal erzeugen)</summary>
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;
}
/// <summary>Ein anderer Spieler hat dieses Item verbraucht: bei uns auch entfernen.</summary>
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<int, SyncedBody> byId = new Dictionary<int, SyncedBody>(); readonly Dictionary<int, SyncedBody> byId = new Dictionary<int, SyncedBody>();
readonly Dictionary<Rigidbody, SyncedBody> byRb = new Dictionary<Rigidbody, SyncedBody>(); readonly Dictionary<Rigidbody, SyncedBody> byRb = new Dictionary<Rigidbody, SyncedBody>();
@@ -207,6 +235,7 @@ namespace MSCO
byId.Clear(); byId.Clear();
byRb.Clear(); byRb.Clear();
rejected.Clear(); rejected.Clear();
networkCreated.Clear();
drivenName = null; drivenName = null;
drivenCache = null; drivenCache = null;
ScanComplete = false; ScanComplete = false;
@@ -252,6 +281,7 @@ namespace MSCO
public void Update(Transform player, float dt) public void Update(Transform player, float dt)
{ {
if (player != null) lastPlayerPos = player.position;
if (Time.time >= nextScan) if (Time.time >= nextScan)
{ {
nextScan = Time.time + ScanInterval; nextScan = Time.time + ScanInterval;
@@ -268,7 +298,17 @@ namespace MSCO
removeList.Clear(); removeList.Clear();
foreach (SyncedBody b in byId.Values) 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 (!b.Rb.gameObject.activeInHierarchy) continue;
if (UpdateAttached(b)) continue; // angebaut: bewegt sich mit dem Auto if (UpdateAttached(b)) continue; // angebaut: bewegt sich mit dem Auto
@@ -847,12 +887,21 @@ namespace MSCO
Id = id, Id = id,
Path = path, Path = path,
Rb = c.Key, Rb = c.Key,
Go = c.Key.gameObject,
LastPos = c.Key.position,
IsVehicle = c.Value, IsVehicle = c.Value,
RootName = c.Key.transform.root.name RootName = c.Key.transform.root.name
}; };
byId[id] = b; byId[id] = b;
byRb[c.Key] = b; byRb[c.Key] = b;
added++; 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 + ")"); if (added > 0) MscoLog.Info("[MSCO] Syncing " + byId.Count + " objects (+" + added + ")");
} }
+2 -2
View File
@@ -32,5 +32,5 @@ using System.Runtime.InteropServices;
// You can specify all the values or you can default the Build and Revision Numbers // You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below: // by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")] // [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("2.7.0.0")] [assembly: AssemblyVersion("2.8.0.0")]
[assembly: AssemblyFileVersion("2.7.0.0")] [assembly: AssemblyFileVersion("2.8.0.0")]
+163
View File
@@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// 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.
/// </summary>
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<int, Car> cars = new Dictionary<int, Car>();
readonly HashSet<Rigidbody> seen = new HashSet<Rigidbody>();
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<MsgType, byte[]> 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<Car>();
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<Rigidbody>())
{
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<Rigidbody>() != 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;
}
}
}
}
+102
View File
@@ -0,0 +1,102 @@
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// 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.
/// </summary>
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<WheelInfo> Wheels = new List<WheelInfo>();
}
readonly Dictionary<int, Vehicle> vehicles = new Dictionary<int, Vehicle>();
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<SyncedBody> 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;
}
}
}
+155
View File
@@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// 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.
/// </summary>
public class WorldVarSync
{
/// <summary>Wurzelobjekte mit Jobs/Aufträgen/Laden.</summary>
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<int, Var> vars = new Dictionary<int, Var>();
readonly HashSet<PlayMakerFSM> scanned = new HashSet<PlayMakerFSM>();
readonly List<Var> outgoing = new List<Var>();
float nextScan, nextCheck;
public int Count { get { return vars.Count; } }
public void Reset()
{
vars.Clear();
scanned.Clear();
nextScan = 0f;
}
public void Update(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 (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;
}
}
}
}