Beta Release
This commit is contained in:
+50
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using Microsoft.Win32;
|
||||
using MSCLoader;
|
||||
|
||||
namespace MSCO
|
||||
{
|
||||
/// <summary>
|
||||
/// "msco://connect/<host>:<port>"-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 <link>" 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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace MSCO
|
||||
{
|
||||
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 Name => "MSCO"; // Your mod name
|
||||
@@ -13,6 +13,8 @@ namespace MSCO
|
||||
public override string Version => ModVersion; // Version
|
||||
public override string Description => "Online mod by mbm.wtf"; // Short description of your mod
|
||||
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;
|
||||
|
||||
@@ -28,10 +30,18 @@ namespace MSCO
|
||||
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()
|
||||
{
|
||||
// All settings should be created 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()
|
||||
|
||||
+34
@@ -46,6 +46,14 @@
|
||||
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\MSCLoader.dll</HintPath>
|
||||
<Private>False</Private>
|
||||
</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">
|
||||
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Assembly-CSharp.dll</HintPath>
|
||||
<Private>False</Private>
|
||||
@@ -64,12 +72,38 @@
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="MoneySync.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="MenuInputBlocker.cs" />
|
||||
<Compile Include="MSCO.cs" />
|
||||
<Compile Include="MultiplayerManager.cs" />
|
||||
<Compile Include="NetSession.cs" />
|
||||
<Compile Include="ObjectSync.cs" />
|
||||
<Compile Include="Passenger.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="RemotePlayer.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -2,37 +2,15 @@ using MSCLoader;
|
||||
|
||||
namespace MSCO
|
||||
{
|
||||
/// <summary>Konsolenbefehl: mp host [port] | mp join <ip> [port] | mp stop</summary>
|
||||
/// <summary>MSCLoader-Konsole: "mp <befehl>" - gleiche Befehle wie in der F8-Konsole.</summary>
|
||||
public class MpCommand : ConsoleCommand
|
||||
{
|
||||
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)
|
||||
{
|
||||
var mp = MultiplayerManager.Instance;
|
||||
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;
|
||||
}
|
||||
Commands.Execute(args, line => ModConsole.Print("[MSCO] " + line));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1163
-137
File diff suppressed because it is too large
Load Diff
+430
-117
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
@@ -9,13 +9,35 @@ namespace MSCO
|
||||
{
|
||||
public enum MsgType : byte
|
||||
{
|
||||
Hello = 1, // Client -> Host: Mod-Version
|
||||
Welcome = 2, // Host -> Client: Handshake ok
|
||||
Reject = 3, // Host -> Client: Grund
|
||||
Player = 4, // Spielerposition
|
||||
SaveBegin = 5, // Host -> Client: Spielstand kommt
|
||||
SaveFile = 6, // Host -> Client: Dateiname + Inhalt
|
||||
StartGame = 7, // Host -> Client: jetzt laden
|
||||
Hello = 1, // Client -> Host: Mod-Version + Name
|
||||
Welcome = 2, // Host -> Client: eigene ID + Spielerliste
|
||||
Reject = 3, // Host -> Client: Grund
|
||||
Player = 4, // Spielerposition
|
||||
SaveBegin = 5, // Host -> Client: Spielstand kommt
|
||||
SaveFile = 6, // Host -> Client: Dateiname + Inhalt
|
||||
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
|
||||
@@ -33,49 +55,299 @@ namespace MSCO
|
||||
{
|
||||
public Vector3 Position;
|
||||
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()
|
||||
{
|
||||
Vector3 pos = Position;
|
||||
float rotY = RotationY;
|
||||
PlayerState s = this;
|
||||
return NetSession.Build(w =>
|
||||
{
|
||||
w.Write(pos.x); w.Write(pos.y); w.Write(pos.z);
|
||||
w.Write(rotY);
|
||||
Write(w, s.Position);
|
||||
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)
|
||||
{
|
||||
return new PlayerState
|
||||
var s = new PlayerState
|
||||
{
|
||||
Position = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()),
|
||||
RotationY = r.ReadSingle()
|
||||
Position = ReadVector(r),
|
||||
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>
|
||||
/// TCP-Verbindung zu genau einem Mitspieler. Nachrichten: [int Länge][byte Typ][Payload].
|
||||
/// Lesen ist nicht-blockierend und wird jedes Frame über Poll() abgefragt.
|
||||
/// Verbindung über Steam P2P (Steam kümmert sich um NAT/Relay). Pakete sind auf 1 MB
|
||||
/// 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>
|
||||
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 bool IsConnected { get { return stream != null; } }
|
||||
public readonly Steamworks.CSteamID Remote;
|
||||
public bool IsClosed { get; private set; }
|
||||
public string CloseReason { get; private set; }
|
||||
|
||||
TcpListener listener;
|
||||
TcpClient client;
|
||||
NetworkStream stream;
|
||||
IAsyncResult connecting;
|
||||
bool justConnected;
|
||||
readonly FrameParser parser = new FrameParser();
|
||||
float lastReceive;
|
||||
|
||||
byte[] buffer = new byte[64 * 1024];
|
||||
int bufferCount;
|
||||
public SteamConnection(Steamworks.CSteamID remote)
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -88,127 +360,169 @@ namespace MSCO
|
||||
public static NetSession StartClient(string ip, int port)
|
||||
{
|
||||
var s = new NetSession();
|
||||
s.client = new TcpClient();
|
||||
s.connecting = s.client.BeginConnect(ip, port, null, null);
|
||||
s.connectingClient = new TcpClient();
|
||||
s.connecting = s.connectingClient.BeginConnect(ip, port, null, null);
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>True genau einmal, direkt nachdem die Verbindung steht.</summary>
|
||||
public bool ConsumeConnected()
|
||||
/// <summary>Steam-Host: nimmt Pakete von Spielern an, die allowPeer erlaubt (Lobby-Mitglieder).</summary>
|
||||
public static NetSession StartSteamHost(Func<Steamworks.CSteamID, bool> allowPeer)
|
||||
{
|
||||
bool c = justConnected;
|
||||
justConnected = false;
|
||||
return c;
|
||||
return new NetSession { IsHost = true, IsSteam = true, allowSteamPeer = allowPeer };
|
||||
}
|
||||
|
||||
public List<Message> Poll()
|
||||
public static NetSession StartSteamClient(Steamworks.CSteamID host)
|
||||
{
|
||||
var messages = new List<Message>();
|
||||
if (IsClosed) return messages;
|
||||
return new NetSession { IsSteam = true, server = new SteamConnection(host), pendingConnected = true };
|
||||
}
|
||||
|
||||
public List<NetEvent> Poll()
|
||||
{
|
||||
var events = new List<NetEvent>();
|
||||
if (IsClosed) return events;
|
||||
try
|
||||
{
|
||||
AcceptOrConnect();
|
||||
if (stream == null) return messages;
|
||||
|
||||
// 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);
|
||||
if (IsSteam) PumpSteam(events);
|
||||
if (IsHost) PollHost(events);
|
||||
else PollClient(events);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Close("Network error: " + e.Message);
|
||||
}
|
||||
return messages;
|
||||
return events;
|
||||
}
|
||||
|
||||
void AcceptOrConnect()
|
||||
void PumpSteam(List<NetEvent> events)
|
||||
{
|
||||
if (IsHost)
|
||||
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)
|
||||
{
|
||||
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())
|
||||
{
|
||||
TcpClient incoming = listener.AcceptTcpClient();
|
||||
if (client != null) { incoming.Close(); continue; } // nur ein Mitspieler
|
||||
client = incoming;
|
||||
OnSocketReady();
|
||||
int id = nextPeerId++;
|
||||
peers[id] = new Connection(listener.AcceptTcpClient());
|
||||
events.Add(new NetEvent { Kind = NetEventKind.Connected, PeerId = id });
|
||||
}
|
||||
}
|
||||
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;
|
||||
connecting = null;
|
||||
client.EndConnect(result); // wirft, wenn nicht erreichbar
|
||||
OnSocketReady();
|
||||
connectingClient.EndConnect(result); // wirft, wenn nicht erreichbar
|
||||
server = new Connection(connectingClient);
|
||||
pendingConnected = true;
|
||||
}
|
||||
if (server == null) return;
|
||||
if (pendingConnected)
|
||||
{
|
||||
pendingConnected = false;
|
||||
events.Add(new NetEvent { Kind = NetEventKind.Connected, PeerId = HostId });
|
||||
}
|
||||
|
||||
foreach (Message m in server.Poll())
|
||||
events.Add(new NetEvent { Kind = NetEventKind.Message, PeerId = HostId, Message = m });
|
||||
if (server.IsClosed) Close(server.CloseReason);
|
||||
}
|
||||
|
||||
void OnSocketReady()
|
||||
/// <summary>An einen bestimmten Spieler (Client: immer an den Host).</summary>
|
||||
public void SendTo(int peerId, MsgType type, byte[] payload = null)
|
||||
{
|
||||
client.NoDelay = true;
|
||||
client.SendBufferSize = 4 * 1024 * 1024;
|
||||
stream = client.GetStream();
|
||||
justConnected = true;
|
||||
if (!IsHost) { if (server != null) server.Send(type, payload); return; }
|
||||
IConnection c;
|
||||
if (peers.TryGetValue(peerId, out c)) c.Send(type, payload);
|
||||
}
|
||||
|
||||
void ParseFrames(List<Message> messages)
|
||||
/// <summary>An alle (Client: an den Host). Optional einen Spieler auslassen.</summary>
|
||||
public void SendToAll(MsgType type, byte[] payload = null, int except = -1)
|
||||
{
|
||||
int offset = 0;
|
||||
while (bufferCount - offset >= 5)
|
||||
{
|
||||
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;
|
||||
}
|
||||
if (!IsHost) { if (server != null) server.Send(type, payload); return; }
|
||||
foreach (KeyValuePair<int, IConnection> p in peers)
|
||||
if (p.Key != except) p.Value.Send(type, payload);
|
||||
}
|
||||
|
||||
void EnsureCapacity(int needed)
|
||||
/// <summary>Host: Verbindung zu einem Spieler trennen (wird beim nächsten Poll gemeldet).</summary>
|
||||
public void Kick(int peerId, string reason)
|
||||
{
|
||||
if (needed <= buffer.Length) return;
|
||||
var bigger = new byte[Math.Max(needed, buffer.Length * 2)];
|
||||
Buffer.BlockCopy(buffer, 0, bigger, 0, bufferCount);
|
||||
buffer = bigger;
|
||||
IConnection c;
|
||||
if (peers.TryGetValue(peerId, out c)) c.Close(reason);
|
||||
}
|
||||
|
||||
public void Send(MsgType type, byte[] payload = null)
|
||||
/// <summary>Host: Steam-Spieler hat die Lobby verlassen.</summary>
|
||||
public void DropSteamPeer(ulong steamId, string reason)
|
||||
{
|
||||
if (stream == null) return;
|
||||
if (payload == null) payload = new byte[0];
|
||||
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)
|
||||
{
|
||||
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)
|
||||
@@ -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;
|
||||
IsClosed = true;
|
||||
CloseReason = reason;
|
||||
stream = null;
|
||||
try { if (client != null) client.Close(); } catch { }
|
||||
try { if (listener != null) listener.Stop(); } catch { }
|
||||
var result = new byte[4 + payload.Length];
|
||||
BitConverter.GetBytes(senderId).CopyTo(result, 0);
|
||||
Buffer.BlockCopy(payload, 0, result, 4, payload.Length);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+702
@@ -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
@@ -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
@@ -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
@@ -1,29 +1,331 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine;
|
||||
|
||||
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
|
||||
{
|
||||
Vector3 targetPos;
|
||||
float targetRotY;
|
||||
const float Smoothing = 15f;
|
||||
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)
|
||||
{
|
||||
targetPos = state.Position;
|
||||
targetRotY = state.RotationY;
|
||||
last = state;
|
||||
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)
|
||||
{
|
||||
// Bei großen Sprüngen (Teleport, Laden) direkt setzen statt gleiten
|
||||
if ((transform.position - targetPos).sqrMagnitude > 100f)
|
||||
transform.position = targetPos;
|
||||
else
|
||||
transform.position = Vector3.Lerp(transform.position, targetPos, dt * 15f);
|
||||
bool snap = (transform.position - targetFeet).sqrMagnitude > 100f;
|
||||
float t = snap ? 1f : Mathf.Clamp01(dt * Smoothing);
|
||||
|
||||
transform.rotation = Quaternion.Slerp(transform.rotation,
|
||||
Quaternion.Euler(0f, targetRotY, 0f), dt * 15f);
|
||||
transform.position = Vector3.Lerp(transform.position, targetFeet, t);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <url>", 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
@@ -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 <id>")</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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user