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 class MSCO : Mod
|
||||||
{
|
{
|
||||||
public const string ModVersion = "1.0";
|
public const string ModVersion = "2.6";
|
||||||
|
|
||||||
public override string ID => "MSCO"; // Your (unique) mod ID
|
public override string ID => "MSCO"; // Your (unique) mod ID
|
||||||
public override string Name => "MSCO"; // Your mod name
|
public override string Name => "MSCO"; // Your mod name
|
||||||
@@ -13,6 +13,8 @@ namespace MSCO
|
|||||||
public override string Version => ModVersion; // Version
|
public override string Version => ModVersion; // Version
|
||||||
public override string Description => "Online mod by mbm.wtf"; // Short description of your mod
|
public override string Description => "Online mod by mbm.wtf"; // Short description of your mod
|
||||||
public override Game SupportedGames => Game.MySummerCar;
|
public override Game SupportedGames => Game.MySummerCar;
|
||||||
|
// Logo in der MSCLoader-Modliste (branding/logo-128.png, in die DLL eingebettet)
|
||||||
|
public override byte[] Icon { get { return Branding.LogoPng; } set { } }
|
||||||
|
|
||||||
bool commandAdded;
|
bool commandAdded;
|
||||||
|
|
||||||
@@ -28,10 +30,18 @@ namespace MSCO
|
|||||||
SetupFunction(Setup.ModSettings, Mod_Settings);
|
SetupFunction(Setup.ModSettings, Mod_Settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Taste zum Ein-/Aussteigen als Beifahrer (in den MSCLoader-Einstellungen änderbar).</summary>
|
||||||
|
internal static SettingsKeybind PassengerKey;
|
||||||
|
/// <summary>MSCO-Konsole (Standard F8) und Chat (Standard T), ebenfalls änderbar.</summary>
|
||||||
|
internal static SettingsKeybind ConsoleKey, ChatKey;
|
||||||
|
|
||||||
private void Mod_Settings()
|
private void Mod_Settings()
|
||||||
{
|
{
|
||||||
// All settings should be created here.
|
// All settings should be created here.
|
||||||
// DO NOT put anything that isn't settings or keybinds in here!
|
// DO NOT put anything that isn't settings or keybinds in here!
|
||||||
|
PassengerKey = Keybind.Add("passenger", "Ride as passenger (toggle)", KeyCode.P);
|
||||||
|
ChatKey = Keybind.Add("chat", "Open chat", KeyCode.T);
|
||||||
|
ConsoleKey = Keybind.Add("console", "Open MSCO console", KeyCode.F8);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Mod_OnMenuLoad()
|
private void Mod_OnMenuLoad()
|
||||||
|
|||||||
+34
@@ -46,6 +46,14 @@
|
|||||||
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\MSCLoader.dll</HintPath>
|
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\MSCLoader.dll</HintPath>
|
||||||
<Private>False</Private>
|
<Private>False</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
|
<Reference Include="Newtonsoft.Json">
|
||||||
|
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Newtonsoft.Json.dll</HintPath>
|
||||||
|
<Private>False</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="Assembly-CSharp-firstpass">
|
||||||
|
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Assembly-CSharp-firstpass.dll</HintPath>
|
||||||
|
<Private>False</Private>
|
||||||
|
</Reference>
|
||||||
<Reference Include="Assembly-CSharp">
|
<Reference Include="Assembly-CSharp">
|
||||||
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Assembly-CSharp.dll</HintPath>
|
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Assembly-CSharp.dll</HintPath>
|
||||||
<Private>False</Private>
|
<Private>False</Private>
|
||||||
@@ -64,12 +72,38 @@
|
|||||||
</Reference>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Compile Include="MoneySync.cs" />
|
||||||
<Compile Include="MpCommand.cs" />
|
<Compile Include="MpCommand.cs" />
|
||||||
|
<Compile Include="Commands.cs" />
|
||||||
|
<Compile Include="InputLock.cs" />
|
||||||
|
<Compile Include="Overlays.cs" />
|
||||||
|
<Compile Include="SafetyPatches.cs" />
|
||||||
|
<Compile Include="SceneCache.cs" />
|
||||||
|
<Compile Include="UiTheme.cs" />
|
||||||
|
<Compile Include="VehicleStateSync.cs" />
|
||||||
|
<Compile Include="Branding.cs" />
|
||||||
|
<Compile Include="DiscordPresence.cs" />
|
||||||
|
<Compile Include="DoorSync.cs" />
|
||||||
|
<Compile Include="FsmSync.cs" />
|
||||||
|
<Compile Include="GameCharacter.cs" />
|
||||||
|
<Compile Include="JoinLinks.cs" />
|
||||||
|
<Compile Include="LobbyUI.cs" />
|
||||||
<Compile Include="MenuHelper.cs" />
|
<Compile Include="MenuHelper.cs" />
|
||||||
|
<Compile Include="MenuInputBlocker.cs" />
|
||||||
<Compile Include="MSCO.cs" />
|
<Compile Include="MSCO.cs" />
|
||||||
<Compile Include="MultiplayerManager.cs" />
|
<Compile Include="MultiplayerManager.cs" />
|
||||||
<Compile Include="NetSession.cs" />
|
<Compile Include="NetSession.cs" />
|
||||||
|
<Compile Include="ObjectSync.cs" />
|
||||||
|
<Compile Include="Passenger.cs" />
|
||||||
<Compile Include="SaveSync.cs" />
|
<Compile Include="SaveSync.cs" />
|
||||||
|
<Compile Include="ServerBrowser.cs" />
|
||||||
|
<Compile Include="SteamLobby.cs" />
|
||||||
|
<Compile Include="VehicleFx.cs" />
|
||||||
|
<Compile Include="WinHttp.cs" />
|
||||||
|
<Compile Include="WorldSync.cs" />
|
||||||
|
<EmbeddedResource Include="..\branding\logo-128.png">
|
||||||
|
<LogicalName>MSCO.logo.png</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||||
<Compile Include="RemotePlayer.cs" />
|
<Compile Include="RemotePlayer.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -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
|
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 class MpCommand : ConsoleCommand
|
||||||
{
|
{
|
||||||
public override string Name => "mp";
|
public override string Name => "mp";
|
||||||
public override string Help => "mp host [port] | mp join <ip> [port] | mp stop";
|
public override string Help => "MSCO commands, e.g. mp help | mp connect <host[:port]> | mp save (or press F8)";
|
||||||
|
|
||||||
public override void Run(string[] args)
|
public override void Run(string[] args)
|
||||||
{
|
{
|
||||||
var mp = MultiplayerManager.Instance;
|
Commands.Execute(args, line => ModConsole.Print("[MSCO] " + line));
|
||||||
if (mp == null) { ModConsole.Print("[MSCO] Not ready yet"); return; }
|
|
||||||
if (args.Length == 0) { ModConsole.Print(Help); return; }
|
|
||||||
|
|
||||||
int port = MultiplayerManager.DefaultPort;
|
|
||||||
switch (args[0].ToLower())
|
|
||||||
{
|
|
||||||
case "host":
|
|
||||||
if (args.Length > 1) int.TryParse(args[1], out port);
|
|
||||||
mp.Host(port);
|
|
||||||
break;
|
|
||||||
case "join":
|
|
||||||
if (args.Length < 2) { ModConsole.Print("mp join <ip> [port]"); return; }
|
|
||||||
if (args.Length > 2) int.TryParse(args[2], out port);
|
|
||||||
mp.Join(args[1], port);
|
|
||||||
break;
|
|
||||||
case "stop":
|
|
||||||
mp.Disconnect();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
ModConsole.Print(Help);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
@@ -9,13 +9,35 @@ namespace MSCO
|
|||||||
{
|
{
|
||||||
public enum MsgType : byte
|
public enum MsgType : byte
|
||||||
{
|
{
|
||||||
Hello = 1, // Client -> Host: Mod-Version
|
Hello = 1, // Client -> Host: Mod-Version + Name
|
||||||
Welcome = 2, // Host -> Client: Handshake ok
|
Welcome = 2, // Host -> Client: eigene ID + Spielerliste
|
||||||
Reject = 3, // Host -> Client: Grund
|
Reject = 3, // Host -> Client: Grund
|
||||||
Player = 4, // Spielerposition
|
Player = 4, // Spielerposition
|
||||||
SaveBegin = 5, // Host -> Client: Spielstand kommt
|
SaveBegin = 5, // Host -> Client: Spielstand kommt
|
||||||
SaveFile = 6, // Host -> Client: Dateiname + Inhalt
|
SaveFile = 6, // Host -> Client: Dateiname + Inhalt
|
||||||
StartGame = 7, // Host -> Client: jetzt laden
|
StartGame = 7, // Host -> Client: jetzt laden
|
||||||
|
ObjStates = 8, // Positionen der Objekte, die man gerade besitzt
|
||||||
|
ObjOwn = 9, // "Ich übernehme dieses Objekt"
|
||||||
|
ObjRelease = 10, // "Objekt liegt still, wieder frei"
|
||||||
|
FsmEvent = 11, // Schraube angezogen/gelöst, Teil an-/abgebaut
|
||||||
|
PlayerJoined = 12,// Host -> alle: neuer Spieler (ID + Name)
|
||||||
|
PlayerLeft = 13, // Host -> alle: Spieler weg (ID)
|
||||||
|
VehicleFx = 14, // Lichter und Motor-/Dauergeräusche eines Fahrzeugs
|
||||||
|
Ping = 15, // Lebenszeichen (Steam hat keinen Verbindungsabbruch)
|
||||||
|
Ready = 16, // Client -> Server: Spiel geladen, Objekte erfasst
|
||||||
|
WorldSnapshot = 17,// Server -> Client: aktueller Weltzustand
|
||||||
|
Info = 18, // Server-Browser fragt Infos ab (ohne Hello)
|
||||||
|
InfoReply = 19,
|
||||||
|
WorldTime = 20, // Uhrzeit + Wetter vom "Weltuhr"-Spieler
|
||||||
|
MoneyDelta = 21, // Geld hat sich geändert (+/- Betrag, gemeinsame Kasse)
|
||||||
|
DoorState = 22, // Tür/Tor/Haube hat den Zustand gewechselt (FSM-Pfad + Zustand)
|
||||||
|
AdminStatus = 23, // Server -> Client: bool, ist Admin
|
||||||
|
AdminListRequest = 24, // Client -> Server: Spielerliste fürs Admin-Menü
|
||||||
|
AdminList = 25, // Server -> Client: int Anzahl, je [int id, string name, string steamId, bool admin]
|
||||||
|
AdminAction = 26, // Client -> Server: byte (1 = kick, 2 = ban), int Spieler-ID, string Grund
|
||||||
|
AdminResult = 27, // Server -> Client: string Meldung
|
||||||
|
Chat = 28, // Chatnachricht (string)
|
||||||
|
VarSync = 29 // Fahrzeugzustand: int n, je [int key, byte typ, wert]
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct Message
|
public struct Message
|
||||||
@@ -33,49 +55,299 @@ namespace MSCO
|
|||||||
{
|
{
|
||||||
public Vector3 Position;
|
public Vector3 Position;
|
||||||
public float RotationY;
|
public float RotationY;
|
||||||
|
public Vector3 CamPos; // Kopf/Kamera in Weltkoordinaten
|
||||||
|
public Quaternion CamRot; // Blickrichtung inkl. hoch/runter
|
||||||
|
public int HeldId; // ID des gehaltenen Objekts, 0 = nichts
|
||||||
|
public Vector3 HeldLocalPos; // relativ zur Kamera
|
||||||
|
public Quaternion HeldLocalRot; // relativ zur Kamera
|
||||||
|
public bool Seated; // fährt oder sitzt als Beifahrer
|
||||||
|
public float FeetY; // Bodenhöhe unter dem Spieler (für Ducken/Lehnen der Figur)
|
||||||
|
|
||||||
public byte[] Serialize()
|
public byte[] Serialize()
|
||||||
{
|
{
|
||||||
Vector3 pos = Position;
|
PlayerState s = this;
|
||||||
float rotY = RotationY;
|
|
||||||
return NetSession.Build(w =>
|
return NetSession.Build(w =>
|
||||||
{
|
{
|
||||||
w.Write(pos.x); w.Write(pos.y); w.Write(pos.z);
|
Write(w, s.Position);
|
||||||
w.Write(rotY);
|
w.Write(s.RotationY);
|
||||||
|
Write(w, s.CamPos);
|
||||||
|
Write(w, s.CamRot);
|
||||||
|
w.Write(s.Seated);
|
||||||
|
w.Write(s.FeetY);
|
||||||
|
w.Write(s.HeldId);
|
||||||
|
if (s.HeldId == 0) return;
|
||||||
|
Write(w, s.HeldLocalPos);
|
||||||
|
Write(w, s.HeldLocalRot);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public static PlayerState Deserialize(BinaryReader r)
|
public static PlayerState Deserialize(BinaryReader r)
|
||||||
{
|
{
|
||||||
return new PlayerState
|
var s = new PlayerState
|
||||||
{
|
{
|
||||||
Position = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()),
|
Position = ReadVector(r),
|
||||||
RotationY = r.ReadSingle()
|
RotationY = r.ReadSingle(),
|
||||||
|
CamPos = ReadVector(r),
|
||||||
|
CamRot = ReadQuaternion(r),
|
||||||
|
Seated = r.ReadBoolean(),
|
||||||
|
FeetY = r.ReadSingle(),
|
||||||
|
HeldId = r.ReadInt32(),
|
||||||
|
HeldLocalRot = Quaternion.identity
|
||||||
};
|
};
|
||||||
|
if (s.HeldId != 0)
|
||||||
|
{
|
||||||
|
s.HeldLocalPos = ReadVector(r);
|
||||||
|
s.HeldLocalRot = ReadQuaternion(r);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Write(BinaryWriter w, Vector3 v) { w.Write(v.x); w.Write(v.y); w.Write(v.z); }
|
||||||
|
static void Write(BinaryWriter w, Quaternion q) { w.Write(q.x); w.Write(q.y); w.Write(q.z); w.Write(q.w); }
|
||||||
|
static Vector3 ReadVector(BinaryReader r) { return new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); }
|
||||||
|
static Quaternion ReadQuaternion(BinaryReader r) { return new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IConnection
|
||||||
|
{
|
||||||
|
bool IsClosed { get; }
|
||||||
|
string CloseReason { get; }
|
||||||
|
List<Message> Poll();
|
||||||
|
void Send(MsgType type, byte[] payload);
|
||||||
|
void Close(string reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Setzt aus einem Byte-Strom Nachrichten zusammen: [int Länge][byte Typ][Payload].</summary>
|
||||||
|
public class FrameParser
|
||||||
|
{
|
||||||
|
const int MaxFrame = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
byte[] buffer = new byte[64 * 1024];
|
||||||
|
int count;
|
||||||
|
|
||||||
|
public void Append(byte[] data, int length)
|
||||||
|
{
|
||||||
|
EnsureCapacity(count + length);
|
||||||
|
Buffer.BlockCopy(data, 0, buffer, count, length);
|
||||||
|
count += length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Parse(List<Message> messages)
|
||||||
|
{
|
||||||
|
int offset = 0;
|
||||||
|
while (count - offset >= 5)
|
||||||
|
{
|
||||||
|
int length = BitConverter.ToInt32(buffer, offset);
|
||||||
|
if (length < 1 || length > MaxFrame) throw new IOException("Invalid packet");
|
||||||
|
if (count - offset - 4 < length) break;
|
||||||
|
|
||||||
|
var payload = new byte[length - 1];
|
||||||
|
Buffer.BlockCopy(buffer, offset + 5, payload, 0, payload.Length);
|
||||||
|
messages.Add(new Message { Type = (MsgType)buffer[offset + 4], Payload = payload });
|
||||||
|
offset += 4 + length;
|
||||||
|
}
|
||||||
|
if (offset > 0)
|
||||||
|
{
|
||||||
|
Buffer.BlockCopy(buffer, offset, buffer, 0, count - offset);
|
||||||
|
count -= offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] Frame(MsgType type, byte[] payload)
|
||||||
|
{
|
||||||
|
if (payload == null) payload = new byte[0];
|
||||||
|
var frame = new byte[5 + payload.Length];
|
||||||
|
BitConverter.GetBytes(payload.Length + 1).CopyTo(frame, 0);
|
||||||
|
frame[4] = (byte)type;
|
||||||
|
Buffer.BlockCopy(payload, 0, frame, 5, payload.Length);
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EnsureCapacity(int needed)
|
||||||
|
{
|
||||||
|
if (needed <= buffer.Length) return;
|
||||||
|
var bigger = new byte[Math.Max(needed, buffer.Length * 2)];
|
||||||
|
Buffer.BlockCopy(buffer, 0, bigger, 0, count);
|
||||||
|
buffer = bigger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>TCP-Verbindung (LAN / IP). Lesen ist nicht-blockierend und wird über Poll() abgefragt.</summary>
|
||||||
|
public class Connection : IConnection
|
||||||
|
{
|
||||||
|
public bool IsClosed { get; private set; }
|
||||||
|
public string CloseReason { get; private set; }
|
||||||
|
|
||||||
|
readonly TcpClient client;
|
||||||
|
readonly NetworkStream stream;
|
||||||
|
readonly FrameParser parser = new FrameParser();
|
||||||
|
readonly byte[] readBuffer = new byte[64 * 1024];
|
||||||
|
|
||||||
|
public Connection(TcpClient client)
|
||||||
|
{
|
||||||
|
this.client = client;
|
||||||
|
client.NoDelay = true;
|
||||||
|
client.SendBufferSize = 4 * 1024 * 1024;
|
||||||
|
stream = client.GetStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Message> Poll()
|
||||||
|
{
|
||||||
|
var messages = new List<Message>();
|
||||||
|
if (IsClosed) return messages;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Verbindung von der Gegenseite geschlossen?
|
||||||
|
if (client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0)
|
||||||
|
{
|
||||||
|
Close("Connection closed");
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
while (stream.DataAvailable)
|
||||||
|
{
|
||||||
|
int read = stream.Read(readBuffer, 0, readBuffer.Length);
|
||||||
|
if (read <= 0) break;
|
||||||
|
parser.Append(readBuffer, read);
|
||||||
|
}
|
||||||
|
parser.Parse(messages);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Close("Network error: " + e.Message);
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Send(MsgType type, byte[] payload)
|
||||||
|
{
|
||||||
|
if (IsClosed) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] frame = FrameParser.Frame(type, payload);
|
||||||
|
stream.Write(frame, 0, frame.Length); // blockierend, damit große Dateien komplett rausgehen
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Close("Network error: " + e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close(string reason)
|
||||||
|
{
|
||||||
|
if (IsClosed) return;
|
||||||
|
IsClosed = true;
|
||||||
|
CloseReason = reason;
|
||||||
|
try { client.Close(); } catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// TCP-Verbindung zu genau einem Mitspieler. Nachrichten: [int Länge][byte Typ][Payload].
|
/// Verbindung über Steam P2P (Steam kümmert sich um NAT/Relay). Pakete sind auf 1 MB
|
||||||
/// Lesen ist nicht-blockierend und wird jedes Frame über Poll() abgefragt.
|
/// begrenzt, deshalb werden Nachrichten in Stücke geteilt und beim Empfänger über den
|
||||||
|
/// FrameParser wieder zusammengesetzt (der Reliable-Kanal hält die Reihenfolge ein).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class NetSession
|
public class SteamConnection : IConnection
|
||||||
{
|
{
|
||||||
const int MaxFrame = 32 * 1024 * 1024;
|
const int ChunkSize = 512 * 1024;
|
||||||
|
const float Timeout = 20f; // ohne Pakete (auch Pings) gilt die Verbindung als tot
|
||||||
|
|
||||||
public bool IsHost { get; private set; }
|
public readonly Steamworks.CSteamID Remote;
|
||||||
public bool IsConnected { get { return stream != null; } }
|
|
||||||
public bool IsClosed { get; private set; }
|
public bool IsClosed { get; private set; }
|
||||||
public string CloseReason { get; private set; }
|
public string CloseReason { get; private set; }
|
||||||
|
|
||||||
TcpListener listener;
|
readonly FrameParser parser = new FrameParser();
|
||||||
TcpClient client;
|
float lastReceive;
|
||||||
NetworkStream stream;
|
|
||||||
IAsyncResult connecting;
|
|
||||||
bool justConnected;
|
|
||||||
|
|
||||||
byte[] buffer = new byte[64 * 1024];
|
public SteamConnection(Steamworks.CSteamID remote)
|
||||||
int bufferCount;
|
{
|
||||||
|
Remote = remote;
|
||||||
|
lastReceive = Time.realtimeSinceStartup;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnPacket(byte[] data, int length)
|
||||||
|
{
|
||||||
|
parser.Append(data, length);
|
||||||
|
lastReceive = Time.realtimeSinceStartup;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Message> Poll()
|
||||||
|
{
|
||||||
|
var messages = new List<Message>();
|
||||||
|
if (IsClosed) return messages;
|
||||||
|
try { parser.Parse(messages); }
|
||||||
|
catch (Exception e) { Close("Network error: " + e.Message); }
|
||||||
|
if (Time.realtimeSinceStartup - lastReceive > Timeout) Close("Timed out");
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Send(MsgType type, byte[] payload)
|
||||||
|
{
|
||||||
|
if (IsClosed) return;
|
||||||
|
byte[] frame = FrameParser.Frame(type, payload);
|
||||||
|
for (int offset = 0; offset < frame.Length; offset += ChunkSize)
|
||||||
|
{
|
||||||
|
int length = Math.Min(ChunkSize, frame.Length - offset);
|
||||||
|
byte[] chunk = frame;
|
||||||
|
if (offset != 0 || length != frame.Length)
|
||||||
|
{
|
||||||
|
chunk = new byte[length];
|
||||||
|
Buffer.BlockCopy(frame, offset, chunk, 0, length);
|
||||||
|
}
|
||||||
|
if (!Steamworks.SteamNetworking.SendP2PPacket(Remote, chunk, (uint)length, Steamworks.EP2PSend.k_EP2PSendReliable, 0))
|
||||||
|
{
|
||||||
|
Close("Steam send failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close(string reason)
|
||||||
|
{
|
||||||
|
if (IsClosed) return;
|
||||||
|
IsClosed = true;
|
||||||
|
CloseReason = reason;
|
||||||
|
try { Steamworks.SteamNetworking.CloseP2PSessionWithUser(Remote); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NetEventKind { Connected, Disconnected, Message }
|
||||||
|
|
||||||
|
public struct NetEvent
|
||||||
|
{
|
||||||
|
public NetEventKind Kind;
|
||||||
|
public int PeerId; // beim Client immer NetSession.HostId
|
||||||
|
public Message Message;
|
||||||
|
public string Reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stern-Topologie: Der Host hält eine Verbindung zu jedem Client,
|
||||||
|
/// Clients sind nur mit dem Host verbunden. Der Host leitet Nachrichten weiter.
|
||||||
|
/// Transport entweder TCP (LAN/IP) oder Steam P2P.
|
||||||
|
/// </summary>
|
||||||
|
public class NetSession
|
||||||
|
{
|
||||||
|
public const int HostId = 0;
|
||||||
|
|
||||||
|
public bool IsHost { get; private set; }
|
||||||
|
public bool IsSteam { get; private set; }
|
||||||
|
public bool IsClosed { get; private set; }
|
||||||
|
public string CloseReason { get; private set; }
|
||||||
|
|
||||||
|
// Host
|
||||||
|
TcpListener listener;
|
||||||
|
readonly Dictionary<int, IConnection> peers = new Dictionary<int, IConnection>();
|
||||||
|
readonly Dictionary<ulong, int> steamPeerIds = new Dictionary<ulong, int>();
|
||||||
|
Func<Steamworks.CSteamID, bool> allowSteamPeer;
|
||||||
|
int nextPeerId = 1;
|
||||||
|
|
||||||
|
// Client
|
||||||
|
TcpClient connectingClient;
|
||||||
|
IAsyncResult connecting;
|
||||||
|
IConnection server;
|
||||||
|
bool pendingConnected;
|
||||||
|
|
||||||
|
byte[] steamBuffer = new byte[64 * 1024];
|
||||||
|
|
||||||
public static NetSession StartHost(int port)
|
public static NetSession StartHost(int port)
|
||||||
{
|
{
|
||||||
@@ -88,127 +360,169 @@ namespace MSCO
|
|||||||
public static NetSession StartClient(string ip, int port)
|
public static NetSession StartClient(string ip, int port)
|
||||||
{
|
{
|
||||||
var s = new NetSession();
|
var s = new NetSession();
|
||||||
s.client = new TcpClient();
|
s.connectingClient = new TcpClient();
|
||||||
s.connecting = s.client.BeginConnect(ip, port, null, null);
|
s.connecting = s.connectingClient.BeginConnect(ip, port, null, null);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>True genau einmal, direkt nachdem die Verbindung steht.</summary>
|
/// <summary>Steam-Host: nimmt Pakete von Spielern an, die allowPeer erlaubt (Lobby-Mitglieder).</summary>
|
||||||
public bool ConsumeConnected()
|
public static NetSession StartSteamHost(Func<Steamworks.CSteamID, bool> allowPeer)
|
||||||
{
|
{
|
||||||
bool c = justConnected;
|
return new NetSession { IsHost = true, IsSteam = true, allowSteamPeer = allowPeer };
|
||||||
justConnected = false;
|
|
||||||
return c;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Message> Poll()
|
public static NetSession StartSteamClient(Steamworks.CSteamID host)
|
||||||
{
|
{
|
||||||
var messages = new List<Message>();
|
return new NetSession { IsSteam = true, server = new SteamConnection(host), pendingConnected = true };
|
||||||
if (IsClosed) return messages;
|
}
|
||||||
|
|
||||||
|
public List<NetEvent> Poll()
|
||||||
|
{
|
||||||
|
var events = new List<NetEvent>();
|
||||||
|
if (IsClosed) return events;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
AcceptOrConnect();
|
if (IsSteam) PumpSteam(events);
|
||||||
if (stream == null) return messages;
|
if (IsHost) PollHost(events);
|
||||||
|
else PollClient(events);
|
||||||
// Verbindung von der Gegenseite geschlossen?
|
|
||||||
if (client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0)
|
|
||||||
{
|
|
||||||
Close("Connection closed by other player");
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (stream.DataAvailable)
|
|
||||||
{
|
|
||||||
EnsureCapacity(bufferCount + client.Available);
|
|
||||||
int read = stream.Read(buffer, bufferCount, buffer.Length - bufferCount);
|
|
||||||
if (read <= 0) break;
|
|
||||||
bufferCount += read;
|
|
||||||
}
|
|
||||||
|
|
||||||
ParseFrames(messages);
|
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
Close("Network error: " + e.Message);
|
Close("Network error: " + e.Message);
|
||||||
}
|
}
|
||||||
return messages;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
void AcceptOrConnect()
|
void PumpSteam(List<NetEvent> events)
|
||||||
{
|
{
|
||||||
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())
|
while (listener.Pending())
|
||||||
{
|
{
|
||||||
TcpClient incoming = listener.AcceptTcpClient();
|
int id = nextPeerId++;
|
||||||
if (client != null) { incoming.Close(); continue; } // nur ein Mitspieler
|
peers[id] = new Connection(listener.AcceptTcpClient());
|
||||||
client = incoming;
|
events.Add(new NetEvent { Kind = NetEventKind.Connected, PeerId = id });
|
||||||
OnSocketReady();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (connecting != null && connecting.IsCompleted)
|
|
||||||
|
List<int> closed = null;
|
||||||
|
foreach (KeyValuePair<int, IConnection> p in peers)
|
||||||
{
|
{
|
||||||
|
foreach (Message m in p.Value.Poll())
|
||||||
|
events.Add(new NetEvent { Kind = NetEventKind.Message, PeerId = p.Key, Message = m });
|
||||||
|
if (p.Value.IsClosed)
|
||||||
|
{
|
||||||
|
if (closed == null) closed = new List<int>();
|
||||||
|
closed.Add(p.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (closed == null) return;
|
||||||
|
foreach (int id in closed)
|
||||||
|
{
|
||||||
|
IConnection c = peers[id];
|
||||||
|
events.Add(new NetEvent { Kind = NetEventKind.Disconnected, PeerId = id, Reason = c.CloseReason });
|
||||||
|
var steamConn = c as SteamConnection;
|
||||||
|
if (steamConn != null) steamPeerIds.Remove(steamConn.Remote.m_SteamID);
|
||||||
|
peers.Remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PollClient(List<NetEvent> events)
|
||||||
|
{
|
||||||
|
if (connecting != null)
|
||||||
|
{
|
||||||
|
if (!connecting.IsCompleted) return;
|
||||||
IAsyncResult result = connecting;
|
IAsyncResult result = connecting;
|
||||||
connecting = null;
|
connecting = null;
|
||||||
client.EndConnect(result); // wirft, wenn nicht erreichbar
|
connectingClient.EndConnect(result); // wirft, wenn nicht erreichbar
|
||||||
OnSocketReady();
|
server = new Connection(connectingClient);
|
||||||
|
pendingConnected = true;
|
||||||
}
|
}
|
||||||
|
if (server == null) return;
|
||||||
|
if (pendingConnected)
|
||||||
|
{
|
||||||
|
pendingConnected = false;
|
||||||
|
events.Add(new NetEvent { Kind = NetEventKind.Connected, PeerId = HostId });
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
if (!IsHost) { if (server != null) server.Send(type, payload); return; }
|
||||||
client.SendBufferSize = 4 * 1024 * 1024;
|
IConnection c;
|
||||||
stream = client.GetStream();
|
if (peers.TryGetValue(peerId, out c)) c.Send(type, payload);
|
||||||
justConnected = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
if (!IsHost) { if (server != null) server.Send(type, payload); return; }
|
||||||
while (bufferCount - offset >= 5)
|
foreach (KeyValuePair<int, IConnection> p in peers)
|
||||||
{
|
if (p.Key != except) p.Value.Send(type, payload);
|
||||||
int length = BitConverter.ToInt32(buffer, offset);
|
|
||||||
if (length < 1 || length > MaxFrame) throw new IOException("Invalid packet");
|
|
||||||
if (bufferCount - offset - 4 < length) break;
|
|
||||||
|
|
||||||
var payload = new byte[length - 1];
|
|
||||||
Buffer.BlockCopy(buffer, offset + 5, payload, 0, payload.Length);
|
|
||||||
messages.Add(new Message { Type = (MsgType)buffer[offset + 4], Payload = payload });
|
|
||||||
offset += 4 + length;
|
|
||||||
}
|
|
||||||
if (offset > 0)
|
|
||||||
{
|
|
||||||
Buffer.BlockCopy(buffer, offset, buffer, 0, bufferCount - offset);
|
|
||||||
bufferCount -= offset;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void EnsureCapacity(int needed)
|
/// <summary>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;
|
IConnection c;
|
||||||
var bigger = new byte[Math.Max(needed, buffer.Length * 2)];
|
if (peers.TryGetValue(peerId, out c)) c.Close(reason);
|
||||||
Buffer.BlockCopy(buffer, 0, bigger, 0, bufferCount);
|
|
||||||
buffer = bigger;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
int id;
|
||||||
if (payload == null) payload = new byte[0];
|
if (steamPeerIds.TryGetValue(steamId, out id)) Kick(id, reason);
|
||||||
try
|
}
|
||||||
{
|
|
||||||
var frame = new byte[5 + payload.Length];
|
public void Close(string reason = "Disconnected")
|
||||||
BitConverter.GetBytes(payload.Length + 1).CopyTo(frame, 0);
|
{
|
||||||
frame[4] = (byte)type;
|
if (IsClosed) return;
|
||||||
Buffer.BlockCopy(payload, 0, frame, 5, payload.Length);
|
IsClosed = true;
|
||||||
stream.Write(frame, 0, frame.Length); // blockierend, damit große Dateien komplett rausgehen
|
CloseReason = reason;
|
||||||
}
|
foreach (IConnection c in peers.Values) c.Close(reason);
|
||||||
catch (Exception e)
|
peers.Clear();
|
||||||
{
|
steamPeerIds.Clear();
|
||||||
Close("Network error: " + e.Message);
|
if (server != null) server.Close(reason);
|
||||||
}
|
try { if (connectingClient != null && server == null) connectingClient.Close(); } catch { }
|
||||||
|
try { if (listener != null) listener.Stop(); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static byte[] Build(Action<BinaryWriter> write)
|
public static byte[] Build(Action<BinaryWriter> write)
|
||||||
@@ -222,14 +536,13 @@ namespace MSCO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Close(string reason = "Disconnected")
|
/// <summary>Stellt der Nachricht die Absender-ID voran (für weitergeleitete Nachrichten).</summary>
|
||||||
|
public static byte[] WithSender(int senderId, byte[] payload)
|
||||||
{
|
{
|
||||||
if (IsClosed) return;
|
var result = new byte[4 + payload.Length];
|
||||||
IsClosed = true;
|
BitConverter.GetBytes(senderId).CopyTo(result, 0);
|
||||||
CloseReason = reason;
|
Buffer.BlockCopy(payload, 0, result, 4, payload.Length);
|
||||||
stream = null;
|
return result;
|
||||||
try { if (client != null) client.Close(); } catch { }
|
|
||||||
try { if (listener != null) listener.Stop(); } catch { }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+702
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+315
-13
@@ -1,29 +1,331 @@
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace MSCO
|
namespace MSCO
|
||||||
{
|
{
|
||||||
/// <summary>Bewegt den anderen Spieler weich zur zuletzt empfangenen Position.</summary>
|
/// <summary>
|
||||||
|
/// Figur eines Mitspielers mit echter Körperhaltung. Aus Kopfposition (Kamera) und Bodenhöhe wird
|
||||||
|
/// jedes Frame die Pose berechnet: Ducken beugt die Knie, nach vorne Lehnen neigt den Oberkörper,
|
||||||
|
/// Laufen bewegt Beine und Arme, beim Halten greift der rechte Arm (mit Ellbogen) zum Item, im Auto
|
||||||
|
/// sitzt die Figur. Die Körperteile sind abgerundete Kapseln zwischen den Gelenken.
|
||||||
|
/// </summary>
|
||||||
public class RemotePlayer : MonoBehaviour
|
public class RemotePlayer : MonoBehaviour
|
||||||
{
|
{
|
||||||
Vector3 targetPos;
|
const float Smoothing = 15f;
|
||||||
float targetRotY;
|
const float StandingEyeHeight = 1.65f;
|
||||||
|
const float MaxHeadPitch = 55f;
|
||||||
|
|
||||||
|
// Körpermaße (Meter)
|
||||||
|
const float ThighLength = 0.45f, ShinLength = 0.45f;
|
||||||
|
const float UpperArmLength = 0.3f, ForearmLength = 0.29f;
|
||||||
|
const float TorsoLength = 0.55f; // Hüfte bis Hals
|
||||||
|
const float HipWidth = 0.1f, ShoulderWidth = 0.21f;
|
||||||
|
const float AnkleHeight = 0.07f;
|
||||||
|
|
||||||
|
static readonly Color Skin = new Color(0.93f, 0.76f, 0.62f);
|
||||||
|
static readonly Color Jeans = new Color(0.18f, 0.25f, 0.42f);
|
||||||
|
static readonly Color Boots = new Color(0.24f, 0.16f, 0.1f);
|
||||||
|
static readonly Color Dark = new Color(0.08f, 0.08f, 0.08f);
|
||||||
|
|
||||||
|
Color jacket;
|
||||||
|
Transform headPivot, view;
|
||||||
|
Transform pelvis, torso, belt, neck;
|
||||||
|
readonly Transform[] thigh = new Transform[2], shin = new Transform[2], boot = new Transform[2];
|
||||||
|
readonly Transform[] upperArm = new Transform[2], forearm = new Transform[2], hand = new Transform[2];
|
||||||
|
|
||||||
|
Vector3 targetFeet;
|
||||||
|
Vector3 targetHead;
|
||||||
|
Vector3 headWorld;
|
||||||
|
float targetYaw, targetPitch, pitch;
|
||||||
|
Quaternion targetViewRot = Quaternion.identity;
|
||||||
|
PlayerState last;
|
||||||
|
|
||||||
|
Vector3 lastPosition;
|
||||||
|
float walkPhase, walkAmount, speed;
|
||||||
|
|
||||||
|
// Echtes Spielmodell (NPC aus der Szene); null = eigene Figur als Rückfall
|
||||||
|
GameCharacter model;
|
||||||
|
|
||||||
|
public int HeldId { get { return last.HeldId; } }
|
||||||
|
public PlayerState LastState { get { return last; } }
|
||||||
|
public Vector3 HeadPosition { get { return model != null ? model.HeadPosition : headPivot.position; } }
|
||||||
|
public string ModelDescription { get { return model != null ? "game model: " + model.Description : "fallback figure (no NPC template found)"; } }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Aufbau
|
||||||
|
|
||||||
|
public static RemotePlayer Create(string name, Color jacketColor, PlayerState state, int variant)
|
||||||
|
{
|
||||||
|
var root = new GameObject(name);
|
||||||
|
var rp = root.AddComponent<RemotePlayer>();
|
||||||
|
rp.jacket = jacketColor;
|
||||||
|
rp.Build();
|
||||||
|
rp.SetTarget(state);
|
||||||
|
root.transform.position = rp.targetFeet;
|
||||||
|
root.transform.rotation = Quaternion.Euler(0f, rp.targetYaw, 0f);
|
||||||
|
|
||||||
|
// Wenn möglich einen echten NPC aus dem Spiel als Figur nehmen, sonst die eigene Figur
|
||||||
|
try { rp.model = GameCharacter.TryCreate(root.transform, variant); }
|
||||||
|
catch (System.Exception e) { MSCLoader.ModConsole.Warning("[MSCO] Game model failed: " + e.Message); }
|
||||||
|
if (rp.model != null)
|
||||||
|
foreach (Renderer r in root.GetComponentsInChildren<Renderer>(true))
|
||||||
|
if (!r.transform.IsChildOf(rp.model.Root)) r.enabled = false;
|
||||||
|
rp.headWorld = rp.targetHead;
|
||||||
|
rp.pitch = rp.targetPitch;
|
||||||
|
rp.view.rotation = rp.targetViewRot;
|
||||||
|
rp.lastPosition = root.transform.position;
|
||||||
|
if (rp.model == null) rp.Pose(0f);
|
||||||
|
return rp;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Build()
|
||||||
|
{
|
||||||
|
Transform r = transform;
|
||||||
|
Color jacketDark = jacket * 0.75f;
|
||||||
|
jacketDark.a = 1f;
|
||||||
|
|
||||||
|
pelvis = Capsule("Pelvis", r, Jeans);
|
||||||
|
torso = Capsule("Torso", r, jacket);
|
||||||
|
belt = Capsule("Belt", r, Dark);
|
||||||
|
neck = Capsule("Neck", r, Skin);
|
||||||
|
|
||||||
|
for (int i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
thigh[i] = Capsule("Thigh", r, Jeans);
|
||||||
|
shin[i] = Capsule("Shin", r, Jeans);
|
||||||
|
boot[i] = Box("Boot", r, Boots);
|
||||||
|
upperArm[i] = Capsule("UpperArm", r, jacket);
|
||||||
|
forearm[i] = Capsule("Forearm", r, jacketDark);
|
||||||
|
hand[i] = Sphere("Hand", r, Skin, 0.1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kopf: dreht mit Blick (hoch/runter), Gesicht nach +Z
|
||||||
|
headPivot = new GameObject("Head").transform;
|
||||||
|
headPivot.SetParent(r, false);
|
||||||
|
Transform skull = Sphere("Skull", headPivot, Skin, 0.24f);
|
||||||
|
skull.localScale = new Vector3(0.22f, 0.26f, 0.24f);
|
||||||
|
Transform hair = Sphere("Hair", headPivot, new Color(0.25f, 0.17f, 0.1f), 0.24f);
|
||||||
|
hair.localPosition = new Vector3(0f, 0.02f, -0.025f);
|
||||||
|
hair.localScale = new Vector3(0.23f, 0.25f, 0.23f);
|
||||||
|
Transform cap = Sphere("Cap", headPivot, jacketDark, 0.25f);
|
||||||
|
cap.localPosition = new Vector3(0f, 0.07f, -0.005f);
|
||||||
|
cap.localScale = new Vector3(0.245f, 0.16f, 0.255f);
|
||||||
|
Transform brim = Box("CapBrim", headPivot, jacketDark);
|
||||||
|
brim.localPosition = new Vector3(0f, 0.075f, 0.13f);
|
||||||
|
brim.localScale = new Vector3(0.2f, 0.02f, 0.11f);
|
||||||
|
brim.localRotation = Quaternion.Euler(-8f, 0f, 0f);
|
||||||
|
for (int i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
Transform eye = Sphere("Eye", headPivot, Dark, 0.035f);
|
||||||
|
eye.localPosition = new Vector3(i == 0 ? -0.045f : 0.045f, 0.015f, 0.105f);
|
||||||
|
}
|
||||||
|
Transform nose = Box("Nose", headPivot, Skin * 0.95f);
|
||||||
|
nose.localPosition = new Vector3(0f, -0.02f, 0.12f);
|
||||||
|
nose.localScale = new Vector3(0.035f, 0.05f, 0.04f);
|
||||||
|
|
||||||
|
// Unsichtbarer "Blick" mit der exakten Kameradrehung - daran hängt das gehaltene Item
|
||||||
|
view = new GameObject("View").transform;
|
||||||
|
view.SetParent(r, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Transform Capsule(string name, Transform parent, Color color) { return Primitive(PrimitiveType.Capsule, name, parent, color); }
|
||||||
|
|
||||||
|
static Transform Box(string name, Transform parent, Color color) { return Primitive(PrimitiveType.Cube, name, parent, color); }
|
||||||
|
|
||||||
|
static Transform Sphere(string name, Transform parent, Color color, float size)
|
||||||
|
{
|
||||||
|
Transform t = Primitive(PrimitiveType.Sphere, name, parent, color);
|
||||||
|
t.localScale = Vector3.one * size;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Transform Primitive(PrimitiveType type, string name, Transform parent, Color color)
|
||||||
|
{
|
||||||
|
GameObject go = GameObject.CreatePrimitive(type);
|
||||||
|
go.name = name;
|
||||||
|
Destroy(go.GetComponent<Collider>()); // stört sonst Physik/Trigger
|
||||||
|
var renderer = go.GetComponent<Renderer>();
|
||||||
|
renderer.material.color = color;
|
||||||
|
renderer.receiveShadows = true;
|
||||||
|
go.transform.SetParent(parent, false);
|
||||||
|
return go.transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Zustand
|
||||||
|
|
||||||
public void SetTarget(PlayerState state)
|
public void SetTarget(PlayerState state)
|
||||||
{
|
{
|
||||||
targetPos = state.Position;
|
last = state;
|
||||||
targetRotY = state.RotationY;
|
targetHead = state.CamPos;
|
||||||
|
|
||||||
|
// Füße: Bodenhöhe vom Spieler, waagerecht an der Spielerposition (Lehnen = Kopf woanders als Füße).
|
||||||
|
// Liegt der Kopf unplausibel weit weg (andere Kamera), Füße unter den Kopf.
|
||||||
|
float feetY = state.FeetY != 0f ? state.FeetY : state.CamPos.y - StandingEyeHeight;
|
||||||
|
var feet = new Vector3(state.Position.x, feetY, state.Position.z);
|
||||||
|
var horizontal = new Vector3(state.CamPos.x - feet.x, 0f, state.CamPos.z - feet.z);
|
||||||
|
if (horizontal.sqrMagnitude > 0.8f * 0.8f || state.Seated) feet = new Vector3(state.CamPos.x, feetY, state.CamPos.z);
|
||||||
|
targetFeet = feet;
|
||||||
|
|
||||||
|
// Blickrichtung ohne seitliches Kippen
|
||||||
|
Vector3 forward = state.CamRot * Vector3.forward;
|
||||||
|
Vector3 flat = new Vector3(forward.x, 0f, forward.z);
|
||||||
|
targetYaw = flat.sqrMagnitude > 0.0001f ? Quaternion.LookRotation(flat).eulerAngles.y : state.RotationY;
|
||||||
|
targetPitch = Mathf.Clamp(-Mathf.Asin(Mathf.Clamp(forward.y, -1f, 1f)) * Mathf.Rad2Deg, -MaxHeadPitch, MaxHeadPitch);
|
||||||
|
targetViewRot = state.CamRot;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Tick(float dt)
|
public void Tick(float dt)
|
||||||
{
|
{
|
||||||
// Bei großen Sprüngen (Teleport, Laden) direkt setzen statt gleiten
|
// Bei großen Sprüngen (Teleport, Laden) direkt setzen statt gleiten
|
||||||
if ((transform.position - targetPos).sqrMagnitude > 100f)
|
bool snap = (transform.position - targetFeet).sqrMagnitude > 100f;
|
||||||
transform.position = targetPos;
|
float t = snap ? 1f : Mathf.Clamp01(dt * Smoothing);
|
||||||
else
|
|
||||||
transform.position = Vector3.Lerp(transform.position, targetPos, dt * 15f);
|
|
||||||
|
|
||||||
transform.rotation = Quaternion.Slerp(transform.rotation,
|
transform.position = Vector3.Lerp(transform.position, targetFeet, t);
|
||||||
Quaternion.Euler(0f, targetRotY, 0f), dt * 15f);
|
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.Euler(0f, targetYaw, 0f), t);
|
||||||
|
headWorld = snap ? targetHead : Vector3.Lerp(headWorld, targetHead, t);
|
||||||
|
pitch = Mathf.Lerp(pitch, targetPitch, t);
|
||||||
|
view.position = headWorld;
|
||||||
|
view.rotation = Quaternion.Slerp(view.rotation, targetViewRot, t);
|
||||||
|
|
||||||
|
UpdateWalk(dt);
|
||||||
|
if (model != null) model.Animate(speed); // Spiel-Animation (Idle/Walk) läuft vor LateUpdate
|
||||||
|
else Pose(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Spielmodell: nach der Animation Ducken/Lehnen/Kopf/Arme anpassen.</summary>
|
||||||
|
void LateUpdate()
|
||||||
|
{
|
||||||
|
if (model == null) return;
|
||||||
|
Vector3 heldPos;
|
||||||
|
Quaternion heldRot;
|
||||||
|
bool holding = TryGetHeldPose(out heldPos, out heldRot);
|
||||||
|
model.Pose(transform, headWorld, pitch, last.Seated, holding, heldPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdateWalk(float dt)
|
||||||
|
{
|
||||||
|
Vector3 moved = transform.position - lastPosition;
|
||||||
|
lastPosition = transform.position;
|
||||||
|
moved.y = 0f;
|
||||||
|
speed = dt > 0f ? moved.magnitude / dt : 0f;
|
||||||
|
if (speed > 12f || last.Seated) speed = 0f; // Teleport oder im Auto
|
||||||
|
|
||||||
|
walkAmount = Mathf.Lerp(walkAmount, Mathf.Clamp01(speed / 2.5f), Mathf.Clamp01(dt * 8f));
|
||||||
|
walkPhase += speed * dt * 3.4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Pose (alles im lokalen Raum der Figur)
|
||||||
|
|
||||||
|
void Pose(float dt)
|
||||||
|
{
|
||||||
|
Vector3 head = transform.InverseTransformPoint(headWorld) + new Vector3(0f, -0.02f, -0.03f);
|
||||||
|
Vector3 neckTop = head + new Vector3(0f, -0.12f, -0.01f);
|
||||||
|
Vector3 neckBase = head + new Vector3(0f, -0.2f, -0.02f);
|
||||||
|
|
||||||
|
// Hüfte: beim Lehnen nach hinten ausweichen, beim Ducken absinken (Füße bleiben am Boden)
|
||||||
|
Vector3 hip;
|
||||||
|
if (last.Seated)
|
||||||
|
{
|
||||||
|
hip = neckBase + new Vector3(0f, -TorsoLength, -0.08f);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var lean = new Vector3(neckBase.x, 0f, neckBase.z);
|
||||||
|
hip = -lean * 0.25f;
|
||||||
|
float horizontal = (new Vector3(neckBase.x - hip.x, 0f, neckBase.z - hip.z)).magnitude;
|
||||||
|
float vertical = Mathf.Sqrt(Mathf.Max(0.04f, TorsoLength * TorsoLength - horizontal * horizontal));
|
||||||
|
hip.y = Mathf.Clamp(neckBase.y - vertical, 0.3f, ThighLength + ShinLength + AnkleHeight - 0.02f);
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector3 up = (neckBase - hip).normalized;
|
||||||
|
Vector3 right = Vector3.right;
|
||||||
|
Vector3 forward = Vector3.Cross(right, up).normalized;
|
||||||
|
|
||||||
|
// Rumpf
|
||||||
|
Segment(torso, hip + up * 0.08f, neckBase, 0.38f, 0.24f);
|
||||||
|
Segment(belt, hip + up * 0.06f, hip + up * 0.1f, 0.37f, 0.23f);
|
||||||
|
Segment(pelvis, hip - right * HipWidth, hip + right * HipWidth, 0.2f, 0.22f);
|
||||||
|
Segment(neck, neckBase, neckTop, 0.09f, 0.09f);
|
||||||
|
|
||||||
|
// Kopf
|
||||||
|
headPivot.localPosition = head;
|
||||||
|
headPivot.localRotation = Quaternion.Euler(pitch, 0f, 0f);
|
||||||
|
|
||||||
|
float swing = Mathf.Sin(walkPhase) * walkAmount;
|
||||||
|
for (int i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
float side = i == 0 ? -1f : 1f;
|
||||||
|
float phase = i == 0 ? swing : -swing;
|
||||||
|
|
||||||
|
// Beine
|
||||||
|
Vector3 hipJoint = hip + right * (HipWidth * side);
|
||||||
|
Vector3 ankle;
|
||||||
|
if (last.Seated)
|
||||||
|
ankle = hipJoint + new Vector3(0f, -0.4f, 0.48f);
|
||||||
|
else
|
||||||
|
ankle = new Vector3(HipWidth * side, AnkleHeight + Mathf.Max(0f, Mathf.Cos(walkPhase + i * Mathf.PI)) * 0.07f * walkAmount, phase * 0.3f);
|
||||||
|
Vector3 knee = Joint(hipJoint, ankle, ThighLength, ShinLength, Vector3.forward);
|
||||||
|
Segment(thigh[i], hipJoint, knee, 0.17f, 0.17f);
|
||||||
|
Segment(shin[i], knee, ankle, 0.14f, 0.14f);
|
||||||
|
boot[i].localPosition = ankle + new Vector3(0f, -0.02f, 0.05f);
|
||||||
|
boot[i].localRotation = Quaternion.identity;
|
||||||
|
boot[i].localScale = new Vector3(0.12f, 0.1f, 0.27f);
|
||||||
|
|
||||||
|
// Arme
|
||||||
|
Vector3 shoulder = neckBase - up * 0.06f + right * (ShoulderWidth * side);
|
||||||
|
Vector3 handTarget;
|
||||||
|
Vector3 heldPos;
|
||||||
|
Quaternion heldRot;
|
||||||
|
if (i == 1 && TryGetHeldPose(out heldPos, out heldRot))
|
||||||
|
handTarget = transform.InverseTransformPoint(heldPos);
|
||||||
|
else if (last.Seated)
|
||||||
|
handTarget = shoulder + new Vector3(-side * 0.05f, -0.3f, 0.38f); // Hände nach vorne (Lenkrad)
|
||||||
|
else
|
||||||
|
handTarget = shoulder + new Vector3(side * 0.03f, -(UpperArmLength + ForearmLength) + 0.04f, -phase * 0.22f);
|
||||||
|
|
||||||
|
// Nicht weiter als die Armlänge
|
||||||
|
Vector3 reach = handTarget - shoulder;
|
||||||
|
float maxReach = UpperArmLength + ForearmLength - 0.01f;
|
||||||
|
if (reach.magnitude > maxReach) handTarget = shoulder + reach.normalized * maxReach;
|
||||||
|
|
||||||
|
Vector3 elbow = Joint(shoulder, handTarget, UpperArmLength, ForearmLength, new Vector3(side * 0.4f, -0.2f, -1f));
|
||||||
|
Segment(upperArm[i], shoulder, elbow, 0.12f, 0.12f);
|
||||||
|
Segment(forearm[i], elbow, handTarget, 0.11f, 0.11f);
|
||||||
|
hand[i].localPosition = handTarget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Zwei-Knochen-Gelenk (Knie/Ellbogen): Punkt zwischen a und b, zur Seite "bend" geknickt.</summary>
|
||||||
|
static Vector3 Joint(Vector3 a, Vector3 b, float lengthA, float lengthB, Vector3 bend)
|
||||||
|
{
|
||||||
|
Vector3 ab = b - a;
|
||||||
|
float d = Mathf.Clamp(ab.magnitude, 0.01f, lengthA + lengthB - 0.001f);
|
||||||
|
Vector3 dir = ab.normalized;
|
||||||
|
// Abstand vom Startpunkt entlang ab und seitlicher Abstand (Kosinussatz)
|
||||||
|
float along = (lengthA * lengthA - lengthB * lengthB + d * d) / (2f * d);
|
||||||
|
float sideways = Mathf.Sqrt(Mathf.Max(0f, lengthA * lengthA - along * along));
|
||||||
|
Vector3 bendDir = bend - dir * Vector3.Dot(bend, dir);
|
||||||
|
if (bendDir.sqrMagnitude < 0.0001f) bendDir = Vector3.forward - dir * Vector3.Dot(Vector3.forward, dir);
|
||||||
|
return a + dir * along + bendDir.normalized * sideways;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Kapsel von a nach b spannen (lokaler Raum der Figur).</summary>
|
||||||
|
static void Segment(Transform part, Vector3 a, Vector3 b, float width, float depth)
|
||||||
|
{
|
||||||
|
Vector3 dir = b - a;
|
||||||
|
float length = dir.magnitude;
|
||||||
|
part.localPosition = (a + b) * 0.5f;
|
||||||
|
part.localRotation = length > 0.0001f ? Quaternion.FromToRotation(Vector3.up, dir / length) : Quaternion.identity;
|
||||||
|
part.localScale = new Vector3(width, length * 0.5f + Mathf.Min(width, depth) * 0.5f, depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wo das gehaltene Item beim Spieler gerade ist (relativ zum Blick).</summary>
|
||||||
|
public bool TryGetHeldPose(out Vector3 pos, out Quaternion rot)
|
||||||
|
{
|
||||||
|
pos = Vector3.zero;
|
||||||
|
rot = Quaternion.identity;
|
||||||
|
if (last.HeldId == 0) return false;
|
||||||
|
pos = view.TransformPoint(last.HeldLocalPos);
|
||||||
|
rot = view.rotation * last.HeldLocalRot;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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