Beta Release

This commit is contained in:
2026-09-26 19:37:28 +02:00
parent adca4d9167
commit e77ccf2f33
29 changed files with 6819 additions and 293 deletions
+50
View File
@@ -0,0 +1,50 @@
using System.IO;
using System.Reflection;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// MSCO-Logo (branding/logo-128.png, als Ressource in die DLL eingebettet):
/// Symbol in der MSCLoader-Modliste und im Launcher.
/// </summary>
public static class Branding
{
const string LogoResource = "MSCO.logo.png";
static byte[] logoPng;
static Texture2D logoTexture;
public static byte[] LogoPng
{
get
{
if (logoPng != null) return logoPng;
using (Stream s = Assembly.GetExecutingAssembly().GetManifestResourceStream(LogoResource))
{
if (s == null) return logoPng = new byte[0];
var data = new byte[s.Length];
int read = 0;
while (read < data.Length) read += s.Read(data, read, data.Length - read);
logoPng = data;
}
return logoPng;
}
}
/// <summary>Logo als Textur für OnGUI (nur im Hauptthread verwenden).</summary>
public static Texture2D LogoTexture
{
get
{
if (logoTexture != null) return logoTexture;
if (LogoPng.Length == 0) return null;
logoTexture = new Texture2D(2, 2, TextureFormat.ARGB32, false);
logoTexture.LoadImage(LogoPng);
logoTexture.filterMode = FilterMode.Bilinear;
logoTexture.hideFlags = HideFlags.HideAndDontSave;
return logoTexture;
}
}
}
}
+89
View File
@@ -0,0 +1,89 @@
using System;
namespace MSCO
{
/// <summary>
/// Alle MSCO-Befehle an einer Stelle - genutzt von der eigenen F8-Konsole (ohne "mp" davor)
/// und von der MSCLoader-Konsole ("mp ...").
/// </summary>
public static class Commands
{
public const string Help =
"connect <host[:port]> - join a dedicated server\n" +
"disconnect - leave the server / game\n" +
"host [port] / join <ip> [port] - LAN test session\n" +
"save - save the game (host / singleplayer)\n" +
"/admin - admin menu (kick / ban)\n" +
"vehicles | rig - debug info about vehicles / player models\n" +
"debug - toggle sync debug output\n" +
"master [url] - show / change the server list address\n" +
"clear - clear this console";
public static void Execute(string[] args, Action<string> print)
{
var mp = MultiplayerManager.Instance;
if (mp == null) { print("Not ready yet"); return; }
if (args.Length == 0) { print(Help); return; }
int port = MultiplayerManager.DefaultPort;
switch (args[0].ToLower())
{
case "help":
case "?":
print(Help);
break;
case "host":
if (args.Length > 1) int.TryParse(args[1], out port);
mp.Host(port);
break;
case "join":
if (args.Length < 2) { print("join <ip> [port]"); return; }
if (args.Length > 2) int.TryParse(args[2], out port);
mp.Join(args[1], port);
break;
case "stop":
case "disconnect":
case "quit":
mp.Disconnect();
print("Disconnected.");
break;
case "connect":
{
string host;
int serverPort;
if (args.Length < 2 || !ServerBrowser.TryParseAddress(args[1], out host, out serverPort))
{
print("connect <host[:port]>");
return;
}
mp.JoinServer(host, serverPort);
break;
}
case "master":
if (args.Length > 1) ServerBrowser.MasterUrl = args[1];
print("Master server: " + ServerBrowser.MasterUrl);
break;
case "save":
mp.SaveGame();
break;
case "admin":
case "/admin":
mp.OpenAdminMenu();
break;
case "rig":
foreach (string line in mp.DescribeRigs()) print(line);
break;
case "vehicles":
foreach (string line in mp.DescribeVehicles()) print(line);
break;
case "debug":
FsmSync.Debug = !FsmSync.Debug;
print("Sync debug output " + (FsmSync.Debug ? "ON" : "OFF"));
break;
default:
print("Unknown command '" + args[0] + "' - type 'help'");
break;
}
}
}
}
+254
View File
@@ -0,0 +1,254 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using MSCLoader;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Discord Rich Presence über Discords lokale IPC-Schnittstelle (Named Pipe "discord-ipc-N"),
/// ohne zusätzliche DLL. Liest/schreibt direkt mit ReadFile/WriteFile (Unitys altes Mono kommt
/// mit Pipes in FileStream nicht zurecht). Läuft in einem Hintergrund-Thread; ist Discord nicht
/// offen, wird es alle 15 s erneut versucht. Statusmeldungen landen über TakeLog() in der Konsole.
/// </summary>
public class DiscordPresence
{
/// <summary>
/// Application ID aus dem Discord Developer Portal (https://discord.com/developers/applications).
/// Unter "Rich Presence → Art Assets" ein Bild mit dem Namen "logo" hochladen (branding/logo-512.png).
/// </summary>
public const string ApplicationId = "1553384871303450664";
const string ServersUrl = "https://msco.mbm.wtf/servers";
const int RetrySeconds = 15;
readonly object gate = new object();
readonly Queue<string> log = new Queue<string>();
string pendingActivity; // JSON der Aktivität, die noch gesendet werden muss
string lastActivity;
string lastStatus;
Thread worker;
volatile bool running;
int nonce;
int pid;
public static bool Configured
{
get { return ApplicationId.Trim('0').Length > 0; }
}
public void Start()
{
if (running) return;
if (!Configured)
{
ModConsole.Print("[MSCO] Discord Rich Presence: no application id set (DiscordPresence.ApplicationId)");
return;
}
if (Application.platform != RuntimePlatform.WindowsPlayer && Application.platform != RuntimePlatform.WindowsEditor) return;
try { pid = System.Diagnostics.Process.GetCurrentProcess().Id; } catch { pid = 0; }
running = true;
worker = new Thread(Run) { IsBackground = true, Name = "MSCO Discord" };
worker.Start();
}
public void Stop()
{
running = false;
}
/// <summary>Statusmeldungen des Hintergrund-Threads (im Hauptthread in die Konsole schreiben).</summary>
public string TakeLog()
{
lock (gate) return log.Count > 0 ? log.Dequeue() : null;
}
void Status(string text)
{
lock (gate)
{
if (text == lastStatus) return; // gleiche Meldung nicht alle 15 s wiederholen
lastStatus = text;
log.Enqueue(text);
}
}
/// <summary>Neue Anzeige setzen (wird nur gesendet, wenn sie sich geändert hat).</summary>
public void SetActivity(string details, string state, int partySize, int partyMax, string partyId, long startUnix)
{
var sb = new StringBuilder();
sb.Append("{\"details\":").Append(Json(details));
if (!string.IsNullOrEmpty(state)) sb.Append(",\"state\":").Append(Json(state));
if (startUnix > 0) sb.Append(",\"timestamps\":{\"start\":").Append(startUnix).Append('}');
sb.Append(",\"assets\":{\"large_image\":\"logo\",\"large_text\":\"My Summer Car Online\"}");
if (partySize > 0 && partyMax >= partySize)
sb.Append(",\"party\":{\"id\":").Append(Json(partyId ?? "msco")).Append(",\"size\":[").Append(partySize).Append(',').Append(partyMax).Append("]}");
sb.Append(",\"buttons\":[{\"label\":\"MSCO Servers\",\"url\":").Append(Json(ServersUrl)).Append("}]}");
string activity = sb.ToString();
lock (gate)
{
if (activity == lastActivity) return;
lastActivity = activity;
pendingActivity = activity;
}
}
// ---------------------------------------------------------------- Hintergrund-Thread
void Run()
{
while (running)
{
IntPtr pipe = InvalidHandle;
try
{
pipe = Connect();
if (pipe == InvalidHandle)
{
Status("Discord not running (will retry)");
Sleep(RetrySeconds);
continue;
}
Write(pipe, 0, "{\"v\":1,\"client_id\":" + Json(ApplicationId) + "}");
string ready = Read(pipe);
if (ready.IndexOf("READY", StringComparison.Ordinal) < 0) throw new Exception("Unexpected handshake reply: " + Short(ready));
Status("Discord connected");
lock (gate) pendingActivity = lastActivity; // nach (Wieder-)Verbindung erneut senden
while (running)
{
string activity;
lock (gate)
{
activity = pendingActivity;
pendingActivity = null;
}
if (activity != null)
{
Write(pipe, 1, "{\"cmd\":\"SET_ACTIVITY\",\"args\":{\"pid\":" + pid + ",\"activity\":" + activity +
"},\"nonce\":\"" + (++nonce) + "\"}");
string reply = Read(pipe);
if (reply.IndexOf("\"evt\":\"ERROR\"", StringComparison.Ordinal) >= 0)
Status("Discord rejected the activity: " + Short(reply));
}
Thread.Sleep(1000);
}
}
catch (Exception e)
{
Status("Discord connection lost: " + e.Message);
}
finally
{
if (pipe != InvalidHandle) CloseHandle(pipe);
}
if (running) Sleep(RetrySeconds);
}
}
void Sleep(int seconds)
{
for (int i = 0; i < seconds * 10 && running; i++) Thread.Sleep(100);
}
static IntPtr Connect()
{
for (int i = 0; i < 10; i++)
{
IntPtr handle = CreateFile(@"\\.\pipe\discord-ipc-" + i, GenericRead | GenericWrite, 0, IntPtr.Zero, OpenExisting, 0, IntPtr.Zero);
if (handle != InvalidHandle) return handle;
}
return InvalidHandle;
}
/// <summary>Discord-IPC-Frame: int32 Opcode, int32 Länge, JSON (UTF-8).</summary>
static void Write(IntPtr pipe, int opcode, string json)
{
byte[] payload = Encoding.UTF8.GetBytes(json);
var frame = new byte[8 + payload.Length];
BitConverter.GetBytes(opcode).CopyTo(frame, 0);
BitConverter.GetBytes(payload.Length).CopyTo(frame, 4);
Buffer.BlockCopy(payload, 0, frame, 8, payload.Length);
int written;
if (!WriteFile(pipe, frame, frame.Length, out written, IntPtr.Zero) || written != frame.Length)
throw new Exception("WriteFile failed (" + Marshal.GetLastWin32Error() + ")");
}
static string Read(IntPtr pipe)
{
byte[] header = ReadExactly(pipe, 8);
int opcode = BitConverter.ToInt32(header, 0);
int length = BitConverter.ToInt32(header, 4);
if (length < 0 || length > 64 * 1024) throw new Exception("Bad Discord frame");
string json = Encoding.UTF8.GetString(ReadExactly(pipe, length));
if (opcode == 2) throw new Exception("Discord closed the connection: " + Short(json));
return json;
}
static byte[] ReadExactly(IntPtr pipe, int count)
{
var data = new byte[count];
int offset = 0;
var chunk = new byte[count];
while (offset < count)
{
int read;
if (!ReadFile(pipe, chunk, count - offset, out read, IntPtr.Zero) || read <= 0)
throw new Exception("ReadFile failed (" + Marshal.GetLastWin32Error() + ")");
Buffer.BlockCopy(chunk, 0, data, offset, read);
offset += read;
}
return data;
}
static string Short(string s)
{
return s == null ? "" : s.Length > 160 ? s.Substring(0, 160) + "..." : s;
}
static string Json(string value)
{
var sb = new StringBuilder("\"");
foreach (char c in value ?? "")
{
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\n': sb.Append("\\n"); break;
case '\r': break;
case '\t': sb.Append(' '); break;
default:
if (c < 0x20) break;
sb.Append(c);
break;
}
}
return sb.Append('"').ToString();
}
static readonly IntPtr InvalidHandle = new IntPtr(-1);
const uint GenericRead = 0x80000000;
const uint GenericWrite = 0x40000000;
const uint OpenExisting = 3;
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern IntPtr CreateFile(string name, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool ReadFile(IntPtr handle, byte[] buffer, int toRead, out int read, IntPtr overlapped);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool WriteFile(IntPtr handle, byte[] buffer, int toWrite, out int written, IntPtr overlapped);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool CloseHandle(IntPtr handle);
}
}
+122
View File
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.IO;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Türen, Tore, Hauben, Klappen (Garagentor, Haustüren, Autotüren ...). Die Mod beobachtet die
/// PlayMaker-FSMs dieser Objekte: Wechselt eine bei uns in einen neuen Zustand (z.B. "Open door"),
/// wird dieser Zustand an die anderen geschickt und dort ebenfalls ausgelöst. Unabhängig davon,
/// wie der Spieler das Objekt bedient hat (Klick, Taste, Hand-FSM ...).
/// </summary>
public class DoorSync
{
static readonly string[] Keywords = { "door", "gate", "hood", "bonnet", "trunk", "lid", "hatch" };
const float Cooldown = 1.5f; // Folge-Zustände der Animation nicht nochmal senden
const float RescanInterval = 10f; // neue Objekte (z.B. angebaute Autotüren) aufnehmen
class Tracked
{
public string Key;
public PlayMakerFSM Fsm;
public string LastState;
public float QuietUntil;
}
readonly Dictionary<string, Tracked> byKey = new Dictionary<string, Tracked>();
float nextScan;
public int Count { get { return byKey.Count; } }
public void Reset()
{
byKey.Clear();
nextScan = 0f;
}
public void Update(Action<MsgType, byte[]> send)
{
float now = Time.realtimeSinceStartup;
if (now >= nextScan)
{
nextScan = now + RescanInterval;
Scan();
}
foreach (Tracked t in byKey.Values)
{
if (t.Fsm == null) continue;
string state = t.Fsm.ActiveStateName;
if (state == t.LastState) continue;
t.LastState = state;
if (now < t.QuietUntil) continue; // Folge einer eigenen oder fremden Aktion
FsmState s = t.Fsm.Fsm.GetState(state);
if (s == null || FsmSync.ChecksInputPublic(s)) continue; // nur "Maus zeigt drauf" o.ä.
t.QuietUntil = now + Cooldown;
string key = t.Key;
send(MsgType.DoorState, NetSession.Build(w => { w.Write(key); w.Write(state); }));
if (FsmSync.Debug) ModConsole.Print("[MSCO] >> DOOR " + key + " -> " + state);
}
}
public void Apply(BinaryReader r)
{
ApplyState(r.ReadString(), r.ReadString());
}
/// <summary>Zustand übernehmen (von einem Mitspieler oder aus dem Server-Snapshot).</summary>
public void ApplyState(string key, string state)
{
Tracked t;
if (!byKey.TryGetValue(key, out t) || t.Fsm == null)
{
Scan(); // Objekt vielleicht erst seit kurzem aktiv
if (!byKey.TryGetValue(key, out t) || t.Fsm == null)
{
if (FsmSync.Debug) ModConsole.Warning("[MSCO] << DOOR not found: " + key);
return;
}
}
if (t.Fsm.ActiveStateName == state) return;
FsmState target = t.Fsm.Fsm.GetState(state);
if (target == null) return;
t.QuietUntil = Time.realtimeSinceStartup + Cooldown; // Folge-Zustände nicht zurückschicken
t.LastState = state;
FsmSync.SwitchStateSilently(t.Fsm, target);
if (FsmSync.Debug) ModConsole.Print("[MSCO] << DOOR " + key + " -> " + state);
}
void Scan()
{
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
{
if (fsm == null) continue;
string root = fsm.transform.root.name;
if (root == "PLAYER" || root.StartsWith("MSCO_")) continue;
if (!Matches(fsm.gameObject.name) && !Matches(fsm.FsmName) && !Matches(fsm.transform.parent != null ? fsm.transform.parent.name : "")) continue;
string key = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName;
Tracked t;
if (byKey.TryGetValue(key, out t) && t.Fsm == fsm) continue;
byKey[key] = new Tracked { Key = key, Fsm = fsm, LastState = fsm.ActiveStateName };
}
}
static bool Matches(string name)
{
if (string.IsNullOrEmpty(name)) return false;
string lower = name.ToLower();
foreach (string k in Keywords)
if (lower.Contains(k)) return true;
return false;
}
}
}
+396
View File
@@ -0,0 +1,396 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using Harmony;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
{
public struct FsmEventMsg
{
public string Path;
public string FsmName;
public string EventName;
public string State;
public Vector3 Position;
public byte[] Serialize()
{
FsmEventMsg m = this;
return NetSession.Build(w =>
{
w.Write(m.Path); w.Write(m.FsmName); w.Write(m.EventName); w.Write(m.State);
w.Write(m.Position.x); w.Write(m.Position.y); w.Write(m.Position.z);
});
}
public static FsmEventMsg Deserialize(BinaryReader r)
{
return new FsmEventMsg
{
Path = r.ReadString(), FsmName = r.ReadString(), EventName = r.ReadString(), State = r.ReadString(),
Position = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle())
};
}
}
/// <summary>
/// Überträgt Spieler-Aktionen, die in PlayMaker-FSMs ablaufen: Schrauben anziehen/lösen,
/// Teile anbauen/abbauen. Dazu wird Fsm.Event per Harmony abgefangen; beim anderen
/// Spieler wird dieselbe FSM in denselben Zielzustand geschaltet.
/// </summary>
public static class FsmSync
{
// Events, die direkt durch den Spieler ausgelöst werden
static readonly string[] SyncedEvents =
{
"TIGHTEN", "UNTIGHTEN", "ASSEMBLE", "DISASSEMBLE", "REMOVE", "INSTALL", "UNINSTALL"
};
// Folge-Events einer Aktion laufen auf beiden Seiten von selbst ab und dürfen nicht nochmal gesendet werden
const float ChainWindow = 0.3f;
public static bool Enabled;
public static bool Debug;
public static readonly List<FsmEventMsg> Outbox = new List<FsmEventMsg>();
static bool patched;
static int applying;
static float lastApplyTime = -100f;
static float lastSendTime = -100f;
static Fsm lastSendFsm;
static readonly MethodInfo switchState =
typeof(Fsm).GetMethod("SwitchState", BindingFlags.NonPublic | BindingFlags.Instance);
// "GameObject-Name|FSM-Name" -> FSMs, zum Wiederfinden beim Empfänger
static Dictionary<string, List<PlayMakerFSM>> fsmCache;
static float lastCacheBuild = -100f;
public static void Init()
{
if (patched) return;
patched = true;
try
{
MethodInfo original = typeof(Fsm).GetMethod("Event", new[] { typeof(FsmEvent) });
MethodInfo prefix = typeof(FsmSync).GetMethod("EventPrefix", BindingFlags.NonPublic | BindingFlags.Static);
HarmonyInstance harmony = HarmonyInstance.Create("mbm.msco.fsmsync");
harmony.Patch(original, new HarmonyMethod(prefix), null);
// Klick-Interaktionen (Lichtschalter, Knöpfe, Radio ...) über Zustandswechsel erkennen
MethodInfo switchPrefix = typeof(FsmSync).GetMethod("SwitchStatePrefix", BindingFlags.NonPublic | BindingFlags.Static);
harmony.Patch(switchState, new HarmonyMethod(switchPrefix), null);
ModConsole.Print("[MSCO] FSM sync ready");
}
catch (Exception e)
{
ModConsole.Error("[MSCO] FSM sync patch failed: " + e);
}
}
public static void Reset()
{
Outbox.Clear();
fsmCache = null;
lastSendFsm = null;
clickCandidates.Clear();
clickActive = false;
}
// ---------------------------------------------------------------- Klick-Interaktionen
// Nach einem Klick wird gesammelt, welche FSMs in der Nähe den Zustand wechseln.
// Das Objekt, auf das der Spieler zielt, gewinnt; gesendet wird der erste Zustand,
// der nicht selbst auf Maus/Tasten/Raycast prüft (das würde beim Empfänger scheitern).
const float ClickWindow = 0.4f;
const float ClickRange = 4f;
static readonly string[] InputActionWords = { "Mouse", "Raycast", "GetKey", "GetButton", "Input", "Pick", "Axis" };
class ClickCandidate
{
public Fsm Fsm;
public float AimDistance;
public readonly List<FsmState> States = new List<FsmState>();
}
static readonly Dictionary<Fsm, ClickCandidate> clickCandidates = new Dictionary<Fsm, ClickCandidate>();
static bool clickActive;
static float clickTime;
static int lastClickCheckFrame = -1;
static bool useKeyAvailable = true;
static bool ClickThisFrame()
{
if (Input.GetMouseButtonDown(0) || Input.GetMouseButtonDown(1)) return true;
if (!useKeyAvailable) return false;
try { return cInput.GetKeyDown("Use"); }
catch { useKeyAvailable = false; return false; }
}
static void SwitchStatePrefix(Fsm __instance, FsmState toState)
{
if (!Enabled || applying > 0 || toState == null) return;
try
{
float now = Time.realtimeSinceStartup;
if (now - lastApplyTime < ChainWindow) return;
if (!clickActive && lastClickCheckFrame != Time.frameCount)
{
lastClickCheckFrame = Time.frameCount;
if (ClickThisFrame())
{
clickActive = true;
clickTime = now;
clickCandidates.Clear();
}
}
if (!clickActive) return;
ClickCandidate c;
if (!clickCandidates.TryGetValue(__instance, out c))
{
float aim;
if (!IsAimedAt(__instance, out aim)) return;
c = new ClickCandidate { Fsm = __instance, AimDistance = aim };
clickCandidates[__instance] = c;
}
c.States.Add(toState);
}
catch (Exception e)
{
ModConsole.Error("[MSCO] Click capture error: " + e.Message);
}
}
static bool IsAimedAt(Fsm fsm, out float aimDistance)
{
aimDistance = float.MaxValue;
GameObject go = fsm.GameObject;
Camera cam = Camera.main;
if (go == null || cam == null) return false;
string root = go.transform.root.name;
if (root == "PLAYER" || root.StartsWith("MSCO_")) return false;
Vector3 toObject = go.transform.position - cam.transform.position;
if (toObject.magnitude > ClickRange || Vector3.Dot(toObject, cam.transform.forward) < 0f) return false;
aimDistance = Vector3.Cross(cam.transform.forward, toObject).magnitude; // Abstand zur Blickachse
return true;
}
/// <summary>Jedes Frame vom Manager aufrufen: abgeschlossene Klicks auswerten.</summary>
public static void Tick()
{
if (!clickActive || Time.realtimeSinceStartup - clickTime < ClickWindow) return;
clickActive = false;
// Schrauben/Montage wurden schon über die Events gesendet
if (Time.realtimeSinceStartup - lastSendTime < ClickWindow + ChainWindow) { clickCandidates.Clear(); return; }
ClickCandidate best = null;
foreach (ClickCandidate c in clickCandidates.Values)
if (best == null || c.AimDistance < best.AimDistance) best = c;
clickCandidates.Clear();
if (best == null || best.Fsm.GameObject == null) return;
FsmState target = null;
foreach (FsmState s in best.States)
if (!ChecksInput(s)) { target = s; break; }
if (target == null) return;
GameObject go = best.Fsm.GameObject;
var msg = new FsmEventMsg
{
Path = BuildPath(go.transform),
FsmName = best.Fsm.Name,
EventName = "CLICK",
State = target.Name,
Position = go.transform.position
};
Outbox.Add(msg);
if (Debug) ModConsole.Print("[MSCO] >> CLICK " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
}
/// <summary>Prüft der Zustand nur Maus/Tasten/Raycast (z.B. "Maus zeigt auf den Griff")?</summary>
public static bool ChecksInputPublic(FsmState state) { return ChecksInput(state); }
static bool ChecksInput(FsmState state)
{
if (!state.ActionsLoaded) state.LoadActions();
if (state.Actions == null) return false;
foreach (FsmStateAction action in state.Actions)
{
if (action == null) continue;
string type = action.GetType().Name;
foreach (string word in InputActionWords)
if (type.Contains(word)) return true;
}
return false;
}
// ---------------------------------------------------------------- Senden
static void EventPrefix(Fsm __instance, FsmEvent fsmEvent)
{
if (!Enabled || applying > 0 || fsmEvent == null) return;
if (Array.IndexOf(SyncedEvents, fsmEvent.Name) < 0) return;
try
{
float now = Time.realtimeSinceStartup;
if (now - lastApplyTime < ChainWindow) return; // Folge einer Remote-Aktion
if (__instance != lastSendFsm && now - lastSendTime < ChainWindow) return; // Folge einer eigenen Aktion
GameObject go = __instance.GameObject;
if (go == null || go.transform.root.name == "PLAYER") return;
string target = FindTargetState(__instance, fsmEvent.Name);
if (target == null) return; // Event bewirkt im aktuellen Zustand nichts
lastSendTime = now;
lastSendFsm = __instance;
var msg = new FsmEventMsg
{
Path = BuildPath(go.transform),
FsmName = __instance.Name,
EventName = fsmEvent.Name,
State = target,
Position = go.transform.position
};
Outbox.Add(msg);
if (Debug) ModConsole.Print("[MSCO] >> " + msg.EventName + " " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
}
catch (Exception e)
{
ModConsole.Error("[MSCO] FSM capture error: " + e.Message);
}
}
static string FindTargetState(Fsm fsm, string eventName)
{
FsmState active = fsm.ActiveState;
if (active != null)
foreach (FsmTransition t in active.Transitions)
if (t.EventName == eventName) return t.ToState;
foreach (FsmTransition t in fsm.GlobalTransitions)
if (t.EventName == eventName) return t.ToState;
return null;
}
// ---------------------------------------------------------------- Empfangen
public static void Apply(FsmEventMsg msg)
{
PlayMakerFSM fsm = Resolve(msg);
if (fsm == null)
{
if (Debug) ModConsole.Warning("[MSCO] << not found: " + msg.Path + " [" + msg.FsmName + "]");
return;
}
FsmState state = fsm.Fsm.GetState(msg.State);
if (state == null || switchState == null) return;
if (Debug) ModConsole.Print("[MSCO] << " + msg.EventName + " " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
applying++;
lastApplyTime = Time.realtimeSinceStartup;
try { switchState.Invoke(fsm.Fsm, new object[] { state }); }
catch (Exception e) { ModConsole.Error("[MSCO] FSM apply error: " + e.Message); }
finally { applying--; }
}
/// <summary>FSM in einen Zustand schalten, ohne dass das als eigene Aktion erkannt und gesendet wird.</summary>
public static void SwitchStateSilently(PlayMakerFSM fsm, FsmState state)
{
if (switchState == null) return;
applying++;
lastApplyTime = Time.realtimeSinceStartup;
try { switchState.Invoke(fsm.Fsm, new object[] { state }); }
catch (Exception e) { ModConsole.Error("[MSCO] FSM switch error: " + e.Message); }
finally { applying--; }
}
/// <summary>Pfad eines Objekts, auf allen Rechnern gleich (siehe BuildPath).</summary>
public static string PathOf(Transform t) { return BuildPath(t); }
static PlayMakerFSM Resolve(FsmEventMsg msg)
{
string goName = LastSegmentName(msg.Path);
PlayMakerFSM found = FindInCache(goName, msg);
if (found == null && Time.realtimeSinceStartup - lastCacheBuild > 1f)
{
BuildCache(); // neu aktivierte Objekte (z.B. Schrauben eines gerade montierten Teils)
found = FindInCache(goName, msg);
}
return found;
}
static PlayMakerFSM FindInCache(string goName, FsmEventMsg msg)
{
if (fsmCache == null) return null;
List<PlayMakerFSM> list;
if (!fsmCache.TryGetValue(goName + "|" + msg.FsmName, out list)) return null;
// Gleicher Pfad; bei mehreren Treffern das nächstgelegene Objekt
PlayMakerFSM best = null;
float bestDist = float.MaxValue;
foreach (PlayMakerFSM fsm in list)
{
if (fsm == null || BuildPath(fsm.transform) != msg.Path) continue;
float d = (fsm.transform.position - msg.Position).sqrMagnitude;
if (d < bestDist) { bestDist = d; best = fsm; }
}
return best;
}
static void BuildCache()
{
lastCacheBuild = Time.realtimeSinceStartup;
fsmCache = new Dictionary<string, List<PlayMakerFSM>>();
foreach (PlayMakerFSM fsm in SceneCache.Fsms(1f))
{
if (fsm == null) continue;
string key = fsm.gameObject.name + "|" + fsm.FsmName;
List<PlayMakerFSM> list;
if (!fsmCache.TryGetValue(key, out list)) fsmCache[key] = list = new List<PlayMakerFSM>();
list.Add(fsm);
}
}
// ---------------------------------------------------------------- Pfade
/// <summary>
/// "SATSUMA(557kg, 248)|Body#0|alternator(Clone)#0|Bolts#0|bolt#2" - Name plus Nummer unter
/// gleichnamigen Geschwistern. Beim Wurzelobjekt nur der Name, Mehrdeutigkeit löst die Position.
/// </summary>
static string BuildPath(Transform t)
{
var segments = new List<string>();
for (; t != null; t = t.parent)
{
if (t.parent == null) { segments.Add(t.name); break; }
int ordinal = 0;
Transform parent = t.parent;
for (int i = 0; i < parent.childCount; i++)
{
Transform sibling = parent.GetChild(i);
if (sibling == t) break;
if (sibling.name == t.name) ordinal++;
}
segments.Add(t.name + "#" + ordinal);
}
segments.Reverse();
return string.Join("|", segments.ToArray());
}
static string LastSegmentName(string path)
{
string last = path.Substring(path.LastIndexOf('|') + 1);
int hash = last.LastIndexOf('#');
return hash >= 0 && path.Contains("|") ? last.Substring(0, hash) : last;
}
}
}
+405
View File
@@ -0,0 +1,405 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Mitspieler-Figur aus einem echten My-Summer-Car-NPC (Körper-Mesh "bodymesh" + Skelett mit "pelvis").
/// Der NPC wird kopiert und von seiner Logik befreit; es bleiben Aussehen und die Spiel-Animationen
/// (Idle/Walk). Nach der Animation werden die Knochen für Ducken, Lehnen, Kopf-Nicken, Sitzen und
/// das Greifen zum Item verbogen (Zwei-Knochen-IK, unabhängig von den Achsen des Skeletts).
/// </summary>
public class GameCharacter
{
const float StandingEyeHeight = 1.65f;
const float EyeAboveHeadBone = 0.1f; // Augen liegen etwas über dem Kopf-Knochen
const float AnkleHeight = 0.07f; // Fuß-Knochen über dem Boden
int frames;
bool calibrated;
public Transform Root { get; private set; }
public string Description { get; private set; }
Animation anim;
string walkClip, idleClip;
Transform pelvis, spine, head;
readonly Transform[] thigh = new Transform[2], knee = new Transform[2], foot = new Transform[2];
readonly Transform[] upperArm = new Transform[2], forearm = new Transform[2], hand = new Transform[2];
readonly Vector3[] animatedFoot = new Vector3[2];
/// <summary>
/// Knochen, die wir verbiegen. Bewegt die Animation einen davon nicht selbst, würden sich unsere
/// Änderungen jedes Frame aufsummieren - dann wird er vorher auf die Grundhaltung zurückgesetzt.
/// </summary>
class BoneState
{
public Transform Bone;
public Vector3 RestPos, OutPos;
public Quaternion RestRot, OutRot;
public bool HasOut;
}
readonly List<BoneState> posed = new List<BoneState>();
public Vector3 HeadPosition { get { return head.position + Root.up * EyeAboveHeadBone; } }
// ---------------------------------------------------------------- Vorlage finden und kopieren
static List<GameObject> templates;
/// <summary>Kopiert einen NPC aus der Spielszene als Figur. null, wenn keine passende Vorlage da ist.</summary>
public static GameCharacter TryCreate(Transform parent, int variant)
{
List<GameObject> candidates = FindTemplates();
if (candidates.Count == 0) return null;
GameObject template = candidates[Mathf.Abs(variant) % candidates.Count];
GameObject clone = (GameObject)UnityEngine.Object.Instantiate(template);
clone.name = "Model";
Strip(clone);
clone.SetActive(true);
clone.transform.SetParent(parent, false);
clone.transform.localPosition = Vector3.zero;
clone.transform.localRotation = Quaternion.identity;
clone.transform.localScale = Vector3.one;
var c = new GameCharacter { Root = clone.transform };
if (!c.FindBones())
{
UnityEngine.Object.Destroy(clone);
return null;
}
c.Normalize();
c.SetupAnimation();
c.RememberRestPose();
c.Description = template.name + " (" + candidates.Count + " templates) - " + c.BoneReport();
return c;
}
/// <summary>NPC-Körper in der Szene (auch inaktive): SkinnedMeshRenderer "bodymesh" mit "pelvis"-Knochen.</summary>
static List<GameObject> FindTemplates()
{
if (templates != null)
{
templates.RemoveAll(t => t == null);
if (templates.Count > 0) return templates;
}
var scored = new List<KeyValuePair<GameObject, int>>();
var seen = new HashSet<string>();
foreach (UnityEngine.Object o in Resources.FindObjectsOfTypeAll(typeof(SkinnedMeshRenderer)))
{
var smr = (SkinnedMeshRenderer)o;
if (smr == null || !smr.name.ToLower().Contains("bodymesh") || smr.transform.parent == null) continue;
bool hasPelvis = false;
if (smr.bones != null)
foreach (Transform b in smr.bones)
if (b != null && b.name == "pelvis") { hasPelvis = true; break; }
if (!hasPelvis) continue;
GameObject root = smr.transform.parent.gameObject; // Körper + Skelett liegen gemeinsam darunter
if (root.transform.root.name == "PLAYER" || root.name.StartsWith("MSCO")) continue;
// Keine Sammelgruppen mit mehreren Personen kopieren (nur eine Figur, evtl. mit Varianten dünn/normal/dick)
if (PelvisCount(root.transform) != 1 || root.GetComponentsInChildren<Transform>(true).Length > 600) continue;
int score = AnimationScore(ref root);
if (!seen.Add(root.name + "/" + smr.sharedMesh)) continue; // gleiche Figur nicht mehrfach
scored.Add(new KeyValuePair<GameObject, int>(root, score));
}
// Nur die besten: am liebsten NPCs mit Lauf- UND Steh-Animation (ohne Gesten wie Anhalter/Betrunken)
int best = 0;
foreach (var kv in scored) best = Mathf.Max(best, kv.Value);
templates = new List<GameObject>();
foreach (var kv in scored) if (kv.Value == best) templates.Add(kv.Key);
templates.Sort((a, b) => string.CompareOrdinal(a.name, b.name));
return templates;
}
static int PelvisCount(Transform root)
{
int count = 0;
foreach (Transform t in root.GetComponentsInChildren<Transform>(true)) if (t.name == "pelvis") count++;
return count;
}
// Animationen, die keine normale Steh-/Laufhaltung sind
static readonly string[] GestureWords = { "hitch", "drunk", "fat", "lift", "sit", "wave", "point", "dance", "angry", "back", "theatre", "car" };
static bool IsGesture(string clip)
{
string n = clip.ToLower();
foreach (string g in GestureWords) if (n.Contains(g)) return true;
return false;
}
/// <summary>
/// Bewertet die Animationen eines NPCs (liegt die Animation eine Ebene höher, wird dieser Knoten
/// die Vorlage). 3 = normales Laufen + Stehen, 2 = nur Laufen, 1 = nur Stehen, 0 = keine.
/// </summary>
static int AnimationScore(ref GameObject root)
{
Animation[] found = root.GetComponentsInChildren<Animation>(true); // Unity 5.0: auch inaktive finden
Animation anim = found.Length > 0 ? found[0] : null;
if (anim == null)
{
Transform p = root.transform.parent;
for (int i = 0; i < 2 && p != null && anim == null; i++, p = p.parent)
{
var candidate = p.GetComponent<Animation>();
if (candidate != null && PelvisCount(p) == 1)
{
anim = candidate;
root = p.gameObject;
}
}
}
if (anim == null) return 0;
bool walk = false, idle = false;
foreach (AnimationState s in anim)
{
if (IsGesture(s.name)) continue;
string n = s.name.ToLower();
if (n.Contains("walk")) walk = true;
if (n.Contains("idle")) idle = true;
}
return (walk ? 2 : 0) + (idle ? 1 : 0);
}
/// <summary>Alles außer Aussehen und Animation entfernen (KI-FSMs, Physik, Sounds, Trigger ...).</summary>
static void Strip(GameObject go)
{
foreach (Joint j in go.GetComponentsInChildren<Joint>(true)) UnityEngine.Object.DestroyImmediate(j);
foreach (Rigidbody r in go.GetComponentsInChildren<Rigidbody>(true)) UnityEngine.Object.DestroyImmediate(r);
foreach (Collider c in go.GetComponentsInChildren<Collider>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (Behaviour b in go.GetComponentsInChildren<Behaviour>(true))
{
if (b is Animation || b is Animator) continue;
UnityEngine.Object.DestroyImmediate(b);
}
foreach (AudioSource a in go.GetComponentsInChildren<AudioSource>(true)) UnityEngine.Object.DestroyImmediate(a);
}
bool FindBones()
{
pelvis = Find("pelvis");
spine = Find("spine_upper") ?? Find("spine_middle") ?? Find("spine_mid");
head = Find("head");
if (pelvis == null || spine == null || head == null) return false;
string[] sides = { "left", "right" };
for (int i = 0; i < 2; i++)
{
knee[i] = Find("knee_" + sides[i]);
if (knee[i] != null)
{
thigh[i] = knee[i].parent;
foot[i] = knee[i].childCount > 0 ? knee[i].GetChild(0) : null;
}
hand[i] = Find("hand_" + sides[i]);
if (hand[i] != null && hand[i].parent != null)
{
forearm[i] = hand[i].parent;
upperArm[i] = forearm[i].parent;
}
}
return true;
}
Transform Find(string name)
{
foreach (Transform t in Root.GetComponentsInChildren<Transform>(true))
if (string.Equals(t.name, name, StringComparison.OrdinalIgnoreCase)) return t;
return null;
}
/// <summary>Größe auf Spielergröße bringen, Füße auf den Boden, Blickrichtung nach +Z.</summary>
void Normalize()
{
// Blickrichtung: Zehen (Kind des Fußes) liegen vor dem Fuß
Transform toe = foot[0] != null && foot[0].childCount > 0 ? foot[0].GetChild(0) : null;
if (toe != null && Root.parent.InverseTransformPoint(toe.position).z < Root.parent.InverseTransformPoint(foot[0].position).z - 0.01f)
Root.localRotation = Quaternion.Euler(0f, 180f, 0f);
float eye = Root.parent.InverseTransformPoint(head.position).y + EyeAboveHeadBone;
if (eye > 0.5f) Root.localScale = Vector3.one * Mathf.Clamp(StandingEyeHeight / eye, 0.6f, 1.6f);
float lowest = float.MaxValue;
for (int i = 0; i < 2; i++)
if (foot[i] != null) lowest = Mathf.Min(lowest, Root.parent.InverseTransformPoint(foot[i].position).y);
if (lowest < float.MaxValue) Root.localPosition = new Vector3(0f, -lowest + 0.06f, 0f);
}
void SetupAnimation()
{
anim = Root.GetComponentInChildren<Animation>();
if (anim == null) return;
foreach (AnimationState s in anim)
{
if (IsGesture(s.name)) continue;
string n = s.name.ToLower();
if (walkClip == null && n.Contains("walk")) walkClip = s.name;
if (idleClip == null && n.Contains("idle")) idleClip = s.name;
}
// Keine Steh-Animation: im Stand die Laufanimation angehalten zeigen (statt einer Geste)
if (idleClip == null && walkClip == null && anim.clip != null && !IsGesture(anim.clip.name)) idleClip = anim.clip.name;
if (walkClip != null) anim[walkClip].wrapMode = WrapMode.Loop;
if (idleClip != null) anim[idleClip].wrapMode = WrapMode.Loop;
if (idleClip != null) anim.Play(idleClip);
}
void RememberRestPose()
{
var bones = new List<Transform> { pelvis, spine, head };
bones.AddRange(thigh);
bones.AddRange(knee);
bones.AddRange(upperArm);
bones.AddRange(forearm);
foreach (Transform b in bones)
if (b != null)
posed.Add(new BoneState { Bone = b, RestPos = b.localPosition, RestRot = b.localRotation });
}
void ResetUnanimatedBones()
{
foreach (BoneState s in posed)
{
if (s.HasOut && s.Bone.localPosition == s.OutPos && s.Bone.localRotation == s.OutRot)
{
s.Bone.localPosition = s.RestPos;
s.Bone.localRotation = s.RestRot;
}
}
}
void RememberOutput()
{
foreach (BoneState s in posed)
{
s.OutPos = s.Bone.localPosition;
s.OutRot = s.Bone.localRotation;
s.HasOut = true;
}
}
string BoneReport()
{
var sb = new StringBuilder();
sb.Append("legs ").Append(knee[0] != null && foot[0] != null ? "ok" : "missing");
sb.Append(", arms ").Append(hand[1] != null && upperArm[1] != null ? "ok" : "missing");
sb.Append(", anim idle=").Append(idleClip ?? "-").Append(" walk=").Append(walkClip ?? "-");
return sb.ToString();
}
// ---------------------------------------------------------------- Pro Frame (LateUpdate, nach der Animation)
public void Animate(float speed)
{
if (anim == null) return;
bool walking = speed > 0.4f && walkClip != null;
string clip = walking ? walkClip : (idleClip ?? walkClip);
if (clip == null) return;
if (!anim.IsPlaying(clip)) anim.CrossFade(clip, 0.25f);
if (clip == walkClip) anim[walkClip].speed = walking ? Mathf.Clamp(speed / 1.4f, 0.6f, 2.2f) : 0f; // angehalten = stehen
}
public void Pose(Transform body, Vector3 headTarget, float pitch, bool seated, bool holding, Vector3 heldPos)
{
Vector3 up = body.up, forward = body.forward, right = body.right;
ResetUnanimatedBones();
frames++;
// 1) Auf den Boden stellen: die (animierten) Füße stehen auf der Bodenhöhe des Spielers.
// Die Grundhaltung eines NPCs passt oft nicht zur Animation - deshalb jedes Frame nachmessen.
float lowestFoot = float.MaxValue;
for (int i = 0; i < 2; i++)
if (foot[i] != null) lowestFoot = Mathf.Min(lowestFoot, Vector3.Dot(foot[i].position - body.position, up));
if (lowestFoot < float.MaxValue)
{
Vector3 lp = Root.localPosition;
lp.y -= lowestFoot - AnkleHeight;
Root.localPosition = lp;
}
// 2) Größe einmal an die laufende Animation anpassen (Augenhöhe wie der Spieler)
if (!calibrated && frames > 5 && !seated)
{
calibrated = true;
float eye = Vector3.Dot(head.position + up * EyeAboveHeadBone - body.position, up);
if (eye > 0.5f) Root.localScale *= Mathf.Clamp(StandingEyeHeight / eye, 0.7f, 1.4f);
RememberOutput();
return; // nächstes Frame mit neuer Größe neu auf den Boden stellen
}
// Fußpositionen der Animation merken (die sollen stehen bleiben)
for (int i = 0; i < 2; i++)
if (foot[i] != null) animatedFoot[i] = foot[i].position;
// 3) Becken: beim Ducken/Sitzen absenken, beim Lehnen nach hinten ausweichen
Vector3 eyeNow = head.position + up * EyeAboveHeadBone;
Vector3 offset = headTarget - eyeNow;
Vector3 horizontal = offset - up * Vector3.Dot(offset, up);
if (horizontal.magnitude > 0.6f) horizontal = horizontal.normalized * 0.6f;
float drop = Mathf.Clamp(Vector3.Dot(offset, up), -1.1f, 0f);
pelvis.position += up * drop - horizontal * 0.25f;
// Wirbelsäule so drehen, dass der Kopf an der Kamera sitzt (nur wenn nötig)
if (horizontal.sqrMagnitude > 0.0025f || drop < -0.05f)
Aim(spine, head.position + up * EyeAboveHeadBone, head.position + up * EyeAboveHeadBone + horizontal * 0.75f);
// Kopf nicken
head.rotation = Quaternion.AngleAxis(pitch, right) * head.rotation;
// Beine
for (int i = 0; i < 2; i++)
{
if (thigh[i] == null || knee[i] == null || foot[i] == null) continue;
Vector3 target = seated
? thigh[i].position + forward * 0.45f - up * 0.4f
: animatedFoot[i];
SolveTwoBone(thigh[i], knee[i], foot[i], target, forward);
}
// Arme: rechts zum gehaltenen Item, im Auto beide nach vorne (Lenkrad)
for (int i = 0; i < 2; i++)
{
if (upperArm[i] == null || forearm[i] == null || hand[i] == null) continue;
if (i == 1 && holding)
SolveTwoBone(upperArm[i], forearm[i], hand[i], heldPos, -forward - up * 0.5f);
else if (seated)
SolveTwoBone(upperArm[i], forearm[i], hand[i], upperArm[i].position + forward * 0.38f - up * 0.28f, -up);
}
RememberOutput();
}
/// <summary>Knochen so drehen, dass "child" in Richtung "target" zeigt - egal wie das Skelett ausgerichtet ist.</summary>
static void Aim(Transform bone, Vector3 childPos, Vector3 target)
{
Vector3 from = childPos - bone.position;
Vector3 to = target - bone.position;
if (from.sqrMagnitude < 1e-6f || to.sqrMagnitude < 1e-6f) return;
bone.rotation = Quaternion.FromToRotation(from, to) * bone.rotation;
}
static void SolveTwoBone(Transform upper, Transform lower, Transform end, Vector3 target, Vector3 bendHint)
{
float a = (lower.position - upper.position).magnitude;
float b = (end.position - lower.position).magnitude;
if (a < 0.01f || b < 0.01f) return;
Vector3 root = upper.position;
Vector3 toTarget = target - root;
float d = Mathf.Clamp(toTarget.magnitude, 0.01f, a + b - 0.001f);
Vector3 dir = toTarget.normalized;
float along = (a * a - b * b + d * d) / (2f * d);
float sideways = Mathf.Sqrt(Mathf.Max(0f, a * a - along * along));
Vector3 bend = bendHint - dir * Vector3.Dot(bendHint, dir);
if (bend.sqrMagnitude < 1e-6f) bend = Vector3.up - dir * Vector3.Dot(Vector3.up, dir);
Vector3 joint = root + dir * along + bend.normalized * sideways;
Aim(upper, lower.position, joint);
Aim(lower, end.position, root + dir * d);
}
}
}
+108
View File
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using Harmony;
using MSCLoader;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Solange ein Eingabefenster offen ist (F8-Konsole, Chat, Admin-Menü): Spieler bewegt sich nicht,
/// Kamera dreht nicht mit, Mauszeiger ist frei. Danach wird genau das wieder eingeschaltet,
/// was hier ausgeschaltet wurde.
/// </summary>
public static class InputLock
{
static readonly string[] ControlScripts =
{
"CharacterMotor", "FPSInputController", "MouseLook", "SimpleSmoothMouseLook", "SmoothMouseLook", "MouseLook2", "MouseLook3"
};
static readonly List<Behaviour> disabled = new List<Behaviour>();
static bool locked;
static bool previousVisible;
static CursorLockMode previousLock;
static bool patched;
public static bool Locked { get { return locked; } }
public static void Set(bool on)
{
if (on) KeepCursorFree();
if (on == locked) return;
locked = on;
if (on)
{
PatchCursorActions();
previousVisible = Cursor.visible;
previousLock = Cursor.lockState;
GameObject player = GameObject.Find("PLAYER");
if (player != null)
{
foreach (MonoBehaviour mb in player.GetComponentsInChildren<MonoBehaviour>())
{
if (mb == null || !mb.enabled || Array.IndexOf(ControlScripts, mb.GetType().Name) < 0) continue;
mb.enabled = false;
disabled.Add(mb);
}
}
KeepCursorFree();
}
else
{
foreach (Behaviour b in disabled) if (b != null) b.enabled = true;
disabled.Clear();
PlayMakerGUI.LockCursor = previousLock != CursorLockMode.None;
PlayMakerGUI.HideCursor = !previousVisible;
Cursor.visible = previousVisible;
Cursor.lockState = previousLock;
}
}
/// <summary>
/// Das Spiel sperrt den Zeiger jeden Frame wieder (PlayMakerGUI.LockCursor + "SetMouseCursor"-Aktionen),
/// dadurch springt er in die Mitte. Solange offen: überall freigeben - auch aus OnGUI/LateUpdate aufrufen.
/// </summary>
public static void KeepCursorFree()
{
if (!locked) return;
PlayMakerGUI.LockCursor = false;
PlayMakerGUI.HideCursor = false;
Cursor.visible = true;
Cursor.lockState = CursorLockMode.None;
}
/// <summary>PlayMaker-Aktionen, die den Mauszeiger setzen, überspringen, solange ein Fenster offen ist.</summary>
static void PatchCursorActions()
{
if (patched) return;
patched = true;
try
{
Type action = null;
foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
{
action = asm.GetType("HutongGames.PlayMaker.Actions.SetMouseCursor");
if (action != null) break;
}
if (action == null) return;
HarmonyInstance harmony = HarmonyInstance.Create("mbm.msco.inputlock");
var prefix = new HarmonyMethod(typeof(InputLock).GetMethod("SkipWhileLocked", BindingFlags.Static | BindingFlags.NonPublic));
foreach (string name in new[] { "OnEnter", "OnUpdate", "DoSetMouseCursor" })
{
MethodInfo m = action.GetMethod(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (m != null && m.DeclaringType == action) harmony.Patch(m, prefix, null);
}
}
catch (Exception e)
{
ModConsole.Warning("[MSCO] Could not patch cursor actions: " + e.Message);
}
}
static bool SkipWhileLocked() { return !locked; }
}
}
+84
View File
@@ -0,0 +1,84 @@
using System;
using Microsoft.Win32;
using MSCLoader;
namespace MSCO
{
/// <summary>
/// "msco://connect/&lt;host&gt;:&lt;port&gt;"-Links (JOIN-Button auf der Website).
/// Die Mod registriert den Link-Typ in Windows so, dass ein Klick Steam direkt mit
/// "-applaunch 516750 +msco_connect &lt;link&gt;" startet - ohne cmd/Skript dazwischen, damit
/// präparierte Links keine Befehle ausführen können. Die Mod liest die Adresse beim Start
/// aus der Kommandozeile und fragt im Hauptmenü, ob verbunden werden soll.
/// (Läuft das Spiel schon, gibt Steam den Link nicht weiter - dann den Server-Browser nutzen.)
/// </summary>
public static class JoinLinks
{
const string Scheme = "msco";
const string Argument = "+msco_connect";
const int MySummerCarAppId = 516750;
/// <summary>Link-Typ msco:// für den aktuellen Windows-Benutzer eintragen (kein Admin nötig).</summary>
public static void Register()
{
try
{
string steamExe = SteamExePath();
if (steamExe == null)
{
ModConsole.Warning("[MSCO] Steam not found - JOIN links from the website are not available.");
return;
}
string command = "\"" + steamExe + "\" -applaunch " + MySummerCarAppId + " " + Argument + " \"%1\"";
using (RegistryKey key = Registry.CurrentUser.CreateSubKey(@"Software\Classes\" + Scheme))
{
key.SetValue("", "URL:MSCO Join Link");
key.SetValue("URL Protocol", "");
using (RegistryKey cmd = key.CreateSubKey(@"shell\open\command"))
cmd.SetValue("", command);
}
}
catch (Exception e)
{
ModConsole.Warning("[MSCO] Could not register msco:// links: " + e.Message);
}
}
static string SteamExePath()
{
using (RegistryKey steam = Registry.CurrentUser.OpenSubKey(@"Software\Valve\Steam"))
{
string exe = steam != null ? steam.GetValue("SteamExe") as string : null;
return string.IsNullOrEmpty(exe) ? null : exe.Replace('/', '\\');
}
}
/// <summary>Wurde das Spiel über einen JOIN-Link gestartet?</summary>
public static bool TryGetFromCommandLine(out string host, out int port)
{
host = null;
port = 0;
string[] args = Environment.GetCommandLineArgs();
for (int i = 0; i < args.Length - 1; i++)
if (args[i] == Argument) return TryParse(args[i + 1], out host, out port);
return false;
}
/// <summary>"msco://connect/1.2.3.4:7777/" -> Host und Port (nur Buchstaben, Ziffern, . - :).</summary>
static bool TryParse(string text, out string host, out int port)
{
host = null;
port = 0;
text = text.Trim().Trim('"').Trim();
const string prefix = "msco://connect/";
if (!text.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
string address = text.Substring(prefix.Length).TrimEnd('/');
foreach (char c in address)
if (!char.IsLetterOrDigit(c) && c != '.' && c != '-' && c != ':') return false;
return ServerBrowser.TryParseAddress(address, out host, out port);
}
}
}
+610
View File
@@ -0,0 +1,610 @@
using System.Collections.Generic;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Multiplayer-Launcher im Hauptmenü (dunkles Design mit Seitenleiste, Statusleiste
/// und Minimieren). Seiten: P2P (Spieler hostet selbst) und Server Browser (Platzhalter,
/// dort kommt später auch Direct Connect zu dedizierten Servern hin).
/// </summary>
public class LobbyUI
{
enum Page { P2P, ServerBrowser }
const float Width = 760f;
const float Height = 460f;
const float SidebarWidth = 190f;
const float StatusHeight = 36f;
static readonly Color Background = Hex(0x17, 0x17, 0x1b);
static readonly Color Sidebar = Hex(0x0f, 0x0f, 0x12);
static readonly Color Field = Hex(0x24, 0x24, 0x2a);
static readonly Color FieldFocus = Hex(0x2e, 0x2e, 0x36);
static readonly Color Accent = Hex(0xf2, 0x6b, 0x1d);
static readonly Color AccentHover = Hex(0xff, 0x82, 0x3a);
static readonly Color Secondary = Hex(0x2c, 0x2c, 0x33);
static readonly Color SecondaryHover = Hex(0x3a, 0x3a, 0x43);
static readonly Color Muted = Hex(0x8e, 0x8e, 0x99);
static readonly Color Online = Hex(0x3d, 0xd6, 0x8c);
static readonly Color Pending = Hex(0xf2, 0xc2, 0x1d);
Page page = Page.P2P;
bool minimized;
Vector2 playerScroll;
Vector2 serverScroll;
bool browserLoaded;
string selectedServer; // "adresse:port" des geöffneten Server-Profils
string searchInput = "";
bool showSteam = true, showNonSteam = true, hideFull;
string nameInput;
string directInput;
const string PrefDirect = "MSCO_LastServer";
bool stylesReady;
Texture2D texBackground, texSidebar, texAccent, texOnline, texPending, texMuted, texRow;
GUIStyle windowBox, sidebarBox, rowBox, accentBox, pillStyle, pillMutedStyle, chipOnStyle, chipOffStyle;
GUIStyle logoStyle, taglineStyle, navStyle, navActiveStyle, titleStyle, textStyle, captionStyle,
fieldStyle, primaryStyle, secondaryStyle, statusStyle, tabStyle, headerCellStyle,
rowNameStyle, rowTagStyle, badgeStyle, toggleStyle, placeholderStyle;
/// <summary>Fläche, die das Fenster gerade belegt (GUI-Koordinaten, oben = 0).</summary>
public Rect ScreenRect { get; private set; }
/// <summary>Fenster offen (nicht minimiert) - dann ist das Spielmenü dahinter gesperrt.</summary>
public bool IsOpen { get { return !minimized; } }
/// <summary>Ist die Maus gerade über dem Fenster bzw. der eingeklappten Leiste?</summary>
public bool ContainsMouse()
{
Vector3 m = Input.mousePosition;
return ScreenRect.Contains(new Vector2(m.x, Screen.height - m.y));
}
public void Draw(MultiplayerManager mp)
{
EnsureStyles();
bool pendingJoin = mp.PendingJoinHost != null && !mp.IsInSession;
bool versionWarning = mp.PendingVersionWarning != null && !mp.IsInSession;
if (pendingJoin || versionWarning) minimized = false; // JOIN-Link / Warnung: Fenster zeigen
if (minimized) { DrawMinimized(mp); return; }
var window = new Rect((Screen.width - Width) / 2f, (Screen.height - Height) / 2f, Width, Height);
ScreenRect = window;
GUI.Box(window, GUIContent.none, windowBox);
DrawSidebar(new Rect(window.x, window.y, SidebarWidth, Height - StatusHeight));
var content = new Rect(window.x + SidebarWidth + 32, window.y + 28,
Width - SidebarWidth - 64, Height - StatusHeight - 52);
GUILayout.BeginArea(content);
if (versionWarning) DrawVersionWarning(mp);
else if (pendingJoin) DrawJoinConfirm(mp);
else if (mp.IsInSession) DrawSession(mp);
else if (page == Page.P2P) DrawP2P(mp);
else DrawServerBrowser(mp);
GUILayout.EndArea();
DrawStatusBar(mp, new Rect(window.x, window.yMax - StatusHeight, Width, StatusHeight));
}
// ---------------------------------------------------------------- Bereiche
void DrawSidebar(Rect r)
{
GUI.Box(r, GUIContent.none, sidebarBox);
// Logo + Schriftzug
Texture2D logo = Branding.LogoTexture;
float textX = r.x + 22;
if (logo != null)
{
GUI.DrawTexture(new Rect(r.x + 20, r.y + 20, 44, 44), logo);
textX = r.x + 72;
}
GUI.Label(new Rect(textX, r.y + 24, r.xMax - textX, 40), "MSCO", logoStyle);
GUI.Label(new Rect(r.x + 22, r.y + 70, r.width - 30, 20), "MY SUMMER CAR ONLINE", taglineStyle);
float y = r.y + 110;
if (NavButton(new Rect(r.x, y, r.width, 42), "P2P", page == Page.P2P)) page = Page.P2P;
y += 44;
if (NavButton(new Rect(r.x, y, r.width, 42), "Server Browser", page == Page.ServerBrowser)) page = Page.ServerBrowser;
GUI.Label(new Rect(r.x + 24, r.yMax - 30, r.width - 48, 20), "v" + MSCO.ModVersion, taglineStyle);
}
bool NavButton(Rect r, string text, bool active)
{
if (active) GUI.Box(new Rect(r.x + 4, r.y + 8, 4, r.height - 16), GUIContent.none, accentBox); // Akzentbalken links
return GUI.Button(r, text, active ? navActiveStyle : navStyle);
}
void DrawP2P(MultiplayerManager mp)
{
GUILayout.Label("P2P", titleStyle);
GUILayout.Label("Host a game from your own save and invite up to " + (MultiplayerManager.MaxPlayers - 1) +
" friends through Steam.", textStyle);
GUILayout.Space(18);
if (mp.SteamAvailable)
{
if (GUILayout.Button("HOST GAME", primaryStyle, GUILayout.Height(46), GUILayout.Width(250)))
mp.HostSteam();
GUILayout.Space(14);
GUILayout.Label("To join a friend: accept their invite in Steam, or right-click them in your " +
"Steam friends list and choose \"Join Game\".", textStyle);
}
else
{
GUILayout.Label("Steam is not running - start the game through Steam to host or join.", textStyle);
}
}
void DrawServerBrowser(MultiplayerManager mp)
{
ServerBrowser browser = mp.Browser;
// Beim ersten Öffnen laden, danach automatisch alle 15 s (Spielerzahl, Ping, neue Server)
if (!browserLoaded || (!browser.Loading && Time.realtimeSinceStartup - browser.LastRefresh > ServerBrowser.AutoRefreshSeconds))
{
browserLoaded = true;
browser.Refresh(mp);
}
// Server, die auf die direkte Abfrage nicht antworten (z.B. gerade geschlossen), gar nicht zeigen
List<ServerListEntry> all = browser.Servers.FindAll(s => !s.Offline);
// Server-Profil (Klick auf einen Server)
if (selectedServer != null)
{
ServerListEntry selected = all.Find(s => s.Address + ":" + s.Port == selectedServer);
DrawServerProfile(mp, selected);
return;
}
GUILayout.BeginHorizontal();
// Titel darf nicht vom REFRESH-Button zusammengedrückt werden (sonst Zeilenumbruch)
GUILayout.Label("Server Browser", titleStyle, GUILayout.ExpandWidth(true));
if (GUILayout.Button(browser.Loading ? "LOADING..." : "REFRESH", tabStyle, GUILayout.Height(28), GUILayout.Width(110)))
browser.Refresh(mp);
GUILayout.EndHorizontal();
// Suche + Filter
GUILayout.BeginHorizontal();
searchInput = GUILayout.TextField(searchInput, 40, fieldStyle, GUILayout.Height(26), GUILayout.Width(170));
if (string.IsNullOrEmpty(searchInput))
{
Rect field = GUILayoutUtility.GetLastRect();
GUI.Label(new Rect(field.x + 10, field.y, field.width, field.height), "Search...", placeholderStyle);
}
GUILayout.Space(12);
showSteam = Chip(showSteam, "STEAM");
GUILayout.Space(6);
showNonSteam = Chip(showNonSteam, "NON-STEAM");
GUILayout.Space(6);
hideFull = Chip(hideFull, "HIDE FULL");
GUILayout.EndHorizontal();
GUILayout.Space(6);
string search = (searchInput ?? "").Trim().ToLower();
List<ServerListEntry> servers = all.FindAll(s =>
(s.RequireSteam ? showSteam : showNonSteam) &&
(!hideFull || s.Players < s.MaxPlayers) &&
(search.Length == 0 || (s.Name ?? "").ToLower().Contains(search) || (s.Owner ?? "").ToLower().Contains(search)));
// Tabellenkopf
Rect header = GUILayoutUtility.GetRect(10, 26, GUILayout.ExpandWidth(true));
GUI.Box(header, GUIContent.none, rowBox);
GUI.Label(new Rect(header.x + 12, header.y, header.width * 0.48f, header.height), "SERVER NAME", headerCellStyle);
GUI.Label(new Rect(header.x + header.width * 0.5f, header.y, 70, header.height), "PLAYERS", headerCellStyle);
GUI.Label(new Rect(header.x + header.width * 0.62f, header.y, 70, header.height), "TYPE", headerCellStyle);
GUI.Label(new Rect(header.x + header.width * 0.76f, header.y, 50, header.height), "PING", headerCellStyle);
serverScroll = GUILayout.BeginScrollView(serverScroll, GUILayout.Height(140));
if (browser.Error != null) GUILayout.Label(browser.Error, textStyle);
if (servers.Count == 0)
GUILayout.Label(browser.Loading ? "Loading servers..." : all.Count > 0 ? "No servers match your filters." : "No servers online right now.", textStyle);
foreach (ServerListEntry s in servers)
{
Rect row = GUILayoutUtility.GetRect(10, 34, GUILayout.ExpandWidth(true));
GUI.Box(new Rect(row.x, row.y + 2, row.width, row.height - 4), GUIContent.none, rowBox);
// Klick auf die Zeile (außer JOIN) öffnet das Server-Profil
if (GUI.Button(new Rect(row.x, row.y, row.width - 82, row.height), GUIContent.none, GUIStyle.none))
selectedServer = s.Address + ":" + s.Port;
GUI.DrawTexture(new Rect(row.x + 10, row.y + 13, 8, 8), s.Ping < 0 ? texPending : texOnline);
float nameX = row.x + 26;
if (s.IsLocal)
{
// Server auf diesem PC (wie bei FiveM "localhost")
nameX += Pill(nameX, row, "LOCAL", pillStyle);
}
if (!string.IsNullOrEmpty(s.Version) && s.Version != MSCO.ModVersion)
{
nameX += Pill(nameX, row, "v" + s.Version, pillStyle); // abweichende Version
}
GUI.Label(new Rect(nameX, row.y, row.x + row.width * 0.49f - nameX, row.height), s.Name, rowNameStyle);
GUI.Label(new Rect(row.x + row.width * 0.5f, row.y, 70, row.height), s.Players + "/" + s.MaxPlayers, rowNameStyle);
Pill(row.x + row.width * 0.62f, row, s.RequireSteam ? "STEAM" : "NO STEAM", s.RequireSteam ? pillStyle : pillMutedStyle);
GUI.Label(new Rect(row.x + row.width * 0.76f, row.y, 50, row.height), s.Ping < 0 ? "..." : s.Ping + " ms", rowNameStyle);
// Andere Version: trotzdem joinbar, beim Verbinden kommt eine Warnung
GUI.enabled = CanJoin(mp, s);
if (GUI.Button(new Rect(row.xMax - 76, row.y + 4, 70, row.height - 8), s.Players >= s.MaxPlayers ? "FULL" : "JOIN", primaryStyle))
mp.JoinServer(s.Address, s.Port, s.Name, s.MaxPlayers);
GUI.enabled = true;
}
GUILayout.EndScrollView();
GUILayout.FlexibleSpace();
GUILayout.BeginHorizontal();
if (!mp.SteamAvailable)
{
// Ohne Steam: eigener Name (nur für Non-Steam-Server)
GUILayout.BeginVertical(GUILayout.Width(150));
GUILayout.Label("YOUR NAME", captionStyle);
DrawNameField(mp, 150);
GUILayout.EndVertical();
GUILayout.Space(10);
}
GUILayout.BeginVertical();
GUILayout.Label("DIRECT CONNECT", captionStyle);
GUILayout.BeginHorizontal();
directInput = GUILayout.TextField(directInput, fieldStyle, GUILayout.Height(34));
GUILayout.Space(10);
if (GUILayout.Button("CONNECT", primaryStyle, GUILayout.Height(34), GUILayout.Width(110)))
{
string host;
int port;
if (ServerBrowser.TryParseAddress(directInput, out host, out port))
{
PlayerPrefs.SetString(PrefDirect, directInput.Trim());
mp.JoinServer(host, port);
}
}
GUILayout.EndHorizontal();
GUILayout.EndVertical();
GUILayout.EndHorizontal();
}
static bool CanJoin(MultiplayerManager mp, ServerListEntry s)
{
return s.Players < s.MaxPlayers && (!s.RequireSteam || mp.SteamAvailable);
}
void DrawNameField(MultiplayerManager mp, float width)
{
if (nameInput == null) nameInput = mp.PlayerName;
string edited = GUILayout.TextField(nameInput, 24, fieldStyle, GUILayout.Height(34), GUILayout.Width(width));
if (edited != nameInput)
{
nameInput = edited;
if (edited.Trim().Length > 0) mp.PlayerName = edited.Trim();
}
}
/// <summary>Server-Profil wie bei FiveM: wem der Server gehört, Version, Spieler, Laufzeit ...</summary>
void DrawServerProfile(MultiplayerManager mp, ServerListEntry s)
{
if (GUILayout.Button("◂ BACK", tabStyle, GUILayout.Height(26), GUILayout.Width(90)))
{
selectedServer = null;
return;
}
GUILayout.Space(6);
if (s == null)
{
GUILayout.Label("Server offline", titleStyle);
GUILayout.Label("This server does not respond anymore.", textStyle);
return;
}
GUILayout.Label(s.Name, titleStyle);
GUILayout.BeginHorizontal();
GUILayout.Label(s.RequireSteam ? "STEAM" : "NON-STEAM", badgeStyle, GUILayout.ExpandWidth(false));
if (s.IsLocal) { GUILayout.Space(10); GUILayout.Label("LOCAL", badgeStyle, GUILayout.ExpandWidth(false)); }
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
if (!string.IsNullOrEmpty(s.Description)) GUILayout.Label(s.Description, textStyle);
GUILayout.Space(10);
bool versionOk = s.Version == MSCO.ModVersion;
GUILayout.BeginHorizontal();
GUILayout.BeginVertical(GUILayout.Width(250));
InfoRow("HOSTED BY", string.IsNullOrEmpty(s.Owner) ? "unknown" : s.Owner, rowNameStyle);
InfoRow("VERSION", "v" + s.Version + (versionOk ? "" : " (you: v" + MSCO.ModVersion + ")"), versionOk ? rowNameStyle : badgeStyle);
InfoRow("STEAM", s.RequireSteam ? "Required" : "Not required", rowNameStyle);
InfoRow("ONLINE FOR", s.UptimeSeconds >= 0 ? FormatUptime(s.UptimeSeconds) : "-", rowNameStyle);
GUILayout.EndVertical();
GUILayout.BeginVertical();
InfoRow("PLAYERS", s.Players + " / " + s.MaxPlayers, rowNameStyle);
InfoRow("PING", s.Ping < 0 ? "-" : s.Ping + " ms", rowNameStyle);
InfoRow("ADDRESS", s.Address + ":" + s.Port, rowNameStyle);
GUILayout.EndVertical();
GUILayout.EndHorizontal();
// Spielerliste
if (s.PlayerNames != null && s.PlayerNames.Count > 0)
{
GUILayout.Space(4);
GUILayout.Label("ONLINE", captionStyle);
GUILayout.Label(string.Join(", ", s.PlayerNames.ToArray()), textStyle);
}
GUILayout.FlexibleSpace();
GUILayout.BeginHorizontal();
GUI.enabled = CanJoin(mp, s);
if (GUILayout.Button(s.Players >= s.MaxPlayers ? "SERVER FULL" : "JOIN SERVER", primaryStyle, GUILayout.Height(42), GUILayout.Width(190)))
mp.JoinServer(s.Address, s.Port, s.Name, s.MaxPlayers);
GUI.enabled = true;
GUILayout.Space(12);
if (s.RequireSteam && !mp.SteamAvailable)
GUILayout.Label("This server requires Steam.", textStyle);
else if (!mp.SteamAvailable)
{
GUILayout.Label("NAME", captionStyle, GUILayout.Width(42));
DrawNameField(mp, 150);
}
GUILayout.EndHorizontal();
}
/// <summary>Kleines abgerundetes Abzeichen, so breit wie sein Text. Gibt die belegte Breite zurück.</summary>
static float Pill(float x, Rect row, string text, GUIStyle style)
{
Vector2 size = style.CalcSize(new GUIContent(text));
GUI.Label(new Rect(x, row.y + (row.height - 18) / 2f, size.x, 18), text, style);
return size.x + 8;
}
/// <summary>Filter-Chip (an = orange umrandet), ersetzt die Unity-Häkchen.</summary>
bool Chip(bool on, string text)
{
if (GUILayout.Button(text, on ? chipOnStyle : chipOffStyle, GUILayout.Height(26), GUILayout.ExpandWidth(false))) on = !on;
return on;
}
void InfoRow(string caption, string value, GUIStyle valueStyle)
{
GUILayout.Label(caption, captionStyle);
GUILayout.Label(value, valueStyle);
GUILayout.Space(2);
}
static string FormatUptime(int seconds)
{
int days = seconds / 86400, hours = seconds / 3600 % 24, minutes = seconds / 60 % 60;
if (days > 0) return days + "d " + hours + "h";
if (hours > 0) return hours + "h " + minutes + "m";
return Mathf.Max(1, minutes) + " min";
}
/// <summary>Server hat eine andere Version: warnen, Verbinden nur auf Wunsch.</summary>
void DrawVersionWarning(MultiplayerManager mp)
{
MultiplayerManager.VersionWarning w = mp.PendingVersionWarning;
GUILayout.Label("Version mismatch", titleStyle);
GUILayout.Label("This server runs a different MSCO version than you. You can still join, but things may not " +
"sync correctly (players, items, cars). For the best experience use the same version.", textStyle);
GUILayout.Space(16);
GUILayout.Label("SERVER", captionStyle);
GUILayout.Label(w.Name + " (" + w.Host + ":" + w.Port + ")", rowNameStyle);
GUILayout.Space(8);
GUILayout.Label("VERSIONS", captionStyle);
GUILayout.Label("Server v" + w.ServerVersion + " - You v" + MSCO.ModVersion, rowTagStyle);
GUILayout.Space(22);
GUILayout.BeginHorizontal();
if (GUILayout.Button("JOIN ANYWAY", primaryStyle, GUILayout.Height(42), GUILayout.Width(170)))
mp.ConfirmVersionWarning();
GUILayout.Space(10);
if (GUILayout.Button("CANCEL", secondaryStyle, GUILayout.Height(42), GUILayout.Width(170)))
mp.CancelVersionWarning();
GUILayout.EndHorizontal();
}
/// <summary>Nachfrage nach einem JOIN-Link der Website (keine Webseite soll einen ungefragt verbinden).</summary>
void DrawJoinConfirm(MultiplayerManager mp)
{
GUILayout.Label("Join server?", titleStyle);
GUILayout.Label("You opened a join link from the server list.", textStyle);
GUILayout.Space(18);
GUILayout.Label("SERVER ADDRESS", captionStyle);
GUILayout.Label(mp.PendingJoinHost + ":" + mp.PendingJoinPort, rowNameStyle);
GUILayout.Space(24);
if (!mp.SteamAvailable)
GUILayout.Label("Steam is required to join servers - start the game through Steam.", textStyle);
GUILayout.BeginHorizontal();
GUI.enabled = mp.SteamAvailable;
if (GUILayout.Button("JOIN", primaryStyle, GUILayout.Height(42), GUILayout.Width(170)))
mp.ConfirmPendingJoin();
GUI.enabled = true;
GUILayout.Space(10);
if (GUILayout.Button("CANCEL", secondaryStyle, GUILayout.Height(42), GUILayout.Width(170)))
mp.CancelPendingJoin();
GUILayout.EndHorizontal();
}
void DrawSession(MultiplayerManager mp)
{
GUILayout.Label(mp.IsHost ? "Hosting" : "Connected", titleStyle);
GUILayout.Label(mp.IsHost
? "Your game is open. Start when everyone has joined."
: "Waiting for the host to start the game.", textStyle);
GUILayout.Space(16);
GUILayout.Label("PLAYERS " + mp.Players.Count + "/" + MultiplayerManager.MaxPlayers, captionStyle);
playerScroll = GUILayout.BeginScrollView(playerScroll, GUILayout.Height(170));
foreach (PlayerInfo p in mp.Players)
{
Rect row = GUILayoutUtility.GetRect(10, 32, GUILayout.ExpandWidth(true));
GUI.Box(new Rect(row.x, row.y, row.width, row.height - 4), GUIContent.none, rowBox);
GUI.DrawTexture(new Rect(row.x + 12, row.y + 11, 8, 8), texOnline);
GUI.Label(new Rect(row.x + 30, row.y, row.width - 150, row.height - 2), p.Name, rowNameStyle);
string tag = p.Id == NetSession.HostId ? "HOST" : "";
if (p.IsLocal) tag = tag.Length > 0 ? tag + " · YOU" : "YOU";
GUI.Label(new Rect(row.xMax - 120, row.y, 110, row.height - 2), tag, rowTagStyle);
}
GUILayout.EndScrollView();
GUILayout.Space(14);
GUILayout.BeginHorizontal();
if (mp.IsHost)
{
if (GUILayout.Button("START GAME", primaryStyle, GUILayout.Height(42), GUILayout.Width(150)))
mp.StartHostGame();
GUILayout.Space(10);
if (mp.IsSteamSession && mp.Players.Count < MultiplayerManager.MaxPlayers &&
GUILayout.Button("INVITE FRIENDS", secondaryStyle, GUILayout.Height(42), GUILayout.Width(150)))
mp.InviteFriends();
GUILayout.Space(10);
}
if (GUILayout.Button("DISCONNECT", secondaryStyle, GUILayout.Height(42), GUILayout.Width(150)))
mp.Disconnect();
GUILayout.EndHorizontal();
}
void DrawStatusBar(MultiplayerManager mp, Rect r)
{
GUI.Box(r, GUIContent.none, sidebarBox);
Texture2D dot = mp.IsPeerReady ? texOnline : mp.IsInSession ? texPending : texMuted;
GUI.DrawTexture(new Rect(r.x + 18, r.y + (r.height - 8) / 2f, 8, 8), dot);
GUI.Label(new Rect(r.x + 36, r.y, r.width - 180, r.height), mp.Status, statusStyle);
if (GUI.Button(new Rect(r.xMax - 118, r.y + 6, 106, r.height - 12), "MINIMIZE", tabStyle))
minimized = true;
}
void DrawMinimized(MultiplayerManager mp)
{
var r = new Rect((Screen.width - 220) / 2f, 0, 220, 30);
ScreenRect = r;
GUI.Box(r, GUIContent.none, sidebarBox);
GUI.Box(new Rect(r.x + 10, r.yMax - 3, r.width - 20, 3), GUIContent.none, accentBox);
Texture2D dot = mp.IsPeerReady ? texOnline : mp.IsInSession ? texPending : texMuted;
Texture2D logo = Branding.LogoTexture;
if (logo != null) GUI.DrawTexture(new Rect(r.x + 8, r.y + 4, 22, 22), logo);
GUI.DrawTexture(new Rect(r.x + 36, r.y + 11, 8, 8), dot);
if (GUI.Button(new Rect(r.x + 48, r.y, r.width - 48, r.height), "MSCO MULTIPLAYER ▾", tabStyle))
minimized = false;
}
// ---------------------------------------------------------------- Hilfen
void EnsureStyles()
{
if (stylesReady && texBackground != null) return;
// PlayerPrefs erst hier lesen (nicht im Konstruktor erlaubt)
directInput = PlayerPrefs.GetString(PrefDirect, "");
texBackground = Solid(Background);
texSidebar = Solid(Sidebar);
texAccent = Solid(Accent);
texOnline = UiTheme.Rounded(Online, 4); // runde Status-Punkte
texPending = UiTheme.Rounded(Pending, 4);
texMuted = UiTheme.Rounded(Muted, 4);
texRow = Solid(Field);
windowBox = UiTheme.Panel9(Background, 10);
sidebarBox = UiTheme.Panel9(Sidebar, 10);
rowBox = UiTheme.Panel9(Field, UiTheme.Radius);
accentBox = UiTheme.Panel9(Accent, 1);
pillStyle = UiTheme.Panel9(new Color(Accent.r, Accent.g, Accent.b, 0.18f), 4);
pillStyle.normal.textColor = Accent;
pillStyle.fontSize = 10;
pillStyle.fontStyle = FontStyle.Bold;
pillStyle.alignment = TextAnchor.MiddleCenter;
pillStyle.padding = new RectOffset(7, 7, 0, 0);
pillStyle.wordWrap = false;
pillMutedStyle = new GUIStyle(pillStyle);
pillMutedStyle.normal.background = UiTheme.Rounded(new Color(1f, 1f, 1f, 0.08f), 4);
pillMutedStyle.normal.textColor = Muted;
chipOnStyle = Button(new Color(Accent.r, Accent.g, Accent.b, 0.22f), new Color(Accent.r, Accent.g, Accent.b, 0.32f), AccentHover, 11, FontStyle.Bold);
chipOffStyle = Button(Field, FieldFocus, Muted, 11, FontStyle.Bold);
chipOnStyle.padding = chipOffStyle.padding = new RectOffset(12, 12, 0, 0);
logoStyle = Label(28, Color.white, FontStyle.Bold);
taglineStyle = Label(10, Muted, FontStyle.Bold);
titleStyle = Label(24, Color.white, FontStyle.Bold);
textStyle = Label(13, Muted, FontStyle.Normal);
textStyle.wordWrap = true;
captionStyle = Label(11, Muted, FontStyle.Bold);
captionStyle.margin.bottom = 6;
statusStyle = Label(12, Color.white, FontStyle.Normal);
statusStyle.alignment = TextAnchor.MiddleLeft;
headerCellStyle = Label(11, Muted, FontStyle.Bold);
headerCellStyle.alignment = TextAnchor.MiddleLeft;
rowNameStyle = Label(14, Color.white, FontStyle.Normal);
rowNameStyle.alignment = TextAnchor.MiddleLeft;
rowTagStyle = Label(11, Accent, FontStyle.Bold);
rowTagStyle.alignment = TextAnchor.MiddleRight;
badgeStyle = new GUIStyle(rowTagStyle);
badgeStyle.alignment = TextAnchor.MiddleLeft;
badgeStyle.padding = new RectOffset(6, 6, 2, 2);
placeholderStyle = Label(13, Muted, FontStyle.Italic);
placeholderStyle.alignment = TextAnchor.MiddleLeft;
toggleStyle = new GUIStyle(GUI.skin.toggle);
toggleStyle.normal.textColor = toggleStyle.onNormal.textColor = toggleStyle.hover.textColor = toggleStyle.onHover.textColor = Color.white;
toggleStyle.fontSize = 11;
toggleStyle.fontStyle = FontStyle.Bold;
navStyle = Button(Sidebar, Secondary, Muted, 14, FontStyle.Normal);
navStyle.alignment = TextAnchor.MiddleLeft;
navStyle.padding = new RectOffset(26, 10, 0, 0);
navActiveStyle = Button(Secondary, Secondary, Color.white, 14, FontStyle.Bold);
navActiveStyle.alignment = TextAnchor.MiddleLeft;
navActiveStyle.padding = new RectOffset(26, 10, 0, 0);
primaryStyle = Button(Accent, AccentHover, Color.white, 14, FontStyle.Bold);
secondaryStyle = Button(Secondary, SecondaryHover, Color.white, 14, FontStyle.Bold);
tabStyle = Button(Sidebar, Secondary, Muted, 11, FontStyle.Bold);
fieldStyle = UiTheme.Field(14);
stylesReady = true;
}
static GUIStyle Label(int size, Color color, FontStyle font)
{
var s = new GUIStyle(GUI.skin.label);
s.fontSize = size;
s.fontStyle = font;
s.normal.textColor = color;
return s;
}
static GUIStyle Button(Color normal, Color hover, Color text, int size, FontStyle font)
{
var s = new GUIStyle(GUI.skin.button);
// abgerundete Ecken (9-Slice), siehe UiTheme
s.normal.background = UiTheme.Rounded(normal);
s.hover.background = UiTheme.Rounded(hover);
s.active.background = UiTheme.Rounded(hover);
s.focused.background = UiTheme.Rounded(normal);
s.normal.textColor = s.hover.textColor = s.active.textColor = s.focused.textColor = text;
s.border = new RectOffset(UiTheme.Radius + 2, UiTheme.Radius + 2, UiTheme.Radius + 2, UiTheme.Radius + 2);
s.fontSize = size;
s.fontStyle = font;
return s;
}
static Texture2D Solid(Color color)
{
var tex = new Texture2D(1, 1);
tex.SetPixel(0, 0, color);
tex.Apply();
tex.hideFlags = HideFlags.HideAndDontSave;
return tex;
}
static Color Hex(int r, int g, int b)
{
return new Color(r / 255f, g / 255f, b / 255f, 1f);
}
}
}
+11 -1
View File
@@ -5,7 +5,7 @@ namespace MSCO
{ {
public class MSCO : Mod public class MSCO : Mod
{ {
public const string ModVersion = "1.0"; public const string ModVersion = "2.6";
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
@@ -13,6 +13,8 @@ namespace MSCO
public override string Version => ModVersion; // Version public override string Version => ModVersion; // Version
public override string Description => "Online mod by mbm.wtf"; // Short description of your mod public override string Description => "Online mod by mbm.wtf"; // Short description of your mod
public override Game SupportedGames => Game.MySummerCar; public override Game SupportedGames => Game.MySummerCar;
// Logo in der MSCLoader-Modliste (branding/logo-128.png, in die DLL eingebettet)
public override byte[] Icon { get { return Branding.LogoPng; } set { } }
bool commandAdded; bool commandAdded;
@@ -28,10 +30,18 @@ namespace MSCO
SetupFunction(Setup.ModSettings, Mod_Settings); SetupFunction(Setup.ModSettings, Mod_Settings);
} }
/// <summary>Taste zum Ein-/Aussteigen als Beifahrer (in den MSCLoader-Einstellungen änderbar).</summary>
internal static SettingsKeybind PassengerKey;
/// <summary>MSCO-Konsole (Standard F8) und Chat (Standard T), ebenfalls änderbar.</summary>
internal static SettingsKeybind ConsoleKey, ChatKey;
private void Mod_Settings() private void Mod_Settings()
{ {
// All settings should be created here. // All settings should be created here.
// DO NOT put anything that isn't settings or keybinds in here! // DO NOT put anything that isn't settings or keybinds in here!
PassengerKey = Keybind.Add("passenger", "Ride as passenger (toggle)", KeyCode.P);
ChatKey = Keybind.Add("chat", "Open chat", KeyCode.T);
ConsoleKey = Keybind.Add("console", "Open MSCO console", KeyCode.F8);
} }
private void Mod_OnMenuLoad() private void Mod_OnMenuLoad()
+34
View File
@@ -46,6 +46,14 @@
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\MSCLoader.dll</HintPath> <HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\MSCLoader.dll</HintPath>
<Private>False</Private> <Private>False</Private>
</Reference> </Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Newtonsoft.Json.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Assembly-CSharp-firstpass">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Assembly-CSharp-firstpass.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Assembly-CSharp"> <Reference Include="Assembly-CSharp">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Assembly-CSharp.dll</HintPath> <HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Assembly-CSharp.dll</HintPath>
<Private>False</Private> <Private>False</Private>
@@ -64,12 +72,38 @@
</Reference> </Reference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="MoneySync.cs" />
<Compile Include="MpCommand.cs" /> <Compile Include="MpCommand.cs" />
<Compile Include="Commands.cs" />
<Compile Include="InputLock.cs" />
<Compile Include="Overlays.cs" />
<Compile Include="SafetyPatches.cs" />
<Compile Include="SceneCache.cs" />
<Compile Include="UiTheme.cs" />
<Compile Include="VehicleStateSync.cs" />
<Compile Include="Branding.cs" />
<Compile Include="DiscordPresence.cs" />
<Compile Include="DoorSync.cs" />
<Compile Include="FsmSync.cs" />
<Compile Include="GameCharacter.cs" />
<Compile Include="JoinLinks.cs" />
<Compile Include="LobbyUI.cs" />
<Compile Include="MenuHelper.cs" /> <Compile Include="MenuHelper.cs" />
<Compile Include="MenuInputBlocker.cs" />
<Compile Include="MSCO.cs" /> <Compile Include="MSCO.cs" />
<Compile Include="MultiplayerManager.cs" /> <Compile Include="MultiplayerManager.cs" />
<Compile Include="NetSession.cs" /> <Compile Include="NetSession.cs" />
<Compile Include="ObjectSync.cs" />
<Compile Include="Passenger.cs" />
<Compile Include="SaveSync.cs" /> <Compile Include="SaveSync.cs" />
<Compile Include="ServerBrowser.cs" />
<Compile Include="SteamLobby.cs" />
<Compile Include="VehicleFx.cs" />
<Compile Include="WinHttp.cs" />
<Compile Include="WorldSync.cs" />
<EmbeddedResource Include="..\branding\logo-128.png">
<LogicalName>MSCO.logo.png</LogicalName>
</EmbeddedResource>
<Compile Include="Properties\AssemblyInfo.cs" /> <Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="RemotePlayer.cs" /> <Compile Include="RemotePlayer.cs" />
</ItemGroup> </ItemGroup>
+37
View File
@@ -0,0 +1,37 @@
using System.Collections.Generic;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Das Hauptmenü reagiert über OnMouseEnter/OnMouseDown auf Collider in der 3D-Szene -
/// auch durch unser GUI hindurch (z.B. öffnet sich der Changelog). Solange die Maus über
/// unserem Fenster ist, werden die Maus-Events der Kameras abgeschaltet (eventMask = 0).
/// </summary>
public static class MenuInputBlocker
{
static readonly Dictionary<Camera, int> savedMasks = new Dictionary<Camera, int>();
public static bool IsBlocked { get { return savedMasks.Count > 0; } }
public static void SetBlocked(bool blocked)
{
if (blocked == IsBlocked) return;
if (blocked)
{
foreach (Camera cam in Camera.allCameras)
{
savedMasks[cam] = cam.eventMask;
cam.eventMask = 0;
}
}
else
{
foreach (KeyValuePair<Camera, int> kv in savedMasks)
if (kv.Key != null) kv.Key.eventMask = kv.Value;
savedMasks.Clear();
}
}
}
}
+66
View File
@@ -0,0 +1,66 @@
using System;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Gemeinsame Kasse: Ändert sich das Geld (globale Variable "PlayerMoney") bei einem Spieler,
/// wird nur die Änderung verschickt und bei allen anderen dazugerechnet. So gehen gleichzeitige
/// Einkäufe/Einnahmen nicht verloren, und niemand überschreibt den Kontostand eines anderen.
/// </summary>
public class MoneySync
{
const string MoneyVariable = "PlayerMoney";
const float MinChange = 0.01f;
FsmFloat money;
float last;
bool tracking;
float pending; // Änderungen, die ankamen, bevor das Geld aus dem Spielstand geladen war
/// <summary>Szenenwechsel / Verbindungsende.</summary>
public void Reset()
{
money = null;
tracking = false;
pending = 0f;
}
/// <summary>
/// Erst beobachten, wenn das Spiel fertig geladen ist - sonst würde das Laden des
/// Kontostands aus dem Spielstand als "Einnahme" verschickt.
/// </summary>
public void Begin()
{
if (tracking) return;
money = FsmVariables.GlobalVariables.FindFsmFloat(MoneyVariable);
if (money == null) return;
money.Value += pending;
pending = 0f;
last = money.Value;
tracking = true;
}
public void Update(Action<MsgType, byte[]> send)
{
if (!tracking || money == null) return;
float delta = money.Value - last;
if (Mathf.Abs(delta) < MinChange) return;
last = money.Value;
send(MsgType.MoneyDelta, NetSession.Build(w => w.Write(delta)));
}
/// <summary>Änderung von einem anderen Spieler (oder vom Server) dazurechnen.</summary>
public void Apply(float delta)
{
if (!tracking || money == null)
{
pending += delta;
return;
}
money.Value += delta;
last += delta; // nicht als eigene Änderung zurückschicken
}
}
}
+3 -25
View File
@@ -2,37 +2,15 @@ using MSCLoader;
namespace MSCO namespace MSCO
{ {
/// <summary>Konsolenbefehl: mp host [port] | mp join <ip> [port] | mp stop</summary> /// <summary>MSCLoader-Konsole: "mp &lt;befehl&gt;" - gleiche Befehle wie in der F8-Konsole.</summary>
public class MpCommand : ConsoleCommand public class MpCommand : ConsoleCommand
{ {
public override string Name => "mp"; public override string Name => "mp";
public override string Help => "mp host [port] | mp join <ip> [port] | mp stop"; public override string Help => "MSCO commands, e.g. mp help | mp connect <host[:port]> | mp save (or press F8)";
public override void Run(string[] args) public override void Run(string[] args)
{ {
var mp = MultiplayerManager.Instance; Commands.Execute(args, line => ModConsole.Print("[MSCO] " + line));
if (mp == null) { ModConsole.Print("[MSCO] Not ready yet"); return; }
if (args.Length == 0) { ModConsole.Print(Help); return; }
int port = MultiplayerManager.DefaultPort;
switch (args[0].ToLower())
{
case "host":
if (args.Length > 1) int.TryParse(args[1], out port);
mp.Host(port);
break;
case "join":
if (args.Length < 2) { ModConsole.Print("mp join <ip> [port]"); return; }
if (args.Length > 2) int.TryParse(args[2], out port);
mp.Join(args[1], port);
break;
case "stop":
mp.Disconnect();
break;
default:
ModConsole.Print(Help);
break;
}
} }
} }
} }
+1148 -122
View File
File diff suppressed because it is too large Load Diff
+422 -109
View File
@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Net; using System.Net;
@@ -9,13 +9,35 @@ namespace MSCO
{ {
public enum MsgType : byte public enum MsgType : byte
{ {
Hello = 1, // Client -> Host: Mod-Version Hello = 1, // Client -> Host: Mod-Version + Name
Welcome = 2, // Host -> Client: Handshake ok Welcome = 2, // Host -> Client: eigene ID + Spielerliste
Reject = 3, // Host -> Client: Grund Reject = 3, // Host -> Client: Grund
Player = 4, // Spielerposition Player = 4, // Spielerposition
SaveBegin = 5, // Host -> Client: Spielstand kommt SaveBegin = 5, // Host -> Client: Spielstand kommt
SaveFile = 6, // Host -> Client: Dateiname + Inhalt SaveFile = 6, // Host -> Client: Dateiname + Inhalt
StartGame = 7, // Host -> Client: jetzt laden StartGame = 7, // Host -> Client: jetzt laden
ObjStates = 8, // Positionen der Objekte, die man gerade besitzt
ObjOwn = 9, // "Ich übernehme dieses Objekt"
ObjRelease = 10, // "Objekt liegt still, wieder frei"
FsmEvent = 11, // Schraube angezogen/gelöst, Teil an-/abgebaut
PlayerJoined = 12,// Host -> alle: neuer Spieler (ID + Name)
PlayerLeft = 13, // Host -> alle: Spieler weg (ID)
VehicleFx = 14, // Lichter und Motor-/Dauergeräusche eines Fahrzeugs
Ping = 15, // Lebenszeichen (Steam hat keinen Verbindungsabbruch)
Ready = 16, // Client -> Server: Spiel geladen, Objekte erfasst
WorldSnapshot = 17,// Server -> Client: aktueller Weltzustand
Info = 18, // Server-Browser fragt Infos ab (ohne Hello)
InfoReply = 19,
WorldTime = 20, // Uhrzeit + Wetter vom "Weltuhr"-Spieler
MoneyDelta = 21, // Geld hat sich geändert (+/- Betrag, gemeinsame Kasse)
DoorState = 22, // Tür/Tor/Haube hat den Zustand gewechselt (FSM-Pfad + Zustand)
AdminStatus = 23, // Server -> Client: bool, ist Admin
AdminListRequest = 24, // Client -> Server: Spielerliste fürs Admin-Menü
AdminList = 25, // Server -> Client: int Anzahl, je [int id, string name, string steamId, bool admin]
AdminAction = 26, // Client -> Server: byte (1 = kick, 2 = ban), int Spieler-ID, string Grund
AdminResult = 27, // Server -> Client: string Meldung
Chat = 28, // Chatnachricht (string)
VarSync = 29 // Fahrzeugzustand: int n, je [int key, byte typ, wert]
} }
public struct Message public struct Message
@@ -33,49 +55,299 @@ namespace MSCO
{ {
public Vector3 Position; public Vector3 Position;
public float RotationY; public float RotationY;
public Vector3 CamPos; // Kopf/Kamera in Weltkoordinaten
public Quaternion CamRot; // Blickrichtung inkl. hoch/runter
public int HeldId; // ID des gehaltenen Objekts, 0 = nichts
public Vector3 HeldLocalPos; // relativ zur Kamera
public Quaternion HeldLocalRot; // relativ zur Kamera
public bool Seated; // fährt oder sitzt als Beifahrer
public float FeetY; // Bodenhöhe unter dem Spieler (für Ducken/Lehnen der Figur)
public byte[] Serialize() public byte[] Serialize()
{ {
Vector3 pos = Position; PlayerState s = this;
float rotY = RotationY;
return NetSession.Build(w => return NetSession.Build(w =>
{ {
w.Write(pos.x); w.Write(pos.y); w.Write(pos.z); Write(w, s.Position);
w.Write(rotY); w.Write(s.RotationY);
Write(w, s.CamPos);
Write(w, s.CamRot);
w.Write(s.Seated);
w.Write(s.FeetY);
w.Write(s.HeldId);
if (s.HeldId == 0) return;
Write(w, s.HeldLocalPos);
Write(w, s.HeldLocalRot);
}); });
} }
public static PlayerState Deserialize(BinaryReader r) public static PlayerState Deserialize(BinaryReader r)
{ {
return new PlayerState var s = new PlayerState
{ {
Position = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()), Position = ReadVector(r),
RotationY = r.ReadSingle() RotationY = r.ReadSingle(),
CamPos = ReadVector(r),
CamRot = ReadQuaternion(r),
Seated = r.ReadBoolean(),
FeetY = r.ReadSingle(),
HeldId = r.ReadInt32(),
HeldLocalRot = Quaternion.identity
}; };
if (s.HeldId != 0)
{
s.HeldLocalPos = ReadVector(r);
s.HeldLocalRot = ReadQuaternion(r);
}
return s;
}
static void Write(BinaryWriter w, Vector3 v) { w.Write(v.x); w.Write(v.y); w.Write(v.z); }
static void Write(BinaryWriter w, Quaternion q) { w.Write(q.x); w.Write(q.y); w.Write(q.z); w.Write(q.w); }
static Vector3 ReadVector(BinaryReader r) { return new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); }
static Quaternion ReadQuaternion(BinaryReader r) { return new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); }
}
public interface IConnection
{
bool IsClosed { get; }
string CloseReason { get; }
List<Message> Poll();
void Send(MsgType type, byte[] payload);
void Close(string reason);
}
/// <summary>Setzt aus einem Byte-Strom Nachrichten zusammen: [int Länge][byte Typ][Payload].</summary>
public class FrameParser
{
const int MaxFrame = 32 * 1024 * 1024;
byte[] buffer = new byte[64 * 1024];
int count;
public void Append(byte[] data, int length)
{
EnsureCapacity(count + length);
Buffer.BlockCopy(data, 0, buffer, count, length);
count += length;
}
public void Parse(List<Message> messages)
{
int offset = 0;
while (count - offset >= 5)
{
int length = BitConverter.ToInt32(buffer, offset);
if (length < 1 || length > MaxFrame) throw new IOException("Invalid packet");
if (count - offset - 4 < length) break;
var payload = new byte[length - 1];
Buffer.BlockCopy(buffer, offset + 5, payload, 0, payload.Length);
messages.Add(new Message { Type = (MsgType)buffer[offset + 4], Payload = payload });
offset += 4 + length;
}
if (offset > 0)
{
Buffer.BlockCopy(buffer, offset, buffer, 0, count - offset);
count -= offset;
}
}
public static byte[] Frame(MsgType type, byte[] payload)
{
if (payload == null) payload = new byte[0];
var frame = new byte[5 + payload.Length];
BitConverter.GetBytes(payload.Length + 1).CopyTo(frame, 0);
frame[4] = (byte)type;
Buffer.BlockCopy(payload, 0, frame, 5, payload.Length);
return frame;
}
void EnsureCapacity(int needed)
{
if (needed <= buffer.Length) return;
var bigger = new byte[Math.Max(needed, buffer.Length * 2)];
Buffer.BlockCopy(buffer, 0, bigger, 0, count);
buffer = bigger;
}
}
/// <summary>TCP-Verbindung (LAN / IP). Lesen ist nicht-blockierend und wird über Poll() abgefragt.</summary>
public class Connection : IConnection
{
public bool IsClosed { get; private set; }
public string CloseReason { get; private set; }
readonly TcpClient client;
readonly NetworkStream stream;
readonly FrameParser parser = new FrameParser();
readonly byte[] readBuffer = new byte[64 * 1024];
public Connection(TcpClient client)
{
this.client = client;
client.NoDelay = true;
client.SendBufferSize = 4 * 1024 * 1024;
stream = client.GetStream();
}
public List<Message> Poll()
{
var messages = new List<Message>();
if (IsClosed) return messages;
try
{
// Verbindung von der Gegenseite geschlossen?
if (client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0)
{
Close("Connection closed");
return messages;
}
while (stream.DataAvailable)
{
int read = stream.Read(readBuffer, 0, readBuffer.Length);
if (read <= 0) break;
parser.Append(readBuffer, read);
}
parser.Parse(messages);
}
catch (Exception e)
{
Close("Network error: " + e.Message);
}
return messages;
}
public void Send(MsgType type, byte[] payload)
{
if (IsClosed) return;
try
{
byte[] frame = FrameParser.Frame(type, payload);
stream.Write(frame, 0, frame.Length); // blockierend, damit große Dateien komplett rausgehen
}
catch (Exception e)
{
Close("Network error: " + e.Message);
}
}
public void Close(string reason)
{
if (IsClosed) return;
IsClosed = true;
CloseReason = reason;
try { client.Close(); } catch { }
} }
} }
/// <summary> /// <summary>
/// TCP-Verbindung zu genau einem Mitspieler. Nachrichten: [int Länge][byte Typ][Payload]. /// Verbindung über Steam P2P (Steam kümmert sich um NAT/Relay). Pakete sind auf 1 MB
/// Lesen ist nicht-blockierend und wird jedes Frame über Poll() abgefragt. /// begrenzt, deshalb werden Nachrichten in Stücke geteilt und beim Empfänger über den
/// FrameParser wieder zusammengesetzt (der Reliable-Kanal hält die Reihenfolge ein).
/// </summary> /// </summary>
public class NetSession public class SteamConnection : IConnection
{ {
const int MaxFrame = 32 * 1024 * 1024; const int ChunkSize = 512 * 1024;
const float Timeout = 20f; // ohne Pakete (auch Pings) gilt die Verbindung als tot
public bool IsHost { get; private set; } public readonly Steamworks.CSteamID Remote;
public bool IsConnected { get { return stream != null; } }
public bool IsClosed { get; private set; } public bool IsClosed { get; private set; }
public string CloseReason { get; private set; } public string CloseReason { get; private set; }
TcpListener listener; readonly FrameParser parser = new FrameParser();
TcpClient client; float lastReceive;
NetworkStream stream;
IAsyncResult connecting;
bool justConnected;
byte[] buffer = new byte[64 * 1024]; public SteamConnection(Steamworks.CSteamID remote)
int bufferCount; {
Remote = remote;
lastReceive = Time.realtimeSinceStartup;
}
public void OnPacket(byte[] data, int length)
{
parser.Append(data, length);
lastReceive = Time.realtimeSinceStartup;
}
public List<Message> Poll()
{
var messages = new List<Message>();
if (IsClosed) return messages;
try { parser.Parse(messages); }
catch (Exception e) { Close("Network error: " + e.Message); }
if (Time.realtimeSinceStartup - lastReceive > Timeout) Close("Timed out");
return messages;
}
public void Send(MsgType type, byte[] payload)
{
if (IsClosed) return;
byte[] frame = FrameParser.Frame(type, payload);
for (int offset = 0; offset < frame.Length; offset += ChunkSize)
{
int length = Math.Min(ChunkSize, frame.Length - offset);
byte[] chunk = frame;
if (offset != 0 || length != frame.Length)
{
chunk = new byte[length];
Buffer.BlockCopy(frame, offset, chunk, 0, length);
}
if (!Steamworks.SteamNetworking.SendP2PPacket(Remote, chunk, (uint)length, Steamworks.EP2PSend.k_EP2PSendReliable, 0))
{
Close("Steam send failed");
return;
}
}
}
public void Close(string reason)
{
if (IsClosed) return;
IsClosed = true;
CloseReason = reason;
try { Steamworks.SteamNetworking.CloseP2PSessionWithUser(Remote); } catch { }
}
}
public enum NetEventKind { Connected, Disconnected, Message }
public struct NetEvent
{
public NetEventKind Kind;
public int PeerId; // beim Client immer NetSession.HostId
public Message Message;
public string Reason;
}
/// <summary>
/// Stern-Topologie: Der Host hält eine Verbindung zu jedem Client,
/// Clients sind nur mit dem Host verbunden. Der Host leitet Nachrichten weiter.
/// Transport entweder TCP (LAN/IP) oder Steam P2P.
/// </summary>
public class NetSession
{
public const int HostId = 0;
public bool IsHost { get; private set; }
public bool IsSteam { get; private set; }
public bool IsClosed { get; private set; }
public string CloseReason { get; private set; }
// Host
TcpListener listener;
readonly Dictionary<int, IConnection> peers = new Dictionary<int, IConnection>();
readonly Dictionary<ulong, int> steamPeerIds = new Dictionary<ulong, int>();
Func<Steamworks.CSteamID, bool> allowSteamPeer;
int nextPeerId = 1;
// Client
TcpClient connectingClient;
IAsyncResult connecting;
IConnection server;
bool pendingConnected;
byte[] steamBuffer = new byte[64 * 1024];
public static NetSession StartHost(int port) public static NetSession StartHost(int port)
{ {
@@ -88,127 +360,169 @@ namespace MSCO
public static NetSession StartClient(string ip, int port) public static NetSession StartClient(string ip, int port)
{ {
var s = new NetSession(); var s = new NetSession();
s.client = new TcpClient(); s.connectingClient = new TcpClient();
s.connecting = s.client.BeginConnect(ip, port, null, null); s.connecting = s.connectingClient.BeginConnect(ip, port, null, null);
return s; return s;
} }
/// <summary>True genau einmal, direkt nachdem die Verbindung steht.</summary> /// <summary>Steam-Host: nimmt Pakete von Spielern an, die allowPeer erlaubt (Lobby-Mitglieder).</summary>
public bool ConsumeConnected() public static NetSession StartSteamHost(Func<Steamworks.CSteamID, bool> allowPeer)
{ {
bool c = justConnected; return new NetSession { IsHost = true, IsSteam = true, allowSteamPeer = allowPeer };
justConnected = false;
return c;
} }
public List<Message> Poll() public static NetSession StartSteamClient(Steamworks.CSteamID host)
{ {
var messages = new List<Message>(); return new NetSession { IsSteam = true, server = new SteamConnection(host), pendingConnected = true };
if (IsClosed) return messages; }
public List<NetEvent> Poll()
{
var events = new List<NetEvent>();
if (IsClosed) return events;
try try
{ {
AcceptOrConnect(); if (IsSteam) PumpSteam(events);
if (stream == null) return messages; if (IsHost) PollHost(events);
else PollClient(events);
// Verbindung von der Gegenseite geschlossen?
if (client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0)
{
Close("Connection closed by other player");
return messages;
}
while (stream.DataAvailable)
{
EnsureCapacity(bufferCount + client.Available);
int read = stream.Read(buffer, bufferCount, buffer.Length - bufferCount);
if (read <= 0) break;
bufferCount += read;
}
ParseFrames(messages);
} }
catch (Exception e) catch (Exception e)
{ {
Close("Network error: " + e.Message); Close("Network error: " + e.Message);
} }
return messages; return events;
} }
void AcceptOrConnect() void PumpSteam(List<NetEvent> events)
{ {
uint size;
while (Steamworks.SteamNetworking.IsP2PPacketAvailable(out size, 0))
{
if (steamBuffer.Length < size) steamBuffer = new byte[size];
uint read;
Steamworks.CSteamID from;
if (!Steamworks.SteamNetworking.ReadP2PPacket(steamBuffer, size, out read, out from, 0)) break;
if (IsHost) if (IsHost)
{
int id;
if (!steamPeerIds.TryGetValue(from.m_SteamID, out id))
{
if (allowSteamPeer == null || !allowSteamPeer(from)) continue; // nicht in der Lobby
id = nextPeerId++;
steamPeerIds[from.m_SteamID] = id;
peers[id] = new SteamConnection(from);
events.Add(new NetEvent { Kind = NetEventKind.Connected, PeerId = id });
}
IConnection c;
if (peers.TryGetValue(id, out c)) ((SteamConnection)c).OnPacket(steamBuffer, (int)read);
}
else
{
var host = server as SteamConnection;
if (host != null && from == host.Remote) host.OnPacket(steamBuffer, (int)read);
}
}
}
void PollHost(List<NetEvent> events)
{
if (listener != null)
{ {
while (listener.Pending()) while (listener.Pending())
{ {
TcpClient incoming = listener.AcceptTcpClient(); int id = nextPeerId++;
if (client != null) { incoming.Close(); continue; } // nur ein Mitspieler peers[id] = new Connection(listener.AcceptTcpClient());
client = incoming; events.Add(new NetEvent { Kind = NetEventKind.Connected, PeerId = id });
OnSocketReady();
} }
} }
else if (connecting != null && connecting.IsCompleted)
List<int> closed = null;
foreach (KeyValuePair<int, IConnection> p in peers)
{ {
foreach (Message m in p.Value.Poll())
events.Add(new NetEvent { Kind = NetEventKind.Message, PeerId = p.Key, Message = m });
if (p.Value.IsClosed)
{
if (closed == null) closed = new List<int>();
closed.Add(p.Key);
}
}
if (closed == null) return;
foreach (int id in closed)
{
IConnection c = peers[id];
events.Add(new NetEvent { Kind = NetEventKind.Disconnected, PeerId = id, Reason = c.CloseReason });
var steamConn = c as SteamConnection;
if (steamConn != null) steamPeerIds.Remove(steamConn.Remote.m_SteamID);
peers.Remove(id);
}
}
void PollClient(List<NetEvent> events)
{
if (connecting != null)
{
if (!connecting.IsCompleted) return;
IAsyncResult result = connecting; IAsyncResult result = connecting;
connecting = null; connecting = null;
client.EndConnect(result); // wirft, wenn nicht erreichbar connectingClient.EndConnect(result); // wirft, wenn nicht erreichbar
OnSocketReady(); server = new Connection(connectingClient);
pendingConnected = true;
} }
if (server == null) return;
if (pendingConnected)
{
pendingConnected = false;
events.Add(new NetEvent { Kind = NetEventKind.Connected, PeerId = HostId });
} }
void OnSocketReady() foreach (Message m in server.Poll())
{ events.Add(new NetEvent { Kind = NetEventKind.Message, PeerId = HostId, Message = m });
client.NoDelay = true; if (server.IsClosed) Close(server.CloseReason);
client.SendBufferSize = 4 * 1024 * 1024;
stream = client.GetStream();
justConnected = true;
} }
void ParseFrames(List<Message> messages) /// <summary>An einen bestimmten Spieler (Client: immer an den Host).</summary>
public void SendTo(int peerId, MsgType type, byte[] payload = null)
{ {
int offset = 0; if (!IsHost) { if (server != null) server.Send(type, payload); return; }
while (bufferCount - offset >= 5) IConnection c;
{ if (peers.TryGetValue(peerId, out c)) c.Send(type, payload);
int length = BitConverter.ToInt32(buffer, offset);
if (length < 1 || length > MaxFrame) throw new IOException("Invalid packet");
if (bufferCount - offset - 4 < length) break;
var payload = new byte[length - 1];
Buffer.BlockCopy(buffer, offset + 5, payload, 0, payload.Length);
messages.Add(new Message { Type = (MsgType)buffer[offset + 4], Payload = payload });
offset += 4 + length;
}
if (offset > 0)
{
Buffer.BlockCopy(buffer, offset, buffer, 0, bufferCount - offset);
bufferCount -= offset;
}
} }
void EnsureCapacity(int needed) /// <summary>An alle (Client: an den Host). Optional einen Spieler auslassen.</summary>
public void SendToAll(MsgType type, byte[] payload = null, int except = -1)
{ {
if (needed <= buffer.Length) return; if (!IsHost) { if (server != null) server.Send(type, payload); return; }
var bigger = new byte[Math.Max(needed, buffer.Length * 2)]; foreach (KeyValuePair<int, IConnection> p in peers)
Buffer.BlockCopy(buffer, 0, bigger, 0, bufferCount); if (p.Key != except) p.Value.Send(type, payload);
buffer = bigger;
} }
public void Send(MsgType type, byte[] payload = null) /// <summary>Host: Verbindung zu einem Spieler trennen (wird beim nächsten Poll gemeldet).</summary>
public void Kick(int peerId, string reason)
{ {
if (stream == null) return; IConnection c;
if (payload == null) payload = new byte[0]; if (peers.TryGetValue(peerId, out c)) c.Close(reason);
try
{
var frame = new byte[5 + payload.Length];
BitConverter.GetBytes(payload.Length + 1).CopyTo(frame, 0);
frame[4] = (byte)type;
Buffer.BlockCopy(payload, 0, frame, 5, payload.Length);
stream.Write(frame, 0, frame.Length); // blockierend, damit große Dateien komplett rausgehen
} }
catch (Exception e)
/// <summary>Host: Steam-Spieler hat die Lobby verlassen.</summary>
public void DropSteamPeer(ulong steamId, string reason)
{ {
Close("Network error: " + e.Message); int id;
if (steamPeerIds.TryGetValue(steamId, out id)) Kick(id, reason);
} }
public void Close(string reason = "Disconnected")
{
if (IsClosed) return;
IsClosed = true;
CloseReason = reason;
foreach (IConnection c in peers.Values) c.Close(reason);
peers.Clear();
steamPeerIds.Clear();
if (server != null) server.Close(reason);
try { if (connectingClient != null && server == null) connectingClient.Close(); } catch { }
try { if (listener != null) listener.Stop(); } catch { }
} }
public static byte[] Build(Action<BinaryWriter> write) public static byte[] Build(Action<BinaryWriter> write)
@@ -222,14 +536,13 @@ namespace MSCO
} }
} }
public void Close(string reason = "Disconnected") /// <summary>Stellt der Nachricht die Absender-ID voran (für weitergeleitete Nachrichten).</summary>
public static byte[] WithSender(int senderId, byte[] payload)
{ {
if (IsClosed) return; var result = new byte[4 + payload.Length];
IsClosed = true; BitConverter.GetBytes(senderId).CopyTo(result, 0);
CloseReason = reason; Buffer.BlockCopy(payload, 0, result, 4, payload.Length);
stream = null; return result;
try { if (client != null) client.Close(); } catch { }
try { if (listener != null) listener.Stop(); } catch { }
} }
} }
} }
+702
View File
@@ -0,0 +1,702 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
{
/// <summary>Ein synchronisierter Rigidbody (Item, loses Teil oder Fahrzeug).</summary>
public class SyncedBody
{
public const int NoOwner = -1;
public int Id;
public string Path;
public Rigidbody Rb;
public bool IsVehicle;
public string RootName;
public int OwnerId = NoOwner;
public bool OwnerActive; // Besitzer hält/fährt es gerade aktiv
public bool WasKinematic; // Zustand bevor ein anderer Spieler es übernommen hat
public float StillTime;
public float NoMotionClaimUntil; // kurz nach einer Übergabe nicht wegen Nachfedern/Nachrollen übernehmen
public Vector3 TargetPos;
public Quaternion TargetRot;
public float LastRecvTime;
public float AvatarHoldTime = -100f; // zuletzt von der Hand eines Mitspielers platziert
public Vector3 LastSentPos;
public Quaternion LastSentRot;
public float LastSentTime;
}
/// <summary>
/// Synchronisiert Items und Fahrzeuge mit Ownership für beliebig viele Spieler:
/// Wer ein Objekt bewegt (aufheben, fahren, mit G schieben, anstoßen), wird Besitzer
/// und sendet dessen Position. Bei allen anderen ist es solange kinematisch
/// und folgt nur. Kommt das Objekt zur Ruhe, wird es wieder freigegeben.
/// </summary>
public class ObjectSync
{
public static readonly string[] VehicleRoots =
{
"SATSUMA", "HAYOSIKO", "KEKMET", "FERNDALE", "GIFU", "JONNEZ",
"RCO_RUSCKO", "FLATBED", "COMBINE", "BOAT"
};
// Wurzelobjekte, die jeder Spieler selbst simuliert (Verkehr, NPCs, man selbst)
static readonly string[] IgnoredRoots = { "PLAYER", "TRAFFIC", "NPC_CARS" };
const float MaxSmallItemMass = 60f; // schwerere Dinge ohne ITEM/PART-Tag sind Teil der Welt
const float SendInterval = 1f / 15f;
const float KeepAliveInterval = 1f;
const float ScanInterval = 3f;
const float FirstScanDelay = 3f;
const float MotionClaimGrace = 10f; // nach dem Laden fällt noch alles an seinen Platz
const float ItemClaimRadius = 4f;
const float VehicleClaimRadius = 8f;
const float ReleaseAfterStill = 1f;
const float SnapDistance = 10f;
const float RemoteTimeout = 3f; // Besitzer meldet sich nicht mehr (z.B. Teil wurde dort montiert)
const float HandoverCooldown = 3f; // nach Freigabe/Übergabe keine Übernahme durch Bewegung
const float MaxHoldDistance = 1.5f; // gehaltene Items sind so nah an der Kamera
const float VehicleClaimSpeed = 0.7f; // Fahrzeuge erst ab echter Bewegung übernehmen (nicht Federn)
/// <summary>Kamera des lokalen Spielers (zum Prüfen, ob ein Item wirklich in der Hand ist).</summary>
public Transform Head;
/// <summary>Eigene Spieler-ID (Host = 0).</summary>
public int LocalId;
/// <summary>Senden an alle anderen Spieler (setzt der MultiplayerManager).</summary>
public Action<MsgType, byte[]> Send;
readonly Dictionary<int, SyncedBody> byId = new Dictionary<int, SyncedBody>();
readonly Dictionary<Rigidbody, SyncedBody> byRb = new Dictionary<Rigidbody, SyncedBody>();
readonly List<SyncedBody> removeList = new List<SyncedBody>();
readonly List<SyncedBody> sendList = new List<SyncedBody>();
readonly HashSet<Rigidbody> rejected = new HashSet<Rigidbody>(); // schon geprüft, gehört nicht dazu
float nextRejectClear;
string drivenName;
SyncedBody drivenCache;
float loadTime;
float nextScan;
float sendTimer;
FsmGameObject pickedObject;
FsmString currentVehicle;
float nextFsmSearch;
public int Count { get { return byId.Count; } }
/// <summary>Erster Suchlauf nach dem Laden erledigt (vorher kann kein Snapshot angewendet werden).</summary>
public bool ScanComplete { get; private set; }
/// <summary>Position aus dem Server-Snapshot übernehmen (nur für Objekte, die gerade niemand bewegt).</summary>
public bool ApplySnapshotPose(int id, Vector3 pos, Quaternion rot)
{
SyncedBody b;
if (!byId.TryGetValue(id, out b) || b.Rb == null || b.OwnerId != SyncedBody.NoOwner) return false;
b.Rb.transform.position = pos;
b.Rb.transform.rotation = rot;
if (!b.Rb.isKinematic)
{
b.Rb.velocity = Vector3.zero;
b.Rb.angularVelocity = Vector3.zero;
b.Rb.Sleep();
}
return true;
}
public SyncedBody Get(int id)
{
SyncedBody b;
return byId.TryGetValue(id, out b) ? b : null;
}
public bool IsRemoteOwnedBody(SyncedBody b) { return IsRemoteOwned(b); }
/// <summary>Für "mp vehicles": Zustand aller erfassten Fahrzeuge (zur Fehlersuche).</summary>
public List<string> DescribeVehicles()
{
var lines = new List<string>();
string driving = currentVehicle != null ? currentVehicle.Value : "";
lines.Add("PlayerCurrentVehicle = '" + driving + "', my id = " + LocalId + ", synced objects = " + byId.Count);
foreach (SyncedBody b in byId.Values)
{
if (!b.IsVehicle) continue;
string owner = b.OwnerId == SyncedBody.NoOwner ? "free" : IsLocal(b) ? "ME" : "player " + b.OwnerId;
lines.Add(b.RootName + " id=" + b.Id + " owner=" + owner + (b.OwnerActive ? " (active)" : "") +
(b.Rb == null ? " [rigidbody gone]" : " kinematic=" + b.Rb.isKinematic + " speed=" + b.Rb.velocity.magnitude.ToString("0.0")));
}
if (lines.Count == 1) lines.Add("No vehicles registered! (SATSUMA, HAYOSIKO, ... not found as root objects with a Rigidbody)");
return lines;
}
/// <summary>Fährt der lokale Spieler gerade selbst?</summary>
public bool IsDriving { get; private set; }
/// <summary>Fahrzeuge, die gerade ein anderer Spieler aktiv fährt.</summary>
public List<SyncedBody> RemoteDrivenVehicles()
{
var list = new List<SyncedBody>();
foreach (SyncedBody b in byId.Values)
if (b.IsVehicle && IsRemoteOwned(b) && b.OwnerActive && b.Rb != null) list.Add(b);
return list;
}
/// <summary>Fahrzeuge, die der lokale Spieler gerade besitzt (fährt/schiebt).</summary>
public List<SyncedBody> LocalVehicles()
{
var list = new List<SyncedBody>();
foreach (SyncedBody b in byId.Values)
if (b.IsVehicle && IsLocal(b) && b.Rb != null) list.Add(b);
return list;
}
/// <summary>Was der lokale Spieler gerade in der Hand hat (nur wenn er es besitzt).</summary>
public SyncedBody Held { get; private set; }
public void OnGameLoaded()
{
Reset();
loadTime = Time.time;
nextScan = Time.time + FirstScanDelay;
}
/// <summary>Alles freigeben und vergessen (Szenenwechsel oder Verbindungsende).</summary>
public void Reset()
{
foreach (SyncedBody b in byId.Values)
if (IsRemoteOwned(b) && b.Rb != null) b.Rb.isKinematic = b.WasKinematic;
byId.Clear();
byRb.Clear();
rejected.Clear();
drivenName = null;
drivenCache = null;
ScanComplete = false;
pickedObject = null;
currentVehicle = null;
}
/// <summary>Spieler hat das Spiel verlassen: seine Objekte freigeben.</summary>
public void ReleaseAllOf(int playerId)
{
foreach (SyncedBody b in byId.Values)
{
if (b.OwnerId != playerId || b.Rb == null) continue;
b.Rb.isKinematic = b.WasKinematic;
b.OwnerId = SyncedBody.NoOwner;
b.NoMotionClaimUntil = Time.time + HandoverCooldown;
}
}
/// <summary>Besitzer eines Fahrzeugs (nach Wurzelname), NoOwner wenn frei oder unbekannt.</summary>
public int VehicleOwner(string rootName)
{
foreach (SyncedBody b in byId.Values)
if (b.IsVehicle && b.RootName == rootName) return b.OwnerId;
return SyncedBody.NoOwner;
}
bool IsLocal(SyncedBody b) { return b.OwnerId == LocalId; }
bool IsRemoteOwned(SyncedBody b) { return b.OwnerId != SyncedBody.NoOwner && b.OwnerId != LocalId; }
/// <summary>
/// Konfliktregel, die jeder Spieler gleich anwendet: Wer das Objekt aktiv hält/fährt
/// gewinnt, sonst die kleinere Spieler-ID (Host = 0 gewinnt immer bei Gleichstand).
/// </summary>
static bool Wins(int newId, bool newActive, int curId, bool curActive)
{
if (curId == SyncedBody.NoOwner || newId == curId) return true;
if (newActive != curActive) return newActive;
return newId < curId;
}
// ---------------------------------------------------------------- Update
public void Update(Transform player, float dt)
{
if (Time.time >= nextScan)
{
nextScan = Time.time + ScanInterval;
Scan();
ScanComplete = true;
}
FindPlayerFsms();
SyncedBody held = HeldBody();
SyncedBody driven = DrivenBody();
IsDriving = driven != null;
bool motionClaimAllowed = Time.time - loadTime > MotionClaimGrace;
removeList.Clear();
foreach (SyncedBody b in byId.Values)
{
if (b.Rb == null) { removeList.Add(b); continue; } // z.B. Teil wurde montiert
if (!b.Rb.gameObject.activeInHierarchy) continue;
bool active = b == held || b == driven;
if (b.OwnerId == SyncedBody.NoOwner)
{
if (active) Claim(b, true);
else if (motionClaimAllowed && Time.time >= b.NoMotionClaimUntil && IsMoving(b) && IsNear(b, player)) Claim(b, false);
}
else if (IsLocal(b))
{
if (b.OwnerActive != active)
{
// z.B. erst angeschoben, dann eingestiegen: allen Bescheid geben
b.OwnerActive = active;
SendOwn(b);
}
if (!active && (b.Rb.IsSleeping() || IsSlow(b.Rb)))
{
b.StillTime += dt;
if (b.StillTime >= ReleaseAfterStill) Release(b);
}
else b.StillTime = 0f;
}
else // gehört einem anderen Spieler
{
if (active && Wins(LocalId, true, b.OwnerId, b.OwnerActive)) Claim(b, true); // Spieler greift sich das Objekt
else if (Time.time - b.LastRecvTime > RemoteTimeout) SetFree(b);
}
}
foreach (SyncedBody b in removeList) Unregister(b);
Held = held != null && held.Rb != null && IsLocal(held) ? held : null;
sendTimer += dt;
if (sendTimer >= SendInterval)
{
sendTimer = 0f;
SendOwnedStates();
}
}
/// <summary>Objekte anderer Spieler weich zur Zielposition bewegen.</summary>
public void FixedUpdate()
{
foreach (SyncedBody b in byId.Values)
{
if (!IsRemoteOwned(b) || b.Rb == null) continue;
if (Time.time - b.AvatarHoldTime < 0.2f) continue; // sitzt gerade in der Hand eines Mitspielers
if ((b.Rb.position - b.TargetPos).sqrMagnitude > SnapDistance * SnapDistance)
{
b.Rb.transform.position = b.TargetPos;
b.Rb.transform.rotation = b.TargetRot;
}
else
{
b.Rb.MovePosition(Vector3.Lerp(b.Rb.position, b.TargetPos, 0.35f));
b.Rb.MoveRotation(Quaternion.Slerp(b.Rb.rotation, b.TargetRot, 0.35f));
}
}
}
/// <summary>
/// Ein Mitspieler hält dieses Objekt: direkt an seine Hand setzen (Position und Winkel
/// relativ zu seinem Kopf), statt der verzögerten Weltposition zu folgen.
/// </summary>
public void ApplyAvatarHold(int playerId, int bodyId, Vector3 pos, Quaternion rot)
{
SyncedBody b;
if (!byId.TryGetValue(bodyId, out b) || b.Rb == null) return;
if (b.OwnerId != playerId)
{
if (!Wins(playerId, true, b.OwnerId, b.OwnerActive)) return;
SetRemote(b, playerId, true);
}
else
{
b.LastRecvTime = Time.time;
b.OwnerActive = true;
}
b.Rb.transform.position = pos;
b.Rb.transform.rotation = rot;
b.TargetPos = pos;
b.TargetRot = rot;
b.AvatarHoldTime = Time.time;
}
// ---------------------------------------------------------------- Ownership
void Claim(SyncedBody b, bool active)
{
if (IsRemoteOwned(b)) b.Rb.isKinematic = b.WasKinematic;
b.OwnerId = LocalId;
b.OwnerActive = active;
b.StillTime = 0f;
b.LastSentTime = -100f; // Zustand sofort senden
SendOwn(b);
}
void SendOwn(SyncedBody b)
{
int id = b.Id;
bool active = b.OwnerActive;
Send(MsgType.ObjOwn, NetSession.Build(w => { w.Write(id); w.Write(active); }));
}
void Release(SyncedBody b)
{
b.OwnerId = SyncedBody.NoOwner;
b.NoMotionClaimUntil = Time.time + HandoverCooldown;
Send(MsgType.ObjRelease, NetSession.Build(w => WriteState(w, b)));
}
void SetRemote(SyncedBody b, int ownerId, bool active)
{
b.LastRecvTime = Time.time;
b.OwnerActive = active;
if (b.OwnerId == ownerId) return;
if (!IsRemoteOwned(b))
{
b.WasKinematic = b.Rb.isKinematic;
b.Rb.isKinematic = true;
b.TargetPos = b.Rb.position;
b.TargetRot = b.Rb.rotation;
}
b.OwnerId = ownerId;
}
void SetFree(SyncedBody b)
{
if (IsRemoteOwned(b)) b.Rb.isKinematic = b.WasKinematic;
b.OwnerId = SyncedBody.NoOwner;
b.NoMotionClaimUntil = Time.time + HandoverCooldown;
}
void SendOwnedStates()
{
sendList.Clear();
float now = Time.time;
foreach (SyncedBody b in byId.Values)
{
if (!IsLocal(b) || b.Rb == null) continue;
bool changed = (b.Rb.position - b.LastSentPos).sqrMagnitude > 0.0001f
|| Quaternion.Angle(b.Rb.rotation, b.LastSentRot) > 0.5f;
if (!changed && now - b.LastSentTime < KeepAliveInterval) continue;
b.LastSentPos = b.Rb.position;
b.LastSentRot = b.Rb.rotation;
b.LastSentTime = now;
sendList.Add(b);
}
if (sendList.Count == 0) return;
Send(MsgType.ObjStates, NetSession.Build(w =>
{
w.Write(sendList.Count);
foreach (SyncedBody b in sendList) WriteState(w, b);
}));
}
// ---------------------------------------------------------------- Empfang
public void Handle(int from, MsgType type, BinaryReader r)
{
switch (type)
{
case MsgType.ObjOwn:
{
int id = r.ReadInt32();
bool active = r.ReadBoolean();
SyncedBody b;
if (!byId.TryGetValue(id, out b) || b.Rb == null) return;
if (!Wins(from, active, b.OwnerId, b.OwnerActive))
{
// Wir besitzen es und gewinnen: nochmal melden, damit alle es wissen
if (IsLocal(b)) SendOwn(b);
return;
}
SetRemote(b, from, active);
break;
}
case MsgType.ObjStates:
{
int count = r.ReadInt32();
for (int i = 0; i < count; i++)
{
int id; Vector3 pos; Quaternion rot; Vector3 vel;
ReadState(r, out id, out pos, out rot, out vel);
SyncedBody b;
if (!byId.TryGetValue(id, out b) || b.Rb == null) continue;
// Nur dem aktuellen Besitzer folgen (oder freie Objekte übernehmen)
if (b.OwnerId != from && b.OwnerId != SyncedBody.NoOwner) continue;
SetRemote(b, from, b.OwnerId == from && b.OwnerActive);
b.TargetPos = pos;
b.TargetRot = rot;
}
break;
}
case MsgType.ObjRelease:
{
int id; Vector3 pos; Quaternion rot; Vector3 vel;
ReadState(r, out id, out pos, out rot, out vel);
SyncedBody b;
if (!byId.TryGetValue(id, out b) || b.Rb == null || b.OwnerId != from) return;
b.Rb.transform.position = pos;
b.Rb.transform.rotation = rot;
b.Rb.isKinematic = b.WasKinematic;
if (!b.Rb.isKinematic)
{
b.Rb.velocity = vel;
b.Rb.angularVelocity = Vector3.zero;
}
b.OwnerId = SyncedBody.NoOwner;
b.NoMotionClaimUntil = Time.time + HandoverCooldown; // Nachrollen/Nachfedern nicht als "ich bewege es" werten
break;
}
}
}
static void WriteState(BinaryWriter w, SyncedBody b)
{
Vector3 p = b.Rb.position;
Quaternion q = b.Rb.rotation;
Vector3 v = b.Rb.isKinematic ? Vector3.zero : b.Rb.velocity;
w.Write(b.Id);
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);
}
static void ReadState(BinaryReader r, out int id, out Vector3 pos, out Quaternion rot, out Vector3 vel)
{
id = r.ReadInt32();
pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
rot = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
vel = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
}
// ---------------------------------------------------------------- Erkennung
static bool IsMoving(SyncedBody b)
{
Rigidbody rb = b.Rb;
if (rb.isKinematic || rb.IsSleeping()) return false;
// Fahrzeuge federn/wackeln ständig ein bisschen - nur echtes Rollen/Schieben zählt
if (b.IsVehicle) return rb.velocity.sqrMagnitude > VehicleClaimSpeed * VehicleClaimSpeed;
return rb.velocity.sqrMagnitude > 0.04f || rb.angularVelocity.sqrMagnitude > 0.25f;
}
static bool IsSlow(Rigidbody rb)
{
if (rb.isKinematic) return true;
return rb.velocity.sqrMagnitude < 0.01f && rb.angularVelocity.sqrMagnitude < 0.04f;
}
static bool IsNear(SyncedBody b, Transform player)
{
float radius = b.IsVehicle ? VehicleClaimRadius : ItemClaimRadius;
return (b.Rb.worldCenterOfMass - player.position).sqrMagnitude < radius * radius;
}
void FindPlayerFsms()
{
if ((pickedObject != null && currentVehicle != null) || Time.time < nextFsmSearch) return;
nextFsmSearch = Time.time + 2f;
if (currentVehicle == null)
currentVehicle = FsmVariables.GlobalVariables.FindFsmString("PlayerCurrentVehicle");
if (pickedObject == null)
{
GameObject player = GameObject.Find("PLAYER");
if (player == null) return;
// Es gibt mehrere Hände (z.B. "HandOld"); die aktive bevorzugen
PlayMakerFSM best = null;
foreach (PlayMakerFSM fsm in player.GetComponentsInChildren<PlayMakerFSM>(true))
{
if (fsm.FsmName != "PickUp" || fsm.FsmVariables.FindFsmGameObject("PickedObject") == null) continue;
if (best == null || (!best.gameObject.activeInHierarchy && fsm.gameObject.activeInHierarchy)) best = fsm;
}
if (best != null)
{
pickedObject = best.FsmVariables.FindFsmGameObject("PickedObject");
ModConsole.Print("[MSCO] Found pickup FSM on " + best.gameObject.name +
(best.gameObject.activeInHierarchy ? "" : " (inactive)"));
}
}
}
SyncedBody HeldBody()
{
if (pickedObject == null || pickedObject.Value == null) return null;
for (Transform t = pickedObject.Value.transform; t != null; t = t.parent)
{
Rigidbody rb = t.GetComponent<Rigidbody>();
SyncedBody b;
if (rb != null && byRb.TryGetValue(rb, out b)) return IsReallyInHand(b) ? b : null;
}
return null;
}
/// <summary>
/// PickedObject bleibt nach dem Loslassen oft gesetzt. Dann würde die Mod das Item weiter als
/// "in der Hand" melden und sich mit dem Mitspieler darum streiten (Item zittert bei beiden).
/// Deshalb zusätzlich prüfen: liegt es wirklich direkt vor der Kamera?
/// </summary>
bool IsReallyInHand(SyncedBody b)
{
if (Head == null) return true;
Vector3 offset = b.Rb.worldCenterOfMass - Head.position;
if (offset.sqrMagnitude > MaxHoldDistance * MaxHoldDistance) return false;
return Vector3.Dot(offset.normalized, Head.forward) > 0.2f; // vor dem Spieler, nicht unter den Füßen
}
SyncedBody DrivenBody()
{
if (currentVehicle == null || string.IsNullOrEmpty(currentVehicle.Value)) return null;
// Nur neu suchen, wenn sich das Fahrzeug geändert hat (wird jeden Frame gefragt)
if (currentVehicle.Value == drivenName && drivenCache != null && drivenCache.Rb != null) return drivenCache;
drivenName = currentVehicle.Value;
drivenCache = null;
string vehicle = drivenName.ToUpper();
foreach (SyncedBody b in byId.Values)
if (b.IsVehicle && b.RootName.ToUpper().Contains(vehicle)) { drivenCache = b; break; }
return drivenCache;
}
// ---------------------------------------------------------------- Registrierung
/// <summary>Neue Rigidbodies suchen (gekaufte Items, abmontierte Teile ...).</summary>
void Scan()
{
// Ab und zu neu prüfen (z.B. abmontierte Teile, die jetzt frei beweglich sind)
if (Time.time >= nextRejectClear) { rejected.Clear(); nextRejectClear = Time.time + 30f; }
var candidates = new List<KeyValuePair<Rigidbody, bool>>();
var rootsByName = new Dictionary<string, List<Transform>>();
foreach (UnityEngine.Object o in UnityEngine.Object.FindObjectsOfType(typeof(Rigidbody)))
{
var rb = (Rigidbody)o;
Transform root = rb.transform.root;
List<Transform> roots;
if (!rootsByName.TryGetValue(root.name, out roots)) rootsByName[root.name] = roots = new List<Transform>();
if (!roots.Contains(root)) roots.Add(root);
bool isVehicle;
if (byRb.ContainsKey(rb) || rejected.Contains(rb)) continue;
if (ShouldSync(rb, out isVehicle)) candidates.Add(new KeyValuePair<Rigidbody, bool>(rb, isVehicle));
else rejected.Add(rb); // nicht bei jedem Suchlauf wieder die ganze Hierarchie prüfen
}
if (candidates.Count == 0) return;
foreach (List<Transform> roots in rootsByName.Values)
roots.Sort((a, b) => a.GetSiblingIndex().CompareTo(b.GetSiblingIndex()));
int added = 0;
foreach (KeyValuePair<Rigidbody, bool> c in candidates)
{
string path = BuildPath(c.Key.transform, rootsByName);
int id = Hash(path);
if (byId.ContainsKey(id)) continue; // gleicher Pfad doppelt - nicht eindeutig zuordenbar
var b = new SyncedBody
{
Id = id,
Path = path,
Rb = c.Key,
IsVehicle = c.Value,
RootName = c.Key.transform.root.name
};
byId[id] = b;
byRb[c.Key] = b;
added++;
}
if (added > 0) ModConsole.Print("[MSCO] Syncing " + byId.Count + " objects (+" + added + ")");
}
static bool ShouldSync(Rigidbody rb, out bool isVehicle)
{
isVehicle = false;
Transform t = rb.transform;
string rootName = t.root.name;
if (rootName.StartsWith("MSCO_") || Array.IndexOf(IgnoredRoots, rootName) >= 0) return false;
// Rigidbodies unter einem anderen Rigidbody (Türen, Motorhaube ...) bewegen sich mit
for (Transform p = t.parent; p != null; p = p.parent)
if (p.GetComponent<Rigidbody>() != null) return false;
bool underVehicle = IsVehicleRoot(rootName);
if (underVehicle && t.parent == null)
{
isVehicle = true;
return true;
}
// Items und lose Teile sind im Spiel getaggt
if (rb.CompareTag("ITEM") || rb.CompareTag("PART")) return true;
// Alle anderen kleinen, frei beweglichen Dinge (Flaschen, Essen, Werkzeug ...),
// aber keine Gelenk-Objekte (Türen, Ragdolls) und nichts Animiertes (NPCs)
if (underVehicle || rb.mass > MaxSmallItemMass) return false;
if (rb.GetComponent<Joint>() != null) return false;
for (Transform p = t; p != null; p = p.parent)
if (p.GetComponent<Animator>() != null || p.GetComponent<Animation>() != null) return false;
return true;
}
static bool IsVehicleRoot(string name)
{
foreach (string v in VehicleRoots)
if (name.StartsWith(v)) return true;
return false;
}
/// <summary>
/// Pfad wie "ITEMS#0/beer case(itemx)#3": Name plus Nummer unter gleichnamigen Geschwistern.
/// Alle Spieler laden denselben Spielstand, dadurch ist der Pfad überall gleich.
/// </summary>
static string BuildPath(Transform t, Dictionary<string, List<Transform>> rootsByName)
{
var segments = new List<string>();
for (; t != null; t = t.parent)
{
int ordinal = 0;
if (t.parent == null)
{
List<Transform> roots;
if (rootsByName.TryGetValue(t.name, out roots)) ordinal = roots.IndexOf(t);
}
else
{
Transform parent = t.parent;
for (int i = 0; i < parent.childCount; i++)
{
Transform sibling = parent.GetChild(i);
if (sibling == t) break;
if (sibling.name == t.name) ordinal++;
}
}
segments.Add(t.name + "#" + ordinal);
}
segments.Reverse();
return string.Join("/", segments.ToArray());
}
static int Hash(string s)
{
unchecked
{
uint h = 2166136261;
foreach (byte c in Encoding.UTF8.GetBytes(s)) { h ^= c; h *= 16777619; }
return (int)h;
}
}
void Unregister(SyncedBody b)
{
byId.Remove(b.Id);
byRb.Remove(b.Rb); // funktioniert auch, wenn der Rigidbody schon zerstört wurde
}
}
}
+351
View File
@@ -0,0 +1,351 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace MSCO
{
/// <summary>Spieler im Admin-Menü.</summary>
public class AdminPlayer
{
public int Id;
public string Name;
public string SteamId;
public bool IsAdmin;
}
/// <summary>
/// Fenster über dem Spiel: F8-Konsole (alle MSCO-Befehle), Chat (T) und Admin-Menü (/admin).
/// Solange eins davon offen ist, sind Spielerbewegung und Kamera gesperrt (InputLock).
/// </summary>
public class Overlays
{
const int MaxConsoleLines = 300;
const int MaxChatLines = 60;
const float ChatFadeSeconds = 15f;
const string ConsoleControl = "msco_console";
const string ChatControl = "msco_chat";
class ChatLine
{
public string Name, Text;
public bool System;
public float Time;
}
// Konsole
public bool ConsoleOpen { get; private set; }
readonly List<string> consoleLines = new List<string>();
readonly List<string> history = new List<string>();
int historyIndex = -1;
string consoleInput = "";
Vector2 consoleScroll;
int consoleOpenedFrame = -1;
bool consoleScrollToEnd;
// Chat
public bool ChatOpen { get; private set; }
readonly List<ChatLine> chat = new List<ChatLine>();
string chatInput = "";
int chatOpenedFrame = -1;
// Admin
public bool AdminOpen { get; private set; }
List<AdminPlayer> adminPlayers;
string adminReason = "";
int confirmBanId = -1;
Vector2 adminScroll;
public bool AnyOpen { get { return ConsoleOpen || ChatOpen || AdminOpen; } }
// Stile
bool stylesReady;
GUIStyle panel, rowStyle, field, logText, caption, title, primary, secondary, danger, chatName, chatText, chatSystem, small;
// ---------------------------------------------------------------- Öffnen/Schließen
public void ToggleConsole()
{
ConsoleOpen = !ConsoleOpen;
if (ConsoleOpen)
{
ChatOpen = false;
consoleOpenedFrame = Time.frameCount;
consoleScrollToEnd = true;
}
}
public void OpenChat()
{
if (AnyOpen) return;
ChatOpen = true;
chatOpenedFrame = Time.frameCount;
chatInput = "";
}
public void OpenAdmin(List<AdminPlayer> players)
{
AdminOpen = true;
ConsoleOpen = false;
ChatOpen = false;
adminPlayers = players;
confirmBanId = -1;
}
public void SetAdminPlayers(List<AdminPlayer> players) { adminPlayers = players; }
public void CloseAll()
{
ConsoleOpen = ChatOpen = AdminOpen = false;
}
// ---------------------------------------------------------------- Inhalte
public void Log(string line)
{
foreach (string part in (line ?? "").Split('\n'))
{
consoleLines.Add(part);
if (consoleLines.Count > MaxConsoleLines) consoleLines.RemoveAt(0);
}
consoleScrollToEnd = true;
}
public void AddChat(string name, string text, bool system = false)
{
chat.Add(new ChatLine { Name = name, Text = text, System = system, Time = Time.realtimeSinceStartup });
if (chat.Count > MaxChatLines) chat.RemoveAt(0);
}
// ---------------------------------------------------------------- Zeichnen (OnGUI)
public void Draw(MultiplayerManager mp, bool inGame)
{
EnsureStyles();
if (inGame) DrawChat(mp);
if (ConsoleOpen) DrawConsole();
if (AdminOpen) DrawAdmin(mp);
}
void DrawConsole()
{
float width = Mathf.Min(Screen.width - 40, 960);
var rect = new Rect((Screen.width - width) / 2f, 20, width, Mathf.Min(340, Screen.height * 0.5f));
GUI.Box(rect, GUIContent.none, panel);
GUI.Label(new Rect(rect.x + 16, rect.y + 10, 300, 20), "MSCO CONSOLE", caption);
GUI.Label(new Rect(rect.xMax - 216, rect.y + 10, 200, 20), "type 'help' - F8 to close", small);
// Tasten: Enter ausführen, Esc schließen, Hoch/Runter Verlauf
Event e = Event.current;
if (e.type == EventType.KeyDown && GUI.GetNameOfFocusedControl() == ConsoleControl)
{
if (e.keyCode == KeyCode.Return || e.keyCode == KeyCode.KeypadEnter)
{
RunConsoleLine(consoleInput);
consoleInput = "";
e.Use();
}
else if (e.keyCode == KeyCode.Escape) { ConsoleOpen = false; e.Use(); return; }
else if (e.keyCode == KeyCode.UpArrow && history.Count > 0)
{
historyIndex = historyIndex < 0 ? history.Count - 1 : Mathf.Max(0, historyIndex - 1);
consoleInput = history[historyIndex];
e.Use();
}
else if (e.keyCode == KeyCode.DownArrow && historyIndex >= 0)
{
historyIndex++;
consoleInput = historyIndex < history.Count ? history[historyIndex] : "";
if (historyIndex >= history.Count) historyIndex = -1;
e.Use();
}
}
GUILayout.BeginArea(new Rect(rect.x + 14, rect.y + 34, rect.width - 28, rect.height - 84));
if (consoleScrollToEnd && e.type == EventType.Repaint) { consoleScroll.y = float.MaxValue; consoleScrollToEnd = false; }
consoleScroll = GUILayout.BeginScrollView(consoleScroll);
foreach (string line in consoleLines) GUILayout.Label(line, logText);
GUILayout.EndScrollView();
GUILayout.EndArea();
// Taste, mit der die Konsole geöffnet wurde, nicht als Zeichen übernehmen
if (Time.frameCount == consoleOpenedFrame && e.type == EventType.KeyDown) e.Use();
GUI.SetNextControlName(ConsoleControl);
consoleInput = GUI.TextField(new Rect(rect.x + 14, rect.yMax - 46, rect.width - 28, 34), consoleInput, 200, field);
GUI.FocusControl(ConsoleControl);
}
void RunConsoleLine(string line)
{
line = (line ?? "").Trim();
if (line.Length == 0) return;
history.Add(line);
if (history.Count > 50) history.RemoveAt(0);
historyIndex = -1;
Log("> " + line);
if (line == "clear") { consoleLines.Clear(); return; }
string[] args = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (args.Length > 0 && args[0] == "mp") // "mp connect ..." geht auch
{
var rest = new string[args.Length - 1];
Array.Copy(args, 1, rest, 0, rest.Length);
args = rest;
}
Commands.Execute(args, Log);
}
void DrawChat(MultiplayerManager mp)
{
float now = Time.realtimeSinceStartup;
float width = Mathf.Min(460, Screen.width - 40);
float lineHeight = 20;
int maxLines = ChatOpen ? 12 : 6;
// Sichtbare Zeilen: offen = die letzten, geschlossen = nur neue (blenden aus)
var visible = new List<ChatLine>();
for (int i = chat.Count - 1; i >= 0 && visible.Count < maxLines; i--)
if (ChatOpen || now - chat[i].Time < ChatFadeSeconds) visible.Insert(0, chat[i]);
float bottom = Screen.height * 0.62f;
float height = visible.Count * lineHeight + (ChatOpen ? 52 : 12);
var rect = new Rect(20, bottom - height, width, height);
if (ChatOpen || visible.Count > 0) GUI.Box(rect, GUIContent.none, ChatOpen ? panel : rowStyle);
Color old = GUI.color;
float y = rect.y + 6;
foreach (ChatLine line in visible)
{
float age = now - line.Time;
float alpha = ChatOpen ? 1f : Mathf.Clamp01((ChatFadeSeconds - age) / 3f);
GUI.color = new Color(1f, 1f, 1f, alpha);
if (line.System)
{
GUI.Label(new Rect(rect.x + 12, y, width - 24, lineHeight), line.Text, chatSystem);
}
else
{
Vector2 size = chatName.CalcSize(new GUIContent(line.Name + ":"));
GUI.Label(new Rect(rect.x + 12, y, size.x, lineHeight), line.Name + ":", chatName);
GUI.Label(new Rect(rect.x + 16 + size.x, y, width - 28 - size.x, lineHeight), line.Text, chatText);
}
y += lineHeight;
}
GUI.color = old;
if (!ChatOpen) return;
Event e = Event.current;
if (e.type == EventType.KeyDown && GUI.GetNameOfFocusedControl() == ChatControl)
{
if (e.keyCode == KeyCode.Return || e.keyCode == KeyCode.KeypadEnter)
{
string text = chatInput.Trim();
if (text.Length > 0)
{
if (text.StartsWith("/")) RunConsoleLine(text.Substring(1)); // z.B. /admin
else mp.SendChat(text);
}
chatInput = "";
ChatOpen = false;
e.Use();
return;
}
if (e.keyCode == KeyCode.Escape) { ChatOpen = false; e.Use(); return; }
}
// Taste, mit der der Chat geöffnet wurde (T), nicht als Zeichen übernehmen
if (Time.frameCount == chatOpenedFrame && e.type == EventType.KeyDown) e.Use();
GUI.SetNextControlName(ChatControl);
chatInput = GUI.TextField(new Rect(rect.x + 10, rect.yMax - 40, width - 20, 30), chatInput, 150, field);
GUI.FocusControl(ChatControl);
}
void DrawAdmin(MultiplayerManager mp)
{
float width = Mathf.Min(600, Screen.width - 40), height = Mathf.Min(460, Screen.height - 40);
var rect = new Rect((Screen.width - width) / 2f, (Screen.height - height) / 2f, width, height);
GUI.Box(rect, GUIContent.none, panel);
if (Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Escape)
{
AdminOpen = false;
Event.current.Use();
return;
}
GUILayout.BeginArea(new Rect(rect.x + 20, rect.y + 16, rect.width - 40, rect.height - 32));
GUILayout.BeginHorizontal();
GUILayout.Label("Admin", title, GUILayout.ExpandWidth(true)); // nicht von den Buttons zusammendrücken lassen
if (GUILayout.Button("REFRESH", secondary, GUILayout.Height(30), GUILayout.Width(100))) mp.RequestAdminList();
GUILayout.Space(8);
if (GUILayout.Button("CLOSE", secondary, GUILayout.Height(30), GUILayout.Width(90))) AdminOpen = false;
GUILayout.EndHorizontal();
GUILayout.Label(mp.IsDedicated ? "Players on this server. Banned players cannot join again." : "Players in your game.", small);
GUILayout.Space(10);
GUILayout.Label("REASON (optional)", caption);
adminReason = GUILayout.TextField(adminReason, 100, field, GUILayout.Height(32));
GUILayout.Space(12);
adminScroll = GUILayout.BeginScrollView(adminScroll);
if (adminPlayers == null) GUILayout.Label("Loading players...", small);
else if (adminPlayers.Count == 0) GUILayout.Label("No players.", small);
else
{
foreach (AdminPlayer p in adminPlayers)
{
GUILayout.BeginHorizontal(rowStyle, GUILayout.Height(44));
GUILayout.BeginVertical();
bool you = p.Id == mp.LocalId;
GUILayout.Label(p.Name + (you ? " (you)" : "") + (p.IsAdmin ? " (admin)" : ""), chatName);
GUILayout.Label(string.IsNullOrEmpty(p.SteamId) ? "id " + p.Id : p.SteamId.StartsWith("name:") ? "no Steam" : "SteamID " + p.SteamId, small);
GUILayout.EndVertical();
GUILayout.FlexibleSpace();
bool self = p.Id == mp.LocalId;
GUI.enabled = !self && !p.IsAdmin;
if (GUILayout.Button("KICK", secondary, GUILayout.Height(30), GUILayout.Width(80)))
mp.AdminAction(false, p.Id, adminReason);
GUILayout.Space(6);
bool confirming = confirmBanId == p.Id;
if (GUILayout.Button(confirming ? "SURE?" : "BAN", danger, GUILayout.Height(30), GUILayout.Width(80)))
{
if (confirming) { mp.AdminAction(true, p.Id, adminReason); confirmBanId = -1; }
else confirmBanId = p.Id;
}
GUI.enabled = true;
GUILayout.EndHorizontal();
GUILayout.Space(4);
}
}
GUILayout.EndScrollView();
GUILayout.EndArea();
}
// ---------------------------------------------------------------- Stile
void EnsureStyles()
{
if (stylesReady && panel.normal.background != null) return;
panel = UiTheme.Panel9(UiTheme.Background);
rowStyle = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.72f));
rowStyle.padding = new RectOffset(12, 12, 6, 6);
field = UiTheme.Field(14);
logText = UiTheme.Label(12, UiTheme.Text);
logText.wordWrap = true;
logText.margin = new RectOffset(0, 0, 0, 0);
caption = UiTheme.Label(11, UiTheme.Muted, FontStyle.Bold);
title = UiTheme.Label(22, UiTheme.Text, FontStyle.Bold);
title.wordWrap = false;
small = UiTheme.Label(11, UiTheme.Muted);
primary = UiTheme.Button(UiTheme.Accent, UiTheme.AccentHover, Color.white);
secondary = UiTheme.Button(UiTheme.Secondary, UiTheme.SecondaryHover, UiTheme.Text, 12);
danger = UiTheme.Button(UiTheme.Danger, UiTheme.DangerHover, Color.white, 12);
chatName = UiTheme.Label(13, UiTheme.AccentHover, FontStyle.Bold, TextAnchor.MiddleLeft);
chatText = UiTheme.Label(13, UiTheme.Text, FontStyle.Normal, TextAnchor.MiddleLeft);
chatSystem = UiTheme.Label(12, UiTheme.Muted, FontStyle.Italic, TextAnchor.MiddleLeft);
stylesReady = true;
}
}
}
+131
View File
@@ -0,0 +1,131 @@
using System.Collections.Generic;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Beifahrer: Neben einem Fahrzeug, das ein anderer Spieler fährt, per Taste einsteigen.
/// Der Sitz ist die Fahrerposition, auf die andere Seite gespiegelt (bei Fahrzeugen mit
/// Sitz in der Mitte, z.B. Traktor oder Moped: dahinter). Während der Fahrt wird der
/// Spieler jedes Frame auf den Sitz gesetzt und dreht mit dem Fahrzeug mit; umschauen geht weiter.
/// </summary>
public class Passenger
{
const float EnterRange = 4f; // so nah muss man am Fahrzeug stehen
const float CenterSeatThreshold = 0.2f;
const float BehindCenterSeat = 0.7f;
const float ExitSideOffset = 1.4f; // beim Aussteigen so weit neben den Sitz
// Bewegungs-Skripte des Spielers (Unity Standard Assets), während der Fahrt aus
static readonly string[] MovementScripts = { "CharacterMotor", "FPSInputController" };
SyncedBody vehicle;
Vector3 seatHeadLocal;
float lastYaw;
readonly List<Behaviour> disabled = new List<Behaviour>();
CharacterController controller;
public bool IsRiding { get { return vehicle != null; } }
/// <summary>Taste gedrückt: einsteigen oder aussteigen. Gibt einen Statustext zurück.</summary>
public string Toggle(Transform player, Camera cam, ObjectSync objects, IDictionary<int, PlayerInfo> players)
{
if (IsRiding)
{
Exit(player, cam);
return "Got out";
}
if (cam == null) return null;
SyncedBody best = null;
RemotePlayer driver = null;
float bestDist = EnterRange * EnterRange;
foreach (SyncedBody b in objects.RemoteDrivenVehicles())
{
PlayerInfo owner;
if (!players.TryGetValue(b.OwnerId, out owner) || owner.Avatar == null) continue;
float d = (b.Rb.worldCenterOfMass - player.position).sqrMagnitude;
if (d < bestDist) { bestDist = d; best = b; driver = owner.Avatar; }
}
if (best == null) return "No vehicle driven by another player nearby";
Transform vt = best.Rb.transform;
Vector3 driverHead = vt.InverseTransformPoint(driver.HeadPosition);
seatHeadLocal = Mathf.Abs(driverHead.x) < CenterSeatThreshold
? driverHead - new Vector3(0f, 0f, BehindCenterSeat) // Sitz in der Mitte: dahinter
: new Vector3(-driverHead.x, driverHead.y, driverHead.z); // gegenüber vom Fahrer
vehicle = best;
lastYaw = vt.eulerAngles.y;
SetMovementEnabled(player, false);
return "Riding as passenger (press again to get out)";
}
/// <summary>Nach der Physik (LateUpdate): Spieler auf den Sitz setzen und mitdrehen.</summary>
public void LateUpdate(Transform player, Camera cam, ObjectSync objects)
{
if (!IsRiding) return;
// Fahrer ausgestiegen oder Fahrzeug weg: automatisch aussteigen
if (vehicle.Rb == null || cam == null || !objects.IsRemoteOwnedBody(vehicle))
{
Exit(player, cam);
return;
}
Transform vt = vehicle.Rb.transform;
float yaw = vt.eulerAngles.y;
player.Rotate(0f, Mathf.DeltaAngle(lastYaw, yaw), 0f, Space.World);
lastYaw = yaw;
Vector3 cameraOffset = cam.transform.position - player.position;
player.position = vt.TransformPoint(seatHeadLocal) - cameraOffset;
}
public void Exit(Transform player, Camera cam)
{
if (!IsRiding) return;
if (player != null && vehicle.Rb != null)
{
// Auf der Seite des Sitzes neben das Fahrzeug stellen
float side = seatHeadLocal.x >= 0f ? 1f : -1f;
Vector3 outside = vehicle.Rb.transform.TransformPoint(seatHeadLocal + new Vector3(side * ExitSideOffset, 0f, 0f));
Vector3 cameraOffset = cam != null ? cam.transform.position - player.position : Vector3.up * 1.6f;
player.position = outside - cameraOffset + Vector3.up * 0.1f;
}
vehicle = null;
SetMovementEnabled(player, true);
}
/// <summary>Szenenwechsel/Verbindungsende: ohne Teleport alles zurücksetzen.</summary>
public void Reset()
{
vehicle = null;
SetMovementEnabled(null, true);
}
void SetMovementEnabled(Transform player, bool enabled)
{
if (!enabled)
{
disabled.Clear();
foreach (MonoBehaviour mb in player.GetComponents<MonoBehaviour>())
{
if (mb == null || !mb.enabled) continue;
if (System.Array.IndexOf(MovementScripts, mb.GetType().Name) < 0) continue;
mb.enabled = false;
disabled.Add(mb);
}
controller = player.GetComponent<CharacterController>();
if (controller != null && controller.enabled) controller.enabled = false;
else controller = null;
}
else
{
foreach (Behaviour b in disabled) if (b != null) b.enabled = true;
disabled.Clear();
if (controller != null) controller.enabled = true;
controller = null;
}
}
}
}
+314 -12
View File
@@ -1,29 +1,331 @@
using UnityEngine; using UnityEngine;
namespace MSCO namespace MSCO
{ {
/// <summary>Bewegt den anderen Spieler weich zur zuletzt empfangenen Position.</summary> /// <summary>
/// Figur eines Mitspielers mit echter Körperhaltung. Aus Kopfposition (Kamera) und Bodenhöhe wird
/// jedes Frame die Pose berechnet: Ducken beugt die Knie, nach vorne Lehnen neigt den Oberkörper,
/// Laufen bewegt Beine und Arme, beim Halten greift der rechte Arm (mit Ellbogen) zum Item, im Auto
/// sitzt die Figur. Die Körperteile sind abgerundete Kapseln zwischen den Gelenken.
/// </summary>
public class RemotePlayer : MonoBehaviour public class RemotePlayer : MonoBehaviour
{ {
Vector3 targetPos; const float Smoothing = 15f;
float targetRotY; const float StandingEyeHeight = 1.65f;
const float MaxHeadPitch = 55f;
// Körpermaße (Meter)
const float ThighLength = 0.45f, ShinLength = 0.45f;
const float UpperArmLength = 0.3f, ForearmLength = 0.29f;
const float TorsoLength = 0.55f; // Hüfte bis Hals
const float HipWidth = 0.1f, ShoulderWidth = 0.21f;
const float AnkleHeight = 0.07f;
static readonly Color Skin = new Color(0.93f, 0.76f, 0.62f);
static readonly Color Jeans = new Color(0.18f, 0.25f, 0.42f);
static readonly Color Boots = new Color(0.24f, 0.16f, 0.1f);
static readonly Color Dark = new Color(0.08f, 0.08f, 0.08f);
Color jacket;
Transform headPivot, view;
Transform pelvis, torso, belt, neck;
readonly Transform[] thigh = new Transform[2], shin = new Transform[2], boot = new Transform[2];
readonly Transform[] upperArm = new Transform[2], forearm = new Transform[2], hand = new Transform[2];
Vector3 targetFeet;
Vector3 targetHead;
Vector3 headWorld;
float targetYaw, targetPitch, pitch;
Quaternion targetViewRot = Quaternion.identity;
PlayerState last;
Vector3 lastPosition;
float walkPhase, walkAmount, speed;
// Echtes Spielmodell (NPC aus der Szene); null = eigene Figur als Rückfall
GameCharacter model;
public int HeldId { get { return last.HeldId; } }
public PlayerState LastState { get { return last; } }
public Vector3 HeadPosition { get { return model != null ? model.HeadPosition : headPivot.position; } }
public string ModelDescription { get { return model != null ? "game model: " + model.Description : "fallback figure (no NPC template found)"; } }
// ---------------------------------------------------------------- Aufbau
public static RemotePlayer Create(string name, Color jacketColor, PlayerState state, int variant)
{
var root = new GameObject(name);
var rp = root.AddComponent<RemotePlayer>();
rp.jacket = jacketColor;
rp.Build();
rp.SetTarget(state);
root.transform.position = rp.targetFeet;
root.transform.rotation = Quaternion.Euler(0f, rp.targetYaw, 0f);
// Wenn möglich einen echten NPC aus dem Spiel als Figur nehmen, sonst die eigene Figur
try { rp.model = GameCharacter.TryCreate(root.transform, variant); }
catch (System.Exception e) { MSCLoader.ModConsole.Warning("[MSCO] Game model failed: " + e.Message); }
if (rp.model != null)
foreach (Renderer r in root.GetComponentsInChildren<Renderer>(true))
if (!r.transform.IsChildOf(rp.model.Root)) r.enabled = false;
rp.headWorld = rp.targetHead;
rp.pitch = rp.targetPitch;
rp.view.rotation = rp.targetViewRot;
rp.lastPosition = root.transform.position;
if (rp.model == null) rp.Pose(0f);
return rp;
}
void Build()
{
Transform r = transform;
Color jacketDark = jacket * 0.75f;
jacketDark.a = 1f;
pelvis = Capsule("Pelvis", r, Jeans);
torso = Capsule("Torso", r, jacket);
belt = Capsule("Belt", r, Dark);
neck = Capsule("Neck", r, Skin);
for (int i = 0; i < 2; i++)
{
thigh[i] = Capsule("Thigh", r, Jeans);
shin[i] = Capsule("Shin", r, Jeans);
boot[i] = Box("Boot", r, Boots);
upperArm[i] = Capsule("UpperArm", r, jacket);
forearm[i] = Capsule("Forearm", r, jacketDark);
hand[i] = Sphere("Hand", r, Skin, 0.1f);
}
// Kopf: dreht mit Blick (hoch/runter), Gesicht nach +Z
headPivot = new GameObject("Head").transform;
headPivot.SetParent(r, false);
Transform skull = Sphere("Skull", headPivot, Skin, 0.24f);
skull.localScale = new Vector3(0.22f, 0.26f, 0.24f);
Transform hair = Sphere("Hair", headPivot, new Color(0.25f, 0.17f, 0.1f), 0.24f);
hair.localPosition = new Vector3(0f, 0.02f, -0.025f);
hair.localScale = new Vector3(0.23f, 0.25f, 0.23f);
Transform cap = Sphere("Cap", headPivot, jacketDark, 0.25f);
cap.localPosition = new Vector3(0f, 0.07f, -0.005f);
cap.localScale = new Vector3(0.245f, 0.16f, 0.255f);
Transform brim = Box("CapBrim", headPivot, jacketDark);
brim.localPosition = new Vector3(0f, 0.075f, 0.13f);
brim.localScale = new Vector3(0.2f, 0.02f, 0.11f);
brim.localRotation = Quaternion.Euler(-8f, 0f, 0f);
for (int i = 0; i < 2; i++)
{
Transform eye = Sphere("Eye", headPivot, Dark, 0.035f);
eye.localPosition = new Vector3(i == 0 ? -0.045f : 0.045f, 0.015f, 0.105f);
}
Transform nose = Box("Nose", headPivot, Skin * 0.95f);
nose.localPosition = new Vector3(0f, -0.02f, 0.12f);
nose.localScale = new Vector3(0.035f, 0.05f, 0.04f);
// Unsichtbarer "Blick" mit der exakten Kameradrehung - daran hängt das gehaltene Item
view = new GameObject("View").transform;
view.SetParent(r, false);
}
static Transform Capsule(string name, Transform parent, Color color) { return Primitive(PrimitiveType.Capsule, name, parent, color); }
static Transform Box(string name, Transform parent, Color color) { return Primitive(PrimitiveType.Cube, name, parent, color); }
static Transform Sphere(string name, Transform parent, Color color, float size)
{
Transform t = Primitive(PrimitiveType.Sphere, name, parent, color);
t.localScale = Vector3.one * size;
return t;
}
static Transform Primitive(PrimitiveType type, string name, Transform parent, Color color)
{
GameObject go = GameObject.CreatePrimitive(type);
go.name = name;
Destroy(go.GetComponent<Collider>()); // stört sonst Physik/Trigger
var renderer = go.GetComponent<Renderer>();
renderer.material.color = color;
renderer.receiveShadows = true;
go.transform.SetParent(parent, false);
return go.transform;
}
// ---------------------------------------------------------------- Zustand
public void SetTarget(PlayerState state) public void SetTarget(PlayerState state)
{ {
targetPos = state.Position; last = state;
targetRotY = state.RotationY; targetHead = state.CamPos;
// Füße: Bodenhöhe vom Spieler, waagerecht an der Spielerposition (Lehnen = Kopf woanders als Füße).
// Liegt der Kopf unplausibel weit weg (andere Kamera), Füße unter den Kopf.
float feetY = state.FeetY != 0f ? state.FeetY : state.CamPos.y - StandingEyeHeight;
var feet = new Vector3(state.Position.x, feetY, state.Position.z);
var horizontal = new Vector3(state.CamPos.x - feet.x, 0f, state.CamPos.z - feet.z);
if (horizontal.sqrMagnitude > 0.8f * 0.8f || state.Seated) feet = new Vector3(state.CamPos.x, feetY, state.CamPos.z);
targetFeet = feet;
// Blickrichtung ohne seitliches Kippen
Vector3 forward = state.CamRot * Vector3.forward;
Vector3 flat = new Vector3(forward.x, 0f, forward.z);
targetYaw = flat.sqrMagnitude > 0.0001f ? Quaternion.LookRotation(flat).eulerAngles.y : state.RotationY;
targetPitch = Mathf.Clamp(-Mathf.Asin(Mathf.Clamp(forward.y, -1f, 1f)) * Mathf.Rad2Deg, -MaxHeadPitch, MaxHeadPitch);
targetViewRot = state.CamRot;
} }
public void Tick(float dt) public void Tick(float dt)
{ {
// Bei großen Sprüngen (Teleport, Laden) direkt setzen statt gleiten // Bei großen Sprüngen (Teleport, Laden) direkt setzen statt gleiten
if ((transform.position - targetPos).sqrMagnitude > 100f) bool snap = (transform.position - targetFeet).sqrMagnitude > 100f;
transform.position = targetPos; float t = snap ? 1f : Mathf.Clamp01(dt * Smoothing);
else
transform.position = Vector3.Lerp(transform.position, targetPos, dt * 15f);
transform.rotation = Quaternion.Slerp(transform.rotation, transform.position = Vector3.Lerp(transform.position, targetFeet, t);
Quaternion.Euler(0f, targetRotY, 0f), dt * 15f); transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.Euler(0f, targetYaw, 0f), t);
headWorld = snap ? targetHead : Vector3.Lerp(headWorld, targetHead, t);
pitch = Mathf.Lerp(pitch, targetPitch, t);
view.position = headWorld;
view.rotation = Quaternion.Slerp(view.rotation, targetViewRot, t);
UpdateWalk(dt);
if (model != null) model.Animate(speed); // Spiel-Animation (Idle/Walk) läuft vor LateUpdate
else Pose(dt);
}
/// <summary>Spielmodell: nach der Animation Ducken/Lehnen/Kopf/Arme anpassen.</summary>
void LateUpdate()
{
if (model == null) return;
Vector3 heldPos;
Quaternion heldRot;
bool holding = TryGetHeldPose(out heldPos, out heldRot);
model.Pose(transform, headWorld, pitch, last.Seated, holding, heldPos);
}
void UpdateWalk(float dt)
{
Vector3 moved = transform.position - lastPosition;
lastPosition = transform.position;
moved.y = 0f;
speed = dt > 0f ? moved.magnitude / dt : 0f;
if (speed > 12f || last.Seated) speed = 0f; // Teleport oder im Auto
walkAmount = Mathf.Lerp(walkAmount, Mathf.Clamp01(speed / 2.5f), Mathf.Clamp01(dt * 8f));
walkPhase += speed * dt * 3.4f;
}
// ---------------------------------------------------------------- Pose (alles im lokalen Raum der Figur)
void Pose(float dt)
{
Vector3 head = transform.InverseTransformPoint(headWorld) + new Vector3(0f, -0.02f, -0.03f);
Vector3 neckTop = head + new Vector3(0f, -0.12f, -0.01f);
Vector3 neckBase = head + new Vector3(0f, -0.2f, -0.02f);
// Hüfte: beim Lehnen nach hinten ausweichen, beim Ducken absinken (Füße bleiben am Boden)
Vector3 hip;
if (last.Seated)
{
hip = neckBase + new Vector3(0f, -TorsoLength, -0.08f);
}
else
{
var lean = new Vector3(neckBase.x, 0f, neckBase.z);
hip = -lean * 0.25f;
float horizontal = (new Vector3(neckBase.x - hip.x, 0f, neckBase.z - hip.z)).magnitude;
float vertical = Mathf.Sqrt(Mathf.Max(0.04f, TorsoLength * TorsoLength - horizontal * horizontal));
hip.y = Mathf.Clamp(neckBase.y - vertical, 0.3f, ThighLength + ShinLength + AnkleHeight - 0.02f);
}
Vector3 up = (neckBase - hip).normalized;
Vector3 right = Vector3.right;
Vector3 forward = Vector3.Cross(right, up).normalized;
// Rumpf
Segment(torso, hip + up * 0.08f, neckBase, 0.38f, 0.24f);
Segment(belt, hip + up * 0.06f, hip + up * 0.1f, 0.37f, 0.23f);
Segment(pelvis, hip - right * HipWidth, hip + right * HipWidth, 0.2f, 0.22f);
Segment(neck, neckBase, neckTop, 0.09f, 0.09f);
// Kopf
headPivot.localPosition = head;
headPivot.localRotation = Quaternion.Euler(pitch, 0f, 0f);
float swing = Mathf.Sin(walkPhase) * walkAmount;
for (int i = 0; i < 2; i++)
{
float side = i == 0 ? -1f : 1f;
float phase = i == 0 ? swing : -swing;
// Beine
Vector3 hipJoint = hip + right * (HipWidth * side);
Vector3 ankle;
if (last.Seated)
ankle = hipJoint + new Vector3(0f, -0.4f, 0.48f);
else
ankle = new Vector3(HipWidth * side, AnkleHeight + Mathf.Max(0f, Mathf.Cos(walkPhase + i * Mathf.PI)) * 0.07f * walkAmount, phase * 0.3f);
Vector3 knee = Joint(hipJoint, ankle, ThighLength, ShinLength, Vector3.forward);
Segment(thigh[i], hipJoint, knee, 0.17f, 0.17f);
Segment(shin[i], knee, ankle, 0.14f, 0.14f);
boot[i].localPosition = ankle + new Vector3(0f, -0.02f, 0.05f);
boot[i].localRotation = Quaternion.identity;
boot[i].localScale = new Vector3(0.12f, 0.1f, 0.27f);
// Arme
Vector3 shoulder = neckBase - up * 0.06f + right * (ShoulderWidth * side);
Vector3 handTarget;
Vector3 heldPos;
Quaternion heldRot;
if (i == 1 && TryGetHeldPose(out heldPos, out heldRot))
handTarget = transform.InverseTransformPoint(heldPos);
else if (last.Seated)
handTarget = shoulder + new Vector3(-side * 0.05f, -0.3f, 0.38f); // Hände nach vorne (Lenkrad)
else
handTarget = shoulder + new Vector3(side * 0.03f, -(UpperArmLength + ForearmLength) + 0.04f, -phase * 0.22f);
// Nicht weiter als die Armlänge
Vector3 reach = handTarget - shoulder;
float maxReach = UpperArmLength + ForearmLength - 0.01f;
if (reach.magnitude > maxReach) handTarget = shoulder + reach.normalized * maxReach;
Vector3 elbow = Joint(shoulder, handTarget, UpperArmLength, ForearmLength, new Vector3(side * 0.4f, -0.2f, -1f));
Segment(upperArm[i], shoulder, elbow, 0.12f, 0.12f);
Segment(forearm[i], elbow, handTarget, 0.11f, 0.11f);
hand[i].localPosition = handTarget;
}
}
/// <summary>Zwei-Knochen-Gelenk (Knie/Ellbogen): Punkt zwischen a und b, zur Seite "bend" geknickt.</summary>
static Vector3 Joint(Vector3 a, Vector3 b, float lengthA, float lengthB, Vector3 bend)
{
Vector3 ab = b - a;
float d = Mathf.Clamp(ab.magnitude, 0.01f, lengthA + lengthB - 0.001f);
Vector3 dir = ab.normalized;
// Abstand vom Startpunkt entlang ab und seitlicher Abstand (Kosinussatz)
float along = (lengthA * lengthA - lengthB * lengthB + d * d) / (2f * d);
float sideways = Mathf.Sqrt(Mathf.Max(0f, lengthA * lengthA - along * along));
Vector3 bendDir = bend - dir * Vector3.Dot(bend, dir);
if (bendDir.sqrMagnitude < 0.0001f) bendDir = Vector3.forward - dir * Vector3.Dot(Vector3.forward, dir);
return a + dir * along + bendDir.normalized * sideways;
}
/// <summary>Kapsel von a nach b spannen (lokaler Raum der Figur).</summary>
static void Segment(Transform part, Vector3 a, Vector3 b, float width, float depth)
{
Vector3 dir = b - a;
float length = dir.magnitude;
part.localPosition = (a + b) * 0.5f;
part.localRotation = length > 0.0001f ? Quaternion.FromToRotation(Vector3.up, dir / length) : Quaternion.identity;
part.localScale = new Vector3(width, length * 0.5f + Mathf.Min(width, depth) * 0.5f, depth);
}
/// <summary>Wo das gehaltene Item beim Spieler gerade ist (relativ zum Blick).</summary>
public bool TryGetHeldPose(out Vector3 pos, out Quaternion rot)
{
pos = Vector3.zero;
rot = Quaternion.identity;
if (last.HeldId == 0) return false;
pos = view.TransformPoint(last.HeldLocalPos);
rot = view.rotation * last.HeldLocalRot;
return true;
} }
} }
} }
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Reflection;
using Harmony;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Schutz vor Abstürzen durch nachgespielte Aktionen: Wird beim Beitreten eine Montage-Aktion in einer
/// etwas anderen Welt nachgespielt, kann die "SetParent"-Aktion eines Teils ein Objekt unter sein eigenes
/// Kind hängen. Das ergibt einen Kreis in der Hierarchie und Unity stürzt nativ ab (Stack Overflow in
/// Transform::GetClassIDStatic). Solche Aufrufe werden hier abgefangen und übersprungen.
/// </summary>
public static class SafetyPatches
{
static bool applied;
static FieldInfo gameObjectField, parentField;
static int blocked;
public static void Apply()
{
if (applied) return;
applied = true;
try
{
Type action = null;
foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
{
action = asm.GetType("HutongGames.PlayMaker.Actions.SetParent");
if (action != null) break;
}
MethodInfo onEnter = action != null ? action.GetMethod("OnEnter", BindingFlags.Instance | BindingFlags.Public) : null;
if (onEnter == null) { ModConsole.Warning("[MSCO] SetParent action not found - hierarchy guard disabled"); return; }
gameObjectField = action.GetField("gameObject");
parentField = action.GetField("parent");
HarmonyInstance.Create("mbm.msco.safety").Patch(onEnter,
new HarmonyMethod(typeof(SafetyPatches).GetMethod("SetParentPrefix", BindingFlags.Static | BindingFlags.NonPublic)), null);
}
catch (Exception e)
{
ModConsole.Warning("[MSCO] Could not install hierarchy guard: " + e.Message);
}
}
static bool SetParentPrefix(FsmStateAction __instance)
{
try
{
var owner = gameObjectField != null ? gameObjectField.GetValue(__instance) as FsmOwnerDefault : null;
var parent = parentField != null ? parentField.GetValue(__instance) as FsmGameObject : null;
if (owner == null || parent == null || parent.Value == null) return true;
GameObject go = __instance.Fsm.GetOwnerDefaultTarget(owner);
if (go == null) return true;
Transform child = go.transform, newParent = parent.Value.transform;
if (newParent != child && !newParent.IsChildOf(child)) return true;
blocked++;
if (blocked <= 20)
ModConsole.Warning("[MSCO] Blocked SetParent that would crash the game: " + go.name + " -> " + parent.Value.name +
" (" + __instance.Fsm.Name + ")");
__instance.Finish();
return false;
}
catch
{
return true;
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
using System.Collections.Generic;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Gemeinsame Liste aller PlayMaker-FSMs der Szene. FindObjectsOfType ist teuer (tausende FSMs in MSC);
/// statt dass Türen-, Fahrzeug-, Wetter- und Montage-Sync jeweils selbst suchen, wird einmal gesucht
/// und das Ergebnis eine Weile geteilt.
/// </summary>
public static class SceneCache
{
static PlayMakerFSM[] fsms = new PlayMakerFSM[0];
static float builtAt = -1000f;
static int builtForLevel = -1;
/// <summary>Alle FSMs; höchstens maxAge Sekunden alt. Einträge können inzwischen zerstört sein (== null prüfen).</summary>
public static PlayMakerFSM[] Fsms(float maxAge)
{
if (Time.realtimeSinceStartup - builtAt > maxAge || builtForLevel != Application.loadedLevel)
{
Object[] found = Object.FindObjectsOfType(typeof(PlayMakerFSM));
var list = new List<PlayMakerFSM>(found.Length);
foreach (Object o in found) list.Add((PlayMakerFSM)o);
fsms = list.ToArray();
builtAt = Time.realtimeSinceStartup;
builtForLevel = Application.loadedLevel;
}
return fsms;
}
}
}
+264
View File
@@ -0,0 +1,264 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net.Sockets;
using System.Threading;
// (Unitys WWW wird nicht mehr benutzt - siehe WinHttp)
using Newtonsoft.Json;
using UnityEngine;
namespace MSCO
{
/// <summary>Ein Eintrag der Serverliste (vom Master-Server, Spielerzahl/Ping direkt beim Server abgefragt).</summary>
public class ServerListEntry
{
public string Name;
public string Description;
public string Address;
public int Port;
public int Players;
public int MaxPlayers;
public string Version;
public bool RequireSteam = true; // false = Non-Steam-Server
public string Owner; // Besitzer des Lizenzschlüssels
[JsonIgnore] public int UptimeSeconds = -1;
[JsonIgnore] public List<string> PlayerNames = new List<string>();
[JsonIgnore] public int Ping = -1; // -1 = noch nicht abgefragt
[JsonIgnore] public bool Offline;
[JsonIgnore] public bool IsLocal; // Server auf diesem PC (localhost)
}
/// <summary>
/// Holt die Serverliste vom Master-Server (Keymaster) und fragt jeden Server im Hintergrund
/// nach aktuellen Infos und Ping (MsgType.Info).
/// </summary>
public class ServerBrowser
{
public const string DefaultMasterUrl = "https://msco.mbm.wtf";
const string PrefMasterUrl = "MSCO_MasterUrl";
const int QueryTimeoutMs = 3000;
// Wie bei FiveM: ein Server auf diesem PC erscheint automatisch oben in der Liste
static readonly int[] LocalPorts = { 7777, 7778, 7779, 7780 };
public const float AutoRefreshSeconds = 15f;
readonly object gate = new object();
List<ServerListEntry> servers = new List<ServerListEntry>();
readonly List<ServerListEntry> localServers = new List<ServerListEntry>();
public bool Loading { get; private set; }
public string Error { get; private set; }
/// <summary>Zeitpunkt der letzten Aktualisierung (Time.realtimeSinceStartup), für das Auto-Refresh.</summary>
public float LastRefresh { get; private set; }
/// <summary>Adresse des Master-Servers (änderbar mit "mp master &lt;url&gt;", z.B. zum lokalen Testen).</summary>
public static string MasterUrl
{
get { return PlayerPrefs.GetString(PrefMasterUrl, DefaultMasterUrl).TrimEnd('/'); }
set { PlayerPrefs.SetString(PrefMasterUrl, string.IsNullOrEmpty(value) ? DefaultMasterUrl : value.TrimEnd('/')); }
}
/// <summary>Lokale Server zuerst, dann die öffentliche Liste.</summary>
public List<ServerListEntry> Servers
{
get
{
lock (gate)
{
var all = new List<ServerListEntry>(localServers);
all.AddRange(servers);
return all;
}
}
}
public void Refresh(MonoBehaviour host)
{
if (Loading) return;
host.StartCoroutine(Fetch());
}
IEnumerator Fetch()
{
Loading = true;
Error = null;
LastRefresh = Time.realtimeSinceStartup;
ProbeLocalServers(); // unabhängig vom Master: auch ohne Internet sichtbar
// Über WinHTTP (TLS 1.2/1.3) im Hintergrund - Unitys WWW kann nur TLS 1.0
string text = null, error = null;
bool done = false;
string url = MasterUrl + "/api/servers";
var thread = new Thread(() =>
{
text = WinHttp.Get(url, 8000, out error);
done = true;
});
thread.IsBackground = true;
thread.Start();
while (!done) yield return null;
Loading = false;
if (text == null)
{
Error = "Server list not reachable (" + error + ")";
yield break;
}
List<ServerListEntry> list;
try { list = JsonConvert.DeserializeObject<List<ServerListEntry>>(text) ?? new List<ServerListEntry>(); }
catch (Exception e)
{
Error = "Invalid server list: " + e.Message;
yield break;
}
lock (gate) servers = list;
foreach (ServerListEntry entry in list) QueryAsync(entry);
}
/// <summary>Server auf diesem PC suchen (127.0.0.1, übliche Ports); nur antwortende werden gezeigt.</summary>
void ProbeLocalServers()
{
lock (gate) localServers.Clear();
foreach (int port in LocalPorts)
{
var entry = new ServerListEntry { Name = "Local server", Address = "127.0.0.1", Port = port, IsLocal = true, Version = "" };
QueryAsync(entry, found => localServers.Add(found));
}
}
/// <summary>Info + Ping im Hintergrund abfragen (blockiert das Spiel nicht).</summary>
void QueryAsync(ServerListEntry entry, Action<ServerListEntry> onOnline = null)
{
var thread = new Thread(() =>
{
ServerInfo info;
bool ok = QueryInfo(entry.Address, entry.Port, QueryTimeoutMs, out info);
lock (gate)
{
if (!ok)
{
entry.Offline = true;
return;
}
entry.Name = info.Name;
entry.Description = info.Description;
entry.Players = info.Players;
entry.MaxPlayers = info.MaxPlayers;
entry.Version = info.Version;
entry.Ping = info.Ping;
if (info.HasProfile)
{
entry.RequireSteam = info.RequireSteam;
if (!string.IsNullOrEmpty(info.Owner)) entry.Owner = info.Owner;
entry.UptimeSeconds = info.UptimeSeconds;
entry.PlayerNames = info.PlayerNames;
}
entry.Offline = false;
if (onOnline != null) onOnline(entry);
}
});
thread.IsBackground = true;
thread.Start();
}
/// <summary>Antwort eines Servers auf die Info-Abfrage.</summary>
public struct ServerInfo
{
public string Name, Description, Version;
public int Players, MaxPlayers, Ping;
// Server-Profil (ab MSCO 2.4; ältere Server liefern das nicht)
public bool HasProfile;
public bool RequireSteam;
public string Owner;
public int UptimeSeconds;
public List<string> PlayerNames;
}
/// <summary>Server direkt nach Name, Spielerzahl und Version fragen (blockierend - im Hintergrund aufrufen).</summary>
public static bool QueryInfo(string host, int port, int timeoutMs, out ServerInfo info)
{
info = new ServerInfo();
try
{
var watch = Stopwatch.StartNew();
using (var client = new TcpClient())
{
IAsyncResult connect = client.BeginConnect(host, port, null, null);
if (!connect.AsyncWaitHandle.WaitOne(timeoutMs)) return false;
client.EndConnect(connect);
client.ReceiveTimeout = timeoutMs;
NetworkStream stream = client.GetStream();
byte[] request = FrameParser.Frame(MsgType.Info, null);
stream.Write(request, 0, request.Length);
byte[] header = ReadExactly(stream, 5);
int length = BitConverter.ToInt32(header, 0);
if (header[4] != (byte)MsgType.InfoReply || length < 1 || length > 64 * 1024) return false;
byte[] payload = ReadExactly(stream, length - 1);
info.Ping = (int)watch.ElapsedMilliseconds;
using (var r = new BinaryReader(new MemoryStream(payload)))
{
info.Name = r.ReadString();
info.Description = r.ReadString();
info.Players = r.ReadInt32();
info.MaxPlayers = r.ReadInt32();
info.Version = r.ReadString();
info.RequireSteam = true;
if (r.BaseStream.Position < r.BaseStream.Length)
{
info.HasProfile = true;
info.RequireSteam = r.ReadBoolean();
info.Owner = r.ReadString();
info.UptimeSeconds = r.ReadInt32();
int count = r.ReadInt32();
info.PlayerNames = new List<string>();
for (int i = 0; i < count && i < 256; i++) info.PlayerNames.Add(r.ReadString());
}
}
return true;
}
}
catch
{
return false;
}
}
static byte[] ReadExactly(Stream stream, int count)
{
var data = new byte[count];
int read = 0;
while (read < count)
{
int n = stream.Read(data, read, count - read);
if (n <= 0) throw new IOException("Connection closed");
read += n;
}
return data;
}
/// <summary>"1.2.3.4:7777" oder "host" (Standard-Port) zerlegen.</summary>
public static bool TryParseAddress(string text, out string host, out int port)
{
host = null;
port = MultiplayerManager.DefaultPort;
if (string.IsNullOrEmpty(text)) return false;
text = text.Trim();
int colon = text.LastIndexOf(':');
if (colon > 0 && text.IndexOf(':') == colon)
{
if (!int.TryParse(text.Substring(colon + 1), out port) || port < 1 || port > 65535) return false;
text = text.Substring(0, colon);
}
host = text;
return host.Length > 0;
}
}
}
+227
View File
@@ -0,0 +1,227 @@
using System;
using MSCLoader;
using Steamworks;
namespace MSCO
{
/// <summary>
/// Steam-Lobby für P2P: Host erstellt eine Freunde-Lobby, lädt über das Steam-Overlay ein,
/// Freunde treten über die Einladung bzw. "Spiel beitreten" bei. Die eigentlichen Daten
/// laufen danach über Steam P2P (siehe SteamConnection).
/// </summary>
public class SteamLobby
{
const string VersionKey = "msco_version";
const uint ChatMemberLeftMask = 2 | 4 | 8 | 16; // Left, Disconnected, Kicked, Banned
public CSteamID LobbyId { get; private set; }
public bool InLobby { get { return LobbyId.m_SteamID != 0; } }
/// <summary>Lobby erstellt (Host).</summary>
public event Action<CSteamID> Created;
/// <summary>Lobby betreten (Client): Lobby-ID und Host.</summary>
public event Action<CSteamID, CSteamID> Entered;
/// <summary>Einladung angenommen / "Spiel beitreten" im Freundesfenster.</summary>
public event Action<CSteamID> JoinRequested;
/// <summary>Jemand hat die Lobby verlassen (Steam-ID).</summary>
public event Action<CSteamID> MemberLeft;
/// <summary>Fehler (Text für die Statuszeile).</summary>
public event Action<string> Failed;
CallResult<LobbyCreated_t> createdResult;
CallResult<LobbyEnter_t> enterResult;
Callback<GameLobbyJoinRequested_t> joinRequestedCallback;
Callback<LobbyChatUpdate_t> chatUpdateCallback;
Callback<P2PSessionRequest_t> sessionRequestCallback;
bool initialized;
public static bool IsAvailable
{
get
{
try { return SteamAPI.IsSteamRunning() && SteamUser.GetSteamID().m_SteamID != 0; }
catch { return false; }
}
}
public static string PersonaName
{
get
{
try { return SteamFriends.GetPersonaName(); }
catch { return null; }
}
}
public static ulong OwnSteamId
{
get
{
try { return SteamUser.GetSteamID().m_SteamID; }
catch { return 0; }
}
}
static HAuthTicket authTicket;
static bool hasAuthTicket;
/// <summary>Steam-Anmeldeticket für dedizierte Server (der Server kann es bei Steam prüfen).</summary>
public static byte[] CreateAuthTicket()
{
try
{
CancelAuthTicket();
var buffer = new byte[1024];
uint length;
authTicket = SteamUser.GetAuthSessionTicket(buffer, buffer.Length, out length);
hasAuthTicket = true;
var ticket = new byte[length];
Array.Copy(buffer, ticket, length);
return ticket;
}
catch
{
return new byte[0];
}
}
public static void CancelAuthTicket()
{
if (!hasAuthTicket) return;
hasAuthTicket = false;
try { SteamUser.CancelAuthTicket(authTicket); } catch { }
}
public bool Init()
{
if (initialized) return true;
if (!IsAvailable) return false;
try
{
createdResult = CallResult<LobbyCreated_t>.Create(OnLobbyCreated);
enterResult = CallResult<LobbyEnter_t>.Create(OnLobbyEntered);
joinRequestedCallback = Callback<GameLobbyJoinRequested_t>.Create(OnJoinRequested);
chatUpdateCallback = Callback<LobbyChatUpdate_t>.Create(OnChatUpdate);
sessionRequestCallback = Callback<P2PSessionRequest_t>.Create(OnSessionRequest);
initialized = true;
}
catch (Exception e)
{
ModConsole.Error("[MSCO] Steam init failed: " + e.Message);
}
return initialized;
}
/// <summary>Jedes Frame aufrufen, damit Steam-Callbacks ankommen.</summary>
public void Tick()
{
if (!initialized) return;
try { SteamAPI.RunCallbacks(); } catch { }
}
public void Create(int maxPlayers)
{
if (!Init()) { Fail("Steam is not running"); return; }
Leave();
createdResult.Set(SteamMatchmaking.CreateLobby(ELobbyType.k_ELobbyTypeFriendsOnly, maxPlayers));
}
public void Join(CSteamID lobby)
{
if (!Init()) { Fail("Steam is not running"); return; }
Leave();
enterResult.Set(SteamMatchmaking.JoinLobby(lobby));
}
public void OpenInviteDialog()
{
if (InLobby) SteamFriends.ActivateGameOverlayInviteDialog(LobbyId);
}
public void Leave()
{
if (!InLobby) return;
try { SteamMatchmaking.LeaveLobby(LobbyId); } catch { }
LobbyId = new CSteamID(0);
}
public bool IsMember(CSteamID user)
{
if (!InLobby) return false;
int count = SteamMatchmaking.GetNumLobbyMembers(LobbyId);
for (int i = 0; i < count; i++)
if (SteamMatchmaking.GetLobbyMemberByIndex(LobbyId, i) == user) return true;
return false;
}
// ---------------------------------------------------------------- Callbacks
void OnLobbyCreated(LobbyCreated_t result, bool ioFailure)
{
if (ioFailure || result.m_eResult != EResult.k_EResultOK)
{
Fail("Could not create Steam lobby (" + result.m_eResult + ")");
return;
}
LobbyId = new CSteamID(result.m_ulSteamIDLobby);
SteamMatchmaking.SetLobbyData(LobbyId, VersionKey, MSCO.ModVersion);
if (Created != null) Created(LobbyId);
}
void OnLobbyEntered(LobbyEnter_t result, bool ioFailure)
{
if (ioFailure || result.m_EChatRoomEnterResponse != 1) // 1 = Success
{
Fail("Could not join the lobby (full or closed)");
return;
}
LobbyId = new CSteamID(result.m_ulSteamIDLobby);
string hostVersion = SteamMatchmaking.GetLobbyData(LobbyId, VersionKey);
if (!string.IsNullOrEmpty(hostVersion) && hostVersion != MSCO.ModVersion)
{
Leave();
Fail("Version mismatch (host " + hostVersion + ", you " + MSCO.ModVersion + ")");
return;
}
CSteamID owner = SteamMatchmaking.GetLobbyOwner(LobbyId);
if (Entered != null) Entered(LobbyId, owner);
}
void OnJoinRequested(GameLobbyJoinRequested_t request)
{
if (JoinRequested != null) JoinRequested(request.m_steamIDLobby);
}
void OnChatUpdate(LobbyChatUpdate_t update)
{
if (update.m_ulSteamIDLobby != LobbyId.m_SteamID) return;
if ((update.m_rgfChatMemberStateChange & ChatMemberLeftMask) == 0) return;
if (MemberLeft != null) MemberLeft(new CSteamID(update.m_ulSteamIDUserChanged));
}
void OnSessionRequest(P2PSessionRequest_t request)
{
// Nur Verbindungen von Spielern aus unserer Lobby annehmen
if (IsMember(request.m_steamIDRemote))
SteamNetworking.AcceptP2PSessionWithUser(request.m_steamIDRemote);
}
void Fail(string message)
{
if (Failed != null) Failed(message);
}
/// <summary>Wurde das Spiel über eine Steam-Einladung gestartet? ("+connect_lobby &lt;id&gt;")</summary>
public static CSteamID LobbyFromCommandLine()
{
string[] args = Environment.GetCommandLineArgs();
for (int i = 0; i < args.Length - 1; i++)
{
ulong id;
if (args[i] == "+connect_lobby" && ulong.TryParse(args[i + 1], out id)) return new CSteamID(id);
}
return new CSteamID(0);
}
}
}
+119
View File
@@ -0,0 +1,119 @@
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Gemeinsamer Stil aller MSCO-Fenster (Launcher, F8-Konsole, Admin-Menü, Statusleiste):
/// dunkle Flächen mit abgerundeten Ecken, orange Akzente, einheitliche Schriften und Abstände.
/// Unitys OnGUI kennt keine runden Ecken - dafür werden kleine Texturen erzeugt und per
/// 9-Slice (GUIStyle.border) gestreckt, sodass die Ecken rund bleiben.
/// </summary>
public static class UiTheme
{
public static readonly Color Background = Hex(0x16, 0x16, 0x1a, 0.97f);
public static readonly Color Panel = Hex(0x0f, 0x0f, 0x12, 0.98f);
public static readonly Color Row = Hex(0x22, 0x22, 0x28);
public static readonly Color RowHover = Hex(0x2b, 0x2b, 0x33);
public static readonly Color FieldColor = Hex(0x22, 0x22, 0x28);
public static readonly Color FieldFocus = Hex(0x2c, 0x2c, 0x34);
public static readonly Color Accent = Hex(0xf2, 0x6b, 0x1d);
public static readonly Color AccentHover = Hex(0xff, 0x82, 0x3a);
public static readonly Color Secondary = Hex(0x2a, 0x2a, 0x31);
public static readonly Color SecondaryHover = Hex(0x36, 0x36, 0x3f);
public static readonly Color Danger = Hex(0xd9, 0x44, 0x48);
public static readonly Color DangerHover = Hex(0xee, 0x5a, 0x5e);
public static readonly Color Text = Hex(0xf2, 0xf2, 0xf5);
public static readonly Color Muted = Hex(0x8e, 0x8e, 0x99);
public static readonly Color Online = Hex(0x3d, 0xd6, 0x8c);
public static readonly Color Pending = Hex(0xf2, 0xc2, 0x1d);
public const int Radius = 6;
public static Color Hex(int r, int g, int b, float a = 1f)
{
return new Color(r / 255f, g / 255f, b / 255f, a);
}
/// <summary>Textur mit abgerundeten Ecken (weiche Kanten) für 9-Slice-Styles.</summary>
public static Texture2D Rounded(Color color, int radius = Radius)
{
int size = radius * 2 + 4;
var tex = new Texture2D(size, size, TextureFormat.ARGB32, false);
tex.wrapMode = TextureWrapMode.Clamp;
tex.filterMode = FilterMode.Bilinear;
for (int y = 0; y < size; y++)
{
for (int x = 0; x < size; x++)
{
// Abstand zur nächsten Ecken-Mitte (innerhalb des geraden Teils = 0)
float cx = Mathf.Clamp(x + 0.5f, radius, size - radius);
float cy = Mathf.Clamp(y + 0.5f, radius, size - radius);
float d = Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy));
float alpha = Mathf.Clamp01(radius - d + 0.5f);
tex.SetPixel(x, y, new Color(color.r, color.g, color.b, color.a * alpha));
}
}
tex.Apply();
tex.hideFlags = HideFlags.HideAndDontSave;
return tex;
}
public static Texture2D Solid(Color color)
{
var tex = new Texture2D(1, 1);
tex.SetPixel(0, 0, color);
tex.Apply();
tex.hideFlags = HideFlags.HideAndDontSave;
return tex;
}
/// <summary>Fläche (Fenster, Karte, Zeile) mit runden Ecken; mit GUI.Box zeichnen.</summary>
public static GUIStyle Panel9(Color color, int radius = Radius)
{
var s = new GUIStyle();
s.normal.background = Rounded(color, radius);
s.border = new RectOffset(radius + 2, radius + 2, radius + 2, radius + 2);
return s;
}
public static GUIStyle Button(Color normal, Color hover, Color text, int fontSize = 13, bool bold = true)
{
var s = new GUIStyle(GUI.skin.button);
s.normal.background = Rounded(normal);
s.hover.background = Rounded(hover);
s.active.background = Rounded(hover);
s.focused.background = Rounded(normal);
s.onNormal.background = Rounded(hover);
s.normal.textColor = s.hover.textColor = s.active.textColor = s.focused.textColor = s.onNormal.textColor = text;
s.border = new RectOffset(Radius + 2, Radius + 2, Radius + 2, Radius + 2);
s.padding = new RectOffset(12, 12, 6, 6);
s.fontSize = fontSize;
s.fontStyle = bold ? FontStyle.Bold : FontStyle.Normal;
return s;
}
public static GUIStyle Field(int fontSize = 14)
{
var s = new GUIStyle(GUI.skin.textField);
s.normal.background = Rounded(FieldColor);
s.hover.background = Rounded(FieldFocus);
s.focused.background = Rounded(FieldFocus);
s.normal.textColor = s.hover.textColor = s.focused.textColor = Text;
s.border = new RectOffset(Radius + 2, Radius + 2, Radius + 2, Radius + 2);
s.padding = new RectOffset(12, 12, 0, 0);
s.alignment = TextAnchor.MiddleLeft;
s.fontSize = fontSize;
return s;
}
public static GUIStyle Label(int fontSize, Color color, FontStyle font = FontStyle.Normal, TextAnchor align = TextAnchor.UpperLeft)
{
var s = new GUIStyle(GUI.skin.label);
s.fontSize = fontSize;
s.fontStyle = font;
s.normal.textColor = color;
s.alignment = align;
return s;
}
}
}
+190
View File
@@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Lichter und Dauergeräusche (Motor, Blinker, Hupe, Radio ...) von Fahrzeugen.
/// Wer ein Fahrzeug besitzt (fährt/schiebt), schickt regelmäßig deren Zustand;
/// bei allen anderen wird dieser Zustand jedes Frame übernommen.
/// </summary>
public class VehicleFx
{
const float SendInterval = 0.2f;
const float CacheRefresh = 5f; // Teile können an-/abgebaut werden
const float ApplyTimeout = 2f; // ohne neue Daten nichts mehr erzwingen
struct AudioState
{
public bool Playing;
public float Pitch;
public float Volume;
}
class Components
{
public float BuiltAt;
public Transform Root;
public readonly Dictionary<int, Light> Lights = new Dictionary<int, Light>();
public readonly Dictionary<int, AudioSource> Audio = new Dictionary<int, AudioSource>();
}
class Snapshot
{
public float ReceivedAt;
public readonly Dictionary<int, bool> Lights = new Dictionary<int, bool>();
public readonly Dictionary<int, AudioState> Audio = new Dictionary<int, AudioState>();
}
readonly Dictionary<int, Components> components = new Dictionary<int, Components>();
readonly Dictionary<int, Snapshot> incoming = new Dictionary<int, Snapshot>();
float sendTimer;
public void Reset()
{
components.Clear();
incoming.Clear();
}
// ---------------------------------------------------------------- Senden
public void Update(ObjectSync objects, Action<MsgType, byte[]> send, float dt)
{
sendTimer += dt;
if (sendTimer < SendInterval) return;
sendTimer = 0f;
foreach (SyncedBody b in objects.LocalVehicles())
{
Components c = GetComponents(b);
if (c == null) continue;
int id = b.Id;
send(MsgType.VehicleFx, NetSession.Build(w =>
{
w.Write(id);
w.Write(c.Lights.Count);
foreach (KeyValuePair<int, Light> l in c.Lights)
{
w.Write(l.Key);
w.Write(l.Value != null && l.Value.enabled && l.Value.gameObject.activeInHierarchy);
}
int audioCount = 0;
foreach (AudioSource a in c.Audio.Values) if (a != null) audioCount++;
w.Write(audioCount);
foreach (KeyValuePair<int, AudioSource> a in c.Audio)
{
if (a.Value == null) continue;
w.Write(a.Key);
w.Write(a.Value.isPlaying);
w.Write(a.Value.pitch);
w.Write(a.Value.volume);
}
}));
}
}
// ---------------------------------------------------------------- Empfangen
public void Handle(BinaryReader r)
{
int id = r.ReadInt32();
var s = new Snapshot { ReceivedAt = Time.time };
int lights = r.ReadInt32();
for (int i = 0; i < lights; i++) s.Lights[r.ReadInt32()] = r.ReadBoolean();
int audio = r.ReadInt32();
for (int i = 0; i < audio; i++)
{
int key = r.ReadInt32();
s.Audio[key] = new AudioState { Playing = r.ReadBoolean(), Pitch = r.ReadSingle(), Volume = r.ReadSingle() };
}
incoming[id] = s;
}
/// <summary>Jedes Frame nach den Spiel-FSMs (LateUpdate), damit unsere Werte gewinnen.</summary>
public void Apply(ObjectSync objects)
{
foreach (KeyValuePair<int, Snapshot> kv in incoming)
{
if (Time.time - kv.Value.ReceivedAt > ApplyTimeout) continue;
SyncedBody b = objects.Get(kv.Key);
if (b == null || !objects.IsRemoteOwnedBody(b)) continue;
Components c = GetComponents(b);
if (c == null) continue;
foreach (KeyValuePair<int, bool> l in kv.Value.Lights)
{
Light light;
if (c.Lights.TryGetValue(l.Key, out light) && light != null) SetLight(light, l.Value, c.Root);
}
foreach (KeyValuePair<int, AudioState> a in kv.Value.Audio)
{
AudioSource src;
if (c.Audio.TryGetValue(a.Key, out src) && src != null) SetAudio(src, a.Value);
}
}
}
static void SetLight(Light light, bool on, Transform root)
{
if (!on)
{
if (light.enabled) light.enabled = false;
return;
}
if (!light.enabled) light.enabled = true;
// Lichter werden im Spiel oft über ein übergeordnetes Objekt ein-/ausgeschaltet
for (Transform t = light.transform; t != null && t != root; t = t.parent)
if (!t.gameObject.activeSelf) t.gameObject.SetActive(true);
}
static void SetAudio(AudioSource src, AudioState state)
{
if (!src.gameObject.activeInHierarchy) return;
if (state.Playing && !src.isPlaying) src.Play();
else if (!state.Playing && src.isPlaying) src.Stop();
src.pitch = state.Pitch;
src.volume = state.Volume;
}
// ---------------------------------------------------------------- Komponenten
Components GetComponents(SyncedBody b)
{
if (b.Rb == null) return null;
Components c;
if (components.TryGetValue(b.Id, out c) && Time.time - c.BuiltAt < CacheRefresh) return c;
c = new Components { BuiltAt = Time.time, Root = b.Rb.transform };
var seen = new Dictionary<string, int>();
foreach (Light l in b.Rb.GetComponentsInChildren<Light>(true))
c.Lights[Key(l.transform, c.Root, seen)] = l;
seen.Clear();
foreach (AudioSource a in b.Rb.GetComponentsInChildren<AudioSource>(true))
if (a.loop) c.Audio[Key(a.transform, c.Root, seen)] = a; // nur Dauergeräusche
components[b.Id] = c;
return c;
}
/// <summary>Schlüssel aus dem Pfad unterhalb des Fahrzeugs (+ Nummer bei gleichen Pfaden).</summary>
static int Key(Transform t, Transform root, Dictionary<string, int> seen)
{
var sb = new StringBuilder();
for (; t != null && t != root; t = t.parent) sb.Insert(0, "/" + t.name);
string path = sb.ToString();
int n;
seen.TryGetValue(path, out n);
seen[path] = n + 1;
path += "#" + n;
unchecked
{
uint h = 2166136261;
foreach (byte c in Encoding.UTF8.GetBytes(path)) { h ^= c; h *= 16777619; }
return (int)h;
}
}
}
}
+242
View File
@@ -0,0 +1,242 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Gesamtzustand der Fahrzeuge (vor allem Satsuma): Werte in den PlayMaker-FSMs unter dem Fahrzeug,
/// z.B. Tankinhalt, Öl, Kühlwasser, Bremsflüssigkeit, Batterieladung, Schrauben-Festigkeit,
/// Schäden/Verschleiß, Kilometerstand, Lack, Einstellungen (Vergaser, Zündung ...).
/// Wer das Fahrzeug gerade besitzt (fährt/schiebt), gibt die Werte vor; ist es frei,
/// schickt jeder seine Änderungen (z.B. Öl nachfüllen, Schraube festziehen).
/// </summary>
public class VehicleStateSync
{
// Nur Werte, die den Zustand beschreiben - nicht Drehzahl, Lenkung, Raycasts usw.
static readonly string[] Keywords =
{
"fuel", "oil", "coolant", "water", "fluid", "brake", "clutchfluid", "battery", "charge",
"bolt", "tight", "damage", "wear", "condition", "odo", "km", "dirt", "paint", "color", "colour",
"tune", "tuning", "ratio", "broken", "fixed", "flat", "pressure", "level", "mixture", "timing",
"idle", "air", "jet", "spark", "gap", "rust", "health", "burn", "cool", "temp"
};
// Montiert-Flags nicht direkt setzen: das echte Anbauen läuft über FsmSync (Events)
static readonly string[] Excluded = { "install", "rpm", "speed", "steer", "throttle", "gear", "raycast", "distance" };
const float ScanInterval = 10f;
const float CheckInterval = 0.5f;
const int MaxPerMessage = 200;
class Var
{
public int Key;
public string Root;
public NamedVariable Variable;
public byte Type; // 1 float, 2 int, 3 bool
public float LastF;
public int LastI;
public bool LastB;
}
readonly Dictionary<int, Var> vars = new Dictionary<int, Var>();
readonly Dictionary<int, byte[]> pending = new Dictionary<int, byte[]>(); // noch nicht gefundene Werte
readonly List<Var> outgoing = new List<Var>();
readonly HashSet<PlayMakerFSM> scanned = new HashSet<PlayMakerFSM>();
float nextScan, nextCheck;
public int Count { get { return vars.Count; } }
public void Reset()
{
vars.Clear();
pending.Clear();
scanned.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;
outgoing.Clear();
foreach (Var v in vars.Values)
{
if (!Changed(v)) continue;
Remember(v);
// Fährt/schiebt ein anderer das Fahrzeug, ist seine Version maßgeblich
int owner = objects.VehicleOwner(v.Root);
if (owner == SyncedBody.NoOwner || owner == localId) outgoing.Add(v);
}
for (int start = 0; start < outgoing.Count; start += MaxPerMessage)
{
int from = start, count = Math.Min(MaxPerMessage, outgoing.Count - start);
send(MsgType.VarSync, NetSession.Build(w =>
{
w.Write(count);
for (int i = from; i < from + count; i++) Write(w, outgoing[i]);
}));
}
}
public void Handle(int from, BinaryReader r, ObjectSync objects, int localId)
{
int count = r.ReadInt32();
for (int i = 0; i < count; i++)
{
int key = r.ReadInt32();
byte type = r.ReadByte();
byte[] value = r.ReadBytes(type == 3 ? 1 : 4);
Var v;
if (!vars.TryGetValue(key, out v)) { pending[key] = Combine(type, value); continue; }
if (from >= 0 && objects.VehicleOwner(v.Root) == localId) continue; // ich fahre gerade: meine Werte gelten
Apply(v, type, value);
}
}
/// <summary>Gespeicherte Werte vom Server (Snapshot) - auch für Teile, die erst später auftauchen.</summary>
public void ApplySnapshot(BinaryReader r)
{
int count = r.ReadInt32();
for (int i = 0; i < count; i++)
{
int key = r.ReadInt32();
byte type = r.ReadByte();
byte[] value = r.ReadBytes(type == 3 ? 1 : 4);
Var v;
if (vars.TryGetValue(key, out v)) Apply(v, type, value);
else pending[key] = Combine(type, value);
}
}
// ---------------------------------------------------------------- intern
void Scan()
{
int before = vars.Count;
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
{
// Schon erfasste FSMs nicht jedes Mal neu durchgehen (Pfad + Hash kosten)
if (fsm == null || !scanned.Add(fsm)) continue;
string root = fsm.transform.root.name;
if (!IsVehicle(root)) continue;
string prefix = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName + "|";
FsmVariables fv = fsm.FsmVariables;
foreach (FsmFloat f in fv.FloatVariables) Register(prefix, root, f, 1);
foreach (FsmInt n in fv.IntVariables) Register(prefix, root, n, 2);
foreach (FsmBool b in fv.BoolVariables) Register(prefix, root, b, 3);
}
if (vars.Count != before) ModConsole.Print("[MSCO] Vehicle state: " + vars.Count + " values synced");
}
void Register(string prefix, string root, NamedVariable variable, byte type)
{
if (variable == null || string.IsNullOrEmpty(variable.Name) || !Wanted(variable.Name)) return;
int key = Hash(prefix + variable.Name);
Var existing;
if (vars.TryGetValue(key, out existing) && existing.Variable == variable) return;
var v = new Var { Key = key, Root = root, Variable = variable, Type = type };
Remember(v);
vars[key] = v;
byte[] stored;
if (pending.TryGetValue(key, out stored))
{
pending.Remove(key);
byte[] value = new byte[stored.Length - 1];
Array.Copy(stored, 1, value, 0, value.Length);
Apply(v, stored[0], value);
}
}
static bool Wanted(string name)
{
string n = name.ToLower();
foreach (string x in Excluded) if (n.Contains(x)) return false;
foreach (string k in Keywords) if (n.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 bool Changed(Var v)
{
switch (v.Type)
{
case 1:
float f = ((FsmFloat)v.Variable).Value;
return Mathf.Abs(f - v.LastF) > Mathf.Max(0.01f, Mathf.Abs(v.LastF) * 0.002f);
case 2: return ((FsmInt)v.Variable).Value != v.LastI;
default: return ((FsmBool)v.Variable).Value != v.LastB;
}
}
static void Remember(Var v)
{
switch (v.Type)
{
case 1: v.LastF = ((FsmFloat)v.Variable).Value; break;
case 2: v.LastI = ((FsmInt)v.Variable).Value; break;
default: v.LastB = ((FsmBool)v.Variable).Value; break;
}
}
static void Apply(Var v, byte type, byte[] value)
{
if (type != v.Type) return;
switch (type)
{
case 1: ((FsmFloat)v.Variable).Value = BitConverter.ToSingle(value, 0); break;
case 2: ((FsmInt)v.Variable).Value = BitConverter.ToInt32(value, 0); break;
default: ((FsmBool)v.Variable).Value = value[0] != 0; break;
}
Remember(v); // nicht als eigene Änderung zurückschicken
}
static void Write(BinaryWriter w, Var v)
{
w.Write(v.Key);
w.Write(v.Type);
switch (v.Type)
{
case 1: w.Write(v.LastF); break;
case 2: w.Write(v.LastI); break;
default: w.Write(v.LastB); break;
}
}
static byte[] Combine(byte type, byte[] value)
{
var all = new byte[value.Length + 1];
all[0] = type;
Array.Copy(value, 0, all, 1, value.Length);
return all;
}
static int Hash(string s)
{
unchecked
{
uint h = 2166136261;
foreach (byte c in Encoding.UTF8.GetBytes(s)) { h ^= c; h *= 16777619; }
return (int)h;
}
}
}
}
+127
View File
@@ -0,0 +1,127 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
namespace MSCO
{
/// <summary>
/// HTTPS-Abruf über Windows (WinHTTP). Unitys WWW in My Summer Car (Unity 5.0) kann nur TLS 1.0,
/// heutige Server verlangen TLS 1.2+. WinHTTP nutzt die TLS-Version des Betriebssystems
/// (unter Proton/Wine über GnuTLS). Blockierend - nur in einem Hintergrund-Thread aufrufen.
/// </summary>
public static class WinHttp
{
public static string Get(string url, int timeoutMs, out string error)
{
error = null;
IntPtr session = IntPtr.Zero, connect = IntPtr.Zero, request = IntPtr.Zero;
try
{
var uri = new Uri(url);
bool secure = uri.Scheme == "https";
session = WinHttpOpen("MSCO/" + MSCO.ModVersion, AccessTypeDefaultProxy, null, null, 0);
if (session == IntPtr.Zero) return Fail(out error, "WinHttpOpen");
WinHttpSetTimeouts(session, timeoutMs, timeoutMs, timeoutMs, timeoutMs);
// Ältere Windows-Versionen haben TLS 1.2 nicht standardmäßig an - ausdrücklich erlauben
int protocols = ProtocolTls12 | ProtocolTls13;
if (!WinHttpSetOption(session, OptionSecureProtocols, ref protocols, 4))
{
protocols = ProtocolTls12;
WinHttpSetOption(session, OptionSecureProtocols, ref protocols, 4);
}
connect = WinHttpConnect(session, uri.Host, (ushort)uri.Port, 0);
if (connect == IntPtr.Zero) return Fail(out error, "WinHttpConnect");
request = WinHttpOpenRequest(connect, "GET", uri.PathAndQuery, null, null, IntPtr.Zero, secure ? FlagSecure : 0);
if (request == IntPtr.Zero) return Fail(out error, "WinHttpOpenRequest");
if (!WinHttpSendRequest(request, null, 0, IntPtr.Zero, 0, 0, IntPtr.Zero)) return Fail(out error, "WinHttpSendRequest");
if (!WinHttpReceiveResponse(request, IntPtr.Zero)) return Fail(out error, "WinHttpReceiveResponse");
int status = 0, size = 4;
WinHttpQueryHeaders(request, QueryStatusCode | QueryFlagNumber, null, ref status, ref size, IntPtr.Zero);
if (status != 0 && status != 200)
{
error = "HTTP " + status;
return null;
}
using (var body = new MemoryStream())
{
var buffer = new byte[16 * 1024];
while (true)
{
int read;
if (!WinHttpReadData(request, buffer, buffer.Length, out read)) return Fail(out error, "WinHttpReadData");
if (read == 0) break;
body.Write(buffer, 0, read);
if (body.Length > 4 * 1024 * 1024) { error = "Response too large"; return null; }
}
return Encoding.UTF8.GetString(body.ToArray());
}
}
catch (Exception e)
{
error = e.Message;
return null;
}
finally
{
if (request != IntPtr.Zero) WinHttpCloseHandle(request);
if (connect != IntPtr.Zero) WinHttpCloseHandle(connect);
if (session != IntPtr.Zero) WinHttpCloseHandle(session);
}
}
static string Fail(out string error, string step)
{
int code = Marshal.GetLastWin32Error();
error = step + " failed (" + code + (code == 12175 ? ", secure connection failed" : code == 12002 ? ", timeout" : code == 12029 ? ", cannot connect" : "") + ")";
return null;
}
const int AccessTypeDefaultProxy = 0;
const int FlagSecure = 0x00800000;
const int OptionSecureProtocols = 84;
const int ProtocolTls12 = 0x00000800;
const int ProtocolTls13 = 0x00002000;
const int QueryStatusCode = 19;
const int QueryFlagNumber = 0x20000000;
[DllImport("winhttp.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern IntPtr WinHttpOpen(string agent, int accessType, string proxy, string bypass, int flags);
[DllImport("winhttp.dll", SetLastError = true)]
static extern bool WinHttpSetTimeouts(IntPtr handle, int resolve, int connect, int send, int receive);
[DllImport("winhttp.dll", SetLastError = true)]
static extern bool WinHttpSetOption(IntPtr handle, int option, ref int value, int length);
[DllImport("winhttp.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern IntPtr WinHttpConnect(IntPtr session, string server, ushort port, int reserved);
[DllImport("winhttp.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern IntPtr WinHttpOpenRequest(IntPtr connect, string verb, string path, string version, string referrer,
IntPtr acceptTypes, int flags);
[DllImport("winhttp.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern bool WinHttpSendRequest(IntPtr request, string headers, int headersLength, IntPtr optional,
int optionalLength, int totalLength, IntPtr context);
[DllImport("winhttp.dll", SetLastError = true)]
static extern bool WinHttpReceiveResponse(IntPtr request, IntPtr reserved);
[DllImport("winhttp.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern bool WinHttpQueryHeaders(IntPtr request, int infoLevel, string name, ref int buffer, ref int bufferLength, IntPtr index);
[DllImport("winhttp.dll", SetLastError = true)]
static extern bool WinHttpReadData(IntPtr request, byte[] buffer, int toRead, out int read);
[DllImport("winhttp.dll", SetLastError = true)]
static extern bool WinHttpCloseHandle(IntPtr handle);
}
}
+183
View File
@@ -0,0 +1,183 @@
using System;
using System.Collections.Generic;
using System.IO;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Uhrzeit und Wetter. Ein Spieler ist "Weltuhr" (P2P: der Host; dedizierter Server: der Spieler
/// mit der kleinsten ID) und schickt regelmäßig den Zustand der Uhr- und Wetter-FSMs
/// (aktiver Zustand + alle Zahlen-/Ja-Nein-Variablen + Drehung, z.B. der Sonne) sowie globale
/// Werte wie den Wochentag. Alle anderen übernehmen diesen Zustand.
/// </summary>
public class WorldSync
{
// FSMs, die Uhrzeit und Wetter steuern
static readonly string[] TrackedFsmNames = { "Clock", "Weather" };
// Globale PlayMaker-Variablen (Wochentag, Tageszeit)
static readonly string[] TrackedGlobals = { "GlobalDay", "TimeOfDay" };
const float SendInterval = 2f;
const float DiscoverRetry = 5f;
class Tracked
{
public string Key;
public PlayMakerFSM Fsm;
}
readonly Dictionary<string, Tracked> tracked = new Dictionary<string, Tracked>();
float sendTimer;
float nextDiscover;
bool discovered;
public int TrackedCount { get { return tracked.Count; } }
public void Reset()
{
tracked.Clear();
discovered = false;
nextDiscover = 0f;
}
// ---------------------------------------------------------------- Senden (Weltuhr)
public void Update(bool isAuthority, Action<MsgType, byte[]> send, float dt)
{
if (!discovered && Time.time >= nextDiscover) Discover();
if (!isAuthority || tracked.Count == 0) return;
sendTimer += dt;
if (sendTimer < SendInterval) return;
sendTimer = 0f;
send(MsgType.WorldTime, BuildState());
}
byte[] BuildState()
{
var alive = new List<Tracked>();
foreach (Tracked t in tracked.Values) if (t.Fsm != null) alive.Add(t);
return NetSession.Build(w =>
{
w.Write(alive.Count);
foreach (Tracked t in alive)
{
w.Write(t.Key);
w.Write(t.Fsm.ActiveStateName ?? "");
Quaternion rot = t.Fsm.transform.localRotation;
w.Write(rot.x); w.Write(rot.y); w.Write(rot.z); w.Write(rot.w);
WriteVariables(w, t.Fsm.FsmVariables, null);
}
WriteVariables(w, FsmVariables.GlobalVariables, TrackedGlobals);
});
}
static void WriteVariables(BinaryWriter w, FsmVariables vars, string[] only)
{
var floats = Filter(vars.FloatVariables, only);
w.Write(floats.Count);
foreach (FsmFloat v in floats) { w.Write(v.Name); w.Write(v.Value); }
var ints = Filter(vars.IntVariables, only);
w.Write(ints.Count);
foreach (FsmInt v in ints) { w.Write(v.Name); w.Write(v.Value); }
var bools = Filter(vars.BoolVariables, only);
w.Write(bools.Count);
foreach (FsmBool v in bools) { w.Write(v.Name); w.Write(v.Value); }
}
static List<T> Filter<T>(T[] vars, string[] only) where T : NamedVariable
{
var list = new List<T>();
if (vars == null) return list;
foreach (T v in vars)
if (v != null && !string.IsNullOrEmpty(v.Name) && (only == null || Array.IndexOf(only, v.Name) >= 0))
list.Add(v);
return list;
}
// ---------------------------------------------------------------- Empfangen
public void Apply(BinaryReader r)
{
if (!discovered) Discover();
int count = r.ReadInt32();
for (int i = 0; i < count; i++)
{
string key = r.ReadString();
string state = r.ReadString();
var rot = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
Tracked t;
PlayMakerFSM fsm = tracked.TryGetValue(key, out t) ? t.Fsm : null;
ReadVariables(r, fsm != null ? fsm.FsmVariables : null);
if (fsm == null) continue;
fsm.transform.localRotation = rot;
if (!string.IsNullOrEmpty(state) && fsm.ActiveStateName != state)
{
FsmState target = fsm.Fsm.GetState(state);
if (target != null) FsmSync.SwitchStateSilently(fsm, target); // z.B. Regen beginnt/hört auf
}
}
ReadVariables(r, FsmVariables.GlobalVariables);
}
static void ReadVariables(BinaryReader r, FsmVariables vars)
{
int floats = r.ReadInt32();
for (int i = 0; i < floats; i++)
{
string name = r.ReadString();
float value = r.ReadSingle();
FsmFloat v = vars != null ? vars.FindFsmFloat(name) : null;
if (v != null) v.Value = value;
}
int ints = r.ReadInt32();
for (int i = 0; i < ints; i++)
{
string name = r.ReadString();
int value = r.ReadInt32();
FsmInt v = vars != null ? vars.FindFsmInt(name) : null;
if (v != null) v.Value = value;
}
int bools = r.ReadInt32();
for (int i = 0; i < bools; i++)
{
string name = r.ReadString();
bool value = r.ReadBoolean();
FsmBool v = vars != null ? vars.FindFsmBool(name) : null;
if (v != null) v.Value = value;
}
}
// ---------------------------------------------------------------- Suchen
void Discover()
{
nextDiscover = Time.time + DiscoverRetry;
tracked.Clear();
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
{
if (fsm == null) continue;
if (Array.IndexOf(TrackedFsmNames, fsm.FsmName) < 0) continue;
string root = fsm.transform.root.name;
if (root == "PLAYER" || root.StartsWith("MSCO_")) continue;
string key = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName;
if (!tracked.ContainsKey(key)) tracked[key] = new Tracked { Key = key, Fsm = fsm };
}
if (tracked.Count == 0) return; // Welt noch nicht fertig geladen - später nochmal
discovered = true;
ModConsole.Print("[MSCO] Time/weather sync: " + tracked.Count + " FSMs");
if (FsmSync.Debug)
foreach (string key in tracked.Keys) ModConsole.Print("[MSCO] " + key);
}
}
}