28 Commits
Author SHA1 Message Date
MrMahoandClaude Opus 5.5 8e00640732 Favorites: heart toggle, Favorites tab with nicknames, /switch <nickname> quick server switch, fav command
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:24:00 +02:00
MrMahoandClaude Opus 5.5 8dae3ac13d Per-player needs, jumping figure, round belt/shoulders/boots, reset-point UI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:48:16 +02:00
MrMahoandClaude Opus 5.5 09c85e3d96 Keep kick reason and reconnect intent across MSCLoader's mod reload on returning to the menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:31:47 +02:00
MrMahoandClaude Opus 5.5 526f9e9c77 Door/hood sync via hinge spring instead of teleport (fixes NaN crash and parts falling off); join/leave toasts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:59:01 +02:00
MrMahoandClaude Opus 5.5 097d1e87de Sync vehicle controls (ignition, engine, lights ...), stop forced starter sound, auto-reconnect after server restart or lost connection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:51:39 +02:00
MrMahoandClaude Opus 5.5 bddc1efe76 Admin WORLD tab: reset world, backup now, list and restore save backups
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:43:40 +02:00
MrMahoandClaude Opus 5.5 31e630dccb Disconnect/kick prompt with reason, reconnect after world reset with retries; stop mass 'consumed' deletes on scene reload
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:31:40 +02:00
MrMahoandClaude Opus 5.5 90c1af4513 Sync car doors, hood and trunk angles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:13:08 +02:00
MrMahoandClaude Opus 5.5 9b8c64d0c6 Performance: time-sliced checks for idle objects, fewer allocations, rarer damage checks for parked cars
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:03:45 +02:00
MrMahoandClaude Opus 5.5 2eb15f2166 2.8: sync bought/consumed items, wheel spin, crash damage, jobs/shop, traffic
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:58:38 +02:00
MrMahoandClaude Opus 5.5 96e8790f0b Stuck bodies are placed on top instead of being reset into the same spot (no fling loop); reset message shown in-game before reconnecting; server: backups / restore-save
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:27:20 +02:00
MrMahoandClaude Opus 5.5 a6e5abfc05 Crash safety: vehicle state sync skips FSMs that set scale/position (value could make a size 0 -> PhysX crash), never applies NaN; light/effect sync no longer activates parent objects
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:25:29 +02:00
MrMahoandClaude Opus 5.5 b3405d3752 Server gets the current save game from a player in the game (on join and every few minutes) instead of replaying actions; vehicle fire/smoke synced, also for parked cars
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:15:18 +02:00
MrMahoandClaude Opus 5.5 bdaedcc1e8 Server closes connections gracefully so kick/ban/reset/shutdown messages arrive; status dot and HUD yellow until really connected (also while reconnecting)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:59:50 +02:00
MrMahoandClaude Opus 5.5 0ae4dae28d Show why you were disconnected in the main menu; reset-world / reload-save make all players reconnect automatically
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:46:34 +02:00
MrMahoandClaude Opus 5.5 bba20cf991 Own main menu (game's Continue/New Game/Credits/Changelog/Quit hidden, QUIT in the launcher); new server profile layout; admin items: ratchet set, battery, shop items; session shows server player limit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:41:54 +02:00
MrMahoandClaude Opus 5.5 9f55646123 Session view: show the server's player limit (not the P2P limit of 3) and a server-appropriate status text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:34:47 +02:00
MrMahoandClaude Opus 5.5 5f1f524bee Server profile: JOIN button (and name field without Steam) at the top so it never slides out of the window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:31:17 +02:00
MrMahoandClaude Opus 5.5 2000fc677f Admin: spawn items (saved on the server), unban (menu tab + console), tabbed admin menu; dashboard needles and steering wheel synced; server browser row opens the profile
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:15:01 +02:00
MrMahoandClaude Opus 5.5 0c15459302 All mod messages go to the MSCO console (F7) instead of the MSCLoader console; still written to output_log.txt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:53:40 +02:00
MrMahoandClaude Opus 5.5 1dc65ac553 Passenger: player colliders off while riding (they pushed seats/parts out, physics crashed); last position: not overwritten by the save's start point, invalid positions ignored, saved when a player leaves
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:51:25 +02:00
MrMahoandClaude Opus 5.5 b853425037 Smoother sync: predict remote objects/cars from their velocity, 30 updates/s for objects and players
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:22:07 +02:00
MrMahoandClaude Opus 5.5 bdd86336c2 Remote-driven car: keep attached parts on unbreakable joints instead of switching them kinematic (that broke the joints and the car fell apart); passenger range measured to the car body
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:18:39 +02:00
MrMahoandClaude Opus 5.5 ce4aa7e0a6 MSCO console on F7 (F8 opens the game's own test menu in the pause menu)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:13:33 +02:00
MrMahoandClaude Opus 5.5 be9abd262f Server browser: keep the list stable between GUI passes (fixes 'Getting control 1's position' error)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:55:11 +02:00
MrMahoandClaude Opus 5.5 647acda46c Join: replay part actions first, then rescan and place objects (detached parts no longer stuck in the car); rescan right after remote disassembly; lift out bodies that keep getting flung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:52:53 +02:00
MrMahoandClaude Opus 5.5 8346cd9b09 Don't snatch an item another player is carrying just because it passes in front of your face
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:42:50 +02:00
MrMahoandClaude Opus 5.5 477949e559 Don't sync parts that are attached to a vehicle (they were flung away / car fell apart); softer plaid, calmer colors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:40:46 +02:00
38 changed files with 3242 additions and 234 deletions
+84 -1
View File
@@ -1,5 +1,69 @@
# Changelog
## 2.8
Server und Mod müssen beide 2.8 sein (Server: `git pull && docker compose up -d --build`).
**Mehr Sync**
- **Einkäufe**: gekaufte/ausgepackte Items tauchen bei allen auf (auch bei später Beitretenden)
- **Verbrauchte Items**: Gegessenes, Getrunkenes und Verbrauchtes verschwindet bei allen
- **Räder** drehen sich bei Autos, die ein anderer Spieler fährt
- **Unfallschäden**: verbeulte Karosserie wird an alle übertragen
- **Jobs, Aufträge und Laden**: Fortschritt (Auftrag angenommen, erledigt, bestellt ...) wird synchronisiert
- **Zündung, Motor, Licht, Choke, Blinker, Wischer**: die Schalterstellungen werden übertragen – beim anderen laufen
Motor und Scheinwerfer dadurch wirklich (nicht nur das Geräusch)
- **Autotüren, Motorhaube, Kofferraum**: stehen bei allen im selben Winkel offen
- **Verkehr**: NPC-Autos fahren bei allen an derselben Stelle (der erste Spieler simuliert, die anderen folgen)
- `vehicles` in der F7-Konsole zeigt, wie viele Karosserie-Meshes, Job-Werte und Verkehrsautos synchronisiert werden
**Admin: Welt verwalten (neuer Reiter WORLD im Admin-Menü)**
- **RESET WORLD**: die ganze Welt zurück auf den Spielstand, wie er auf den Server hochgeladen wurde (Reset-Punkt) –
das automatische Hochladen alle 5 Minuten ändert den Reset-Punkt nicht; der Stand davor wird gesichert
- **SET RESET POINT**: den aktuellen Spielstand als neuen Reset-Punkt nehmen (Server-Befehl: `set-reset-point`)
- **BACKUP NOW**: aktuellen Server-Spielstand sichern (Server-Befehl: `backup`)
- **Backups** mit Datum/Uhrzeit, **RESTORE** holt einen älteren Stand zurück (der aktuelle wird vorher gesichert)
- Jede Aktion mit Sicherheitsabfrage; nur für Admins (vom Server geprüft)
**Favoriten**
- Herz neben jedem Server (Liste und Profil) merkt ihn als Favorit
- Neuer Tab **Favorites**: jedem Server einen eigenen Kurznamen geben, beitreten, entfernen, per Adresse hinzufügen
- Schnell wechseln: `/switch <kurzname>` im Chat oder `switch <kurzname>` in der F7-Konsole – trennt und verbindet
direkt mit dem anderen Server; `fav` listet alle, `fav add <name>` merkt den aktuellen Server
**Pro Spieler**
- Hunger, Durst, Stress, Harndrang, Müdigkeit und Schmutz werden für jeden Spieler einzeln auf dem Server gespeichert
und beim Beitreten zurückgegeben (nicht mehr die Werte aus dem gemeinsamen Spielstand)
**Spielerfiguren**
- Springen: die ganze Figur hebt ab (vorher ging nur der Oberkörper nach oben), Knie werden angezogen
- Runder Gürtel, runde Schultern, abgerundete Stiefel mit Sohle
**Hinweise**
- Kleine Benachrichtigung oben rechts, wenn jemand beitritt oder das Spiel verlässt
**Trennen & Neu-Verbinden**
- Gekickt, gebannt, Server beendet, Verbindung verloren: eigenes Fenster in der Bildmitte mit **Grund** und OK
(im Spiel wird der Grund vorher 2,5 s groß angezeigt)
- `reset-world` / `reload-save` / **Server-Neustart** (z.B. Update per `docker compose up`) / **Verbindung verloren**:
Fenster „Reconnecting“ – verbindet automatisch neu, bis zu 10 Versuche (ca. 1 Minute), mit CANCEL-Knopf.
Nach Kick, Ban oder eigenem Trennen wird nicht neu verbunden
**Behoben**
- Nach Kick/Reset kam weder das Fenster mit dem Grund noch das automatische Neu-Verbinden: MSCLoader lädt beim Wechsel
ins Hauptmenü alle Mods neu und damit ging der Zustand verloren. Er wird jetzt vorher gesichert und danach fortgesetzt
- Absturz („NaN … collider“) und abfallende Teile am Satsuma: Türen/Haube/Kofferraum wurden gegen das Scharnier an
ihre Position teleportiert – das erzeugte riesige Kräfte. Jetzt zieht die Feder des Scharniers sie sanft auf den Winkel
- Beim anderen Spieler „orgelte“ der Anlasser endlos: erzwungene Geräusche/Lichter werden losgelassen, sobald keine
Daten mehr vom Fahrer kommen
- Beim Neuladen der Welt wurden herumliegende Objekte als „verbraucht“ gemeldet und bei allen gelöscht. Jetzt werden
nur einzelne echte Items gemeldet, Fahrzeuge/Teile nie gelöscht, und der Server nimmt höchstens 20 pro Minute an
**Leistung**
- Mod: herumliegende Objekte werden nur noch jedes 4. Frame geprüft (eigene/gehaltene/gefahrene weiterhin jedes Frame),
weniger Speicher-Allokationen pro Frame, Gelenke fremd gefahrener Autos seltener geprüft
- Server: Nachrichten an alle werden nur einmal verpackt; der Server wartet auf eingehende Daten statt fest zu schlafen
(geringere Verzögerung, fast keine CPU-Last im Leerlauf)
## 2.7
Server und Mod müssen beide 2.7 sein (Server: `git pull && docker compose up -d --build`).
@@ -7,10 +71,29 @@ Server und Mod müssen beide 2.7 sein (Server: `git pull && docker compose up -d
**Neu**
- Eigene Spielerfigur im MSC-Stil (Karohemd, Jeans, Arbeitsschuhe, Schnurrbart, Kappe oder Haare, je Spieler etwas
anders) statt kopierter NPCs – keine liegenden/verbuggten Figuren mehr
- Ping-Anzeige oben rechts: `ping` in der F8-Konsole (grün/gelb/rot)
- Ping-Anzeige oben rechts: `ping` in der F7-Konsole (grün/gelb/rot)
- MSCO-Konsole jetzt auf **F7** (F8 öffnet im Pausenmenü ein Test-Menü des Spiels)
- Admin-Menü: Cheats (+1.000 / +10.000 MK, Bedürfnisse auffüllen, Satsuma zu sich holen), GOTO / BRING (teleportieren)
- Fahrzeugzustand-Sync: Tank, Öl, Kühlwasser, Bremsflüssigkeit, Batterie, Schäden, Verschleiß, Kilometerstand, Lack
- `vehicles` in der Konsole zeigt die Position jedes Fahrzeugs
- Admin-Menü mit Reitern: **PLAYERS** (Kick/Ban/GOTO/BRING, Cheats), **ITEMS** (Items spawnen – alle sehen sie, auf dem
Server gespeichert), **BANS** (Sperren aufheben); Server-Befehle `bans` / `unban`
- Tacho, Drehzahlmesser, Tank-/Temperaturanzeige und Lenkrad werden beim Beifahrer mitbewegt
- Flüssigerer Sync: Position wird aus der Geschwindigkeit vorausberechnet, 30 Updates/s
- Letzte Position wird zuverlässig gespeichert (nicht mehr immer an der Toilette spawnen)
- Server-Browser: Klick auf den Server öffnet das Profil (JOIN-Knopf in der Liste entfernt)
- Alle Mod-Meldungen nur noch in der MSCO-Konsole (F7)
- Eigenes Hauptmenü: Continue, New Game, Credits, Changelog und Quit des Spiels sind ausgeblendet, der MSCO-Launcher
ist das Hauptmenü (mit eigenem QUIT)
- Server-Profil neu: JOIN oben, Spielerliste rechts, Namensfeld ohne Steam in eigenem Kasten
- Admin-ITEMS: Werkzeuge und Laden-Artikel (Ratschenkasten, Batterie, Motoröl, Kühlmittel, Bremsflüssigkeit ...)
- Nach dem Trennen (Kick, Ban, Shutdown, Timeout ...) zeigt das Hauptmenü den Grund
- `reset-world` / `reload-save` auf dem Server: alle Spieler bekommen eine Meldung und verbinden sich automatisch neu
- **Server holt sich den aktuellen Spielstand vom Spiel** (beim Beitreten und alle 5 Minuten): neue Spieler bekommen
exakt die aktuelle Welt – Schrauben fest, Teile dran – statt nachgespielter Aktionen; Backups der letzten 5 Stände
- Feuer, Rauch, Dampf und Funken an Fahrzeugen werden synchronisiert, auch bei geparkten Autos
- Kick-, Ban-, Reset- und Shutdown-Meldungen kommen zuverlässig an (Server trennt sauber)
- Status-Punkt gelb, bis man wirklich verbunden ist
**Behoben**
- Auto fiel beim Fahren eines anderen Spielers auseinander (angebaute Teile rissen an ihren Gelenken ab)
+45 -1
View File
@@ -3,13 +3,17 @@ using System;
namespace MSCO
{
/// <summary>
/// Alle MSCO-Befehle an einer Stelle - genutzt von der eigenen F8-Konsole (ohne "mp" davor)
/// Alle MSCO-Befehle an einer Stelle - genutzt von der eigenen F7-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" +
"switch <nickname> - quickly switch to a favorite server (also /switch in chat)\n" +
"fav - list your favorite servers\n" +
"fav add <nick> [host:port] - add a favorite (without address: the server you are on)\n" +
"fav remove <nick> - remove a favorite\n" +
"disconnect - leave the server / game\n" +
"host [port] / join <ip> [port] - direct LAN game (no Steam)\n" +
"save - save the game (host / singleplayer)\n" +
@@ -19,6 +23,37 @@ namespace MSCO
"debug - toggle sync debug output\n" +
"clear - clear this console";
static void Favorite(MultiplayerManager mp, string[] args, Action<string> print)
{
string sub = args.Length > 1 ? args[1].ToLower() : "list";
if (sub == "list")
{
if (Favorites.All.Count == 0) { print("No favorites yet - open a server in the browser and press FAVORITE, or: fav add <nick>"); return; }
foreach (Favorites.Favorite f in Favorites.All)
print(" " + f.Nickname + " -> " + f.Address + (string.IsNullOrEmpty(f.ServerName) ? "" : " (" + f.ServerName + ")"));
print("switch <nickname> to join one");
return;
}
if (sub == "add" || sub == "set")
{
if (args.Length < 3) { print("fav add <nickname> [host:port]"); return; }
string host = mp.CurrentServerHost, name = mp.CurrentServerName;
int port = mp.CurrentServerPort;
if (args.Length > 3 && !ServerBrowser.TryParseAddress(args[3], out host, out port)) { print("Invalid address: " + args[3]); return; }
if (host == null) { print("You are not on a server - use: fav add <nickname> <host:port>"); return; }
string error = Favorites.Set(args[2], host, port, name);
print(error ?? "Favorite '" + Favorites.CleanNickname(args[2]) + "' saved (" + host + ":" + port + ")");
return;
}
if (sub == "remove" || sub == "del" || sub == "delete")
{
Favorites.Favorite f = args.Length > 2 ? Favorites.Find(args[2]) : null;
print(f != null && Favorites.Remove(f) ? "Favorite '" + f.Nickname + "' removed" : "No such favorite");
return;
}
print("fav | fav add <nick> [host:port] | fav remove <nick>");
}
public static void Execute(string[] args, Action<string> print)
{
var mp = MultiplayerManager.Instance;
@@ -59,6 +94,15 @@ namespace MSCO
mp.JoinServer(host, serverPort);
break;
}
case "switch":
case "sw":
print(mp.SwitchServer(args.Length > 1 ? args[1] : ""));
break;
case "fav":
case "favs":
case "favorites":
Favorite(mp, args, print);
break;
case "save":
mp.SaveGame();
break;
+167
View File
@@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Bedienelemente der Fahrzeuge: Zündschloss, Anlasser, Motor, Lichtschalter, Choke, Blinker, Scheibenwischer ...
/// Das Spiel schaltet damit Motor und Licht über PlayMaker-FSMs (Zustände wie "Off" / "Lights" / "Running").
/// Übertragen wird der aktive Zustand; beim anderen Spieler wird dieselbe FSM in denselben Zustand geschaltet,
/// sodass sein Spiel Motor, Geräusch, Scheinwerfer und Armaturen selbst richtig anschaltet.
/// Wer sendet: der Fahrer; ist das Fahrzeug frei, wer daneben steht bzw. drin sitzt (Schalter betätigt).
/// FSMs, die ständig den Zustand wechseln (interne Schleifen), werden nach kurzer Zeit nicht mehr übertragen.
/// </summary>
public class ControlSync
{
// Objekt- oder FSM-Name enthält eines davon
static readonly string[] Keywords =
{
"ignition", "starter", "engine", "light", "choke", "wiper", "hazard", "indicator", "turnsignal", "blinker",
"horn", "heater", "fan", "radio", "key", "switch", "knob", "button"
};
// Montage/Speichern/Schrauben - läuft über den Objekt- bzw. FSM-Sync, hier nicht anfassen
static readonly string[] Excluded =
{
"removal", "assembly", "assemble", "bolt", "screw", "data", "save", "load", "paint", "detach", "attach",
"install", "trigger", "tighten", "wear", "damage", "oil", "coolant", "fuel", "inspection"
};
const float ScanInterval = 10f;
const float CheckInterval = 0.1f;
const float NearRange = 5f;
const int FlipLimit = 12; // so viele Zustandswechsel ...
const float FlipWindow = 10f; // ... in dieser Zeit = interne Schleife, nicht übertragen
const int MaxPerMessage = 100;
class Control
{
public int Key;
public string Root;
public PlayMakerFSM Fsm;
public string LastState;
public float WindowStart;
public int Flips;
public bool Muted;
}
readonly Dictionary<int, Control> controls = new Dictionary<int, Control>();
readonly HashSet<PlayMakerFSM> scanned = new HashSet<PlayMakerFSM>();
readonly List<Control> outgoing = new List<Control>();
float nextScan, nextCheck;
public int Count { get { return controls.Count; } }
public int MutedCount { get { int n = 0; foreach (Control c in controls.Values) if (c.Muted) n++; return n; } }
public void Reset()
{
controls.Clear();
scanned.Clear();
nextScan = 0f;
}
public void Update(ObjectSync objects, int localId, Vector3 playerPos, 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 (Control c in controls.Values)
{
if (c.Fsm == null || c.Muted) continue;
string state = c.Fsm.ActiveStateName;
if (state == c.LastState || string.IsNullOrEmpty(state)) continue;
c.LastState = state;
if (Time.time - c.WindowStart > FlipWindow) { c.WindowStart = Time.time; c.Flips = 0; }
if (++c.Flips > FlipLimit)
{
c.Muted = true;
MscoLog.Info("[MSCO] Control not synced (changes all the time): " + c.Fsm.gameObject.name + " [" + c.Fsm.FsmName + "]");
continue;
}
int owner = objects.VehicleOwner(c.Root);
if (owner != SyncedBody.NoOwner && owner != localId) continue; // der Fahrer gibt vor
if (owner == SyncedBody.NoOwner && (c.Fsm.transform.position - playerPos).sqrMagnitude > NearRange * NearRange) continue;
outgoing.Add(c);
}
for (int start = 0; start < outgoing.Count; start += MaxPerMessage)
{
int from = start, count = Math.Min(MaxPerMessage, outgoing.Count - start);
send(MsgType.VehicleControls, NetSession.Build(w =>
{
w.Write(count);
for (int i = from; i < from + count; i++) { w.Write(outgoing[i].Key); w.Write(outgoing[i].LastState); }
}));
}
}
public void Handle(BinaryReader r, ObjectSync objects, int localId)
{
int count = r.ReadInt32();
for (int i = 0; i < count; i++)
{
int key = r.ReadInt32();
string state = r.ReadString();
Control c;
if (!controls.TryGetValue(key, out c) || c.Fsm == null) { nextScan = 0f; continue; }
if (objects.VehicleOwner(c.Root) == localId) continue; // ich fahre: meine Schalter gelten
if (c.Fsm.ActiveStateName == state) { c.LastState = state; continue; }
FsmState target = c.Fsm.Fsm.GetState(state);
if (target == null) continue;
c.LastState = state; // nicht als eigene Änderung zurückschicken
FsmSync.SwitchStateSilently(c.Fsm, target);
c.LastState = c.Fsm.ActiveStateName; // FSM kann sofort weiterschalten (z.B. "Start" -> "Running")
}
}
void Scan()
{
int before = controls.Count;
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
{
if (fsm == null || !scanned.Add(fsm)) continue;
string root = fsm.transform.root.name;
if (!IsVehicle(root) || !Wanted(fsm.gameObject.name, fsm.FsmName)) continue;
int key = Hash(FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName);
if (controls.ContainsKey(key)) continue;
controls[key] = new Control { Key = key, Root = root, Fsm = fsm, LastState = fsm.ActiveStateName, WindowStart = Time.time };
}
if (controls.Count != before) MscoLog.Info("[MSCO] Vehicle controls (ignition, lights, engine ...): " + controls.Count + " synced");
}
static bool Wanted(string objectName, string fsmName)
{
string o = objectName.ToLower(), f = fsmName.ToLower();
foreach (string x in Excluded) if (o.Contains(x) || f.Contains(x)) return false;
foreach (string k in Keywords) if (o.Contains(k) || f.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 int Hash(string s)
{
unchecked
{
uint h = 2166136261;
foreach (byte c in Encoding.UTF8.GetBytes(s)) { h ^= c; h *= 16777619; }
return (int)h;
}
}
}
}
+186
View File
@@ -0,0 +1,186 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Unfallschäden (verbeulte Karosserie): Das Spiel verformt die Meshes der Fahrzeuge bei Kollisionen
/// (CarDamage, CarDamage2, Deformable). Das lässt sich nicht nachspielen, also übertragen wir das Ergebnis:
/// Wer das Fahrzeug fährt (oder bei freien Fahrzeugen: bei wem es passiert), schickt die geänderten Eckpunkte.
/// </summary>
public class DamageSync
{
const float ScanInterval = 10f;
const float CheckInterval = 1f; // Fahrzeuge, die wir fahren
const float FreeCheckInterval = 5f; // geparkte Fahrzeuge (Unfall nur durch Anstoßen) seltener
const float MinMove = 0.002f;
const int MaxPerMessage = 4000;
static readonly string[] DamageTypes = { "CarDamage", "CarDamage2", "Deformable" };
class DamageMesh
{
public int Key;
public string Root;
public MeshFilter Filter;
public Vector3[] Known; // zuletzt gesendet/empfangen
}
readonly Dictionary<int, DamageMesh> meshes = new Dictionary<int, DamageMesh>();
readonly HashSet<MeshFilter> seen = new HashSet<MeshFilter>();
readonly List<int> changed = new List<int>();
float nextScan, nextCheck, nextFreeCheck;
static List<Type> types;
public int Count { get { return meshes.Count; } }
public void Reset()
{
meshes.Clear();
seen.Clear();
nextScan = 0f;
}
public void Update(ObjectSync objects, int localId, Action<MsgType, byte[]> send)
{
if (Time.time >= nextScan)
{
nextScan = Time.time + ScanInterval;
Scan();
}
if (Time.time < nextCheck) return;
nextCheck = Time.time + CheckInterval;
bool checkFree = Time.time >= nextFreeCheck;
if (checkFree) nextFreeCheck = Time.time + FreeCheckInterval;
foreach (DamageMesh m in meshes.Values)
{
if (m.Filter == null) continue;
int owner = objects.VehicleOwner(m.Root);
if (owner != SyncedBody.NoOwner && owner != localId) continue; // der Fahrer schickt die Schäden
if (owner == SyncedBody.NoOwner && !checkFree) continue;
Mesh mesh = m.Filter.sharedMesh;
if (mesh == null) continue;
Vector3[] now = mesh.vertices;
if (now.Length != m.Known.Length) { m.Known = now; continue; }
changed.Clear();
for (int i = 0; i < now.Length; i++)
if ((now[i] - m.Known[i]).sqrMagnitude > MinMove * MinMove) changed.Add(i);
if (changed.Count == 0) continue;
for (int start = 0; start < changed.Count; start += MaxPerMessage)
{
int from = start, count = Math.Min(MaxPerMessage, changed.Count - start);
int key = m.Key, total = now.Length;
send(MsgType.VehicleDamage, NetSession.Build(w =>
{
w.Write(key);
w.Write(total);
w.Write(count);
for (int i = from; i < from + count; i++)
{
int idx = changed[i];
Vector3 v = now[idx];
w.Write(idx); w.Write(v.x); w.Write(v.y); w.Write(v.z);
}
}));
}
m.Known = now;
}
}
public void Handle(BinaryReader r)
{
int key = r.ReadInt32();
int total = r.ReadInt32();
int count = r.ReadInt32();
DamageMesh m;
if (!meshes.TryGetValue(key, out m) || m.Filter == null) { nextScan = 0f; return; }
Mesh mesh = m.Filter.mesh; // eigene Kopie, falls das Mesh (noch) mit anderen geteilt ist
if (mesh == null) return;
Vector3[] verts = mesh.vertices;
if (verts.Length != total) return; // anderes Mesh (anderes Teil verbaut)
for (int i = 0; i < count; i++)
{
int idx = r.ReadInt32();
var v = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
if (idx < 0 || idx >= verts.Length || float.IsNaN(v.x) || float.IsNaN(v.y) || float.IsNaN(v.z)) continue;
if (v.sqrMagnitude > 1e6f) continue; // Unsinn nie übernehmen
verts[idx] = v;
}
mesh.vertices = verts;
mesh.RecalculateNormals();
mesh.RecalculateBounds();
m.Known = verts;
}
void Scan()
{
if (types == null)
{
types = new List<Type>();
foreach (Assembly a in AppDomain.CurrentDomain.GetAssemblies())
{
if (a.GetName().Name != "Assembly-CSharp") continue;
foreach (string n in DamageTypes)
{
Type t = a.GetType(n);
if (t != null) types.Add(t);
}
}
}
int before = meshes.Count;
foreach (Type t in types)
{
foreach (UnityEngine.Object o in UnityEngine.Object.FindObjectsOfType(t))
{
var c = o as Component;
if (c == null || !IsVehicle(c.transform.root.name)) continue;
foreach (FieldInfo f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
if (f.FieldType == typeof(MeshFilter)) Register(f.GetValue(c) as MeshFilter);
else if (f.FieldType == typeof(MeshFilter[]))
{
var arr = f.GetValue(c) as MeshFilter[];
if (arr != null) foreach (MeshFilter mf in arr) Register(mf);
}
}
}
}
if (meshes.Count != before) MscoLog.Info("[MSCO] Crash damage: " + meshes.Count + " body meshes synced");
}
void Register(MeshFilter mf)
{
if (mf == null || !seen.Add(mf)) return;
Mesh mesh = mf.sharedMesh;
if (mesh == null || mesh.vertexCount == 0 || mesh.vertexCount > 65000) return;
int key = Hash(FsmSync.PathOf(mf.transform));
if (meshes.ContainsKey(key)) return; // nicht eindeutig
meshes[key] = new DamageMesh { Key = key, Root = mf.transform.root.name, Filter = mf, Known = mesh.vertices };
}
static bool IsVehicle(string root)
{
foreach (string v in ObjectSync.VehicleRoots) if (root.StartsWith(v)) return true;
return false;
}
static int Hash(string s)
{
unchecked
{
uint h = 2166136261;
foreach (char c in s) { h ^= c; h *= 16777619; }
return (int)h;
}
}
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ namespace MSCO
if (running) return;
if (!Configured)
{
ModConsole.Print("[MSCO] Discord Rich Presence: no application id set (DiscordPresence.ApplicationId)");
MscoLog.Info("[MSCO] Discord Rich Presence: no application id set (DiscordPresence.ApplicationId)");
return;
}
if (Application.platform != RuntimePlatform.WindowsPlayer && Application.platform != RuntimePlatform.WindowsEditor) return;
+3 -3
View File
@@ -62,7 +62,7 @@ namespace MSCO
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);
if (FsmSync.Debug) MscoLog.Info("[MSCO] >> DOOR " + key + " -> " + state);
}
}
@@ -80,7 +80,7 @@ namespace MSCO
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);
if (FsmSync.Debug) MscoLog.Warning("[MSCO] << DOOR not found: " + key);
return;
}
}
@@ -91,7 +91,7 @@ namespace MSCO
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);
if (FsmSync.Debug) MscoLog.Info("[MSCO] << DOOR " + key + " -> " + state);
}
void Scan()
+114
View File
@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Lieblings-Server mit eigenem Kurznamen (z.B. "mbm"). Gespeichert in den PlayerPrefs, eine Zeile pro Server:
/// Kurzname, Adresse, Port, Servername (Tab-getrennt). Mit "switch <kurzname>" wechselt man schnell den Server.
/// </summary>
public static class Favorites
{
public class Favorite
{
public string Nickname;
public string Host;
public int Port;
public string ServerName;
public string Address { get { return Host + ":" + Port; } }
}
const string Pref = "MSCO_Favorites";
const int MaxFavorites = 30;
static List<Favorite> list;
public static List<Favorite> All { get { Load(); return list; } }
static void Load()
{
if (list != null) return;
list = new List<Favorite>();
string raw = PlayerPrefs.GetString(Pref, "");
foreach (string line in raw.Split('\n'))
{
string[] p = line.Split('\t');
int port;
if (p.Length < 3 || p[0].Length == 0 || p[1].Length == 0 || !int.TryParse(p[2], out port)) continue;
list.Add(new Favorite { Nickname = p[0], Host = p[1], Port = port, ServerName = p.Length > 3 ? p[3] : "" });
}
}
static void Save()
{
var sb = new StringBuilder();
foreach (Favorite f in list)
sb.Append(Clean(f.Nickname)).Append('\t').Append(Clean(f.Host)).Append('\t').Append(f.Port).Append('\t').Append(Clean(f.ServerName)).Append('\n');
PlayerPrefs.SetString(Pref, sb.ToString());
PlayerPrefs.Save();
}
static string Clean(string s) { return (s ?? "").Replace("\t", " ").Replace("\n", " ").Trim(); }
/// <summary>Kurzname: Buchstaben, Zahlen, - und _ (ohne Leerzeichen, damit "switch name" eindeutig ist).</summary>
public static string CleanNickname(string nickname)
{
var sb = new StringBuilder();
foreach (char c in (nickname ?? "").Trim())
if (char.IsLetterOrDigit(c) || c == '-' || c == '_') sb.Append(c);
string s = sb.ToString();
return s.Length > 20 ? s.Substring(0, 20) : s;
}
public static Favorite Find(string nickname)
{
Load();
return list.Find(f => string.Equals(f.Nickname, nickname, StringComparison.OrdinalIgnoreCase));
}
public static Favorite FindByAddress(string host, int port)
{
Load();
return list.Find(f => f.Port == port && string.Equals(f.Host, host, StringComparison.OrdinalIgnoreCase));
}
/// <summary>Hinzufügen oder (gleiche Adresse) umbenennen. Gibt einen Fehler zurück oder null.</summary>
public static string Set(string nickname, string host, int port, string serverName)
{
Load();
nickname = CleanNickname(nickname);
if (nickname.Length == 0) return "Nickname: letters, numbers, - and _";
if (string.IsNullOrEmpty(host) || port <= 0 || port > 65535) return "Invalid server address";
Favorite sameName = Find(nickname);
Favorite existing = FindByAddress(host, port);
if (sameName != null && sameName != existing) return "The nickname '" + nickname + "' is already used for " + sameName.Address;
if (existing == null)
{
if (list.Count >= MaxFavorites) return "Too many favorites (max " + MaxFavorites + ")";
existing = new Favorite { Host = host, Port = port };
list.Add(existing);
}
existing.Nickname = nickname;
if (!string.IsNullOrEmpty(serverName)) existing.ServerName = serverName;
Save();
return null;
}
public static bool Remove(Favorite f)
{
Load();
if (f == null || !list.Remove(f)) return false;
Save();
return true;
}
/// <summary>Vorschlag für den Kurznamen aus dem Servernamen (erstes Wort, klein).</summary>
public static string SuggestNickname(string serverName)
{
string first = (serverName ?? "").Trim().Split(' ')[0];
string nick = CleanNickname(first).ToLower();
return nick.Length > 0 ? nick : "server";
}
}
}
+10 -10
View File
@@ -84,11 +84,11 @@ namespace MSCO
// 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");
MscoLog.Info("[MSCO] FSM sync ready");
}
catch (Exception e)
{
ModConsole.Error("[MSCO] FSM sync patch failed: " + e);
MscoLog.Error("[MSCO] FSM sync patch failed: " + e);
}
}
@@ -163,7 +163,7 @@ namespace MSCO
}
catch (Exception e)
{
ModConsole.Error("[MSCO] Click capture error: " + e.Message);
MscoLog.Error("[MSCO] Click capture error: " + e.Message);
}
}
@@ -212,7 +212,7 @@ namespace MSCO
Position = go.transform.position
};
Outbox.Add(msg);
if (Debug) ModConsole.Print("[MSCO] >> CLICK " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
if (Debug) MscoLog.Info("[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>
@@ -262,11 +262,11 @@ namespace MSCO
Position = go.transform.position
};
Outbox.Add(msg);
if (Debug) ModConsole.Print("[MSCO] >> " + msg.EventName + " " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
if (Debug) MscoLog.Info("[MSCO] >> " + msg.EventName + " " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
}
catch (Exception e)
{
ModConsole.Error("[MSCO] FSM capture error: " + e.Message);
MscoLog.Error("[MSCO] FSM capture error: " + e.Message);
}
}
@@ -288,17 +288,17 @@ namespace MSCO
PlayMakerFSM fsm = Resolve(msg);
if (fsm == null)
{
if (Debug) ModConsole.Warning("[MSCO] << not found: " + msg.Path + " [" + msg.FsmName + "]");
if (Debug) MscoLog.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 + "]");
if (Debug) MscoLog.Info("[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); }
catch (Exception e) { MscoLog.Error("[MSCO] FSM apply error: " + e.Message); }
finally { applying--; }
}
@@ -309,7 +309,7 @@ namespace MSCO
applying++;
lastApplyTime = Time.realtimeSinceStartup;
try { switchState.Invoke(fsm.Fsm, new object[] { state }); }
catch (Exception e) { ModConsole.Error("[MSCO] FSM switch error: " + e.Message); }
catch (Exception e) { MscoLog.Error("[MSCO] FSM switch error: " + e.Message); }
finally { applying--; }
}
+187
View File
@@ -0,0 +1,187 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Türen, Motorhaube und Kofferraum an Fahrzeugen: hängen mit einem Scharnier (HingeJoint) an der Karosserie
/// und werden deshalb nicht vom Objekt-Sync bewegt. Übertragen wird der Öffnungswinkel des Scharniers.
/// Beim Empfänger wird die Tür NICHT hingesetzt (Teleportieren gegen das Gelenk erzeugt riesige Kräfte - Teile
/// reißen ab, im schlimmsten Fall stürzt die Physik ab), sondern die Feder des Scharniers zieht sie sanft auf
/// den Winkel. Danach bekommt das Scharnier seine ursprüngliche Einstellung zurück.
/// Wer sendet: Fährt jemand das Auto, der Fahrer. Ist es frei, wer gerade daneben steht (Tür auf/zu gemacht).
/// </summary>
public class HingeSync
{
const float ScanInterval = 10f;
const float SendInterval = 0.1f;
const float MinAngle = 2f; // Grad - kleiner ist Zittern
const float NearRange = 4f; // so nah muss man an einer Tür stehen, um sie zu bewegen
const float ApplyHold = 1.5f; // so lange nach einer Nachricht die Feder halten und selbst nicht senden
const float SpringForce = 120f;
const float SpringDamper = 12f;
class Hinge
{
public int Key;
public string Root;
public HingeJoint Joint;
public Rigidbody Rb;
public float SentAngle;
public bool HasSent;
public float TargetAngle;
public float TargetTime = -100f;
public bool Driving; // Feder gerade von uns gesetzt
public bool OrigUseSpring;
public JointSpring OrigSpring;
}
readonly Dictionary<int, Hinge> hinges = new Dictionary<int, Hinge>();
readonly HashSet<HingeJoint> seen = new HashSet<HingeJoint>();
readonly List<Hinge> outgoing = new List<Hinge>();
float nextScan, sendTimer;
public int Count { get { return hinges.Count; } }
public void Reset()
{
foreach (Hinge h in hinges.Values) StopDriving(h);
hinges.Clear();
seen.Clear();
nextScan = 0f;
}
public void Update(ObjectSync objects, int localId, Vector3 playerPos, float dt, Action<MsgType, byte[]> send)
{
if (Time.time >= nextScan)
{
nextScan = Time.time + ScanInterval;
Scan();
}
sendTimer += dt;
if (sendTimer < SendInterval) return;
sendTimer = 0f;
outgoing.Clear();
foreach (Hinge h in hinges.Values)
{
if (h.Joint == null || h.Rb == null || !h.Rb.gameObject.activeInHierarchy) continue;
if (Time.time - h.TargetTime < ApplyHold) continue; // gerade von anderen gesetzt
int owner = objects.VehicleOwner(h.Root);
if (owner != SyncedBody.NoOwner && owner != localId) continue; // der Fahrer schickt
if (owner == SyncedBody.NoOwner && (h.Rb.position - playerPos).sqrMagnitude > NearRange * NearRange) continue;
float angle = h.Joint.angle;
if (float.IsNaN(angle) || float.IsInfinity(angle)) continue;
if (h.HasSent && Mathf.Abs(angle - h.SentAngle) < MinAngle) continue;
h.SentAngle = angle;
h.HasSent = true;
outgoing.Add(h);
}
if (outgoing.Count == 0) return;
send(MsgType.HingeStates, NetSession.Build(w =>
{
w.Write(outgoing.Count);
foreach (Hinge h in outgoing) { w.Write(h.Key); w.Write(h.SentAngle); }
}));
}
public void Handle(BinaryReader r)
{
int count = r.ReadInt32();
for (int i = 0; i < count; i++)
{
int key = r.ReadInt32();
float angle = r.ReadSingle();
Hinge h;
if (!hinges.TryGetValue(key, out h)) { nextScan = 0f; continue; }
if (float.IsNaN(angle) || float.IsInfinity(angle) || Mathf.Abs(angle) > 180f) continue;
h.TargetAngle = angle;
h.TargetTime = Time.time;
h.SentAngle = angle; // nicht als eigene Bewegung zurückschicken
h.HasSent = true;
}
}
/// <summary>Feder des Scharniers auf den empfangenen Winkel stellen bzw. wieder zurückgeben.</summary>
public void FixedUpdate()
{
foreach (Hinge h in hinges.Values)
{
if (h.Joint == null || h.Rb == null) continue;
bool fresh = Time.time - h.TargetTime < ApplyHold && !h.Rb.isKinematic;
if (!fresh) { StopDriving(h); continue; }
if (!h.Driving)
{
h.OrigUseSpring = h.Joint.useSpring;
h.OrigSpring = h.Joint.spring;
h.Driving = true;
}
float target = h.TargetAngle;
if (h.Joint.useLimits)
{
JointLimits l = h.Joint.limits;
target = Mathf.Clamp(target, Mathf.Min(l.min, l.max), Mathf.Max(l.min, l.max));
}
JointSpring s = h.Joint.spring;
s.spring = SpringForce;
s.damper = SpringDamper;
s.targetPosition = target;
h.Joint.spring = s;
h.Joint.useSpring = true;
h.Rb.WakeUp();
}
}
static void StopDriving(Hinge h)
{
if (!h.Driving) return;
h.Driving = false;
if (h.Joint == null) return;
h.Joint.spring = h.OrigSpring;
h.Joint.useSpring = h.OrigUseSpring;
}
void Scan()
{
int before = hinges.Count;
foreach (HingeJoint j in UnityEngine.Object.FindObjectsOfType<HingeJoint>())
{
if (j == null || !seen.Add(j)) continue;
Rigidbody rb = j.GetComponent<Rigidbody>();
Rigidbody body = j.connectedBody;
if (rb == null || body == null || !IsVehicle(body.transform.root.name)) continue;
int key = Hash(FsmSync.PathOf(j.transform) + "|" + FsmSync.PathOf(body.transform));
if (hinges.ContainsKey(key)) continue;
hinges[key] = new Hinge { Key = key, Root = body.transform.root.name, Joint = j, Rb = rb };
}
// Abgebaute Teile (Scharnier weg) vergessen
var gone = new List<int>();
foreach (KeyValuePair<int, Hinge> kv in hinges)
if (kv.Value.Joint == null || kv.Value.Rb == null) gone.Add(kv.Key);
foreach (int k in gone) hinges.Remove(k);
if (gone.Count > 0) seen.RemoveWhere(x => x == null);
if (hinges.Count != before) MscoLog.Info("[MSCO] Vehicle doors/hood/trunk: " + hinges.Count + " synced");
}
static bool IsVehicle(string root)
{
foreach (string v in ObjectSync.VehicleRoots) if (root.StartsWith(v)) return true;
return false;
}
static int Hash(string s)
{
unchecked
{
uint h = 2166136261;
foreach (char c in s) { h ^= c; h *= 16777619; }
return (int)h;
}
}
}
}
+4 -4
View File
@@ -8,7 +8,7 @@ using UnityEngine;
namespace MSCO
{
/// <summary>
/// Solange ein Eingabefenster offen ist (F8-Konsole, Chat, Admin-Menü): Spieler bewegt sich nicht,
/// Solange ein Eingabefenster offen ist (F7-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>
@@ -103,7 +103,7 @@ namespace MSCO
}
catch (Exception e)
{
ModConsole.Warning("[MSCO] Could not patch cursor actions: " + e.Message);
MscoLog.Warning("[MSCO] Could not patch cursor actions: " + e.Message);
}
}
@@ -126,11 +126,11 @@ namespace MSCO
if (keyQuery && m.ReturnType == typeof(bool)) { harmony.Patch(m, boolPrefix, null); count++; }
else if (axisQuery && m.ReturnType == typeof(float)) { harmony.Patch(m, floatPrefix, null); count++; }
}
ModConsole.Print("[MSCO] Game input is paused while typing (" + count + " cInput queries)");
MscoLog.Info("[MSCO] Game input is paused while typing (" + count + " cInput queries)");
}
catch (Exception e)
{
ModConsole.Warning("[MSCO] Could not pause game input while typing: " + e.Message);
MscoLog.Warning("[MSCO] Could not pause game input while typing: " + e.Message);
}
}
+120
View File
@@ -0,0 +1,120 @@
using System.Collections.Generic;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Admin: Items spawnen. Kopiert ein vorhandenes Item des Spiels (z.B. "beer case(itemx)" aus dem Laden) und
/// setzt die Kopie an die gewünschte Stelle. Die Kopie behält den Namen des Originals, damit sich die Spiel-Logik
/// (Trinken, Essen, Tanken ...) wie beim echten Item verhält und der Objekt-Sync sie bei allen gleich benennt.
/// Werkzeuge wie der Ratschenkasten sind im Spiel keine Gegenstände, sondern ein Schalter ("hat der Spieler") -
/// die werden dem Admin direkt gegeben.
/// </summary>
public static class ItemSpawner
{
/// <summary>Werkzeug statt Gegenstand: globale Spielvariable, die "gekauft" bedeutet.</summary>
public const string RatchetSet = "ratchet set";
/// <summary>Auswahl im Admin-Menü (Anzeigename).</summary>
public static readonly string[] Items =
{
// Werkzeug / Auto
RatchetSet, "spanner set", "battery", "car jack", "floor jack", "motor hoist", "flashlight", "lantern",
"fire extinguisher", "spray can", "sledgehammer", "ax", "digging bar",
// Flüssigkeiten / Kraftstoff
"motor oil", "coolant", "brake fluid", "two stroke fuel", "gasoline", "diesel", "n2o bottle",
// Essen / Trinken / Laden
"beer case", "beer", "booze", "vodka shot", "milk", "juice", "coffee", "ground coffee", "sugar", "yeast",
"sausages", "pizza", "macaron box", "potato chips", "cigarettes", "mosquito spray", "grill charcoal",
// Sonstiges
"grill", "radio", "camera", "helmet", "bucket", "water bucket", "fireworks bag", "parts magazine"
};
// Items, die im Spiel nicht "(itemx)" heißen
static readonly Dictionary<string, string> objectNames = new Dictionary<string, string>
{
{ "battery", "battery(Clone)" }
};
static readonly Dictionary<string, GameObject> templates = new Dictionary<string, GameObject>();
public static bool IsKnown(string item) { return System.Array.IndexOf(Items, item) >= 0; }
static string ObjectName(string item)
{
string name;
return objectNames.TryGetValue(item, out name) ? name : item + "(itemx)";
}
static GameObject FindTemplate(string item) { return FindTemplateByObjectName(ObjectName(item)); }
static GameObject FindTemplateByObjectName(string name)
{
GameObject cached;
if (templates.TryGetValue(name, out cached) && cached != null) return cached;
GameObject best = null;
foreach (Object o in Resources.FindObjectsOfTypeAll(typeof(GameObject)))
{
var go = (GameObject)o;
if (go.name != name || go.GetComponent<Rigidbody>() == null) continue;
// Keine eingebauten Teile kopieren (z.B. die Batterie im Auto): nicht unter einem anderen Rigidbody
if (go.transform.parent != null && go.transform.parent.GetComponentInParent<Rigidbody>() != null) continue;
if (best == null || (go.activeInHierarchy && !best.activeInHierarchy)) best = go; // am liebsten eins aus der Welt
}
templates[name] = best;
return best;
}
/// <summary>Zuletzt erzeugtes Objekt (damit der Objekt-Sync es nicht als "neu gekauft" meldet).</summary>
public static GameObject LastSpawned { get; private set; }
/// <summary>Item nach seinem Objektnamen erzeugen (z.B. "beer case(itemx)") - für Einkäufe anderer Spieler.</summary>
public static bool SpawnObjectNamed(string objectName, Vector3 position, Quaternion rotation)
{
if (string.IsNullOrEmpty(objectName) || !objectName.EndsWith("(itemx)") || objectName.Length > 100) return false;
return SpawnTemplate(FindTemplateByObjectName(objectName), objectName, position, rotation);
}
/// <summary>Item erzeugen. Gibt false zurück, wenn es im Spiel kein solches Item gibt.</summary>
public static bool Spawn(string item, Vector3 position, Quaternion rotation)
{
return SpawnTemplate(FindTemplate(item), item, position, rotation);
}
static bool SpawnTemplate(GameObject template, string item, Vector3 position, Quaternion rotation)
{
LastSpawned = null;
if (template == null)
{
MscoLog.Warning("Cannot spawn '" + item + "' - no such item in this game");
return false;
}
var clone = (GameObject)Object.Instantiate(template, position, rotation);
LastSpawned = clone;
clone.name = template.name;
clone.transform.SetParent(null, true);
clone.SetActive(true);
Rigidbody rb = clone.GetComponent<Rigidbody>();
if (rb != null)
{
rb.isKinematic = false;
rb.velocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
}
return true;
}
/// <summary>Ratschenkasten "kaufen": der Spieler hat ihn dann wie nach dem Kauf im Laden.</summary>
public static bool GiveRatchetSet()
{
FsmBool has = FsmVariables.GlobalVariables.FindFsmBool("PlayerHasRatchet");
if (has == null) return false;
has.Value = true;
return true;
}
public static void ClearCache() { templates.Clear(); }
}
}
+2 -2
View File
@@ -26,7 +26,7 @@ namespace MSCO
string steamExe = SteamExePath();
if (steamExe == null)
{
ModConsole.Warning("[MSCO] Steam not found - JOIN links from the website are not available.");
MscoLog.Warning("[MSCO] Steam not found - JOIN links from the website are not available.");
return;
}
string command = "\"" + steamExe + "\" -applaunch " + MySummerCarAppId + " " + Argument + " \"%1\"";
@@ -41,7 +41,7 @@ namespace MSCO
}
catch (Exception e)
{
ModConsole.Warning("[MSCO] Could not register msco:// links: " + e.Message);
MscoLog.Warning("[MSCO] Could not register msco:// links: " + e.Message);
}
}
+342 -49
View File
@@ -10,7 +10,7 @@ namespace MSCO
/// </summary>
public class LobbyUI
{
enum Page { P2P, ServerBrowser }
enum Page { P2P, ServerBrowser, Favorites }
const float Width = 760f;
const float Height = 460f;
@@ -31,6 +31,8 @@ namespace MSCO
static readonly Color Pending = Hex(0xf2, 0xc2, 0x1d);
Page page = Page.P2P;
Vector2 profileScroll;
bool quitConfirm;
bool minimized;
Vector2 playerScroll;
Vector2 serverScroll;
@@ -38,12 +40,19 @@ namespace MSCO
string selectedServer; // "adresse:port" des geöffneten Server-Profils
string searchInput = "";
bool showSteam = true, showNonSteam = true, hideFull;
readonly Dictionary<string, string> nicknameEdits = new Dictionary<string, string>(); // Favoriten-Tab: Adresse -> bearbeiteter Kurzname
string favoriteAddInput = "";
Vector2 favoritesScroll;
Texture2D texHeartOn, texHeartOff;
string nameInput;
string directInput;
const string PrefDirect = "MSCO_LastServer";
bool stylesReady;
Texture2D texBackground, texSidebar, texAccent, texOnline, texPending, texMuted, texRow;
List<ServerListEntry> shownAll, shownServers; // Stand des letzten Layout-Durchgangs
string layoutError;
GUIStyle dangerStyle;
GUIStyle windowBox, sidebarBox, rowBox, accentBox, pillStyle, pillMutedStyle, chipOnStyle, chipOffStyle;
GUIStyle logoStyle, taglineStyle, navStyle, navActiveStyle, titleStyle, textStyle, captionStyle,
fieldStyle, primaryStyle, secondaryStyle, statusStyle, tabStyle, headerCellStyle,
@@ -67,6 +76,10 @@ namespace MSCO
EnsureStyles();
bool pendingJoin = mp.PendingJoinHost != null && !mp.IsInSession;
bool versionWarning = mp.PendingVersionWarning != null && !mp.IsInSession;
bool disconnected = mp.DisconnectNotice != null && !mp.IsInSession;
// Getrennt / Neu-Verbinden: eigenes Fenster in der Bildmitte, alles andere gesperrt
if (disconnected) { DrawPrompt(mp, false); return; }
if (mp.IsReconnecting) { DrawPrompt(mp, true); return; }
if (pendingJoin || versionWarning) minimized = false; // JOIN-Link / Warnung: Fenster zeigen
if (minimized) { DrawMinimized(mp); return; }
@@ -83,6 +96,7 @@ namespace MSCO
else if (pendingJoin) DrawJoinConfirm(mp);
else if (mp.IsInSession) DrawSession(mp);
else if (page == Page.P2P) DrawP2P(mp);
else if (page == Page.Favorites) DrawFavorites(mp);
else DrawServerBrowser(mp);
GUILayout.EndArea();
@@ -109,6 +123,17 @@ namespace MSCO
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;
y += 44;
int favCount = Favorites.All.Count;
if (NavButton(new Rect(r.x, y, r.width, 42), "Favorites" + (favCount > 0 ? " (" + favCount + ")" : ""), page == Page.Favorites)) page = Page.Favorites;
// Eigenes Hauptmenü: Spiel beenden (die Knöpfe des Spiel-Menüs sind ausgeblendet)
if (quitConfirm)
{
if (GUI.Button(new Rect(r.x + 16, r.yMax - 118, r.width - 32, 36), "QUIT GAME?", dangerStyle)) Application.Quit();
if (GUI.Button(new Rect(r.x + 16, r.yMax - 76, r.width - 32, 30), "CANCEL", tabStyle)) quitConfirm = false;
}
else if (GUI.Button(new Rect(r.x + 16, r.yMax - 76, r.width - 32, 36), "QUIT", tabStyle)) quitConfirm = true;
GUI.Label(new Rect(r.x + 24, r.yMax - 30, r.width - 48, 20), "v" + MSCO.ModVersion, taglineStyle);
}
@@ -151,7 +176,11 @@ namespace MSCO
}
// Server, die auf die direkte Abfrage nicht antworten (z.B. gerade geschlossen), gar nicht zeigen
List<ServerListEntry> all = browser.Servers.FindAll(s => !s.Offline);
// Unity-GUI läuft in mehreren Durchgängen (Layout, Klick, Zeichnen). Die Liste nur im Layout-Durchgang
// übernehmen - ändert sie sich dazwischen (Aktualisierung im Hintergrund), bricht das Layout sonst ab
if (shownAll == null || Event.current.type == EventType.Layout)
shownAll = browser.Servers.FindAll(s => !s.Offline);
List<ServerListEntry> all = shownAll;
// Server-Profil (Klick auf einen Server)
if (selectedServer != null)
@@ -186,10 +215,13 @@ namespace MSCO
GUILayout.Space(6);
string search = (searchInput ?? "").Trim().ToLower();
List<ServerListEntry> servers = all.FindAll(s =>
if (shownServers == null || Event.current.type == EventType.Layout)
shownServers = 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)));
List<ServerListEntry> servers = shownServers;
string shownError = Event.current.type == EventType.Layout ? (layoutError = browser.Error) : layoutError;
// Tabellenkopf
Rect header = GUILayoutUtility.GetRect(10, 26, GUILayout.ExpandWidth(true));
@@ -200,7 +232,7 @@ namespace MSCO
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 (shownError != null) GUILayout.Label(shownError, 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)
@@ -208,12 +240,17 @@ namespace MSCO
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))
// Herz: als Favorit merken / entfernen (vor dem Zeilen-Knopf, damit es den Klick bekommt)
Heart(new Rect(row.xMax - 58, row.y + 7, 20, 20), s);
// Klick auf die Zeile öffnet das Server-Profil (dort wird verbunden)
if (GUI.Button(new Rect(row.x, row.y, row.width, 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;
Favorites.Favorite fav = Favorites.FindByAddress(s.Address, s.Port);
if (fav != null) nameX += Pill(nameX, row, fav.Nickname, pillMutedStyle);
if (s.IsLocal)
{
// Server auf diesem PC (localhost)
@@ -227,12 +264,7 @@ namespace MSCO
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;
GUI.Label(new Rect(row.xMax - 30, row.y, 20, row.height), "›", headerCellStyle); // Hinweis: Profil öffnen
}
GUILayout.EndScrollView();
@@ -283,6 +315,168 @@ namespace MSCO
}
}
// ---------------------------------------------------------------- Favoriten
/// <summary>Herz: gefüllt = Favorit. Klick merkt den Server (Kurzname aus dem Namen) bzw. entfernt ihn.</summary>
void Heart(Rect r, ServerListEntry s)
{
EnsureHearts();
Favorites.Favorite fav = Favorites.FindByAddress(s.Address, s.Port);
bool hover = r.Contains(Event.current.mousePosition);
GUI.DrawTexture(r, fav != null || hover ? texHeartOn : texHeartOff);
if (GUI.Button(r, GUIContent.none, GUIStyle.none))
{
if (fav != null) Favorites.Remove(fav);
else AddFavorite(s.Name, s.Address, s.Port);
}
}
static void AddFavorite(string serverName, string host, int port)
{
// Kurzname aus dem Servernamen; ist er schon vergeben, eine Zahl anhängen
string baseNick = Favorites.SuggestNickname(serverName), nick = baseNick;
for (int i = 2; Favorites.Find(nick) != null && i < 100; i++) nick = baseNick + i;
string error = Favorites.Set(nick, host, port, serverName);
if (error != null) MscoLog.Warning(error);
}
/// <summary>Tab "Favorites": Kurznamen setzen, beitreten, entfernen, per Adresse hinzufügen.</summary>
void DrawFavorites(MultiplayerManager mp)
{
EnsureHearts();
GUILayout.Label("Favorites", titleStyle);
GUILayout.Label("Give each server a nickname - switch in the game with /switch <nickname> in the chat or F7 console.", textStyle);
GUILayout.Space(8);
List<Favorites.Favorite> favs = Favorites.All;
favoritesScroll = GUILayout.BeginScrollView(favoritesScroll, GUILayout.Height(220));
if (favs.Count == 0)
GUILayout.Label("No favorites yet. Click the heart next to a server in the Server Browser, or add one by address below.", textStyle);
Favorites.Favorite remove = null;
foreach (Favorites.Favorite f in favs)
{
ServerListEntry live = mp.Browser.Servers.Find(x => x.Port == f.Port && string.Equals(x.Address, f.Host, System.StringComparison.OrdinalIgnoreCase));
GUILayout.BeginHorizontal(rowBox, GUILayout.Height(44));
GUILayout.Space(6);
// Herz (entfernen)
Rect heart = GUILayoutUtility.GetRect(22, 22, GUILayout.Width(22), GUILayout.Height(40));
Rect heartIcon = new Rect(heart.x, heart.y + 10, 20, 20);
GUI.DrawTexture(heartIcon, texHeartOn);
if (GUI.Button(heartIcon, GUIContent.none, GUIStyle.none)) remove = f;
GUILayout.Space(8);
// Kurzname bearbeiten
string edit;
if (!nicknameEdits.TryGetValue(f.Address, out edit)) edit = f.Nickname;
GUILayout.BeginVertical(GUILayout.Width(150));
GUILayout.Space(7);
edit = GUILayout.TextField(edit, 20, fieldStyle, GUILayout.Height(28), GUILayout.Width(150));
GUILayout.EndVertical();
nicknameEdits[f.Address] = edit;
GUILayout.Space(6);
bool changed = Favorites.CleanNickname(edit) != f.Nickname;
GUILayout.BeginVertical(GUILayout.Width(64));
GUILayout.Space(7);
GUI.enabled = changed;
if (GUILayout.Button("SAVE", tabStyle, GUILayout.Height(28), GUILayout.Width(64)))
{
string error = Favorites.Set(edit, f.Host, f.Port, f.ServerName);
if (error != null) MscoLog.Warning(error);
nicknameEdits.Remove(f.Address);
}
GUI.enabled = true;
GUILayout.EndVertical();
GUILayout.Space(10);
// Server + Status
GUILayout.BeginVertical();
GUILayout.Space(4);
GUILayout.Label(string.IsNullOrEmpty(f.ServerName) ? f.Address : f.ServerName, rowNameStyle);
GUILayout.Label(live == null ? f.Address + " · not listed / offline"
: f.Address + " · " + live.Players + "/" + live.MaxPlayers + " · " + (live.Ping < 0 ? "..." : live.Ping + " ms"), textStyle);
GUILayout.EndVertical();
GUILayout.FlexibleSpace();
GUILayout.BeginVertical(GUILayout.Width(90));
GUILayout.Space(7);
if (GUILayout.Button("JOIN", primaryStyle, GUILayout.Height(30), GUILayout.Width(90))) mp.SwitchServer(f.Nickname);
GUILayout.EndVertical();
GUILayout.Space(6);
GUILayout.EndHorizontal();
GUILayout.Space(4);
}
if (remove != null) { Favorites.Remove(remove); nicknameEdits.Remove(remove.Address); }
GUILayout.EndScrollView();
GUILayout.FlexibleSpace();
GUILayout.Label("ADD BY ADDRESS", captionStyle);
GUILayout.BeginHorizontal();
favoriteAddInput = GUILayout.TextField(favoriteAddInput ?? "", 60, fieldStyle, GUILayout.Height(34));
if (string.IsNullOrEmpty(favoriteAddInput))
{
Rect field = GUILayoutUtility.GetLastRect();
GUI.Label(new Rect(field.x + 10, field.y, field.width, field.height), "host:port", placeholderStyle);
}
GUILayout.Space(10);
if (GUILayout.Button("ADD", secondaryStyle, GUILayout.Height(34), GUILayout.Width(110)))
{
string host;
int port;
if (ServerBrowser.TryParseAddress(favoriteAddInput, out host, out port))
{
ServerListEntry live = mp.Browser.Servers.Find(x => x.Port == port && x.Address == host);
AddFavorite(live != null ? live.Name : host, host, port);
favoriteAddInput = "";
}
else MscoLog.Warning("Enter an address like 1.2.3.4:7777");
}
GUILayout.EndHorizontal();
}
void EnsureHearts()
{
if (texHeartOn != null && texHeartOff != null) return;
texHeartOn = HeartTexture(new Color(0.93f, 0.28f, 0.33f), true);
texHeartOff = HeartTexture(new Color(1f, 1f, 1f, 0.35f), false);
}
/// <summary>Herz als Textur (gefüllt oder nur Umriss), weich geglättet.</summary>
static Texture2D HeartTexture(Color color, bool filled)
{
const int size = 48;
var tex = new Texture2D(size, size, TextureFormat.ARGB32, false);
for (int py = 0; py < size; py++)
for (int px = 0; px < size; px++)
{
// Herz-Kurve (x² + y² - 1)³ - x² y³ <= 0, mehrfach abgetastet für glatte Kanten
int inside = 0, samples = 0;
for (int sy = 0; sy < 3; sy++)
for (int sx = 0; sx < 3; sx++)
{
float x = ((px + (sx + 0.5f) / 3f) / size - 0.5f) * 2.6f;
float y = ((py + (sy + 0.5f) / 3f) / size - 0.55f) * 2.6f;
float a = x * x + y * y - 1f;
bool inHeart = a * a * a - x * x * y * y * y <= 0f;
if (!filled && inHeart)
{
// Umriss: innen ein kleineres Herz aussparen
float x2 = x * 1.28f, y2 = (y + 0.08f) * 1.28f;
float b = x2 * x2 + y2 * y2 - 1f;
if (b * b * b - x2 * x2 * y2 * y2 * y2 <= 0f) inHeart = false;
}
if (inHeart) inside++;
samples++;
}
tex.SetPixel(px, py, new Color(color.r, color.g, color.b, color.a * inside / samples));
}
tex.filterMode = FilterMode.Bilinear;
tex.wrapMode = TextureWrapMode.Clamp;
tex.Apply();
tex.hideFlags = HideFlags.HideAndDontSave;
return tex;
}
/// <summary>Server-Profil: wem der Server gehört, Version, Spieler, Laufzeit ...</summary>
void DrawServerProfile(MultiplayerManager mp, ServerListEntry s)
{
@@ -291,7 +485,7 @@ namespace MSCO
selectedServer = null;
return;
}
GUILayout.Space(6);
GUILayout.Space(8);
if (s == null)
{
@@ -300,52 +494,95 @@ namespace MSCO
return;
}
// Kopf: Name + Abzeichen links, JOIN rechts - immer sichtbar
GUILayout.BeginHorizontal();
GUILayout.BeginVertical();
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();
Rect badgeRow = GUILayoutUtility.GetRect(10, 20, GUILayout.ExpandWidth(true));
float bx = badgeRow.x;
bx += Pill(bx, badgeRow, s.RequireSteam ? "STEAM" : "NON-STEAM", s.RequireSteam ? pillStyle : pillMutedStyle);
if (s.IsLocal) bx += Pill(bx, badgeRow, "LOCAL", pillStyle);
if (s.Version != MSCO.ModVersion) Pill(bx, badgeRow, "v" + s.Version, pillStyle);
GUILayout.EndHorizontal();
GUILayout.EndVertical();
GUILayout.Space(12);
Rect heartSlot = GUILayoutUtility.GetRect(44, 44, GUILayout.Width(44), GUILayout.Height(44));
GUI.Box(heartSlot, GUIContent.none, rowBox);
Heart(new Rect(heartSlot.x + 11, heartSlot.y + 11, 22, 22), s);
GUILayout.Space(8);
GUI.enabled = CanJoin(mp, s);
if (GUILayout.Button(s.Players >= s.MaxPlayers ? "SERVER FULL" : "JOIN SERVER", primaryStyle, GUILayout.Height(44), GUILayout.Width(180)))
mp.JoinServer(s.Address, s.Port, s.Name, s.MaxPlayers);
GUI.enabled = true;
GUILayout.EndHorizontal();
if (!string.IsNullOrEmpty(s.Description)) GUILayout.Label(s.Description, textStyle);
GUILayout.Space(10);
// Ohne Steam: Name wählen (sauber in einem eigenen Kasten)
if (s.RequireSteam && !mp.SteamAvailable)
{
GUILayout.BeginHorizontal(rowBox, GUILayout.Height(40));
GUILayout.Label("This server requires Steam - start the game through Steam to join.", textStyle);
GUILayout.EndHorizontal();
GUILayout.Space(10);
}
else if (!mp.SteamAvailable)
{
GUILayout.BeginHorizontal(rowBox, GUILayout.Height(44));
GUILayout.Space(6);
GUILayout.BeginVertical(GUILayout.Width(110));
GUILayout.Space(4);
GUILayout.Label("PLAYING AS", captionStyle);
GUILayout.Label("no Steam", textStyle);
GUILayout.EndVertical();
GUILayout.BeginVertical();
GUILayout.Space(5);
DrawNameField(mp, 220);
GUILayout.EndVertical();
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
GUILayout.Space(10);
}
// Infos links, Spielerliste rechts
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);
InfoLine("HOSTED BY", string.IsNullOrEmpty(s.Owner) ? "unknown" : s.Owner, rowNameStyle);
InfoLine("VERSION", "v" + s.Version + (versionOk ? "" : " (you: v" + MSCO.ModVersion + ")"), versionOk ? rowNameStyle : badgeStyle);
InfoLine("STEAM", s.RequireSteam ? "Required" : "Not required", rowNameStyle);
InfoLine("ONLINE FOR", s.UptimeSeconds >= 0 ? FormatUptime(s.UptimeSeconds) : "-", rowNameStyle);
InfoLine("PING", s.Ping < 0 ? "-" : s.Ping + " ms", rowNameStyle);
InfoLine("ADDRESS", s.Address + ":" + s.Port, 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.Space(16);
GUILayout.BeginVertical(rowBox, GUILayout.ExpandWidth(true), GUILayout.MinHeight(150));
GUILayout.Space(6);
GUILayout.Label("PLAYERS " + s.Players + " / " + s.MaxPlayers, captionStyle);
profileScroll = GUILayout.BeginScrollView(profileScroll, GUILayout.Height(130));
if (s.PlayerNames == null || s.PlayerNames.Count == 0)
GUILayout.Label("Nobody online right now.", textStyle);
else
foreach (string name in s.PlayerNames)
{
Rect line = GUILayoutUtility.GetRect(10, 24, GUILayout.ExpandWidth(true));
GUI.DrawTexture(new Rect(line.x + 2, line.y + 8, 8, 8), texOnline);
GUI.Label(new Rect(line.x + 18, line.y, line.width - 18, line.height), name, rowNameStyle);
}
GUILayout.EndScrollView();
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);
}
/// <summary>Eine Info-Zeile: Bezeichnung links (grau), Wert rechts.</summary>
void InfoLine(string caption, string value, GUIStyle valueStyle)
{
GUILayout.BeginHorizontal(GUILayout.Height(26));
GUILayout.Label(caption, captionStyle, GUILayout.Width(100));
GUILayout.Label(value, valueStyle);
GUILayout.EndHorizontal();
}
@@ -429,21 +666,76 @@ namespace MSCO
GUILayout.EndHorizontal();
}
const float PromptWidth = 540f;
const float PromptHeight = 270f;
Texture2D texDim, texDanger;
GUIStyle promptTextStyle;
/// <summary>
/// Fenster in der Bildmitte über abgedunkeltem Hintergrund: nach dem Trennen (Kick, Ban, Shutdown, Timeout ...)
/// mit Grund und OK, bzw. beim automatischen Neu-Verbinden nach reset-world mit Versuch-Anzeige und CANCEL.
/// </summary>
void DrawPrompt(MultiplayerManager mp, bool reconnecting)
{
if (texDim == null) texDim = Solid(new Color(0f, 0f, 0f, 0.6f));
if (texDanger == null) texDanger = Solid(Hex(0xe5, 0x48, 0x4d));
if (promptTextStyle == null)
{
promptTextStyle = new GUIStyle(rowNameStyle);
promptTextStyle.wordWrap = true;
promptTextStyle.fontSize = 15;
}
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), texDim);
var r = new Rect((Screen.width - PromptWidth) / 2f, (Screen.height - PromptHeight) / 2f, PromptWidth, PromptHeight);
ScreenRect = new Rect(0, 0, Screen.width, Screen.height); // Spielmenü dahinter komplett sperren
GUI.Box(r, GUIContent.none, windowBox);
string title = reconnecting
? ((mp.ReconnectMessage ?? "").StartsWith("Switching") ? "SWITCHING SERVER" : "RECONNECTING")
: mp.DisconnectTitle;
bool bad = !reconnecting && title != "DISCONNECTED";
GUI.DrawTexture(new Rect(r.x, r.y, r.width, 4), bad ? texDanger : texAccent);
float x = r.x + 32, w = r.width - 64, y = r.y + 28;
GUI.Label(new Rect(x, y, w, 34), title, titleStyle);
y += 50;
GUI.Label(new Rect(x, y, w, 20), "REASON", captionStyle);
if (reconnecting)
{
GUI.Label(new Rect(x, y + 22, w, 44), mp.ReconnectMessage ?? "", promptTextStyle);
string attempt = mp.ReconnectAttempt == 0
? "Connecting again in a moment..."
: "Attempt " + mp.ReconnectAttempt + " of " + mp.ReconnectMaxAttempts +
(string.IsNullOrEmpty(mp.Status) ? "" : " - " + mp.Status);
GUI.Label(new Rect(x, y + 74, w, 40), attempt, textStyle);
if (GUI.Button(new Rect(r.xMax - 32 - 170, r.yMax - 28 - 42, 170, 42), "CANCEL", secondaryStyle)) mp.CancelReconnect();
return;
}
GUI.Label(new Rect(x, y + 22, w, 60), mp.DisconnectNotice ?? "", promptTextStyle);
GUI.Label(new Rect(x, y + 88, w, 20), "Your own save game has been restored.", textStyle);
if (GUI.Button(new Rect(r.xMax - 32 - 170, r.yMax - 28 - 42, 170, 42), "OK", primaryStyle)) mp.ClearDisconnectNotice();
}
void DrawSession(MultiplayerManager mp)
{
GUILayout.Label(mp.IsHost ? "Hosting" : "Connected", titleStyle);
GUILayout.Label(mp.IsHost
? "Your game is open. Start when everyone has joined."
: mp.IsDedicated || mp.IsConnectingToServer
? "Connected to the server - the world is loading" + (string.IsNullOrEmpty(mp.Status) ? "." : ": " + mp.Status)
: "Waiting for the host to start the game.", textStyle);
GUILayout.Space(16);
GUILayout.Label("PLAYERS " + mp.Players.Count + "/" + MultiplayerManager.MaxPlayers, captionStyle);
GUILayout.Label("PLAYERS " + mp.Players.Count + "/" + mp.SessionMaxPlayers, 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);
// Eigener Punkt gelb, solange man noch lädt/synchronisiert; grün, wenn man wirklich im Spiel ist
GUI.DrawTexture(new Rect(row.x + 12, row.y + 11, 8, 8), p.IsLocal && !mp.IsFullyConnected ? texPending : 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";
@@ -471,7 +763,7 @@ namespace MSCO
void DrawStatusBar(MultiplayerManager mp, Rect r)
{
GUI.Box(r, GUIContent.none, sidebarBox);
Texture2D dot = mp.IsPeerReady ? texOnline : mp.IsInSession ? texPending : texMuted;
Texture2D dot = mp.IsFullyConnected ? texOnline : mp.IsInSession || mp.IsReconnecting ? 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))
@@ -484,7 +776,7 @@ namespace MSCO
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 dot = mp.IsFullyConnected ? texOnline : mp.IsInSession || mp.IsReconnecting ? 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);
@@ -561,6 +853,7 @@ namespace MSCO
navActiveStyle.padding = new RectOffset(26, 10, 0, 0);
primaryStyle = Button(Accent, AccentHover, Color.white, 14, FontStyle.Bold);
dangerStyle = Button(UiTheme.Danger, UiTheme.DangerHover, Color.white, 13, FontStyle.Bold);
secondaryStyle = Button(Secondary, SecondaryHover, Color.white, 14, FontStyle.Bold);
tabStyle = Button(Sidebar, Secondary, Muted, 11, FontStyle.Bold);
+5 -4
View File
@@ -5,7 +5,7 @@ namespace MSCO
{
public class MSCO : Mod
{
public const string ModVersion = "2.7";
public const string ModVersion = "2.8";
public override string ID => "MSCO"; // Your (unique) mod ID
public override string Name => "MSCO"; // Your mod name
@@ -32,7 +32,7 @@ namespace MSCO
/// <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>
/// <summary>MSCO-Konsole (Standard F7) und Chat (Standard T), ebenfalls änderbar.</summary>
internal static SettingsKeybind ConsoleKey, ChatKey;
private void Mod_Settings()
@@ -41,12 +41,13 @@ namespace MSCO
// 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);
// Nicht F8: im Pausenmenü öffnet F8 ein Test-Menü des Spiels. Neue ID, damit alte F8-Belegungen nicht übernommen werden
ConsoleKey = Keybind.Add("console_f7", "Open MSCO console", KeyCode.F7);
}
private void Mod_OnMenuLoad()
{
ModConsole.Print("Welcome to MSCO by mbm.wtf");
MscoLog.Info("Welcome to MSCO by mbm.wtf");
// Netzwerk + Lobby leben in einem eigenen Objekt, das Szenenwechsel überlebt
MultiplayerManager.Ensure();
if (!commandAdded)
+11
View File
@@ -73,8 +73,19 @@
</ItemGroup>
<ItemGroup>
<Compile Include="MoneySync.cs" />
<Compile Include="WheelSpin.cs" />
<Compile Include="DamageSync.cs" />
<Compile Include="WorldVarSync.cs" />
<Compile Include="TrafficSync.cs" />
<Compile Include="HingeSync.cs" />
<Compile Include="ControlSync.cs" />
<Compile Include="Toasts.cs" />
<Compile Include="PlayerNeeds.cs" />
<Compile Include="Favorites.cs" />
<Compile Include="MpCommand.cs" />
<Compile Include="Commands.cs" />
<Compile Include="MscoLog.cs" />
<Compile Include="ItemSpawner.cs" />
<Compile Include="Cheats.cs" />
<Compile Include="InputLock.cs" />
<Compile Include="Overlays.cs" />
+23 -2
View File
@@ -51,18 +51,39 @@ namespace MSCO
}
/// <summary>Startet das Spiel wie ein Klick auf CONTINUE.</summary>
// Knöpfe des Spiel-Hauptmenüs, die MSCO ersetzt (der MSCO-Launcher ist das Hauptmenü)
static readonly string[] HiddenMenuButtons = { "ButtonContinue", "ButtonNewgame", "ButtonCredits", "ButtonQuit", "ButtonChangelog" };
/// <summary>
/// Continue, New Game, Credits, Changelog und Quit des Spiels unsichtbar und unklickbar machen. Nicht abschalten:
/// die Logik hinter CONTINUE lädt beim Beitreten den übertragenen Spielstand (StartGame). Gibt die Anzahl zurück.
/// </summary>
public static int HideGameMenuButtons()
{
int hidden = 0;
foreach (string name in HiddenMenuButtons)
{
GameObject button = GameObject.Find(name);
if (button == null) continue;
foreach (Renderer r in button.GetComponentsInChildren<Renderer>(true)) r.enabled = false;
foreach (Collider c in button.GetComponentsInChildren<Collider>(true)) c.enabled = false;
hidden++;
}
return hidden;
}
public static void StartGame()
{
PlayMakerFSM fsm;
FsmState state;
if (switchState != null && FindContinueLoadState(out fsm, out state))
{
ModConsole.Print("[MSCO] Loading via CONTINUE (" + fsm.name + " / " + state.Name + ")");
MscoLog.Info("[MSCO] Loading via CONTINUE (" + fsm.name + " / " + state.Name + ")");
switchState.Invoke(fsm.Fsm, new object[] { state });
}
else
{
ModConsole.Warning("[MSCO] CONTINUE button not found, loading GAME directly");
MscoLog.Warning("[MSCO] CONTINUE button not found, loading GAME directly");
Application.LoadLevel(GameScene);
}
}
+2 -2
View File
@@ -2,11 +2,11 @@ using MSCLoader;
namespace MSCO
{
/// <summary>MSCLoader-Konsole: "mp &lt;befehl&gt;" - gleiche Befehle wie in der F8-Konsole.</summary>
/// <summary>MSCLoader-Konsole: "mp &lt;befehl&gt;" - gleiche Befehle wie in der F7-Konsole.</summary>
public class MpCommand : ConsoleCommand
{
public override string Name => "mp";
public override string Help => "MSCO commands, e.g. mp help | mp connect <host[:port]> | mp save (or press F8)";
public override string Help => "MSCO commands, e.g. mp help | mp connect <host[:port]> | mp save (or press F7)";
public override void Run(string[] args)
{
+46
View File
@@ -0,0 +1,46 @@
using System.Collections.Generic;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Zentrales Log der Mod: alle Meldungen landen in der MSCO-Konsole (F7), nicht in der MSCLoader-Konsole.
/// Zusätzlich gehen sie per Debug.Log in die output_log.txt des Spiels (zur Fehlersuche), ohne dass sich die
/// MSCLoader-Konsole öffnet. Thread-sicher: Meldungen aus Hintergrund-Threads werden im Hauptthread übernommen.
/// </summary>
public static class MscoLog
{
const string Prefix = "[MSCO] ";
static readonly Queue<string> pending = new Queue<string>();
static readonly object gate = new object();
public static void Info(string text) { Add(text, ""); }
public static void Warning(string text) { Add(text, "<color=#f2c21d>! </color>"); }
public static void Error(string text) { Add(text, "<color=#ee5a5e>ERROR </color>"); }
static void Add(string text, string marker)
{
string line = Strip(text ?? "");
Debug.Log(Prefix + line); // output_log.txt
lock (gate)
{
pending.Enqueue(marker + line);
while (pending.Count > 500) pending.Dequeue(); // falls die Konsole (noch) nicht abgeholt hat
}
}
/// <summary>Neue Meldungen abholen (Hauptthread, vom MultiplayerManager jedes Frame).</summary>
public static void Drain(System.Action<string> sink)
{
lock (gate)
{
while (pending.Count > 0) sink(pending.Dequeue());
}
}
static string Strip(string text)
{
return text.StartsWith(Prefix) ? text.Substring(Prefix.Length) : text;
}
}
}
+657 -36
View File
File diff suppressed because it is too large Load Diff
+15 -2
View File
@@ -34,12 +34,25 @@ namespace MSCO
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
AdminAction = 26, // Client -> Server: byte (1 kick, 2 ban, 3 bring, 4 unban, 5 reset world, 6 backup, 7 restore backup, 8 set reset point), int Spieler-ID, string Grund/Name
AdminResult = 27, // Server -> Client: string Meldung
Chat = 28, // Chatnachricht (string)
VarSync = 29, // Fahrzeugzustand: int n, je [int key, byte typ, wert]
Teleport = 30, // Admin holt einen Spieler zu sich: 3 float Position
Pong = 31 // Antwort auf Ping mit demselben Zeitstempel (Laufzeit messen)
Pong = 31, // Antwort auf Ping mit demselben Zeitstempel (Laufzeit messen)
SpawnItem = 32, // Admin spawnt ein Item: string Item-Name, 3 float Position, 4 float Drehung
Reconnect = 33, // Server -> Client: string Meldung; ins Menü und neu verbinden (z.B. Welt zurückgesetzt)
SaveRequest = 34, // Server -> Client: int Anfrage; im Spiel speichern und den Spielstand hochladen
SaveUploadPart = 35, // Client -> Server: int Anfrage, string Datei, int Gesamtgröße, int Offset, int Länge, bytes
SaveUploadDone = 36, // Client -> Server: int Anfrage, int Anzahl Dateien
ItemAppeared = 37, // neues Item bei einem Spieler (Einkauf, Tüte ausgepackt ...): int Objekt-ID, string Objektname, 3 float Pos, 4 float Rot
ObjDestroy = 38, // Item wurde verbraucht/zerstört (gegessen, getrunken ...): int Objekt-ID
VehicleDamage = 39, // Unfallschaden: int Mesh-Schlüssel, int Eckpunkte gesamt, int n, je [int Index, 3 float]
TrafficStates = 40, // Verkehr (nur Welt-Autorität): int n, je [int Schlüssel, 3 float Pos, 4 float Rot, 3 float Geschw.]
WorldVars = 41, // Jobs/Laden: int n, je [int Schlüssel, byte Typ (2 int, 3 bool), Wert]
HingeStates = 42, // Autotüren/Haube/Kofferraum: int n, je [int Schlüssel, float Scharnier-Winkel]
VehicleControls = 43, // Zündung/Motor/Licht ...: int n, je [int FSM-Schlüssel, string Zustand]
PlayerNeeds = 44 // Client -> Server: eigene Bedürfnisse (Hunger, Durst ...): int n, je [string Name, float Wert]
}
public struct Message
+238 -46
View File
@@ -16,20 +16,33 @@ namespace MSCO
public int Id;
public string Path;
public Rigidbody Rb;
public string Name;
public GameObject Go; // zum Unterscheiden: nur Rigidbody weg (angebaut) oder ganzes Objekt weg (verbraucht)
public Vector3 LastPos;
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
// Angebaute Teile eines Fahrzeugs (eigene Rigidbodies mit Gelenken), solange ein anderer fährt: kinematisch
public Dictionary<Rigidbody, bool> FrozenParts;
// Gelenke der angebauten Teile, solange ein anderer das Fahrzeug fährt: unzerbrechlich (vorherige Bruchkraft/-moment)
public Dictionary<Joint, Vector2> HeldJoints;
public float NextPartScan;
public float StillTime;
public float NoMotionClaimUntil; // kurz nach einer Übergabe nicht wegen Nachfedern/Nachrollen übernehmen
// Am Auto angebautes Teil (Gelenk zum Fahrzeug oder unter einem anderen Rigidbody): bewegt sich mit dem Auto,
// wird dann NICHT als loses Objekt synchronisiert - sonst reißt das Gelenk und das Teil fliegt weg
public bool Attached;
public float NextAttachCheck;
// Wie oft kurz hintereinander weggeschleudert (steckt es in etwas fest?)
public int RunawayCount;
public float RunawayWindowStart;
public float LastRunawayLog = -100f;
public Vector3 TargetPos;
public Quaternion TargetRot;
public Vector3 TargetVel; // Geschwindigkeit beim Besitzer: damit wird die Position vorausberechnet
public float TargetTime; // wann TargetPos ankam
public float LastRecvTime;
public float AvatarHoldTime = -100f; // zuletzt von der Hand eines Mitspielers platziert
@@ -56,7 +69,8 @@ namespace MSCO
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 SendInterval = 1f / 30f; // unbewegte Objekte werden ohnehin nicht gesendet
const float MaxExtrapolation = 0.25f; // höchstens so weit (Sekunden) in die Zukunft vorausrechnen
const float KeepAliveInterval = 1f;
const float ScanInterval = 3f;
const float FirstScanDelay = 3f;
@@ -78,6 +92,51 @@ namespace MSCO
public int LocalId;
/// <summary>Senden an alle anderen Spieler (setzt der MultiplayerManager).</summary>
public Action<MsgType, byte[]> Send;
/// <summary>Neues Item bei uns aufgetaucht (Einkauf ...) bzw. bei uns verbraucht - der Manager meldet es allen.</summary>
public Action<SyncedBody> ItemAppeared, ItemDestroyed;
// Übers Netz erzeugte Items (nicht nochmal als "neu" melden)
readonly HashSet<GameObject> networkCreated = new HashSet<GameObject>();
Vector3 lastPlayerPos;
const float LocalEventRange = 8f; // Aufgetaucht/verbraucht in dieser Nähe = wir haben es ausgelöst
public void MarkNetworkCreated(GameObject go) { if (go != null) networkCreated.Add(go); }
/// <summary>
/// Verschwundene Items melden (gegessen, getrunken, verbraucht) - nur wenn das ganze Objekt weg ist (nicht nur der
/// Rigidbody wie beim Anbauen), es ein Item ist und es bei uns passiert ist. Verschwinden viele auf einmal, wird
/// gerade die Szene entladen (Neu laden, Hauptmenü) - dann NICHTS melden, sonst löscht man bei allen die Welt.
/// </summary>
void ReportConsumed(Transform player)
{
if (ItemDestroyed == null || removeList.Count == 0) return;
if (player == null || removeList.Count > 2 || Time.time - loadTime < 30f) return;
foreach (SyncedBody b in removeList)
{
if (b.Go != null || b.IsVehicle || b.Name == null || !b.Name.EndsWith("(itemx)")) continue;
if (IsLocal(b) || (b.LastPos - lastPlayerPos).sqrMagnitude < LocalEventRange * LocalEventRange)
ItemDestroyed(b);
}
}
/// <summary>Gibt es dieses Objekt bei uns schon an dieser Stelle? (dann nicht nochmal erzeugen)</summary>
public bool HasNear(int id, Vector3 pos, float range)
{
SyncedBody b;
return byId.TryGetValue(id, out b) && b.Rb != null && (b.Rb.position - pos).sqrMagnitude < range * range;
}
/// <summary>Ein anderer Spieler hat dieses Item verbraucht: bei uns auch entfernen.</summary>
public bool DestroyRemote(int id)
{
SyncedBody b;
if (!byId.TryGetValue(id, out b)) return false;
// Nur echte Items (Essen, Getränke ...) - nie Fahrzeuge oder Teile, egal was ein anderer meldet
if (b.IsVehicle || b.Name == null || !b.Name.EndsWith("(itemx)")) return false;
if (b.Go != null) UnityEngine.Object.Destroy(b.Go);
Unregister(b);
return true;
}
readonly Dictionary<int, SyncedBody> byId = new Dictionary<int, SyncedBody>();
readonly Dictionary<Rigidbody, SyncedBody> byRb = new Dictionary<Rigidbody, SyncedBody>();
@@ -90,6 +149,8 @@ namespace MSCO
float loadTime;
float nextScan;
int frame;
readonly List<SyncedBody> drivenList = new List<SyncedBody>();
float sendTimer;
FsmGameObject pickedObject;
@@ -107,6 +168,7 @@ namespace MSCO
SyncedBody b;
if (!byId.TryGetValue(id, out b) || b.Rb == null || b.OwnerId != SyncedBody.NoOwner) return false;
if (!IsValidPose(pos, rot)) return false;
if (IsAttached(b)) return false; // inzwischen angebaut: Position kommt vom Auto
if (b.IsVehicle) pos = AboveGround(pos, b.Rb.transform); // gespeicherte Position evtl. knapp im Boden
b.Rb.transform.position = pos;
b.Rb.transform.rotation = rot;
@@ -151,12 +213,24 @@ namespace MSCO
/// <summary>Fahrzeuge, die gerade ein anderer Spieler aktiv fährt.</summary>
public List<SyncedBody> RemoteDrivenVehicles()
{
var list = new List<SyncedBody>();
var list = drivenList; // wird jedes Frame gebraucht: Liste wiederverwenden
list.Clear();
foreach (SyncedBody b in byId.Values)
if (b.IsVehicle && IsRemoteOwned(b) && b.OwnerActive && b.Rb != null) list.Add(b);
return list;
}
/// <summary>Fahrzeuge, die gerade niemand besitzt (geparkt).</summary>
public List<SyncedBody> FreeVehicles()
{
var list = new List<SyncedBody>();
foreach (SyncedBody b in byId.Values)
if (b.IsVehicle && b.OwnerId == SyncedBody.NoOwner && b.Rb != null) list.Add(b);
return list;
}
public bool IsLocalBody(SyncedBody b) { return IsLocal(b); }
/// <summary>Fahrzeuge, die der lokale Spieler gerade besitzt (fährt/schiebt).</summary>
public List<SyncedBody> LocalVehicles()
{
@@ -184,6 +258,7 @@ namespace MSCO
byId.Clear();
byRb.Clear();
rejected.Clear();
networkCreated.Clear();
drivenName = null;
drivenCache = null;
ScanComplete = false;
@@ -229,6 +304,7 @@ namespace MSCO
public void Update(Transform player, float dt)
{
if (player != null) lastPlayerPos = player.position;
if (Time.time >= nextScan)
{
nextScan = Time.time + ScanInterval;
@@ -243,10 +319,20 @@ namespace MSCO
bool motionClaimAllowed = Time.time - loadTime > MotionClaimGrace;
removeList.Clear();
frame++;
foreach (SyncedBody b in byId.Values)
{
if (b.Rb == null) { removeList.Add(b); continue; } // z.B. Teil wurde montiert
// Leistung: freie Objekte (der Großteil, meist liegen sie nur herum) nur jedes 4. Frame prüfen -
// eigene, fremde, gehaltene und gefahrene jedes Frame
if (b.OwnerId == SyncedBody.NoOwner && ((b.Id ^ frame) & 3) != 0 && b != held && b != driven) continue;
if (b.Rb == null)
{
removeList.Add(b);
continue;
}
b.LastPos = b.Rb.position;
if (!b.Rb.gameObject.activeInHierarchy) continue;
if (UpdateAttached(b)) continue; // angebaut: bewegt sich mit dem Auto
bool active = b == held || b == driven;
if (!IsRemoteOwned(b)) StopRunaway(b);
@@ -272,10 +358,14 @@ namespace MSCO
}
else // gehört einem anderen Spieler
{
if (active && Wins(LocalId, true, b.OwnerId, b.OwnerActive)) Claim(b, true); // Spieler greift sich das Objekt
// Trägt es gerade ein anderer, nur übernehmen, wenn wir es wirklich eben gegriffen haben - PickedObject
// zeigt oft noch auf ein altes Item; trägt es jemand an unserem Kopf vorbei, würden wir es ihm sonst entreißen
bool grab = b == driven || !b.OwnerActive || JustPicked;
if (active && grab && Wins(LocalId, true, b.OwnerId, b.OwnerActive)) Claim(b, true); // Spieler greift sich das Objekt
else if (Time.time - b.LastRecvTime > RemoteTimeout) SetFree(b);
}
}
ReportConsumed(player);
foreach (SyncedBody b in removeList) Unregister(b);
Held = held != null && held.Rb != null && IsLocal(held) ? held : null;
@@ -292,18 +382,23 @@ namespace MSCO
{
foreach (SyncedBody b in byId.Values)
{
if (!IsRemoteOwned(b) || b.Rb == null) continue;
if (b.IsVehicle && Time.time >= b.NextPartScan) FreezeParts(b);
if (!IsRemoteOwned(b) || b.Rb == null || b.Attached) continue;
if (b.IsVehicle && Time.time >= b.NextPartScan) HoldParts(b);
if (Time.time - b.AvatarHoldTime < 0.2f) continue; // sitzt gerade in der Hand eines Mitspielers
if ((b.Rb.position - b.TargetPos).sqrMagnitude > SnapDistance * SnapDistance)
// Dort, wo das Objekt beim Besitzer JETZT ungefähr ist: letzte Position + Geschwindigkeit * Alter.
// Ohne das hinken schnelle Objekte (fahrende Autos) immer einen Paketabstand hinterher und ruckeln.
float age = Mathf.Min(Time.time - b.TargetTime, MaxExtrapolation);
Vector3 predicted = b.TargetPos + b.TargetVel * age;
if ((b.Rb.position - predicted).sqrMagnitude > SnapDistance * SnapDistance)
{
b.Rb.transform.position = b.TargetPos;
b.Rb.transform.position = predicted;
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));
float follow = b.IsVehicle ? 0.5f : 0.35f;
b.Rb.MovePosition(Vector3.Lerp(b.Rb.position, predicted, follow));
b.Rb.MoveRotation(Quaternion.Slerp(b.Rb.rotation, b.TargetRot, follow));
}
}
}
@@ -315,7 +410,7 @@ namespace MSCO
public void ApplyAvatarHold(int playerId, int bodyId, Vector3 pos, Quaternion rot)
{
SyncedBody b;
if (!byId.TryGetValue(bodyId, out b) || b.Rb == null || b.IsVehicle) return; // ältere Mods melden evtl. ein Auto als "gehalten"
if (!byId.TryGetValue(bodyId, out b) || b.Rb == null || b.IsVehicle || b.Attached) return; // ältere Mods melden evtl. ein Auto als "gehalten"
if (b.OwnerId != playerId)
{
if (!Wins(playerId, true, b.OwnerId, b.OwnerActive)) return;
@@ -330,6 +425,8 @@ namespace MSCO
b.Rb.transform.rotation = rot;
b.TargetPos = pos;
b.TargetRot = rot;
b.TargetVel = Vector3.zero;
b.TargetTime = Time.time;
b.AvatarHoldTime = Time.time;
}
@@ -369,6 +466,8 @@ namespace MSCO
FreezeForRemote(b);
b.TargetPos = b.Rb.position;
b.TargetRot = b.Rb.rotation;
b.TargetVel = Vector3.zero;
b.TargetTime = Time.time;
}
b.OwnerId = ownerId;
}
@@ -381,45 +480,43 @@ namespace MSCO
}
/// <summary>
/// Ein anderer Spieler bewegt das Objekt: bei uns nur noch folgen (kinematisch). Bei Fahrzeugen auch alle
/// angebauten Teile - die hängen in MSC mit Gelenken (mit Bruchkraft) am Auto. Würde nur das Auto ruckartig
/// versetzt, rissen die Gelenke und das Auto fiele auseinander.
/// Ein anderer Spieler bewegt das Objekt: bei uns nur noch folgen (kinematisch). Bei Fahrzeugen hängen die
/// angebauten Teile (Türen, Kotflügel, Sitze ...) mit Gelenken mit Bruchkraft am Auto. Die Teile dürfen NICHT
/// selbst kinematisch geschaltet werden (das bricht die Gelenke sofort, MSC wertet es als "Teil abgefallen"),
/// und das ruckartige Folgen würde sie ebenfalls abreißen. Deshalb werden ihre Gelenke solange unzerbrechlich.
/// </summary>
void FreezeForRemote(SyncedBody b)
{
b.WasKinematic = b.Rb.isKinematic;
b.Rb.isKinematic = true;
if (b.IsVehicle) FreezeParts(b);
if (b.IsVehicle) HoldParts(b);
}
void FreezeParts(SyncedBody b)
void HoldParts(SyncedBody b)
{
b.NextPartScan = Time.time + 1f;
if (b.FrozenParts == null) b.FrozenParts = new Dictionary<Rigidbody, bool>();
foreach (Rigidbody part in b.Rb.GetComponentsInChildren<Rigidbody>())
b.NextPartScan = Time.time + 2f; // später angebaute Teile ebenfalls
if (b.HeldJoints == null) b.HeldJoints = new Dictionary<Joint, Vector2>();
foreach (Joint j in b.Rb.GetComponentsInChildren<Joint>(true))
{
if (part == b.Rb || b.FrozenParts.ContainsKey(part)) continue;
b.FrozenParts[part] = part.isKinematic;
part.isKinematic = true;
if (j == null || b.HeldJoints.ContainsKey(j)) continue;
b.HeldJoints[j] = new Vector2(j.breakForce, j.breakTorque);
j.breakForce = Mathf.Infinity;
j.breakTorque = Mathf.Infinity;
}
}
/// <summary>Wieder selbst simulieren: Auto und angebaute Teile zurück auf ihren vorherigen Zustand.</summary>
/// <summary>Wieder selbst simulieren: Auto zurück auf seinen Zustand, Gelenke wieder mit ihrer Bruchkraft.</summary>
void RestoreKinematic(SyncedBody b)
{
if (b.Rb != null) b.Rb.isKinematic = b.WasKinematic;
if (b.FrozenParts == null) return;
foreach (KeyValuePair<Rigidbody, bool> kv in b.FrozenParts)
if (b.HeldJoints == null) return;
foreach (KeyValuePair<Joint, Vector2> kv in b.HeldJoints)
{
if (kv.Key == null) continue;
kv.Key.isKinematic = kv.Value;
if (!kv.Value)
{
kv.Key.velocity = Vector3.zero; // keinen Schwung aus dem Folgen mitnehmen
kv.Key.angularVelocity = Vector3.zero;
if (kv.Key == null) continue; // Teil wurde inzwischen abgebaut
kv.Key.breakForce = kv.Value.x;
kv.Key.breakTorque = kv.Value.y;
}
}
b.FrozenParts = null;
b.HeldJoints = null;
}
void SendOwnedStates()
@@ -428,7 +525,7 @@ namespace MSCO
float now = Time.time;
foreach (SyncedBody b in byId.Values)
{
if (!IsLocal(b) || b.Rb == null) continue;
if (!IsLocal(b) || b.Rb == null || b.Attached) 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;
@@ -457,7 +554,7 @@ namespace MSCO
int id = r.ReadInt32();
bool active = r.ReadBoolean();
SyncedBody b;
if (!byId.TryGetValue(id, out b) || b.Rb == null) return;
if (!byId.TryGetValue(id, out b) || b.Rb == null || IsAttached(b)) return;
if (!Wins(from, active, b.OwnerId, b.OwnerActive))
{
// Wir besitzen es und gewinnen: nochmal melden, damit alle es wissen
@@ -476,12 +573,14 @@ namespace MSCO
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 || !IsValidPose(pos, rot)) continue;
if (!byId.TryGetValue(id, out b) || b.Rb == null || !IsValidPose(pos, rot) || IsAttached(b)) 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;
b.TargetVel = vel.sqrMagnitude < 90f * 90f ? vel : Vector3.zero;
b.TargetTime = Time.time;
}
break;
}
@@ -492,7 +591,7 @@ namespace MSCO
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;
if (!IsValidPose(pos, rot)) { SetFree(b); return; }
if (!IsValidPose(pos, rot) || IsAttached(b)) { SetFree(b); return; }
b.Rb.transform.position = pos;
b.Rb.transform.rotation = rot;
RestoreKinematic(b);
@@ -514,17 +613,81 @@ namespace MSCO
/// </summary>
void StopRunaway(SyncedBody b)
{
if (b.Attached) return;
Rigidbody rb = b.Rb;
if (rb.isKinematic || rb.velocity.sqrMagnitude < RunawaySpeed * RunawaySpeed) return;
rb.velocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
if (IsLocal(b) && b.LastSentTime > 0f && IsValidPose(b.LastSentPos, b.LastSentRot))
b.NoMotionClaimUntil = Time.time + HandoverCooldown;
// Wird es kurz hintereinander wieder weggeschleudert, steckt es in etwas fest (z.B. loses Teil im Auto).
// NICHT an die alte Stelle zurücksetzen (dort steckt es ja) - sondern von oben auf die nächste freie Fläche.
if (Time.time - b.RunawayWindowStart > 5f) { b.RunawayWindowStart = Time.time; b.RunawayCount = 0; }
b.RunawayCount++;
bool stuck = b.RunawayCount >= 2;
if (stuck)
{
rb.transform.position = AboveGround(b.LastSentPos, rb.transform);
rb.transform.rotation = b.LastSentRot;
PlaceOnTop(rb);
b.RunawayCount = 0;
}
else rb.transform.position = AboveGround(rb.position, rb.transform);
ModConsole.Warning("[MSCO] Stopped " + b.RootName + " - it was flung away by the physics");
rb.Sleep();
// Nicht das Log fluten: pro Objekt höchstens alle 30 s melden
if (Time.time - b.LastRunawayLog > 30f)
{
b.LastRunawayLog = Time.time;
MscoLog.Warning("Stopped " + b.RootName + " - it was flung away by the physics" + (stuck ? " (was stuck, placed on top)" : ""));
}
}
/// <summary>Objekt von oben auf die höchste Fläche an seiner Stelle legen (eigene Collider und Spieler ignoriert).</summary>
static void PlaceOnTop(Rigidbody rb)
{
Bounds bounds = new Bounds(rb.worldCenterOfMass, Vector3.zero);
foreach (Collider c in rb.GetComponentsInChildren<Collider>()) bounds.Encapsulate(c.bounds);
Vector3 from = new Vector3(bounds.center.x, bounds.max.y + 50f, bounds.center.z);
float top = float.MinValue;
foreach (RaycastHit hit in Physics.RaycastAll(from, Vector3.down, 120f))
{
if (hit.collider.isTrigger || hit.transform.IsChildOf(rb.transform) || hit.transform.root.name == "PLAYER") continue;
if (hit.point.y > top) top = hit.point.y;
}
if (top == float.MinValue) return;
float lift = top + bounds.extents.y + 0.3f - bounds.center.y;
rb.transform.position += Vector3.up * lift;
}
/// <summary>
/// Ist das Objekt gerade an ein Fahrzeug angebaut? Dann Besitz aufgeben und nicht mehr als loses Objekt behandeln.
/// Wird es wieder abgebaut, läuft der Sync ganz normal weiter. Gibt true zurück, solange es angebaut ist.
/// </summary>
bool UpdateAttached(SyncedBody b)
{
if (Time.time < b.NextAttachCheck) return b.Attached;
b.NextAttachCheck = Time.time + 0.5f;
bool attached = IsAttached(b);
if (attached == b.Attached) return attached;
b.Attached = attached;
if (attached)
{
if (IsRemoteOwned(b)) RestoreKinematic(b);
b.OwnerId = SyncedBody.NoOwner; // jeder Spieler erkennt das Anbauen selbst (Montage kommt über FsmSync)
b.OwnerActive = false;
}
else b.NoMotionClaimUntil = Time.time + HandoverCooldown; // gerade abgebaut: kurz zur Ruhe kommen lassen
return attached;
}
static bool IsAttached(SyncedBody b)
{
if (b.IsVehicle || b.Rb == null) return false;
// Unter einem anderen Rigidbody (z.B. am Auto eingehängt)
for (Transform p = b.Rb.transform.parent; p != null; p = p.parent)
if (p.GetComponent<Rigidbody>() != null) return true;
// Per Gelenk mit etwas verbunden, das nicht der Spieler ist (die Hand hält Items nicht über Gelenke fest)
foreach (Joint j in b.Rb.GetComponents<Joint>())
if (j != null && j.connectedBody != null && j.connectedBody.transform.root.name != "PLAYER") return true;
return false;
}
/// <summary>Position innerhalb der Karte, Drehung gültig (gleiche Regel wie auf dem Server).</summary>
@@ -642,14 +805,26 @@ namespace MSCO
if (best != null)
{
pickedObject = best.FsmVariables.FindFsmGameObject("PickedObject");
ModConsole.Print("[MSCO] Found pickup FSM on " + best.gameObject.name +
MscoLog.Info("[MSCO] Found pickup FSM on " + best.gameObject.name +
(best.gameObject.activeInHierarchy ? "" : " (inactive)"));
}
}
}
GameObject lastPicked; // letzter Wert von PickedObject - ändert er sich, hat der Spieler wirklich neu gegriffen
float pickTime = -100f;
/// <summary>Hat der Spieler gerade eben (nicht vor Minuten) etwas gegriffen?</summary>
bool JustPicked { get { return Time.time - pickTime < 0.6f; } }
SyncedBody HeldBody()
{
GameObject picked = pickedObject != null ? pickedObject.Value : null;
if (picked != lastPicked)
{
lastPicked = picked;
if (picked != null) pickTime = Time.time;
}
if (pickedObject == null || pickedObject.Value == null) return null;
for (Transform t = pickedObject.Value.transform; t != null; t = t.parent)
{
@@ -690,6 +865,13 @@ namespace MSCO
// ---------------------------------------------------------------- Registrierung
/// <summary>Sofort nach neuen Objekten suchen (z.B. nach dem Nachspielen von Montage-Aktionen).</summary>
public void ScanNow()
{
Scan();
nextScan = Time.time + ScanInterval;
}
/// <summary>Neue Rigidbodies suchen (gekaufte Items, abmontierte Teile ...).</summary>
void Scan()
{
@@ -728,14 +910,24 @@ namespace MSCO
Id = id,
Path = path,
Rb = c.Key,
Go = c.Key.gameObject,
Name = c.Key.gameObject.name,
LastPos = c.Key.position,
IsVehicle = c.Value,
RootName = c.Key.transform.root.name
};
byId[id] = b;
byRb[c.Key] = b;
added++;
// Neues Item nach dem ersten Suchlauf, bei uns in der Nähe und nicht übers Netz entstanden:
// wahrscheinlich gekauft / ausgepackt / geliefert - den anderen melden
bool isItem = !b.IsVehicle && b.Go.name.EndsWith("(itemx)");
if (ScanComplete && isItem && ItemAppeared != null && !networkCreated.Contains(b.Go) && Time.time - loadTime > 30f
&& (b.LastPos - lastPlayerPos).sqrMagnitude < LocalEventRange * LocalEventRange)
ItemAppeared(b);
}
if (added > 0) ModConsole.Print("[MSCO] Syncing " + byId.Count + " objects (+" + added + ")");
if (added > 0) MscoLog.Info("[MSCO] Syncing " + byId.Count + " objects (+" + added + ")");
}
static bool ShouldSync(Rigidbody rb, out bool isVehicle)
+153 -12
View File
@@ -14,7 +14,7 @@ namespace MSCO
}
/// <summary>
/// Fenster über dem Spiel: F8-Konsole (alle MSCO-Befehle), Chat (T) und Admin-Menü (/admin).
/// Fenster über dem Spiel: F7-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
@@ -51,6 +51,8 @@ namespace MSCO
// Admin
public bool AdminOpen { get; private set; }
List<AdminPlayer> adminPlayers;
List<string> bannedIds;
int adminTab;
string adminReason = "";
int confirmBanId = -1;
Vector2 adminScroll;
@@ -92,6 +94,10 @@ namespace MSCO
}
public void SetAdminPlayers(List<AdminPlayer> players) { adminPlayers = players; }
public void SetBanned(List<string> ids) { bannedIds = ids; }
public void SetBackups(List<string> names) { backupNames = names; }
List<string> backupNames;
string confirmWorld; // "reset" oder Name des Backups, das wiederhergestellt werden soll
public void CloseAll()
{
@@ -133,7 +139,7 @@ namespace MSCO
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);
GUI.Label(new Rect(rect.xMax - 216, rect.y + 10, 200, 20), "type 'help' - F7 to close", small);
// Tasten: Enter ausführen, Esc schließen, Hoch/Runter Verlauf
Event e = Event.current;
@@ -263,7 +269,7 @@ namespace MSCO
void DrawAdmin(MultiplayerManager mp)
{
float width = Mathf.Min(680, Screen.width - 40), height = Mathf.Min(560, Screen.height - 40);
float width = Mathf.Min(720, Screen.width - 40), height = Mathf.Min(580, Screen.height - 40);
var rect = new Rect((Screen.width - width) / 2f, (Screen.height - height) / 2f, width, height);
GUI.Box(rect, GUIContent.none, panel);
@@ -281,13 +287,31 @@ namespace MSCO
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(6);
// Reiter
GUILayout.BeginHorizontal();
string[] tabs = { "PLAYERS", "ITEMS", "BANS" + (bannedIds != null && bannedIds.Count > 0 ? " (" + bannedIds.Count + ")" : ""), "WORLD" };
for (int i = 0; i < tabs.Length; i++)
{
if (GUILayout.Button(tabs[i], adminTab == i ? primary : secondary, GUILayout.Height(30), GUILayout.Width(130))) adminTab = i;
GUILayout.Space(6);
}
GUILayout.EndHorizontal();
GUILayout.Space(10);
if (adminTab == 1) DrawAdminItems(mp);
else if (adminTab == 2) DrawAdminBans(mp);
else if (adminTab == 3) DrawAdminWorld(mp);
else DrawAdminPlayers(mp);
GUILayout.EndArea();
}
void DrawAdminPlayers(MultiplayerManager mp)
{
// Cheats für den eigenen Spieler (nur im Spiel)
if (mp.CanCheat)
{
GUILayout.Label("CHEATS", caption);
GUILayout.BeginHorizontal();
if (GUILayout.Button("+1.000 MK", primary, GUILayout.Height(30))) mp.CheatMoney(1000f);
GUILayout.Space(6);
@@ -297,13 +321,13 @@ namespace MSCO
GUILayout.Space(6);
if (GUILayout.Button("SATSUMA TO ME", primary, GUILayout.Height(30))) mp.BringCar();
GUILayout.EndHorizontal();
GUILayout.Label("Cheats are announced in the chat. GOTO / BRING teleport you to a player or a player to you.", small);
GUILayout.Space(10);
GUILayout.Label("Cheats are announced in the chat. GOTO teleports you to a player, BRING teleports a player to you.", small);
GUILayout.Space(8);
}
GUILayout.Label("REASON (optional)", caption);
GUILayout.Label("REASON FOR KICK / BAN (optional)", caption);
adminReason = GUILayout.TextField(adminReason, 100, field, GUILayout.Height(32));
GUILayout.Space(12);
GUILayout.Space(10);
adminScroll = GUILayout.BeginScrollView(adminScroll);
if (adminPlayers == null) GUILayout.Label("Loading players...", small);
@@ -330,11 +354,11 @@ namespace MSCO
GUILayout.Space(12);
}
GUI.enabled = !self && !p.IsAdmin;
if (GUILayout.Button("KICK", secondary, GUILayout.Height(30), GUILayout.Width(80)))
if (GUILayout.Button("KICK", secondary, GUILayout.Height(30), GUILayout.Width(70)))
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 (GUILayout.Button(confirming ? "SURE?" : "BAN", danger, GUILayout.Height(30), GUILayout.Width(70)))
{
if (confirming) { mp.AdminAction(true, p.Id, adminReason); confirmBanId = -1; }
else confirmBanId = p.Id;
@@ -345,7 +369,123 @@ namespace MSCO
}
}
GUILayout.EndScrollView();
GUILayout.EndArea();
}
void DrawAdminItems(MultiplayerManager mp)
{
if (!mp.CanCheat)
{
GUILayout.Label("Items can only be spawned in the game.", small);
return;
}
GUILayout.Label("Click an item to spawn it in front of you. Everyone sees it, and it is saved on the server.", small);
GUILayout.Space(8);
adminScroll = GUILayout.BeginScrollView(adminScroll);
const int perRow = 4;
for (int i = 0; i < ItemSpawner.Items.Length; i += perRow)
{
GUILayout.BeginHorizontal();
for (int k = i; k < Mathf.Min(i + perRow, ItemSpawner.Items.Length); k++)
{
string item = ItemSpawner.Items[k];
if (GUILayout.Button(item.ToUpper(), secondary, GUILayout.Height(34), GUILayout.Width(155))) mp.SpawnItem(item);
GUILayout.Space(6);
}
GUILayout.EndHorizontal();
GUILayout.Space(6);
}
GUILayout.EndScrollView();
}
/// <summary>Welt zurücksetzen, Backups anlegen und wiederherstellen (dedizierter Server).</summary>
void DrawAdminWorld(MultiplayerManager mp)
{
if (!mp.IsDedicated)
{
GUILayout.Label("World reset and save backups are only available on a dedicated server.", small);
return;
}
GUILayout.Label("Everyone reconnects after a reset or restore. The current state is backed up first.", small);
GUILayout.Space(8);
GUILayout.BeginHorizontal();
if (confirmWorld == "reset")
{
if (GUILayout.Button("REALLY RESET?", danger, GUILayout.Height(32), GUILayout.Width(170))) { mp.WorldAction(5, null); confirmWorld = null; }
GUILayout.Space(6);
if (GUILayout.Button("CANCEL", secondary, GUILayout.Height(32), GUILayout.Width(100))) confirmWorld = null;
}
else if (GUILayout.Button("RESET WORLD", danger, GUILayout.Height(32), GUILayout.Width(170))) confirmWorld = "reset";
GUILayout.Space(10);
if (GUILayout.Button("BACKUP NOW", primary, GUILayout.Height(32), GUILayout.Width(150))) mp.WorldAction(6, null);
GUILayout.Space(6);
if (confirmWorld == "point")
{
if (GUILayout.Button("REALLY?", danger, GUILayout.Height(32), GUILayout.Width(100))) { mp.WorldAction(8, null); confirmWorld = null; }
GUILayout.Space(4);
if (GUILayout.Button("CANCEL", secondary, GUILayout.Height(32), GUILayout.Width(90))) confirmWorld = null;
}
else if (GUILayout.Button("SET RESET POINT", secondary, GUILayout.Height(32), GUILayout.Width(170))) confirmWorld = "point";
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
GUILayout.Label("Reset: the whole world goes back to the save game as it was uploaded to the server (the reset point). " +
"SET RESET POINT makes the current save the new reset point.", small);
GUILayout.Space(10);
GUILayout.Label("SAVE BACKUPS (NEWEST FIRST)", small);
adminScroll = GUILayout.BeginScrollView(adminScroll);
if (backupNames == null) GUILayout.Label("Loading...", small);
else if (backupNames.Count == 0) GUILayout.Label("No backups yet. They are created automatically when the server takes over a save game, or with BACKUP NOW.", small);
else
{
foreach (string name in backupNames)
{
GUILayout.BeginHorizontal(rowStyle, GUILayout.Height(40));
GUILayout.Label(PrettyBackup(name), chatName);
GUILayout.FlexibleSpace();
if (confirmWorld == name)
{
if (GUILayout.Button("REALLY?", danger, GUILayout.Height(28), GUILayout.Width(100))) { mp.WorldAction(7, name); confirmWorld = null; }
GUILayout.Space(4);
if (GUILayout.Button("CANCEL", secondary, GUILayout.Height(28), GUILayout.Width(90))) confirmWorld = null;
}
else if (GUILayout.Button("RESTORE", primary, GUILayout.Height(28), GUILayout.Width(100))) confirmWorld = name;
GUILayout.EndHorizontal();
GUILayout.Space(4);
}
}
GUILayout.EndScrollView();
}
/// <summary>"20260927-104626" -> "27.09.2026 10:46:26".</summary>
static string PrettyBackup(string name)
{
if (name == null || name.Length != 15 || name[8] != '-') return name;
return name.Substring(6, 2) + "." + name.Substring(4, 2) + "." + name.Substring(0, 4) + " " +
name.Substring(9, 2) + ":" + name.Substring(11, 2) + ":" + name.Substring(13, 2);
}
void DrawAdminBans(MultiplayerManager mp)
{
GUILayout.Label(mp.IsDedicated ? "Banned players cannot join this server. UNBAN lets them in again."
: "Players banned from your game in this session.", small);
GUILayout.Space(8);
adminScroll = GUILayout.BeginScrollView(adminScroll);
if (bannedIds == null) GUILayout.Label("Loading...", small);
else if (bannedIds.Count == 0) GUILayout.Label("Nobody is banned.", small);
else
{
foreach (string id in bannedIds)
{
GUILayout.BeginHorizontal(rowStyle, GUILayout.Height(40));
GUILayout.Label(id.StartsWith("name:") ? id.Substring(5) + " (no Steam)" : "SteamID " + id, chatName);
GUILayout.FlexibleSpace();
if (GUILayout.Button("UNBAN", primary, GUILayout.Height(28), GUILayout.Width(90))) mp.Unban(id);
GUILayout.EndHorizontal();
GUILayout.Space(4);
}
}
GUILayout.EndScrollView();
}
// ---------------------------------------------------------------- Stile
@@ -359,6 +499,7 @@ namespace MSCO
field = UiTheme.Field(14);
logText = UiTheme.Label(12, UiTheme.Text);
logText.wordWrap = true;
logText.richText = true; // Warnungen/Fehler farbig markiert (MscoLog)
logText.margin = new RectOffset(0, 0, 0, 0);
caption = UiTheme.Label(11, UiTheme.Muted, FontStyle.Bold);
title = UiTheme.Label(22, UiTheme.Text, FontStyle.Bold);
+42 -2
View File
@@ -11,7 +11,7 @@ namespace MSCO
/// </summary>
public class Passenger
{
const float EnterRange = 5f; // so nah muss man am Fahrzeug stehen (Abstand zur Fahrzeugmitte)
const float EnterRange = 3f; // so nah muss man an der Karosserie stehen
const float CenterSeatThreshold = 0.2f;
const float BehindCenterSeat = 0.7f;
const float ExitSideOffset = 1.4f; // beim Aussteigen so weit neben den Sitz
@@ -24,6 +24,9 @@ namespace MSCO
float lastYaw;
readonly List<Behaviour> disabled = new List<Behaviour>();
CharacterController controller;
// Feste Collider des Spielers, die während der Fahrt aus sind (sonst stecken sie in Sitzen und Teilen des Autos)
readonly List<Collider> disabledColliders = new List<Collider>();
readonly List<Rigidbody> frozenBodies = new List<Rigidbody>();
public bool IsRiding { get { return vehicle != null; } }
@@ -44,7 +47,7 @@ namespace MSCO
float nearest = float.MaxValue;
foreach (SyncedBody b in driven)
{
float d = (b.Rb.worldCenterOfMass - player.position).sqrMagnitude;
float d = DistanceToVehicleSq(b, player.position);
nearest = Mathf.Min(nearest, d);
PlayerInfo owner;
if (!players.TryGetValue(b.OwnerId, out owner) || owner.Avatar == null) continue;
@@ -69,6 +72,22 @@ namespace MSCO
return "Riding as passenger (press again to get out)";
}
/// <summary>
/// Abstand zur nächsten Stelle der Karosserie (Collider), nicht zum Schwerpunkt - den legt MSC teils weit weg,
/// dann hieß es "26 m entfernt", obwohl man direkt an der Tür stand.
/// </summary>
static float DistanceToVehicleSq(SyncedBody b, Vector3 point)
{
float best = (b.Rb.position - point).sqrMagnitude;
foreach (Collider c in b.Rb.GetComponentsInChildren<Collider>())
{
if (c == null || !c.enabled || c.isTrigger) continue;
float d = (c.bounds.ClosestPoint(point) - point).sqrMagnitude;
if (d < best) best = d;
}
return best;
}
/// <summary>Nach der Physik (LateUpdate): Spieler auf den Sitz setzen und mitdrehen.</summary>
public void LateUpdate(Transform player, Camera cam, ObjectSync objects)
{
@@ -126,6 +145,23 @@ namespace MSCO
controller = player.GetComponent<CharacterController>();
if (controller != null && controller.enabled) controller.enabled = false;
else controller = null;
// Der Spieler sitzt mitten im Auto: seine festen Collider würden Sitze und Teile wegdrücken
// (Teile reißen ab, Sitze fliegen, bei Tempo stürzt die Physik ab). Wie beim echten Einsteigen: aus.
disabledColliders.Clear();
foreach (Collider c in player.GetComponentsInChildren<Collider>())
{
if (c == null || !c.enabled || c.isTrigger || c is CharacterController) continue;
c.enabled = false;
disabledColliders.Add(c);
}
frozenBodies.Clear();
foreach (Rigidbody rb in player.GetComponentsInChildren<Rigidbody>())
{
if (rb == null || rb.isKinematic) continue;
rb.isKinematic = true;
frozenBodies.Add(rb);
}
}
else
{
@@ -133,6 +169,10 @@ namespace MSCO
disabled.Clear();
if (controller != null) controller.enabled = true;
controller = null;
foreach (Collider c in disabledColliders) if (c != null) c.enabled = true;
disabledColliders.Clear();
foreach (Rigidbody rb in frozenBodies) if (rb != null) rb.isKinematic = false;
frozenBodies.Clear();
}
}
}
+55
View File
@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.IO;
using HutongGames.PlayMaker;
namespace MSCO
{
/// <summary>
/// Bedürfnisse pro Spieler (Hunger, Durst, Stress, Harndrang, Müdigkeit, Schmutz): Im gemeinsamen Spielstand gibt es
/// sie nur einmal - ohne das hätte jeder beim Beitreten die Werte desjenigen, der den Spielstand zuletzt hochgeladen
/// hat. Deshalb schickt jeder seine eigenen Werte regelmäßig an den Server, der sie pro Spieler speichert und beim
/// nächsten Beitreten zurückgibt.
/// </summary>
public static class PlayerNeeds
{
public static readonly string[] Names =
{
"PlayerHunger", "PlayerThirst", "PlayerStress", "PlayerUrine", "PlayerFatigue", "PlayerDirtiness"
};
/// <summary>Eigene Werte: int n, je [string Name, float Wert]. null, wenn das Spiel sie (noch) nicht hat.</summary>
public static byte[] Build()
{
var values = new List<KeyValuePair<string, float>>();
foreach (string name in Names)
{
FsmFloat f = FsmVariables.GlobalVariables.FindFsmFloat(name);
if (f != null && !float.IsNaN(f.Value) && !float.IsInfinity(f.Value)) values.Add(new KeyValuePair<string, float>(name, f.Value));
}
if (values.Count == 0) return null;
return NetSession.Build(w =>
{
w.Write(values.Count);
foreach (KeyValuePair<string, float> v in values) { w.Write(v.Key); w.Write(v.Value); }
});
}
/// <summary>Gespeicherte Werte vom Server übernehmen (Format wie Build).</summary>
public static int Apply(BinaryReader r)
{
int count = r.ReadInt32();
int applied = 0;
for (int i = 0; i < count; i++)
{
string name = r.ReadString();
float value = r.ReadSingle();
if (System.Array.IndexOf(Names, name) < 0 || float.IsNaN(value) || float.IsInfinity(value)) continue;
FsmFloat f = FsmVariables.GlobalVariables.FindFsmFloat(name);
if (f == null) continue;
f.Value = value;
applied++;
}
return applied;
}
}
}
+2 -2
View File
@@ -32,5 +32,5 @@ using System.Runtime.InteropServices;
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("2.7.0.0")]
[assembly: AssemblyFileVersion("2.7.0.0")]
[assembly: AssemblyVersion("2.8.0.0")]
[assembly: AssemblyFileVersion("2.8.0.0")]
+2 -2
View File
@@ -31,7 +31,7 @@ Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern
**Im Spiel**
- **T** – Chat
- **F8** – MSCO-Konsole mit allen Befehlen
- **F7** – MSCO-Konsole mit allen Befehlen
- **/admin** – Admin-Menü für Server-Admins bzw. den P2P-Host: Spieler kicken/bannen, GOTO/BRING (teleportieren),
Cheats (+1.000 / +10.000 MK, Bedürfnisse auffüllen, Satsuma zu sich holen) – Cheats werden im Chat angezeigt
- **P** – als Beifahrer ein-/aussteigen
@@ -62,7 +62,7 @@ unter https://msco.mbm.wtf/keys.
## Befehle
In der F8-Konsole direkt, in der MSCLoader-Konsole mit `mp` davor (z.B. `mp connect ...`):
In der F7-Konsole direkt, in der MSCLoader-Konsole mit `mp` davor (z.B. `mp connect ...`):
| Befehl | Beschreibung |
|---|---|
+40 -25
View File
@@ -21,6 +21,7 @@ namespace MSCO
const float TorsoLength = 0.55f; // Hüfte bis Hals
const float HipWidth = 0.1f, ShoulderWidth = 0.21f;
const float AnkleHeight = 0.07f;
const float LegReach = ThighLength + ShinLength + AnkleHeight - 0.02f; // Hüfthöhe bei gestrecktem Bein
static readonly Color Skin = new Color(0.86f, 0.66f, 0.52f);
static readonly Color Jeans = new Color(0.2f, 0.26f, 0.38f);
@@ -39,7 +40,7 @@ namespace MSCO
Transform pelvis, torso, belt, neck, belly, collar, placket, buckle;
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];
readonly Transform[] sole = new Transform[2], cuff = new Transform[2];
readonly Transform[] sole = new Transform[2], cuff = new Transform[2], shoulderBall = new Transform[2];
Vector3 targetFeet;
Vector3 targetHead;
@@ -50,6 +51,7 @@ namespace MSCO
Vector3 lastPosition;
float walkPhase, walkAmount, speed;
float airAmount; // 0 = steht am Boden, 1 = in der Luft (Sprung, Fallen)
public int HeldId { get { return last.HeldId; } }
public PlayerState LastState { get { return last; } }
@@ -88,9 +90,9 @@ namespace MSCO
// Körper: Karohemd/Jacke, Jeans, Gürtel, leichter Bauch (wie die Leute in MSC)
pelvis = Capsule("Pelvis", r, Jeans);
torso = Textured(Capsule("Chest", r, Color.white), plaid, new Vector2(2f, 2f));
belly = Textured(Capsule("Belly", r, Color.white), plaid, new Vector2(2f, 1f));
belt = Box("Belt", r, new Color(0.2f, 0.13f, 0.08f));
torso = Textured(Capsule("Chest", r, Color.white), plaid, new Vector2(4f, 4f));
belly = Textured(Capsule("Belly", r, Color.white), plaid, new Vector2(4f, 2f));
belt = Primitive(PrimitiveType.Cylinder, "Belt", r, new Color(0.2f, 0.13f, 0.08f)); // rund um den Bauch
buckle = Box("Buckle", r, Metal);
placket = Box("Buttons", r, jacketDark);
collar = Capsule("Collar", r, jacketDark);
@@ -100,11 +102,12 @@ namespace MSCO
{
thigh[i] = Capsule("Thigh", r, Jeans);
shin[i] = Capsule("Shin", r, Jeans * 0.92f);
boot[i] = Box("Boot", r, Boots);
sole[i] = Box("Sole", r, Sole);
upperArm[i] = Textured(Capsule("UpperArm", r, Color.white), plaid, new Vector2(1f, 2f));
forearm[i] = Textured(Capsule("Forearm", r, Color.white), plaid, new Vector2(1f, 2f));
boot[i] = Capsule("Boot", r, Boots); // abgerundete Arbeitsstiefel statt Klötze
sole[i] = Capsule("Sole", r, Sole);
upperArm[i] = Textured(Capsule("UpperArm", r, Color.white), plaid, new Vector2(2f, 3f));
forearm[i] = Textured(Capsule("Forearm", r, Color.white), plaid, new Vector2(2f, 3f));
cuff[i] = Capsule("Cuff", r, jacketDark);
shoulderBall[i] = Textured(Sphere("Shoulder", r, Color.white, 0.13f), plaid, new Vector2(2f, 2f)); // runde Schulter
hand[i] = Sphere("Hand", r, Skin, 0.1f);
}
@@ -159,7 +162,8 @@ namespace MSCO
brim.localPosition = new Vector3(0f, 0.07f, 0.15f);
brim.localScale = new Vector3(0.2f, 0.014f, 0.1f);
brim.localRotation = Quaternion.Euler(8f, 0f, 0f); // Schirm leicht nach unten
Transform patch = Box("CapPatch", headPivot, new Color(0.9f, 0.88f, 0.8f));
Color patchColor = Color.Lerp(jacketDark, Color.white, 0.35f);
Transform patch = Box("CapPatch", headPivot, patchColor);
patch.localPosition = new Vector3(0f, 0.115f, 0.118f);
patch.localScale = new Vector3(0.06f, 0.035f, 0.01f);
patch.localRotation = Quaternion.Euler(-25f, 0f, 0f);
@@ -175,20 +179,21 @@ namespace MSCO
{
const int size = 16;
var tex = new Texture2D(size, size, TextureFormat.RGB24, false);
Color light = Color.Lerp(baseColor, Color.white, 0.18f);
Color dark = baseColor * 0.62f;
// Weiches Flanell-Karo: wenig Kontrast, dünne Linien - wirkt aus der Entfernung wie Stoff statt Schachbrett
Color light = Color.Lerp(baseColor, Color.white, 0.1f);
Color dark = baseColor * 0.78f;
for (int y = 0; y < size; y++)
{
for (int x = 0; x < size; x++)
{
bool bandX = (x / 4) % 2 == 0, bandY = (y / 4) % 2 == 0;
Color c = bandX && bandY ? dark : bandX || bandY ? baseColor : light;
if (x % 8 == 1 || y % 8 == 1) c = Color.Lerp(c, Color.black, 0.45f); // dünne dunkle Linien
if (x % 8 == 1 || y % 8 == 1) c = Color.Lerp(c, Color.black, 0.25f); // dünne dunkle Linien
c.a = 1f;
tex.SetPixel(x, y, c);
}
}
tex.filterMode = FilterMode.Point;
tex.filterMode = FilterMode.Bilinear;
tex.wrapMode = TextureWrapMode.Repeat;
tex.Apply();
return tex;
@@ -290,6 +295,7 @@ namespace MSCO
// Hüfte: beim Lehnen nach hinten ausweichen, beim Ducken absinken (Füße bleiben am Boden)
Vector3 hip;
float air = 0f;
if (last.Seated)
{
hip = neckBase + new Vector3(0f, -TorsoLength, -0.08f);
@@ -300,8 +306,12 @@ namespace MSCO
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);
// Ist der Kopf höher, als die Beine reichen (Sprung, Fallen), hebt die ganze Figur ab - vorher blieben die
// Füße am Boden und nur der Oberkörper ging nach oben
hip.y = Mathf.Max(0.3f, neckBase.y - vertical);
air = Mathf.Max(0f, hip.y - LegReach);
}
airAmount = Mathf.Lerp(airAmount, last.Seated ? 0f : Mathf.Clamp01(air / 0.12f), dt > 0f ? Mathf.Clamp01(dt * 10f) : 1f);
Vector3 up = (neckBase - hip).normalized;
Vector3 right = Vector3.right;
@@ -314,8 +324,8 @@ namespace MSCO
Segment(belly, hip + up * 0.07f + forward * 0.015f, hip + up * 0.3f + forward * 0.015f, 0.33f, 0.25f);
belt.localPosition = hip + up * 0.06f;
belt.localRotation = torsoRot;
belt.localScale = new Vector3(0.345f, 0.045f, 0.275f);
buckle.localPosition = hip + up * 0.06f + forward * 0.14f;
belt.localScale = new Vector3(0.34f, 0.024f, 0.27f); // Zylinder: 0.048 m hoch, elliptisch
buckle.localPosition = hip + up * 0.06f + forward * 0.136f;
buckle.localRotation = torsoRot;
buckle.localScale = new Vector3(0.055f, 0.04f, 0.012f);
placket.localPosition = Vector3.Lerp(hip + up * 0.12f, neckBase, 0.6f) + forward * 0.124f;
@@ -329,7 +339,7 @@ namespace MSCO
headPivot.localPosition = head;
headPivot.localRotation = Quaternion.Euler(pitch, 0f, 0f);
float swing = Mathf.Sin(walkPhase) * walkAmount;
float swing = Mathf.Sin(walkPhase) * walkAmount * (1f - airAmount);
for (int i = 0; i < 2; i++)
{
float side = i == 0 ? -1f : 1f;
@@ -341,19 +351,24 @@ namespace MSCO
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);
// In der Luft: Füße unter der Hüfte mitnehmen und die Knie etwas anziehen
ankle.y += air + 0.1f * airAmount;
ankle.z -= 0.06f * airAmount;
}
Vector3 knee = Joint(hipJoint, ankle, ThighLength, ShinLength, Vector3.forward);
Segment(thigh[i], hipJoint, knee, 0.15f, 0.16f);
Segment(shin[i], knee, ankle, 0.12f, 0.13f);
boot[i].localPosition = ankle + new Vector3(0f, -0.015f, 0.05f);
boot[i].localRotation = Quaternion.identity;
boot[i].localScale = new Vector3(0.12f, 0.1f, 0.27f);
sole[i].localPosition = ankle + new Vector3(0f, -0.065f, 0.05f);
sole[i].localRotation = Quaternion.identity;
sole[i].localScale = new Vector3(0.13f, 0.025f, 0.29f);
// Stiefel: Kapsel von der Ferse zur Spitze, darunter eine flache Sohle
Segment(boot[i], ankle + new Vector3(0f, -0.025f, -0.06f), ankle + new Vector3(0f, -0.03f, 0.14f), 0.12f, 0.1f);
Segment(sole[i], ankle + new Vector3(0f, -0.07f, -0.075f), ankle + new Vector3(0f, -0.07f, 0.155f), 0.125f, 0.03f);
// Arme
Vector3 shoulder = neckBase - up * 0.06f + right * (ShoulderWidth * side);
shoulderBall[i].localPosition = shoulder + up * 0.005f;
shoulderBall[i].localRotation = torsoRot;
shoulderBall[i].localScale = new Vector3(0.125f, 0.12f, 0.13f);
Vector3 handTarget;
Vector3 heldPos;
Quaternion heldRot;
@@ -362,7 +377,7 @@ namespace MSCO
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.06f, -(UpperArmLength + ForearmLength) + 0.05f, 0.02f - phase * 0.22f);
handTarget = shoulder + new Vector3(side * (0.06f + 0.12f * airAmount), -(UpperArmLength + ForearmLength) + 0.05f + 0.14f * airAmount, 0.02f - phase * 0.22f);
// Nicht weiter als die Armlänge
Vector3 reach = handTarget - shoulder;
@@ -375,7 +390,7 @@ namespace MSCO
Segment(cuff[i], Vector3.Lerp(elbow, handTarget, 0.82f), Vector3.Lerp(elbow, handTarget, 0.9f), 0.105f, 0.105f);
hand[i].localPosition = handTarget;
hand[i].localRotation = Quaternion.LookRotation(Vector3.forward, (handTarget - elbow).sqrMagnitude > 0f ? elbow - handTarget : Vector3.up);
hand[i].localScale = new Vector3(0.075f, 0.115f, 0.1f);
hand[i].localScale = new Vector3(0.085f, 0.125f, 0.11f);
}
}
+3 -3
View File
@@ -32,7 +32,7 @@ namespace MSCO
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; }
if (onEnter == null) { MscoLog.Warning("[MSCO] SetParent action not found - hierarchy guard disabled"); return; }
gameObjectField = action.GetField("gameObject");
parentField = action.GetField("parent");
@@ -41,7 +41,7 @@ namespace MSCO
}
catch (Exception e)
{
ModConsole.Warning("[MSCO] Could not install hierarchy guard: " + e.Message);
MscoLog.Warning("[MSCO] Could not install hierarchy guard: " + e.Message);
}
}
@@ -60,7 +60,7 @@ namespace MSCO
blocked++;
if (blocked <= 20)
ModConsole.Warning("[MSCO] Blocked SetParent that would crash the game: " + go.name + " -> " + parent.Value.name +
MscoLog.Warning("[MSCO] Blocked SetParent that would crash the game: " + go.name + " -> " + parent.Value.name +
" (" + __instance.Fsm.Name + ")");
__instance.Finish();
return false;
+3 -3
View File
@@ -48,7 +48,7 @@ namespace MSCO
if (File.Exists(path)) File.Copy(path, Path.Combine(BackupDir, name), true);
}
File.WriteAllText(Marker, "MSCO backup of the client's own save. Restored automatically.");
ModConsole.Print("[MSCO] Own save backed up to " + BackupDir);
MscoLog.Info("[MSCO] Own save backed up to " + BackupDir);
}
DeleteSaveFiles();
}
@@ -58,7 +58,7 @@ namespace MSCO
// Nur bekannte Dateinamen erlauben, damit der Host nichts anderes überschreiben kann
if (System.Array.IndexOf(Files, name) < 0)
{
ModConsole.Warning("[MSCO] Ignored unexpected save file: " + name);
MscoLog.Warning("[MSCO] Ignored unexpected save file: " + name);
return;
}
File.WriteAllBytes(Path.Combine(SaveDir, name), data);
@@ -74,7 +74,7 @@ namespace MSCO
if (File.Exists(backup)) File.Copy(backup, Path.Combine(SaveDir, name), true);
}
Directory.Delete(BackupDir, true);
ModConsole.Print("[MSCO] Own save restored.");
MscoLog.Info("[MSCO] Own save restored.");
}
static void DeleteSaveFiles()
+1 -1
View File
@@ -107,7 +107,7 @@ namespace MSCO
}
catch (Exception e)
{
ModConsole.Error("[MSCO] Steam init failed: " + e.Message);
MscoLog.Error("[MSCO] Steam init failed: " + e.Message);
}
return initialized;
}
+100
View File
@@ -0,0 +1,100 @@
using System.Collections.Generic;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Kleine Hinweise oben rechts (unter der Ping-Anzeige), z.B. "Bob joined" / "Bob left".
/// Gleiten von rechts herein, bleiben ein paar Sekunden und blenden dann aus. Höchstens 4 gleichzeitig.
/// </summary>
public class Toasts
{
const float Lifetime = 4.5f;
const float SlideIn = 0.25f;
const float FadeOut = 0.6f;
const float Width = 250f;
const float Height = 36f;
const int MaxShown = 4;
class Toast
{
public string Text;
public string Detail;
public Color Dot;
public float Start;
}
readonly List<Toast> toasts = new List<Toast>();
GUIStyle box, title, detail;
Texture2D dotTex;
public void Add(string text, string detail, Color dot)
{
toasts.Add(new Toast { Text = text, Detail = detail, Dot = dot, Start = Time.realtimeSinceStartup });
if (toasts.Count > MaxShown) toasts.RemoveAt(0);
}
public void Draw()
{
if (toasts.Count == 0) return;
EnsureStyles();
float now = Time.realtimeSinceStartup;
toasts.RemoveAll(t => now - t.Start > Lifetime);
Color before = GUI.color;
float y = 40f; // unter der Ping-Anzeige
for (int i = toasts.Count - 1; i >= 0; i--) // neueste oben
{
Toast t = toasts[i];
float age = now - t.Start;
float slide = Mathf.Clamp01(age / SlideIn);
slide = 1f - (1f - slide) * (1f - slide); // weich abbremsen
float alpha = Mathf.Clamp01((Lifetime - age) / FadeOut);
var r = new Rect(Screen.width - 12 - Width + (1f - slide) * (Width + 20f), y, Width, Height);
GUI.color = new Color(1f, 1f, 1f, alpha);
GUI.Box(r, GUIContent.none, box);
GUI.color = new Color(t.Dot.r, t.Dot.g, t.Dot.b, alpha);
GUI.DrawTexture(new Rect(r.x + 14, r.y + Height / 2f - 4, 8, 8), dotTex);
GUI.color = new Color(1f, 1f, 1f, alpha);
GUI.Label(new Rect(r.x + 32, r.y, r.width - 40, Height), t.Text, title);
if (!string.IsNullOrEmpty(t.Detail))
{
float w = title.CalcSize(new GUIContent(t.Text)).x;
GUI.Label(new Rect(r.x + 32 + w + 6, r.y, r.width - 46 - w, Height), t.Detail, detail);
}
y += Height + 6f;
}
GUI.color = before;
}
void EnsureStyles()
{
if (box != null && box.normal.background != null) return;
box = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.88f), 6);
title = new GUIStyle(GUI.skin.label) { fontSize = 13, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft };
title.normal.textColor = Color.white;
title.wordWrap = false;
detail = new GUIStyle(title) { fontStyle = FontStyle.Normal, fontSize = 12 };
detail.normal.textColor = UiTheme.Muted;
dotTex = RoundDot(16);
}
static Texture2D RoundDot(int size)
{
var t = new Texture2D(size, size, TextureFormat.ARGB32, false);
float c = (size - 1) / 2f;
for (int x = 0; x < size; x++)
for (int y = 0; y < size; y++)
{
float d = Mathf.Sqrt((x - c) * (x - c) + (y - c) * (y - c));
t.SetPixel(x, y, new Color(1f, 1f, 1f, Mathf.Clamp01(c + 0.5f - d)));
}
t.Apply();
t.hideFlags = HideFlags.HideAndDontSave;
return t;
}
}
}
+163
View File
@@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Verkehr (NPC-Autos): Der Spieler mit der kleinsten ID (Welt-Autorität, gibt auch Uhrzeit/Wetter vor)
/// simuliert den Verkehr und schickt die Positionen. Bei allen anderen fahren die Autos dann nur noch nach
/// (kinematisch) - so stehen sie überall an derselben Stelle und man kann sich gemeinsam darüber ärgern.
/// </summary>
public class TrafficSync
{
static readonly string[] Roots = { "TRAFFIC", "NPC_CARS" };
const float ScanInterval = 10f;
const float SendInterval = 0.1f;
const float Stale = 1.5f; // so lange ohne Daten: wieder selbst simulieren
const float Smoothing = 12f;
const float MaxExtrapolation = 0.3f;
class Car
{
public int Key;
public Rigidbody Rb;
public bool WasKinematic;
public bool Following;
public Vector3 TargetPos, TargetVel;
public Quaternion TargetRot;
public float TargetTime = -100f;
}
readonly Dictionary<int, Car> cars = new Dictionary<int, Car>();
readonly HashSet<Rigidbody> seen = new HashSet<Rigidbody>();
float nextScan, sendTimer;
public int Count { get { return cars.Count; } }
public void Reset()
{
foreach (Car c in cars.Values) StopFollowing(c);
cars.Clear();
seen.Clear();
nextScan = 0f;
}
public void Update(bool authority, float dt, Action<MsgType, byte[]> send)
{
if (Time.time >= nextScan)
{
nextScan = Time.time + ScanInterval;
Scan();
}
if (!authority)
{
foreach (Car c in cars.Values)
if (c.Following && Time.time - c.TargetTime > Stale) StopFollowing(c);
return;
}
// Wir simulieren: eventuell noch nachfahrende Autos freigeben, Positionen senden
foreach (Car c in cars.Values) if (c.Following) StopFollowing(c);
sendTimer += dt;
if (sendTimer < SendInterval) return;
sendTimer = 0f;
var list = new List<Car>();
foreach (Car c in cars.Values)
if (c.Rb != null && c.Rb.gameObject.activeInHierarchy) list.Add(c);
if (list.Count == 0) return;
send(MsgType.TrafficStates, NetSession.Build(w =>
{
w.Write(list.Count);
foreach (Car c in list)
{
Vector3 p = c.Rb.position, v = c.Rb.velocity;
Quaternion q = c.Rb.rotation;
w.Write(c.Key);
w.Write(p.x); w.Write(p.y); w.Write(p.z);
w.Write(q.x); w.Write(q.y); w.Write(q.z); w.Write(q.w);
w.Write(v.x); w.Write(v.y); w.Write(v.z);
}
}));
}
public void Handle(BinaryReader r, bool authority)
{
int count = r.ReadInt32();
for (int i = 0; i < count; i++)
{
int key = r.ReadInt32();
var p = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
var q = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
var v = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
Car c;
if (authority || !cars.TryGetValue(key, out c) || c.Rb == null) continue;
if (float.IsNaN(p.x) || float.IsNaN(q.w) || p.sqrMagnitude > 1e8f || v.sqrMagnitude > 1e4f) continue;
c.TargetPos = p;
c.TargetRot = q;
c.TargetVel = v;
c.TargetTime = Time.time;
if (!c.Following)
{
c.WasKinematic = c.Rb.isKinematic;
c.Rb.isKinematic = true;
c.Rb.position = p;
c.Rb.rotation = q;
c.Following = true;
}
}
}
public void FixedUpdate()
{
foreach (Car c in cars.Values)
{
if (!c.Following || c.Rb == null) continue;
float ahead = Mathf.Min(Time.time - c.TargetTime, MaxExtrapolation);
Vector3 goal = c.TargetPos + c.TargetVel * ahead;
float t = Mathf.Clamp01(Time.fixedDeltaTime * Smoothing);
if ((c.Rb.position - goal).sqrMagnitude > 25f) { c.Rb.position = goal; c.Rb.rotation = c.TargetRot; continue; }
c.Rb.MovePosition(Vector3.Lerp(c.Rb.position, goal, t));
c.Rb.MoveRotation(Quaternion.Slerp(c.Rb.rotation, c.TargetRot, t));
}
}
static void StopFollowing(Car c)
{
if (!c.Following) return;
c.Following = false;
if (c.Rb != null) c.Rb.isKinematic = c.WasKinematic;
}
void Scan()
{
int before = cars.Count;
foreach (Rigidbody rb in UnityEngine.Object.FindObjectsOfType<Rigidbody>())
{
if (rb == null || !seen.Add(rb)) continue;
if (Array.IndexOf(Roots, rb.transform.root.name) < 0) continue;
// nur das oberste Rigidbody je Auto (keine Räder/Türen)
Transform parent = rb.transform.parent;
if (parent != null && parent.GetComponentInParent<Rigidbody>() != null) continue;
int key = Hash(FsmSync.PathOf(rb.transform));
if (cars.ContainsKey(key)) continue;
cars[key] = new Car { Key = key, Rb = rb };
}
if (cars.Count != before) MscoLog.Info("[MSCO] Traffic: " + cars.Count + " cars synced");
}
static int Hash(string s)
{
unchecked
{
uint h = 2166136261;
foreach (char c in s) { h ^= c; h *= 16777619; }
return (int)h;
}
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ using UnityEngine;
namespace MSCO
{
/// <summary>
/// Gemeinsamer Stil aller MSCO-Fenster (Launcher, F8-Konsole, Admin-Menü, Statusleiste):
/// Gemeinsamer Stil aller MSCO-Fenster (Launcher, F7-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.
+118 -11
View File
@@ -7,13 +7,22 @@ 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.
/// Lichter, Dauergeräusche (Motor, Blinker, Hupe, Radio ...) und Anzeigen (Tacho, Drehzahlmesser, Tank-/Temperatur-
/// zeiger, Lenkrad) von Fahrzeugen. Wer ein Fahrzeug besitzt (fährt/schiebt), schickt regelmäßig deren Zustand;
/// bei allen anderen wird dieser Zustand jedes Frame übernommen. Die Zeiger bewegen sich beim Beifahrer sonst nicht,
/// weil das Auto dort nicht selbst fährt (die Spiel-Logik sieht Geschwindigkeit/Drehzahl 0).
/// </summary>
public class VehicleFx
{
const float SendInterval = 0.2f;
const float SendInterval = 0.1f;
const float DialSmoothing = 14f; // Zeiger gleiten zwischen den Paketen weich zur neuen Stellung
/// <summary>Objekte, deren Drehung eine Anzeige ist: Tacho-/Drehzahl-/Tank-/Temperaturzeiger, Lenkrad.</summary>
static bool IsDial(string name)
{
string n = name.ToLower();
return n.Contains("needle") || n.Contains("steeringwheel");
}
const float CacheRefresh = 5f; // Teile können an-/abgebaut werden
const float ApplyTimeout = 2f; // ohne neue Daten nichts mehr erzwingen
@@ -30,13 +39,18 @@ namespace MSCO
public Transform Root;
public readonly Dictionary<int, Light> Lights = new Dictionary<int, Light>();
public readonly Dictionary<int, AudioSource> Audio = new Dictionary<int, AudioSource>();
public readonly Dictionary<int, Transform> Dials = new Dictionary<int, Transform>();
public readonly Dictionary<int, ParticleSystem> Particles = new Dictionary<int, ParticleSystem>();
}
class Snapshot
{
public float ReceivedAt;
public bool Released; // nach dem Timeout einmal freigegeben
public readonly Dictionary<int, bool> Lights = new Dictionary<int, bool>();
public readonly Dictionary<int, AudioState> Audio = new Dictionary<int, AudioState>();
public readonly Dictionary<int, Quaternion> Dials = new Dictionary<int, Quaternion>();
public readonly Dictionary<int, bool> Particles = new Dictionary<int, bool>();
}
readonly Dictionary<int, Components> components = new Dictionary<int, Components>();
@@ -51,13 +65,17 @@ namespace MSCO
// ---------------------------------------------------------------- Senden
public void Update(ObjectSync objects, Action<MsgType, byte[]> send, float dt)
public void Update(ObjectSync objects, Action<MsgType, byte[]> send, float dt, bool worldAuthority)
{
sendTimer += dt;
if (sendTimer < SendInterval) return;
sendTimer = 0f;
foreach (SyncedBody b in objects.LocalVehicles())
// Eigene Fahrzeuge (fahren/schieben) und - wenn wir die Welt vorgeben - auch freie, geparkte Fahrzeuge:
// sonst würde z.B. ein brennendes, abgestelltes Auto nur bei einem Spieler brennen
List<SyncedBody> vehicles = objects.LocalVehicles();
if (worldAuthority) vehicles.AddRange(objects.FreeVehicles());
foreach (SyncedBody b in vehicles)
{
Components c = GetComponents(b);
if (c == null) continue;
@@ -82,6 +100,27 @@ namespace MSCO
w.Write(a.Value.pitch);
w.Write(a.Value.volume);
}
// Anzeigen (am Ende angehängt - ältere Versionen lesen hier nicht weiter)
int dialCount = 0;
foreach (Transform t in c.Dials.Values) if (t != null) dialCount++;
w.Write(dialCount);
foreach (KeyValuePair<int, Transform> d in c.Dials)
{
if (d.Value == null) continue;
Quaternion q = d.Value.localRotation;
w.Write(d.Key);
w.Write(q.x); w.Write(q.y); w.Write(q.z); w.Write(q.w);
}
// Partikel-Effekte (Feuer, Rauch, Dampf, Funken): läuft / läuft nicht
int particleCount = 0;
foreach (ParticleSystem ps in c.Particles.Values) if (ps != null) particleCount++;
w.Write(particleCount);
foreach (KeyValuePair<int, ParticleSystem> ps in c.Particles)
{
if (ps.Value == null) continue;
w.Write(ps.Key);
w.Write(ps.Value.gameObject.activeInHierarchy && ps.Value.isPlaying);
}
}));
}
}
@@ -100,6 +139,21 @@ namespace MSCO
int key = r.ReadInt32();
s.Audio[key] = new AudioState { Playing = r.ReadBoolean(), Pitch = r.ReadSingle(), Volume = r.ReadSingle() };
}
if (r.BaseStream.Length - r.BaseStream.Position >= 4)
{
int dials = r.ReadInt32();
for (int i = 0; i < dials; i++)
{
int key = r.ReadInt32();
var q = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
if (!float.IsNaN(q.x) && !float.IsNaN(q.w)) s.Dials[key] = q;
}
}
if (r.BaseStream.Length - r.BaseStream.Position >= 4)
{
int particles = r.ReadInt32();
for (int i = 0; i < particles; i++) s.Particles[r.ReadInt32()] = r.ReadBoolean();
}
incoming[id] = s;
}
@@ -108,11 +162,17 @@ namespace MSCO
{
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;
if (b == null || objects.IsLocalBody(b)) continue; // eigene Fahrzeuge simulieren wir selbst
Components c = GetComponents(b);
if (c == null) continue;
if (Time.time - kv.Value.ReceivedAt > ApplyTimeout)
{
// Keine Daten mehr (Fahrer ausgestiegen o.ä.): einmal alles loslassen, was wir erzwungen haben -
// sonst läuft z.B. das Anlasser-Geräusch endlos weiter ("orgelt")
if (!kv.Value.Released) Release(kv.Value, c);
continue;
}
foreach (KeyValuePair<int, bool> l in kv.Value.Lights)
{
@@ -124,6 +184,38 @@ namespace MSCO
AudioSource src;
if (c.Audio.TryGetValue(a.Key, out src) && src != null) SetAudio(src, a.Value);
}
foreach (KeyValuePair<int, bool> p in kv.Value.Particles)
{
ParticleSystem ps;
if (c.Particles.TryGetValue(p.Key, out ps) && ps != null) SetParticles(ps, p.Value, c.Root);
}
float t = Mathf.Clamp01(Time.deltaTime * DialSmoothing);
foreach (KeyValuePair<int, Quaternion> d in kv.Value.Dials)
{
Transform dial;
if (c.Dials.TryGetValue(d.Key, out dial) && dial != null)
dial.localRotation = Quaternion.Slerp(dial.localRotation, d.Value, t);
}
}
}
static void Release(Snapshot s, Components c)
{
s.Released = true;
foreach (KeyValuePair<int, AudioState> a in s.Audio)
{
AudioSource src;
if (a.Value.Playing && c.Audio.TryGetValue(a.Key, out src) && src != null && src.isPlaying) src.Stop();
}
foreach (KeyValuePair<int, bool> p in s.Particles)
{
ParticleSystem ps;
if (p.Value && c.Particles.TryGetValue(p.Key, out ps) && ps != null && ps.isPlaying) ps.Stop();
}
foreach (KeyValuePair<int, bool> l in s.Lights)
{
Light light;
if (l.Value && c.Lights.TryGetValue(l.Key, out light) && light != null) light.enabled = false;
}
}
@@ -134,10 +226,19 @@ namespace MSCO
if (light.enabled) light.enabled = false;
return;
}
// Nur die Lampe selbst einschalten - übergeordnete Objekte NICHT aktivieren: das weckt Spiel-Logik auf
// (FSMs im OnEnable), die dort nicht laufen soll, und kann das Spiel abstürzen lassen
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 SetParticles(ParticleSystem ps, bool playing, Transform root)
{
if (playing)
{
// Nur Effekte auf bereits aktiven Objekten starten (Objekte aktivieren weckt Spiel-Logik - Absturzgefahr)
if (ps.gameObject.activeInHierarchy && !ps.isPlaying) ps.Play();
}
else if (ps.isPlaying) ps.Stop();
}
static void SetAudio(AudioSource src, AudioState state)
@@ -164,6 +265,12 @@ namespace MSCO
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
seen.Clear();
foreach (Transform t in b.Rb.GetComponentsInChildren<Transform>(true))
if (IsDial(t.name)) c.Dials[Key(t, c.Root, seen)] = t;
seen.Clear();
foreach (ParticleSystem ps in b.Rb.GetComponentsInChildren<ParticleSystem>(true))
c.Particles[Key(ps.transform, c.Root, seen)] = ps;
components[b.Id] = c;
return c;
}
+32 -2
View File
@@ -138,13 +138,39 @@ namespace MSCO
if (fsm == null || !scanned.Add(fsm)) continue;
string root = fsm.transform.root.name;
if (!IsVehicle(root)) continue;
// FSMs, die Größen/Positionen setzen (z.B. Füllstands-Anzeigen), NICHT anfassen: ein übertragener Wert
// kann dort eine Größe 0 ergeben - Unity stürzt dann in der Physik ab ("cleaning the mesh failed")
if (TouchesTransform(fsm)) 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");
if (vars.Count != before) MscoLog.Info("[MSCO] Vehicle state: " + vars.Count + " values synced");
}
static readonly string[] TransformActionWords = { "Scale", "Position", "Rotate", "Rotation", "Translate", "Parent" };
static bool TouchesTransform(PlayMakerFSM fsm)
{
try
{
foreach (FsmState state in fsm.FsmStates)
{
if (!state.ActionsLoaded) state.LoadActions();
if (state.Actions == null) continue;
foreach (FsmStateAction action in state.Actions)
{
if (action == null) continue;
string type = action.GetType().Name;
foreach (string word in TransformActionWords)
if (type.Contains(word)) return true;
}
}
}
catch { return true; } // im Zweifel nicht anfassen
return false;
}
void Register(string prefix, string root, NamedVariable variable, byte type)
@@ -211,7 +237,11 @@ namespace MSCO
if (type != v.Type) return;
switch (type)
{
case 1: ((FsmFloat)v.Variable).Value = BitConverter.ToSingle(value, 0); break;
case 1:
float f = BitConverter.ToSingle(value, 0);
if (float.IsNaN(f) || float.IsInfinity(f)) return; // nie ungültige Werte übernehmen
((FsmFloat)v.Variable).Value = f;
break;
case 2: ((FsmInt)v.Variable).Value = BitConverter.ToInt32(value, 0); break;
default: ((FsmBool)v.Variable).Value = value[0] != 0; break;
}
+103
View File
@@ -0,0 +1,103 @@
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Räder an Fahrzeugen, die ein anderer Spieler fährt, drehen sich mit. Bei uns wird so ein Auto nur verschoben
/// (kinematisch), die Rad-Physik des Spiels (CarDynamics "Wheel") dreht die Räder dann nicht. Wir drehen das
/// Rad-Modell passend zur Geschwindigkeit um seine Achse - nach dem Spiel (LateUpdate), ohne dessen Werte zu ändern.
/// </summary>
public class WheelSpin
{
const float CacheRefresh = 5f; // Räder können ab-/angebaut werden
class WheelInfo
{
public Transform Model;
public Transform Hub; // Transform der Wheel-Komponente (Fahrtrichtung)
public float Radius;
public float Angle;
public Quaternion Base; // Drehung ohne unsere Rollbewegung
public Quaternion Written; // zuletzt von uns gesetzt
public bool HasWritten;
}
class Vehicle
{
public float BuiltAt;
public readonly List<WheelInfo> Wheels = new List<WheelInfo>();
}
readonly Dictionary<int, Vehicle> vehicles = new Dictionary<int, Vehicle>();
static System.Type wheelType;
static FieldInfo modelField, radiusField;
static bool lookedUp;
public void Reset() { vehicles.Clear(); }
public void Apply(ObjectSync objects, float dt)
{
if (!Lookup()) return;
List<SyncedBody> driven = objects.RemoteDrivenVehicles();
if (driven.Count == 0) { if (vehicles.Count > 0) vehicles.Clear(); return; }
foreach (SyncedBody b in driven)
{
Vehicle v;
if (!vehicles.TryGetValue(b.Id, out v) || Time.time - v.BuiltAt > CacheRefresh)
{
v = Build(b.Rb.transform);
vehicles[b.Id] = v;
}
Vector3 vel = b.TargetVel;
foreach (WheelInfo w in v.Wheels)
{
if (w.Model == null || w.Hub == null) continue;
float speed = Vector3.Dot(vel, w.Hub.forward);
w.Angle = Mathf.Repeat(w.Angle + speed / w.Radius * Mathf.Rad2Deg * dt, 360f);
if (float.IsNaN(w.Angle) || float.IsInfinity(w.Angle)) w.Angle = 0f;
// Hat das Spiel das Modell in diesem Frame selbst gedreht (z.B. Lenkung), ist das die neue Basis
Quaternion current = w.Model.localRotation;
if (!w.HasWritten || current != w.Written) w.Base = current;
w.Written = w.Base * Quaternion.Euler(w.Angle, 0f, 0f);
w.Model.localRotation = w.Written;
w.HasWritten = true;
}
}
}
static Vehicle Build(Transform root)
{
var v = new Vehicle { BuiltAt = Time.time };
foreach (Component c in root.GetComponentsInChildren(wheelType, true))
{
var model = modelField.GetValue(c) as GameObject;
float radius = radiusField != null ? (float)radiusField.GetValue(c) : 0.3f;
if (model == null || radius < 0.05f || radius > 2f) continue;
v.Wheels.Add(new WheelInfo { Model = model.transform, Hub = c.transform, Radius = radius });
}
return v;
}
static bool Lookup()
{
if (lookedUp) return wheelType != null;
lookedUp = true;
foreach (Assembly a in System.AppDomain.CurrentDomain.GetAssemblies())
{
if (a.GetName().Name != "Assembly-CSharp") continue;
wheelType = a.GetType("Wheel");
break;
}
if (wheelType == null) { MscoLog.Warning("[MSCO] Wheel type not found - remote wheels will not spin"); return false; }
modelField = wheelType.GetField("model", BindingFlags.Public | BindingFlags.Instance);
radiusField = wheelType.GetField("radius", BindingFlags.Public | BindingFlags.Instance);
if (modelField == null) { wheelType = null; return false; }
return true;
}
}
}
+2 -2
View File
@@ -175,9 +175,9 @@ namespace MSCO
}
if (tracked.Count == 0) return; // Welt noch nicht fertig geladen - später nochmal
discovered = true;
ModConsole.Print("[MSCO] Time/weather sync: " + tracked.Count + " FSMs");
MscoLog.Info("[MSCO] Time/weather sync: " + tracked.Count + " FSMs");
if (FsmSync.Debug)
foreach (string key in tracked.Keys) ModConsole.Print("[MSCO] " + key);
foreach (string key in tracked.Keys) MscoLog.Info("[MSCO] " + key);
}
}
}
+155
View File
@@ -0,0 +1,155 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Jobs, Aufträge und Laden: deren Fortschritt steht in PlayMaker-FSMs (z.B. "Auftrag angenommen", Anzahl
/// geleerter Klärgruben, bestellte Teile). Übertragen werden nur Ganzzahlen und Schalter - keine Kommazahlen
/// (Timer, Positionen), die jeder selbst weiterzählt. Wer etwas ändert, schickt es; Werte, die ständig hin und her
/// springen (interne Zähler), werden nach kurzer Zeit nicht mehr übertragen.
/// </summary>
public class WorldVarSync
{
/// <summary>Wurzelobjekte mit Jobs/Aufträgen/Laden.</summary>
public static readonly string[] Roots = { "JOBS", "STORE", "REPAIRSHOP", "INSPECTION", "LANDFILL", "DANCEHALL", "COTTAGE", "RALLY" };
const float ScanInterval = 15f;
const float CheckInterval = 0.5f;
const float MinResend = 2f; // höchstens so oft pro Wert
const int FlipLimit = 6; // so viele Änderungen ...
const float FlipWindow = 30f; // ... in dieser Zeit = interner Zähler, nicht mehr übertragen
const int MaxPerMessage = 200;
class Var
{
public int Key;
public NamedVariable Variable;
public byte Type; // 2 int, 3 bool
public int LastI;
public bool LastB;
public float LastSent = -100f;
public float WindowStart;
public int Flips;
public bool Muted;
}
readonly Dictionary<int, Var> vars = new Dictionary<int, Var>();
readonly HashSet<PlayMakerFSM> scanned = new HashSet<PlayMakerFSM>();
readonly List<Var> outgoing = new List<Var>();
float nextScan, nextCheck;
public int Count { get { return vars.Count; } }
public void Reset()
{
vars.Clear();
scanned.Clear();
nextScan = 0f;
}
public void Update(Action<MsgType, byte[]> send)
{
if (Time.time >= nextScan)
{
nextScan = Time.time + ScanInterval;
Scan();
}
if (Time.time < nextCheck) return;
nextCheck = Time.time + CheckInterval;
outgoing.Clear();
foreach (Var v in vars.Values)
{
if (v.Muted || !Changed(v)) continue;
if (Time.time - v.LastSent < MinResend) continue; // später nochmal prüfen
Remember(v);
if (Time.time - v.WindowStart > FlipWindow) { v.WindowStart = Time.time; v.Flips = 0; }
if (++v.Flips > FlipLimit) { v.Muted = true; continue; }
v.LastSent = Time.time;
outgoing.Add(v);
}
for (int start = 0; start < outgoing.Count; start += MaxPerMessage)
{
int from = start, count = Math.Min(MaxPerMessage, outgoing.Count - start);
send(MsgType.WorldVars, NetSession.Build(w =>
{
w.Write(count);
for (int i = from; i < from + count; i++)
{
Var v = outgoing[i];
w.Write(v.Key);
w.Write(v.Type);
if (v.Type == 2) w.Write(v.LastI); else w.Write(v.LastB);
}
}));
}
}
public void Handle(BinaryReader r)
{
int count = r.ReadInt32();
for (int i = 0; i < count; i++)
{
int key = r.ReadInt32();
byte type = r.ReadByte();
int iv = 0; bool bv = false;
if (type == 2) iv = r.ReadInt32(); else if (type == 3) bv = r.ReadBoolean(); else return;
Var v;
if (!vars.TryGetValue(key, out v) || v.Type != type) continue;
if (type == 2) ((FsmInt)v.Variable).Value = iv; else ((FsmBool)v.Variable).Value = bv;
Remember(v); // nicht zurückschicken
}
}
void Scan()
{
int before = vars.Count;
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
{
if (fsm == null || !scanned.Add(fsm)) continue;
if (Array.IndexOf(Roots, fsm.transform.root.name) < 0) continue;
string prefix = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName + "|";
FsmVariables fv = fsm.FsmVariables;
foreach (FsmInt n in fv.IntVariables) Register(prefix, n, 2);
foreach (FsmBool b in fv.BoolVariables) Register(prefix, b, 3);
}
if (vars.Count != before) MscoLog.Info("[MSCO] Jobs/shop: " + vars.Count + " values synced");
}
void Register(string prefix, NamedVariable variable, byte type)
{
if (variable == null || string.IsNullOrEmpty(variable.Name)) return;
int key = Hash(prefix + variable.Name);
if (vars.ContainsKey(key)) return;
var v = new Var { Key = key, Variable = variable, Type = type };
Remember(v);
vars[key] = v;
}
static bool Changed(Var v)
{
return v.Type == 2 ? ((FsmInt)v.Variable).Value != v.LastI : ((FsmBool)v.Variable).Value != v.LastB;
}
static void Remember(Var v)
{
if (v.Type == 2) v.LastI = ((FsmInt)v.Variable).Value; else v.LastB = ((FsmBool)v.Variable).Value;
}
static int Hash(string s)
{
unchecked
{
uint h = 2166136261;
foreach (byte c in Encoding.UTF8.GetBytes(s)) { h ^= c; h *= 16777619; }
return (int)h;
}
}
}
}