Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e125ff9d74 | ||
|
|
56c3990650 | ||
|
|
ea1b0f74d9 | ||
|
|
f707e67e5c | ||
|
|
dd31587d61 | ||
|
|
5f8062c649 | ||
|
|
52b1219934 | ||
|
|
3b83644ce9 | ||
|
|
0b2834282d | ||
|
|
cb116da2b8 | ||
|
|
cd8c1293e0 | ||
|
|
983291a4b3 | ||
|
|
73004340ce | ||
|
|
2faf03035a | ||
|
|
4801539b9d | ||
|
|
7d341126e8 | ||
|
|
0b55d270e5 | ||
|
|
e844446a21 | ||
|
|
ba4a3d1b9a | ||
|
|
ce2fbdfc10 | ||
|
|
33a415139b | ||
|
|
8e00640732 | ||
|
|
8dae3ac13d | ||
|
|
09c85e3d96 | ||
|
|
526f9e9c77 | ||
|
|
097d1e87de | ||
|
|
bddc1efe76 | ||
|
|
31e630dccb | ||
|
|
90c1af4513 | ||
|
|
9b8c64d0c6 | ||
|
|
2eb15f2166 | ||
|
|
96e8790f0b | ||
|
|
a6e5abfc05 | ||
|
|
b3405d3752 | ||
|
|
bdaedcc1e8 | ||
|
|
0ae4dae28d | ||
|
|
bba20cf991 | ||
|
|
9f55646123 | ||
|
|
5f1f524bee | ||
|
|
2000fc677f | ||
|
|
0c15459302 | ||
|
|
1dc65ac553 | ||
|
|
b853425037 | ||
|
|
bdd86336c2 | ||
|
|
ce4aa7e0a6 | ||
|
|
be9abd262f | ||
|
|
647acda46c | ||
|
|
8346cd9b09 | ||
|
|
477949e559 | ||
|
|
3970ef130b | ||
|
|
c3f84895a7 | ||
|
|
dfdb78db17 | ||
|
|
d7a9479baa | ||
|
|
f4d154d4a5 | ||
|
|
e050712c0d | ||
|
|
24cd485bcc | ||
|
|
b33cd82e68 | ||
|
|
d32cdd5693 | ||
|
|
6d349399ab | ||
|
|
b408be7559 | ||
|
|
bf01651ccd |
+215
@@ -0,0 +1,215 @@
|
||||
# Changelog
|
||||
|
||||
## 2.8.1
|
||||
|
||||
**Mehr Events synchron**
|
||||
- Zusätzlich zu Jobs, Laden, Werkstatt, Prüfstelle, Müllkippe, Tanzhalle, Ferienhaus und Rallye: Dragrace, Kilju-Käufer,
|
||||
Theater, Fußball, Peräjärvi (Dorf) und die Hütte - ständig wechselnde interne Zähler werden weiterhin automatisch
|
||||
ausgelassen
|
||||
|
||||
**Admin-Menü**
|
||||
- Im Admin-Menü (Spieler-Reiter) gibt es **MAKE ADMIN / REMOVE ADMIN**: Admins können anderen Spielern Admin-Rechte
|
||||
geben oder nehmen (dedizierter Server ab 2.9.4; der Spieler braucht Steam)
|
||||
|
||||
**Kleine Updates**
|
||||
- Mod-Version (2.8.1 für MSCLoader/MSC Launcher) und Netzwerk-Version (2.8) sind getrennt: Updates an der dritten Stelle
|
||||
passen zu allen 2.8-Servern und -Mitspielern. Anleitung im README („Updates veröffentlichen“)
|
||||
|
||||
**Server-Browser**
|
||||
- **Hervorgehobene Server**: vom Keymaster-Admin markierte Server (Tag `featured`) stehen ganz oben, mit goldener
|
||||
Zeile und **FEATURED**-Abzeichen (Liste und Profil)
|
||||
- **Tags** der Server erscheinen im Server-Profil; die Suche findet auch Tags. Serverbetreiber setzen eigene Tags im
|
||||
Web-Panel (Einstellungen), **OFFICIAL** vergeben nur die MSCO-Admins und wird hervorgehoben
|
||||
- Braucht keinen neuen Server: die Infos kommen vom Master (msco.mbm.wtf) und funktionieren mit 2.8-Servern
|
||||
|
||||
**Mitspieler als echte MSC-Menschen**
|
||||
- Andere Spieler sehen aus wie die Leute im Spiel: derselbe Körper wie alle MSC-Menschen, mit einem normalen Hemd,
|
||||
einer Hose und einem Gesicht aus dem Spiel (keine Kleider/Vorhänge/Geister). Bewegt wird ein eigenes Skelett mit der
|
||||
bisherigen Animation - laufen, springen, ducken, sitzen, Items halten
|
||||
- Jeder Spieler bekommt (nach Spieler-Nummer) eine andere Kombination; alle Mitspieler sehen dieselbe
|
||||
- Konsole (F7): `model classic` zeigt wieder die MSCO-Figur, `model game` die MSC-Menschen; `rig` zeigt, wer welches
|
||||
Modell hat. Findet MSCO den Körper nicht, bleibt automatisch die MSCO-Figur
|
||||
|
||||
**Türen an allen Fahrzeugen**
|
||||
- Türen hingen schief, sprangen zurück oder lösten sich: der Tür-Sync erfasste auch die FSMs zum An-/Abbauen,
|
||||
Festschrauben und Lackieren der Tür-Teile und erzwang deren Zustände beim Mitspieler. Jetzt nur noch Öffnen/Schließen
|
||||
- Türen schaukelten hin und her: an einem stehenden Auto schickte jeder in der Nähe den Türwinkel zurück. Jetzt schickt
|
||||
nur, wer an der Tür geklickt hat (bzw. beim fahrenden Auto der Fahrer); empfangene Winkel werden länger gehalten
|
||||
|
||||
**Mitfahren**
|
||||
- **Wer im Fahrzeug ist, fährt mit - genau an seiner Stelle**: Kopf in der Kabine, in der offenen Tür oder auf der
|
||||
Ladefläche -> die Position im Fahrzeug wird festgehalten, kein Umsetzen auf einen berechneten Sitz. Geht auch, wenn
|
||||
noch niemand fährt. Aussteigen mit Leertaste oder der Beifahrer-Taste (P) - man steht dann auf der näheren Seite
|
||||
neben dem Fahrzeug. P neben dem Auto setzt einen wie bisher auf den Beifahrersitz
|
||||
- Beim Ein-/Aussteigen am Fahrersitz und kurz nach dem Fahren wird man nicht festgehalten
|
||||
|
||||
**Satsuma zerfällt nicht mehr beim gemeinsamen Fahren**
|
||||
- Hing das Spiel des Fahrers kurz (z.B. beim Speichern für den Server), gab der Mitfahrer das Auto nach 3 s frei: es
|
||||
blieb bei ihm schlagartig stehen, die Teile wollten mit Tempo weiter und rissen ab - und das „Teil abgefallen“ ging an
|
||||
alle. Ein gefahrenes Auto wird jetzt erst nach 30 s ohne Daten freigegeben, rollt dann mit der Geschwindigkeit des
|
||||
Fahrers weiter, und die Teile bleiben noch 3 s fest, bis alles eingeschwungen ist
|
||||
- Ladung im Auto (Kisten, Werkzeug, Teile im Kofferraum oder auf dem Sitz) gehört jetzt immer dem Fahrer. Vorher
|
||||
übernahm sie oft der Mitfahrer; beim Fahrer folgte sie dann verspätet und „unendlich schwer“ und schlug Sitze und Teile ab
|
||||
|
||||
**Mitfahren ohne Ruckeln**
|
||||
- Fahrer und Beifahrer sitzen beim anderen fest auf dem Sitz: die Figur wird relativ zum Auto übertragen und geglättet,
|
||||
statt in Weltkoordinaten hinter dem fahrenden Auto herzuhinken (vorher wurde der Beifahrer bei Tempo nach hinten gezogen)
|
||||
- Der Beifahrersitz wird jetzt aus der richtigen Fahrerposition berechnet (vorher aus der hinterherhinkenden Figur)
|
||||
- Autos anderer Spieler ruckeln nicht mehr bei jedem Netzwerkpaket zurück (Vorausberechnung in Physik-Zeit) und werden
|
||||
zwischen den Physik-Schritten flüssig bewegt – auch der NPC-Verkehr
|
||||
- Kompatibel: ältere Mods und der Server ignorieren die neuen Sitz-Daten
|
||||
|
||||
**Schönere Oberfläche**
|
||||
- Fenster (Launcher, Konsole, Admin-Menü, Hinweise) mit weichem Schatten und feinem Rand, orange Knöpfe mit Verlauf
|
||||
- Server-Browser: mehr Platz für die Liste, Zeilen leuchten beim Überfahren auf, Füllbalken für die Spielerzahl,
|
||||
Ping farbig (grün/gelb/rot), goldener Streifen bei hervorgehobenen Servern
|
||||
- Seitenleiste mit Trennlinie, Menüpunkte werden beim Überfahren hell
|
||||
|
||||
## 2.8
|
||||
|
||||
Server und Mod müssen beide 2.8 sein (Server: `git pull && docker compose up -d --build`).
|
||||
|
||||
**Weniger Hänger**
|
||||
- Der Server holt sich den Spielstand nur noch, wenn sich seit dem letzten etwas geändert hat (Speichern lässt das Spiel
|
||||
kurz hängen), Standard für neue Server alle 10 statt 5 Minuten
|
||||
- Die teure Suche über alle PlayMaker-FSMs läuft seltener; die Schadens-Prüfung liest ab und zu nur noch ein Mesh
|
||||
- Hänger-Erkennung: dauert ein Bild länger als 0,2 s, steht in der F7-Konsole, ob MSCO schuld war (und welcher Teil)
|
||||
oder das Spiel selbst (z.B. beim Speichern)
|
||||
|
||||
**Behoben: Öl lief aus dem Satsuma**
|
||||
- Beim Beitreten lief Öl aus, obwohl der Spielstand lokal dicht war: die vom Spiel selbst berechneten Werte
|
||||
`LeakOilpan`, `LeakOilfilter`, `LeakRockerCover`, `LeakHeadgasket` (und Raten wie `…LeakRate`) wurden wie echte
|
||||
Fahrzeugwerte synchronisiert und überschrieben den Stand aus dem Spielstand. Werden jetzt nicht mehr übertragen
|
||||
- Diagnose: `vars <filter>` in der F7-Konsole zeigt die synchronisierten Fahrzeugwerte (z.B. `vars oil`)
|
||||
|
||||
**Scharfe Oberfläche, FPS, Leistung**
|
||||
- Launcher wird in voller Auflösung gezeichnet (Schrift und Kanten scharf statt hochgezogen/verpixelt)
|
||||
- `fps` in der F7-Konsole: FPS-Anzeige oben rechts (grün/gelb/rot), Konsole zeigt auch das längste Bild (Ruckler)
|
||||
- Weniger Ruckler: Sync-Teile suchen nach dem Laden nicht mehr alle im selben Frame, gemeinsame Suche nach Rigidbodys,
|
||||
Unfallschäden werden nur noch nach einem Aufprall geprüft (vorher jede Sekunde große Mesh-Kopien), Fahrzeug-Effekte
|
||||
schlagen ihre Bauteile seltener nach, Ping-/FPS-Anzeige ohne Registry-Zugriff pro Bild
|
||||
|
||||
**Launcher im Vollbild**
|
||||
- Der MSCO-Launcher füllt das ganze Hauptmenü und **skaliert automatisch** mit der Auflösung; Listen wachsen mit dem
|
||||
Platz (keine abgeschnittenen Knöpfe mehr). Das Spielmenü dahinter (Logo, Farbauswahl, MP3 …) ist komplett verdeckt
|
||||
und gesperrt, die Menü-Musik ist stumm. MINIMIZE entfällt
|
||||
|
||||
**P2P: Weiterspielen oder neues Spiel**
|
||||
- In der Host-Lobby: **CONTINUE** (eigenen Spielstand weiterspielen, mit Datum) oder **NEW GAME** (frische Welt für
|
||||
alle). Der bisherige Spielstand wird vorher nach `MSCO_Saves/<Datum>` gesichert (die letzten 10 bleiben), mit
|
||||
Sicherheitsabfrage. Mitspieler bekommen die neue Welt automatisch, sobald sie einmal gespeichert ist
|
||||
|
||||
**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`).
|
||||
|
||||
**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 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)
|
||||
- Auto kippte mit dem Kopf des Fahrers mit und zitterte (Fahrzeug wurde als „in der Hand gehalten“ erkannt)
|
||||
- Satsuma wurde weggeschleudert und war „verschwunden“ – ungültige/im Boden liegende Positionen werden jetzt
|
||||
verworfen bzw. angehoben, weggeschleuderte Objekte sofort gestoppt
|
||||
- Items und Autos zitterten nach dem Loslassen (Ownership-Pingpong)
|
||||
- Absturz beim Beitreten durch einen Kreis in der Objekt-Hierarchie (nachgespielte Montage-Aktion)
|
||||
- Tippen im Chat/in der Konsole löste Spieltasten aus (Benutzen, Beifahrer-Taste P …)
|
||||
- Maus sprang bei offenem Admin-Menü/Chat immer in die Bildmitte
|
||||
- Ein Fehler in einem Sync-Teil legt nicht mehr den ganzen Sync lahm
|
||||
- Freie Fahrzeuge: nur noch deutliche Wertänderungen werden abgeglichen (kein gegenseitiges Überschreiben)
|
||||
|
||||
**Sicherheit (Server)**
|
||||
- Admin-Rechte nur noch für von Steam bestätigte SteamIDs – der Server braucht dafür einen `SteamWebApiKey`
|
||||
(sonst könnte sich jeder als Admin ausgeben). Unbestätigte SteamIDs können niemanden mehr vom Server drängen.
|
||||
- Ungültige Positionen werden nicht gespeichert, Paketgröße und Weltzustand sind begrenzt
|
||||
|
||||
**Server & Keymaster**
|
||||
- Master-Adresse fest (`msco.mbm.wtf`), nicht mehr änderbar
|
||||
- Docker läuft als dein Linux-Benutzer (kein „Permission denied“ in `data/` mehr), klare Meldungen statt Absturz
|
||||
- `docker exec msco-server msco <befehl>` für Server-Befehle
|
||||
- Keymaster: Rate-Limits, Security-Header, Healthcheck, Docs-Seite
|
||||
- Automatische Tests: Server `dotnet run --project Tests` (30 Checks), Keymaster `npm run smoke` (24 Checks)
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
using HutongGames.PlayMaker;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MSCO
|
||||
{
|
||||
/// <summary>
|
||||
/// Admin-Cheats für den eigenen Spieler (Geld, Bedürfnisse). Nur aus dem Admin-Menü erreichbar, also nur für
|
||||
/// Server-Admins bzw. den P2P-Host. Geld wird über MoneySync automatisch an alle weitergegeben.
|
||||
/// </summary>
|
||||
public static class Cheats
|
||||
{
|
||||
// Globale PlayMaker-Variablen des Spielers: Wert, auf den "auffüllen" sie setzt
|
||||
static readonly KeyValuePair<string, float>[] Needs =
|
||||
{
|
||||
new KeyValuePair<string, float>("PlayerHunger", 0f),
|
||||
new KeyValuePair<string, float>("PlayerThirst", 0f),
|
||||
new KeyValuePair<string, float>("PlayerStress", 0f),
|
||||
new KeyValuePair<string, float>("PlayerUrine", 0f),
|
||||
new KeyValuePair<string, float>("PlayerFatigue", 0f),
|
||||
new KeyValuePair<string, float>("PlayerDirtiness", 0f),
|
||||
};
|
||||
|
||||
public static string AddMoney(float amount)
|
||||
{
|
||||
FsmFloat money = FsmVariables.GlobalVariables.FindFsmFloat("PlayerMoney");
|
||||
if (money == null) return "Money not found (only works in the game)";
|
||||
money.Value += amount;
|
||||
return "+" + amount.ToString("0") + " MK (now " + money.Value.ToString("0") + " MK)";
|
||||
}
|
||||
|
||||
public static string FillNeeds()
|
||||
{
|
||||
int done = 0;
|
||||
foreach (KeyValuePair<string, float> need in Needs)
|
||||
{
|
||||
FsmFloat v = FsmVariables.GlobalVariables.FindFsmFloat(need.Key);
|
||||
if (v == null) continue;
|
||||
v.Value = need.Value;
|
||||
done++;
|
||||
}
|
||||
return done > 0 ? "Hunger, thirst, stress, fatigue, bladder and dirt reset" : "Player needs not found (only works in the game)";
|
||||
}
|
||||
|
||||
/// <summary>Eigenen Spieler an eine Position setzen (etwas darüber, damit er nicht im Boden steckt).</summary>
|
||||
public static bool Teleport(Transform player, Vector3 position)
|
||||
{
|
||||
if (player == null) return false;
|
||||
player.position = position + Vector3.up * 0.3f;
|
||||
Rigidbody rb = player.GetComponent<Rigidbody>();
|
||||
if (rb != null && !rb.isKinematic) rb.velocity = Vector3.zero;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
-7
@@ -3,22 +3,60 @@ 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] - LAN test session\n" +
|
||||
"host [port] / join <ip> [port] - direct LAN game (no Steam)\n" +
|
||||
"save - save the game (host / singleplayer)\n" +
|
||||
"/admin - admin menu (kick / ban)\n" +
|
||||
"ping [on|off] - show your ping to the server (toggle)\n" +
|
||||
"fps [on|off] - show the frames per second (toggle)\n" +
|
||||
"/admin - admin menu (kick / ban / teleport / cheats)\n" +
|
||||
"vehicles | rig - debug info about vehicles / player models\n" +
|
||||
"model [game|classic] - other players as MSC characters or the MSCO figure\n" +
|
||||
"vars <filter> - synced vehicle values, e.g. vars oil (debug)\n" +
|
||||
"debug - toggle sync debug output\n" +
|
||||
"master [url] - show / change the server list address\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,13 +97,30 @@ namespace MSCO
|
||||
mp.JoinServer(host, serverPort);
|
||||
break;
|
||||
}
|
||||
case "master":
|
||||
if (args.Length > 1) ServerBrowser.MasterUrl = args[1];
|
||||
print("Master server: " + ServerBrowser.MasterUrl);
|
||||
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;
|
||||
case "ping":
|
||||
// "ping" schaltet die Anzeige um, "ping on/off" setzt sie
|
||||
if (args.Length > 1) mp.ShowPing = args[1].ToLower() == "on" || args[1] == "1";
|
||||
else mp.ShowPing = !mp.ShowPing;
|
||||
print("Ping: " + mp.PingText + " (display " + (mp.ShowPing ? "on" : "off") + ")");
|
||||
break;
|
||||
case "fps":
|
||||
// "fps" schaltet die Anzeige um, "fps on/off" setzt sie
|
||||
if (args.Length > 1) mp.ShowFps = args[1].ToLower() == "on" || args[1] == "1";
|
||||
else mp.ShowFps = !mp.ShowFps;
|
||||
print("FPS: " + mp.FpsText + " (display " + (mp.ShowFps ? "on" : "off") + ")");
|
||||
break;
|
||||
case "admin":
|
||||
case "/admin":
|
||||
mp.OpenAdminMenu();
|
||||
@@ -73,9 +128,20 @@ namespace MSCO
|
||||
case "rig":
|
||||
foreach (string line in mp.DescribeRigs()) print(line);
|
||||
break;
|
||||
case "model":
|
||||
// Mitspieler als echte MSC-Menschen ("game") oder als MSCO-Figur ("classic")
|
||||
if (args.Length > 1) RemotePlayer.UseGameModels = args[1].ToLower() != "classic";
|
||||
else RemotePlayer.UseGameModels = !RemotePlayer.UseGameModels;
|
||||
mp.RebuildAvatars();
|
||||
print("Player models: " + (RemotePlayer.UseGameModels ? "MSC characters (model classic = MSCO figure)" : "MSCO figure (model game = MSC characters)"));
|
||||
break;
|
||||
case "vehicles":
|
||||
foreach (string line in mp.DescribeVehicles()) print(line);
|
||||
break;
|
||||
case "vars":
|
||||
// Diagnose: welche Fahrzeugwerte synchronisiert werden (z.B. "vars oil")
|
||||
foreach (string line in mp.DescribeVehicleValues(args.Length > 1 ? args[1] : "")) print(line);
|
||||
break;
|
||||
case "debug":
|
||||
FsmSync.Debug = !FsmSync.Debug;
|
||||
print("Sync debug output " + (FsmSync.Debug ? "ON" : "OFF"));
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
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",
|
||||
// Handbremse, Sonnenblenden, Sitzlehne, Gurt, CD-Spieler, Innenlicht, Tankdeckel
|
||||
"handbrake", "sunvisor", "seattop", "seatbelt", "cdplayer", "interiorlight", "opencap"
|
||||
};
|
||||
// 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>();
|
||||
// Zustände für Schalter, die wir noch nicht kennen (Teil noch nicht angebaut/aktiv, Server-Snapshot vor dem Suchlauf)
|
||||
readonly Dictionary<int, string> pending = new Dictionary<int, string>();
|
||||
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();
|
||||
pending.Clear();
|
||||
nextScan = -1f; // erster Suchlauf verteilt, siehe Update
|
||||
}
|
||||
|
||||
public void Update(ObjectSync objects, int localId, Vector3 playerPos, Action<MsgType, byte[]> send)
|
||||
{
|
||||
if (nextScan < 0f) nextScan = Time.time + 2.4f; // nicht alle Sync-Teile im selben Frame suchen lassen
|
||||
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) { pending[key] = state; nextScan = 0f; continue; }
|
||||
if (objects.VehicleOwner(c.Root) == localId) continue; // ich fahre: meine Schalter gelten
|
||||
SwitchTo(c, state);
|
||||
}
|
||||
}
|
||||
|
||||
void SwitchTo(Control c, string state)
|
||||
{
|
||||
if (c.Fsm.ActiveStateName == state) { c.LastState = state; return; }
|
||||
FsmState target = c.Fsm.Fsm.GetState(state);
|
||||
if (target == null) return;
|
||||
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(20f))
|
||||
{
|
||||
if (fsm == null || scanned.Contains(fsm)) continue;
|
||||
string root = fsm.transform.root.name;
|
||||
// Noch nicht am Fahrzeug (z.B. Radio liegt in CARPARTS): beim nächsten Suchlauf nochmal ansehen
|
||||
if (!IsVehicle(root)) continue;
|
||||
scanned.Add(fsm);
|
||||
if (!Wanted(fsm.gameObject.name, fsm.FsmName)) continue;
|
||||
int key = Hash(FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName);
|
||||
if (controls.ContainsKey(key)) continue;
|
||||
var control = new Control { Key = key, Root = root, Fsm = fsm, LastState = fsm.ActiveStateName, WindowStart = Time.time };
|
||||
controls[key] = control;
|
||||
string waiting;
|
||||
if (pending.TryGetValue(key, out waiting)) { pending.Remove(key); SwitchTo(control, waiting); }
|
||||
}
|
||||
if (controls.Count != before) MscoLog.Info("[MSCO] Vehicle controls (ignition, lights, engine ...): " + controls.Count + " synced");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P2P-Host: alle Schalterzustände nochmal schicken (Licht an, Motor läuft ...) - für später Beigetretene und
|
||||
/// falls eine Nachricht verloren ging. Fahrzeuge, die ein anderer fährt, gibt dieser vor.
|
||||
/// </summary>
|
||||
public void ResendAll(ObjectSync objects, int localId, Action<MsgType, byte[]> send)
|
||||
{
|
||||
outgoing.Clear();
|
||||
foreach (Control c in controls.Values)
|
||||
{
|
||||
if (c.Fsm == null || c.Muted || string.IsNullOrEmpty(c.Fsm.ActiveStateName)) continue;
|
||||
int owner = objects.VehicleOwner(c.Root);
|
||||
if (owner != SyncedBody.NoOwner && owner != localId) 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].Fsm.ActiveStateName); }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
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 = 0.5f; // so oft nach einem Aufprall prüfen
|
||||
const float ImpactWindow = 4f; // so lange nach einem Aufprall prüfen (Verformung läuft ein paar Frames)
|
||||
const float ImpactDeltaV = 2.5f; // plötzliche Geschwindigkeitsänderung (m/s pro Frame) = Aufprall
|
||||
const float SafetyCheckInterval = 2f; // sonst nur ab und zu EIN Mesh (z.B. Reparatur in der Werkstatt) - verteilt statt alle auf einmal
|
||||
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 Rigidbody Body; // Fahrzeug, zu dem das Mesh gehört
|
||||
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, nextSafetyCheck;
|
||||
int safetyIndex;
|
||||
// Mesh-Daten lesen ist teuer (große Arrays, Speicher-Müll) - nur nach einem Aufprall
|
||||
readonly Dictionary<Rigidbody, Vector3> lastVelocity = new Dictionary<Rigidbody, Vector3>();
|
||||
readonly Dictionary<Rigidbody, float> lastImpact = new Dictionary<Rigidbody, float>();
|
||||
readonly List<Rigidbody> bodies = new List<Rigidbody>();
|
||||
static List<Type> types;
|
||||
|
||||
public int Count { get { return meshes.Count; } }
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
meshes.Clear();
|
||||
seen.Clear();
|
||||
lastVelocity.Clear();
|
||||
lastImpact.Clear();
|
||||
bodies.Clear();
|
||||
nextScan = -1f; // erster Suchlauf verteilt, siehe Update
|
||||
}
|
||||
|
||||
public void Update(ObjectSync objects, int localId, Action<MsgType, byte[]> send)
|
||||
{
|
||||
if (nextScan < 0f) nextScan = Time.time + 1.0f; // nicht alle Sync-Teile im selben Frame suchen lassen
|
||||
if (Time.time >= nextScan)
|
||||
{
|
||||
nextScan = Time.time + ScanInterval;
|
||||
Scan();
|
||||
}
|
||||
// Aufprall erkennen: Geschwindigkeit ändert sich schlagartig (billig, jedes Frame)
|
||||
for (int i = 0; i < bodies.Count; i++)
|
||||
{
|
||||
Rigidbody rb = bodies[i];
|
||||
if (rb == null || rb.isKinematic) continue;
|
||||
Vector3 v = rb.velocity, before;
|
||||
if (lastVelocity.TryGetValue(rb, out before) && (v - before).sqrMagnitude > ImpactDeltaV * ImpactDeltaV)
|
||||
lastImpact[rb] = Time.time;
|
||||
lastVelocity[rb] = v;
|
||||
}
|
||||
|
||||
if (Time.time < nextCheck) return;
|
||||
nextCheck = Time.time + CheckInterval;
|
||||
bool safety = Time.time >= nextSafetyCheck;
|
||||
if (safety) { nextSafetyCheck = Time.time + SafetyCheckInterval; safetyIndex++; }
|
||||
int index = -1;
|
||||
|
||||
foreach (DamageMesh m in meshes.Values)
|
||||
{
|
||||
index++;
|
||||
if (m.Filter == null) continue;
|
||||
bool safetyThis = safety && meshes.Count > 0 && index == safetyIndex % meshes.Count;
|
||||
int owner = objects.VehicleOwner(m.Root);
|
||||
if (owner != SyncedBody.NoOwner && owner != localId) continue; // der Fahrer schickt die Schäden
|
||||
float impact;
|
||||
bool recentImpact = m.Body != null && lastImpact.TryGetValue(m.Body, out impact) && Time.time - impact < ImpactWindow;
|
||||
if (!recentImpact && !safetyThis) 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
|
||||
Rigidbody body = mf.transform.root.GetComponent<Rigidbody>();
|
||||
meshes[key] = new DamageMesh { Key = key, Root = mf.transform.root.name, Filter = mf, Body = body, Known = mesh.vertices };
|
||||
if (body != null && !bodies.Contains(body)) bodies.Add(body);
|
||||
}
|
||||
|
||||
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
@@ -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;
|
||||
|
||||
+20
-4
@@ -16,6 +16,13 @@ namespace MSCO
|
||||
public class DoorSync
|
||||
{
|
||||
static readonly string[] Keywords = { "door", "gate", "hood", "bonnet", "trunk", "lid", "hatch" };
|
||||
// Nicht die FSMs zum An-/Abbauen, Festschrauben, Lackieren, Speichern ... der Tür-TEILE: deren Zustände wurden
|
||||
// beim Mitspieler erzwungen - Türen hingen schief, lösten sich oder sprangen zurück. Montage läuft über FsmSync.
|
||||
static readonly string[] ExcludedFsms =
|
||||
{
|
||||
"removal", "assembly", "assemble", "boltcheck", "bolt", "screw", "paint", "data", "save", "load", "wiring",
|
||||
"setup", "damage", "tightness", "detach", "attach", "install"
|
||||
};
|
||||
|
||||
const float Cooldown = 1.5f; // Folge-Zustände der Animation nicht nochmal senden
|
||||
const float RescanInterval = 10f; // neue Objekte (z.B. angebaute Autotüren) aufnehmen
|
||||
@@ -62,7 +69,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 +87,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,17 +98,18 @@ 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()
|
||||
{
|
||||
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
|
||||
foreach (PlayMakerFSM fsm in SceneCache.Fsms(20f))
|
||||
{
|
||||
if (fsm == null) continue;
|
||||
string root = fsm.transform.root.name;
|
||||
if (root == "PLAYER" || root.StartsWith("MSCO_")) continue;
|
||||
if (!Matches(fsm.gameObject.name) && !Matches(fsm.FsmName) && !Matches(fsm.transform.parent != null ? fsm.transform.parent.name : "")) continue;
|
||||
if (IsPartLogic(fsm)) continue;
|
||||
|
||||
string key = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName;
|
||||
Tracked t;
|
||||
@@ -110,6 +118,14 @@ namespace MSCO
|
||||
}
|
||||
}
|
||||
|
||||
static bool IsPartLogic(PlayMakerFSM fsm)
|
||||
{
|
||||
string f = fsm.FsmName.ToLower(), o = fsm.gameObject.name.ToLower();
|
||||
if (o.StartsWith("trigger_") || o.StartsWith("bolt")) return true;
|
||||
foreach (string x in ExcludedFsms) if (f.Contains(x)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool Matches(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name)) return false;
|
||||
|
||||
+114
@@ -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
@@ -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--; }
|
||||
}
|
||||
|
||||
|
||||
+195
-358
@@ -1,405 +1,242 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MSCO
|
||||
{
|
||||
/// <summary>
|
||||
/// Mitspieler-Figur aus einem echten My-Summer-Car-NPC (Körper-Mesh "bodymesh" + Skelett mit "pelvis").
|
||||
/// Der NPC wird kopiert und von seiner Logik befreit; es bleiben Aussehen und die Spiel-Animationen
|
||||
/// (Idle/Walk). Nach der Animation werden die Knochen für Ducken, Lehnen, Kopf-Nicken, Sitzen und
|
||||
/// das Greifen zum Item verbogen (Zwei-Knochen-IK, unabhängig von den Achsen des Skeletts).
|
||||
/// Mitspieler als MSC-Mensch: Alle Leute im Spiel benutzen denselben Körper ("bodymesh", 20 Knochen) und
|
||||
/// unterscheiden sich nur durch drei Materialien - Hemd (char_shirtXX), Hose (char_pantsXX) und Gesicht
|
||||
/// (char_faceXX). MSCO nimmt nur dieses Mesh, baut ein eigenes Skelett aus seiner Bind-Pose und gibt jedem
|
||||
/// Spieler ein normales Hemd/Hose/Gesicht (keine Kleider, Vorhänge oder Geister). Die Knochen werden jedes Frame
|
||||
/// direkt aus den Gelenkpunkten der MSCO-Figur gesetzt (laufen, springen, ducken, sitzen, Items halten).
|
||||
///
|
||||
/// Knochen-Reihenfolge ist bei allen MSC-Menschen gleich (nur die Namen unterscheiden sich):
|
||||
/// 0 Becken, 1-2 Wirbelsäule, 3 Kopf, 4/9 Schlüsselbein, 5/10 Schulter, 6/11 Ellbogen, 7/12 Hand, 8/13 Finger,
|
||||
/// 14/17 Hüfte, 15/18 Knie, 16/19 Knöchel (links/rechts). Bind-Pose: Z oben, X vorne, -Y links.
|
||||
/// </summary>
|
||||
public class GameCharacter
|
||||
{
|
||||
const float StandingEyeHeight = 1.65f;
|
||||
const float EyeAboveHeadBone = 0.1f; // Augen liegen etwas über dem Kopf-Knochen
|
||||
const float AnkleHeight = 0.07f; // Fuß-Knochen über dem Boden
|
||||
/// <summary>Gelenkpunkte der MSCO-Figur im lokalen Raum des Spielers (jedes Frame von RemotePlayer.Pose).</summary>
|
||||
public class Rig
|
||||
{
|
||||
public Vector3 Hip, NeckBase, Head;
|
||||
public Vector3 Up = Vector3.up;
|
||||
public float Pitch;
|
||||
public readonly Vector3[] HipJoint = new Vector3[2], Knee = new Vector3[2], Ankle = new Vector3[2];
|
||||
public readonly Vector3[] Shoulder = new Vector3[2], Elbow = new Vector3[2], Hand = new Vector3[2];
|
||||
}
|
||||
|
||||
int frames;
|
||||
bool calibrated;
|
||||
const int BoneCount = 20;
|
||||
const int Pelvis = 0, Head = 3;
|
||||
static readonly int[] Torso = { 0, 1, 2, 4, 9 };
|
||||
static readonly int[] Shoulder = { 5, 10 }, Elbow = { 6, 11 }, Wrist = { 7, 12 }, Finger = { 8, 13 };
|
||||
static readonly int[] HipBone = { 14, 17 }, Knee = { 15, 18 }, Ankle = { 16, 19 };
|
||||
|
||||
public Transform Root { get; private set; }
|
||||
// ---------------------------------------------------------------- Mesh und Kleidung aus dem Spiel
|
||||
|
||||
static Mesh bodyMesh;
|
||||
static List<Material> shirts, pants, faces;
|
||||
static float nextSearch;
|
||||
|
||||
static bool FindAssets()
|
||||
{
|
||||
if (bodyMesh != null && shirts.Count > 0 && pants.Count > 0 && faces.Count > 0) return true;
|
||||
if (Time.realtimeSinceStartup < nextSearch) return false;
|
||||
nextSearch = Time.realtimeSinceStartup + 10f;
|
||||
|
||||
var users = new Dictionary<Mesh, int>();
|
||||
var shirtByName = new Dictionary<string, Material>();
|
||||
var pantsByName = new Dictionary<string, Material>();
|
||||
var faceByName = new Dictionary<string, Material>();
|
||||
foreach (SkinnedMeshRenderer smr in Resources.FindObjectsOfTypeAll<SkinnedMeshRenderer>())
|
||||
{
|
||||
if (smr == null || smr.name != "bodymesh") continue;
|
||||
Mesh m = smr.sharedMesh;
|
||||
if (m == null || m.bindposes == null || m.bindposes.Length != BoneCount || m.subMeshCount != 3) continue;
|
||||
int n;
|
||||
users.TryGetValue(m, out n);
|
||||
users[m] = n + 1;
|
||||
Material[] mats = smr.sharedMaterials;
|
||||
if (mats == null || mats.Length != 3) continue;
|
||||
Add(shirtByName, mats[0], "char_shirt");
|
||||
Add(pantsByName, mats[1], "char_pants");
|
||||
if (mats[2] != null && !mats[2].name.Contains("woman")) Add(faceByName, mats[2], "char_face");
|
||||
}
|
||||
// Das Mesh, das die meisten Leute benutzen = der normale Mann
|
||||
Mesh best = null;
|
||||
int bestUsers = 0;
|
||||
foreach (KeyValuePair<Mesh, int> kv in users)
|
||||
if (kv.Value > bestUsers && LooksHuman(kv.Key)) { best = kv.Key; bestUsers = kv.Value; }
|
||||
bodyMesh = best;
|
||||
shirts = Sorted(shirtByName);
|
||||
pants = Sorted(pantsByName);
|
||||
faces = Sorted(faceByName);
|
||||
bool ok = bodyMesh != null && shirts.Count > 0 && pants.Count > 0 && faces.Count > 0;
|
||||
if (ok) MscoLog.Info("[MSCO] Player models: MSC body with " + shirts.Count + " shirts, " + pants.Count + " trousers, " + faces.Count + " faces");
|
||||
return ok;
|
||||
}
|
||||
|
||||
static void Add(Dictionary<string, Material> into, Material m, string prefix)
|
||||
{
|
||||
if (m != null && m.name.StartsWith(prefix) && !into.ContainsKey(m.name)) into[m.name] = m;
|
||||
}
|
||||
|
||||
static List<Material> Sorted(Dictionary<string, Material> d)
|
||||
{
|
||||
var names = new List<string>(d.Keys);
|
||||
names.Sort(string.CompareOrdinal); // bei allen Spielern gleiche Reihenfolge -> gleiches Aussehen
|
||||
return names.ConvertAll(n => d[n]);
|
||||
}
|
||||
|
||||
/// <summary>Prüft die erwartete Knochen-Reihenfolge an der Bind-Pose (Kopf oben, Knöchel unten, Beine lang).</summary>
|
||||
static bool LooksHuman(Mesh m)
|
||||
{
|
||||
Vector3[] p = BindPositions(m);
|
||||
if (p[Head].z < p[Pelvis].z + 0.4f) return false;
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
if (p[Ankle[i]].z > p[Knee[i]].z || p[Knee[i]].z > p[HipBone[i]].z) return false;
|
||||
if (p[Wrist[i]].z > p[Shoulder[i]].z) return false;
|
||||
}
|
||||
return p[Shoulder[0]].y < 0f && p[Shoulder[1]].y > 0f; // links = -Y
|
||||
}
|
||||
|
||||
static Vector3[] BindPositions(Mesh m)
|
||||
{
|
||||
var p = new Vector3[BoneCount];
|
||||
for (int i = 0; i < BoneCount; i++) p[i] = m.bindposes[i].inverse.GetColumn(3);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Instanz
|
||||
|
||||
GameObject model;
|
||||
Transform owner;
|
||||
readonly Transform[] bones = new Transform[BoneCount];
|
||||
readonly Vector3[] restPos = new Vector3[BoneCount]; // relativ zum Spieler
|
||||
readonly Quaternion[] restRot = new Quaternion[BoneCount];
|
||||
Vector3 restHipCenter;
|
||||
public string Description { get; private set; }
|
||||
|
||||
Animation anim;
|
||||
string walkClip, idleClip;
|
||||
Transform pelvis, spine, head;
|
||||
readonly Transform[] thigh = new Transform[2], knee = new Transform[2], foot = new Transform[2];
|
||||
readonly Transform[] upperArm = new Transform[2], forearm = new Transform[2], hand = new Transform[2];
|
||||
readonly Vector3[] animatedFoot = new Vector3[2];
|
||||
|
||||
/// <summary>
|
||||
/// Knochen, die wir verbiegen. Bewegt die Animation einen davon nicht selbst, würden sich unsere
|
||||
/// Änderungen jedes Frame aufsummieren - dann wird er vorher auf die Grundhaltung zurückgesetzt.
|
||||
/// </summary>
|
||||
class BoneState
|
||||
/// <summary>Figur bauen (variant wählt Hemd, Hose und Gesicht). null, wenn das Spiel-Mesh nicht gefunden wird.</summary>
|
||||
public static GameCharacter Create(Transform owner, int variant)
|
||||
{
|
||||
public Transform Bone;
|
||||
public Vector3 RestPos, OutPos;
|
||||
public Quaternion RestRot, OutRot;
|
||||
public bool HasOut;
|
||||
}
|
||||
readonly List<BoneState> posed = new List<BoneState>();
|
||||
|
||||
public Vector3 HeadPosition { get { return head.position + Root.up * EyeAboveHeadBone; } }
|
||||
|
||||
// ---------------------------------------------------------------- Vorlage finden und kopieren
|
||||
|
||||
static List<GameObject> templates;
|
||||
|
||||
/// <summary>Kopiert einen NPC aus der Spielszene als Figur. null, wenn keine passende Vorlage da ist.</summary>
|
||||
public static GameCharacter TryCreate(Transform parent, int variant)
|
||||
if (!FindAssets()) return null;
|
||||
var c = new GameCharacter();
|
||||
try
|
||||
{
|
||||
List<GameObject> candidates = FindTemplates();
|
||||
if (candidates.Count == 0) return null;
|
||||
GameObject template = candidates[Mathf.Abs(variant) % candidates.Count];
|
||||
|
||||
GameObject clone = (GameObject)UnityEngine.Object.Instantiate(template);
|
||||
clone.name = "Model";
|
||||
Strip(clone);
|
||||
clone.SetActive(true);
|
||||
clone.transform.SetParent(parent, false);
|
||||
clone.transform.localPosition = Vector3.zero;
|
||||
clone.transform.localRotation = Quaternion.identity;
|
||||
clone.transform.localScale = Vector3.one;
|
||||
|
||||
var c = new GameCharacter { Root = clone.transform };
|
||||
if (!c.FindBones())
|
||||
{
|
||||
UnityEngine.Object.Destroy(clone);
|
||||
return null;
|
||||
}
|
||||
c.Normalize();
|
||||
c.SetupAnimation();
|
||||
c.RememberRestPose();
|
||||
c.Description = template.name + " (" + candidates.Count + " templates) - " + c.BoneReport();
|
||||
c.Build(owner, Mathf.Abs(variant));
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <summary>NPC-Körper in der Szene (auch inaktive): SkinnedMeshRenderer "bodymesh" mit "pelvis"-Knochen.</summary>
|
||||
static List<GameObject> FindTemplates()
|
||||
catch (System.Exception e)
|
||||
{
|
||||
if (templates != null)
|
||||
{
|
||||
templates.RemoveAll(t => t == null);
|
||||
if (templates.Count > 0) return templates;
|
||||
}
|
||||
var scored = new List<KeyValuePair<GameObject, int>>();
|
||||
var seen = new HashSet<string>();
|
||||
foreach (UnityEngine.Object o in Resources.FindObjectsOfTypeAll(typeof(SkinnedMeshRenderer)))
|
||||
{
|
||||
var smr = (SkinnedMeshRenderer)o;
|
||||
if (smr == null || !smr.name.ToLower().Contains("bodymesh") || smr.transform.parent == null) continue;
|
||||
bool hasPelvis = false;
|
||||
if (smr.bones != null)
|
||||
foreach (Transform b in smr.bones)
|
||||
if (b != null && b.name == "pelvis") { hasPelvis = true; break; }
|
||||
if (!hasPelvis) continue;
|
||||
|
||||
GameObject root = smr.transform.parent.gameObject; // Körper + Skelett liegen gemeinsam darunter
|
||||
if (root.transform.root.name == "PLAYER" || root.name.StartsWith("MSCO")) continue;
|
||||
// Keine Sammelgruppen mit mehreren Personen kopieren (nur eine Figur, evtl. mit Varianten dünn/normal/dick)
|
||||
if (PelvisCount(root.transform) != 1 || root.GetComponentsInChildren<Transform>(true).Length > 600) continue;
|
||||
|
||||
int score = AnimationScore(ref root);
|
||||
if (!seen.Add(root.name + "/" + smr.sharedMesh)) continue; // gleiche Figur nicht mehrfach
|
||||
scored.Add(new KeyValuePair<GameObject, int>(root, score));
|
||||
}
|
||||
|
||||
// Nur die besten: am liebsten NPCs mit Lauf- UND Steh-Animation (ohne Gesten wie Anhalter/Betrunken)
|
||||
int best = 0;
|
||||
foreach (var kv in scored) best = Mathf.Max(best, kv.Value);
|
||||
templates = new List<GameObject>();
|
||||
foreach (var kv in scored) if (kv.Value == best) templates.Add(kv.Key);
|
||||
templates.Sort((a, b) => string.CompareOrdinal(a.name, b.name));
|
||||
return templates;
|
||||
}
|
||||
|
||||
static int PelvisCount(Transform root)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (Transform t in root.GetComponentsInChildren<Transform>(true)) if (t.name == "pelvis") count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
// Animationen, die keine normale Steh-/Laufhaltung sind
|
||||
static readonly string[] GestureWords = { "hitch", "drunk", "fat", "lift", "sit", "wave", "point", "dance", "angry", "back", "theatre", "car" };
|
||||
|
||||
static bool IsGesture(string clip)
|
||||
{
|
||||
string n = clip.ToLower();
|
||||
foreach (string g in GestureWords) if (n.Contains(g)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bewertet die Animationen eines NPCs (liegt die Animation eine Ebene höher, wird dieser Knoten
|
||||
/// die Vorlage). 3 = normales Laufen + Stehen, 2 = nur Laufen, 1 = nur Stehen, 0 = keine.
|
||||
/// </summary>
|
||||
static int AnimationScore(ref GameObject root)
|
||||
{
|
||||
Animation[] found = root.GetComponentsInChildren<Animation>(true); // Unity 5.0: auch inaktive finden
|
||||
Animation anim = found.Length > 0 ? found[0] : null;
|
||||
if (anim == null)
|
||||
{
|
||||
Transform p = root.transform.parent;
|
||||
for (int i = 0; i < 2 && p != null && anim == null; i++, p = p.parent)
|
||||
{
|
||||
var candidate = p.GetComponent<Animation>();
|
||||
if (candidate != null && PelvisCount(p) == 1)
|
||||
{
|
||||
anim = candidate;
|
||||
root = p.gameObject;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (anim == null) return 0;
|
||||
|
||||
bool walk = false, idle = false;
|
||||
foreach (AnimationState s in anim)
|
||||
{
|
||||
if (IsGesture(s.name)) continue;
|
||||
string n = s.name.ToLower();
|
||||
if (n.Contains("walk")) walk = true;
|
||||
if (n.Contains("idle")) idle = true;
|
||||
}
|
||||
return (walk ? 2 : 0) + (idle ? 1 : 0);
|
||||
}
|
||||
|
||||
/// <summary>Alles außer Aussehen und Animation entfernen (KI-FSMs, Physik, Sounds, Trigger ...).</summary>
|
||||
static void Strip(GameObject go)
|
||||
{
|
||||
foreach (Joint j in go.GetComponentsInChildren<Joint>(true)) UnityEngine.Object.DestroyImmediate(j);
|
||||
foreach (Rigidbody r in go.GetComponentsInChildren<Rigidbody>(true)) UnityEngine.Object.DestroyImmediate(r);
|
||||
foreach (Collider c in go.GetComponentsInChildren<Collider>(true)) UnityEngine.Object.DestroyImmediate(c);
|
||||
foreach (Behaviour b in go.GetComponentsInChildren<Behaviour>(true))
|
||||
{
|
||||
if (b is Animation || b is Animator) continue;
|
||||
UnityEngine.Object.DestroyImmediate(b);
|
||||
}
|
||||
foreach (AudioSource a in go.GetComponentsInChildren<AudioSource>(true)) UnityEngine.Object.DestroyImmediate(a);
|
||||
}
|
||||
|
||||
bool FindBones()
|
||||
{
|
||||
pelvis = Find("pelvis");
|
||||
spine = Find("spine_upper") ?? Find("spine_middle") ?? Find("spine_mid");
|
||||
head = Find("head");
|
||||
if (pelvis == null || spine == null || head == null) return false;
|
||||
|
||||
string[] sides = { "left", "right" };
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
knee[i] = Find("knee_" + sides[i]);
|
||||
if (knee[i] != null)
|
||||
{
|
||||
thigh[i] = knee[i].parent;
|
||||
foot[i] = knee[i].childCount > 0 ? knee[i].GetChild(0) : null;
|
||||
}
|
||||
hand[i] = Find("hand_" + sides[i]);
|
||||
if (hand[i] != null && hand[i].parent != null)
|
||||
{
|
||||
forearm[i] = hand[i].parent;
|
||||
upperArm[i] = forearm[i].parent;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Transform Find(string name)
|
||||
{
|
||||
foreach (Transform t in Root.GetComponentsInChildren<Transform>(true))
|
||||
if (string.Equals(t.name, name, StringComparison.OrdinalIgnoreCase)) return t;
|
||||
MscoLog.Warning("[MSCO] Player model could not be built: " + e.Message);
|
||||
c.Destroy();
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Größe auf Spielergröße bringen, Füße auf den Boden, Blickrichtung nach +Z.</summary>
|
||||
void Normalize()
|
||||
{
|
||||
// Blickrichtung: Zehen (Kind des Fußes) liegen vor dem Fuß
|
||||
Transform toe = foot[0] != null && foot[0].childCount > 0 ? foot[0].GetChild(0) : null;
|
||||
if (toe != null && Root.parent.InverseTransformPoint(toe.position).z < Root.parent.InverseTransformPoint(foot[0].position).z - 0.01f)
|
||||
Root.localRotation = Quaternion.Euler(0f, 180f, 0f);
|
||||
|
||||
float eye = Root.parent.InverseTransformPoint(head.position).y + EyeAboveHeadBone;
|
||||
if (eye > 0.5f) Root.localScale = Vector3.one * Mathf.Clamp(StandingEyeHeight / eye, 0.6f, 1.6f);
|
||||
|
||||
float lowest = float.MaxValue;
|
||||
for (int i = 0; i < 2; i++)
|
||||
if (foot[i] != null) lowest = Mathf.Min(lowest, Root.parent.InverseTransformPoint(foot[i].position).y);
|
||||
if (lowest < float.MaxValue) Root.localPosition = new Vector3(0f, -lowest + 0.06f, 0f);
|
||||
}
|
||||
|
||||
void SetupAnimation()
|
||||
void Build(Transform owner, int variant)
|
||||
{
|
||||
anim = Root.GetComponentInChildren<Animation>();
|
||||
if (anim == null) return;
|
||||
foreach (AnimationState s in anim)
|
||||
this.owner = owner;
|
||||
Vector3[] bind = BindPositions(bodyMesh);
|
||||
float leg = (bind[Knee[0]] - bind[HipBone[0]]).magnitude + (bind[Ankle[0]] - bind[Knee[0]]).magnitude;
|
||||
float scale = 0.84f / Mathf.Max(0.1f, leg); // Bein der MSCO-Figur
|
||||
|
||||
model = new GameObject("MSCO_Model");
|
||||
model.transform.SetParent(owner, false);
|
||||
// Bind-Pose: X vorne, Z oben -> Spieler: Z vorne, Y oben
|
||||
model.transform.localRotation = Quaternion.Inverse(Quaternion.LookRotation(Vector3.right, Vector3.forward));
|
||||
model.transform.localScale = Vector3.one * scale;
|
||||
|
||||
Matrix4x4[] bindposes = bodyMesh.bindposes;
|
||||
for (int i = 0; i < BoneCount; i++)
|
||||
{
|
||||
if (IsGesture(s.name)) continue;
|
||||
string n = s.name.ToLower();
|
||||
if (walkClip == null && n.Contains("walk")) walkClip = s.name;
|
||||
if (idleClip == null && n.Contains("idle")) idleClip = s.name;
|
||||
var b = new GameObject("bone" + i).transform;
|
||||
b.SetParent(model.transform, false);
|
||||
Matrix4x4 m = bindposes[i].inverse;
|
||||
b.localPosition = m.GetColumn(3);
|
||||
b.localRotation = Quaternion.LookRotation(m.GetColumn(2), m.GetColumn(1));
|
||||
bones[i] = b;
|
||||
restPos[i] = owner.InverseTransformPoint(b.position);
|
||||
restRot[i] = Quaternion.Inverse(owner.rotation) * b.rotation;
|
||||
}
|
||||
// Keine Steh-Animation: im Stand die Laufanimation angehalten zeigen (statt einer Geste)
|
||||
if (idleClip == null && walkClip == null && anim.clip != null && !IsGesture(anim.clip.name)) idleClip = anim.clip.name;
|
||||
if (walkClip != null) anim[walkClip].wrapMode = WrapMode.Loop;
|
||||
if (idleClip != null) anim[idleClip].wrapMode = WrapMode.Loop;
|
||||
if (idleClip != null) anim.Play(idleClip);
|
||||
restHipCenter = (restPos[HipBone[0]] + restPos[HipBone[1]]) * 0.5f;
|
||||
|
||||
var smr = model.AddComponent<SkinnedMeshRenderer>();
|
||||
smr.sharedMesh = bodyMesh;
|
||||
smr.bones = bones;
|
||||
smr.rootBone = bones[Pelvis];
|
||||
smr.updateWhenOffscreen = true; // die Knochen bewegen sich weit weg von der gespeicherten Hülle
|
||||
Material shirt = shirts[(variant * 7 + 3) % shirts.Count];
|
||||
Material trousers = pants[(variant * 3 + 1) % pants.Count];
|
||||
Material face = faces[(variant * 5 + 2) % faces.Count];
|
||||
smr.sharedMaterials = new[] { shirt, trousers, face };
|
||||
Description = "MSC body (" + shirt.name + ", " + trousers.name + ", " + face.name + ")";
|
||||
}
|
||||
|
||||
void RememberRestPose()
|
||||
{
|
||||
var bones = new List<Transform> { pelvis, spine, head };
|
||||
bones.AddRange(thigh);
|
||||
bones.AddRange(knee);
|
||||
bones.AddRange(upperArm);
|
||||
bones.AddRange(forearm);
|
||||
foreach (Transform b in bones)
|
||||
if (b != null)
|
||||
posed.Add(new BoneState { Bone = b, RestPos = b.localPosition, RestRot = b.localRotation });
|
||||
}
|
||||
// ---------------------------------------------------------------- jedes Frame
|
||||
|
||||
void ResetUnanimatedBones()
|
||||
public void Apply(Rig rig)
|
||||
{
|
||||
foreach (BoneState s in posed)
|
||||
{
|
||||
if (s.HasOut && s.Bone.localPosition == s.OutPos && s.Bone.localRotation == s.OutRot)
|
||||
{
|
||||
s.Bone.localPosition = s.RestPos;
|
||||
s.Bone.localRotation = s.RestRot;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (model == null) return;
|
||||
Quaternion root = owner.rotation;
|
||||
Quaternion lean = Quaternion.FromToRotation(Vector3.up, rig.Up);
|
||||
Quaternion torso = root * lean;
|
||||
|
||||
void RememberOutput()
|
||||
foreach (int i in Torso) Place(i, TorsoPoint(rig, lean, restPos[i]), torso * restRot[i]);
|
||||
Place(Head, TorsoPoint(rig, lean, restPos[Head]), root * Quaternion.Euler(rig.Pitch, 0f, 0f) * restRot[Head]);
|
||||
|
||||
for (int s = 0; s < 2; s++)
|
||||
{
|
||||
foreach (BoneState s in posed)
|
||||
{
|
||||
s.OutPos = s.Bone.localPosition;
|
||||
s.OutRot = s.Bone.localRotation;
|
||||
s.HasOut = true;
|
||||
// Bein: Hüfte am Becken, dann Richtung Knie bzw. Knöchel der MSCO-Figur
|
||||
Vector3 hip = TorsoPoint(rig, lean, restPos[HipBone[s]]);
|
||||
Quaternion thigh = AimDelta(HipBone[s], Knee[s], rig.Knee[s] - rig.HipJoint[s]);
|
||||
Place(HipBone[s], hip, root * thigh * restRot[HipBone[s]]);
|
||||
Vector3 knee = hip + root * (thigh * (restPos[Knee[s]] - restPos[HipBone[s]]));
|
||||
Quaternion shin = AimDelta(Knee[s], Ankle[s], rig.Ankle[s] - rig.Knee[s]);
|
||||
Place(Knee[s], knee, root * shin * restRot[Knee[s]]);
|
||||
Vector3 ankle = knee + root * (shin * (restPos[Ankle[s]] - restPos[Knee[s]]));
|
||||
Place(Ankle[s], ankle, root * restRot[Ankle[s]]); // Fuß bleibt flach
|
||||
|
||||
// Arm: Schulter am Oberkörper, dann Richtung Ellbogen bzw. Hand
|
||||
Vector3 shoulder = TorsoPoint(rig, lean, restPos[Shoulder[s]]);
|
||||
Quaternion upper = AimDelta(Shoulder[s], Elbow[s], rig.Elbow[s] - rig.Shoulder[s]);
|
||||
Place(Shoulder[s], shoulder, root * upper * restRot[Shoulder[s]]);
|
||||
Vector3 elbow = shoulder + root * (upper * (restPos[Elbow[s]] - restPos[Shoulder[s]]));
|
||||
Quaternion fore = AimDelta(Elbow[s], Wrist[s], rig.Hand[s] - rig.Elbow[s]);
|
||||
Place(Elbow[s], elbow, root * fore * restRot[Elbow[s]]);
|
||||
Vector3 wrist = elbow + root * (fore * (restPos[Wrist[s]] - restPos[Elbow[s]]));
|
||||
Place(Wrist[s], wrist, root * fore * restRot[Wrist[s]]);
|
||||
Vector3 finger = wrist + root * (fore * (restPos[Finger[s]] - restPos[Wrist[s]]));
|
||||
Place(Finger[s], finger, root * fore * restRot[Finger[s]]);
|
||||
}
|
||||
}
|
||||
|
||||
string BoneReport()
|
||||
/// <summary>Punkt des Oberkörpers: an der Hüfte der MSCO-Figur, mit ihr geneigt.</summary>
|
||||
Vector3 TorsoPoint(Rig rig, Quaternion lean, Vector3 rest)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("legs ").Append(knee[0] != null && foot[0] != null ? "ok" : "missing");
|
||||
sb.Append(", arms ").Append(hand[1] != null && upperArm[1] != null ? "ok" : "missing");
|
||||
sb.Append(", anim idle=").Append(idleClip ?? "-").Append(" walk=").Append(walkClip ?? "-");
|
||||
return sb.ToString();
|
||||
return owner.TransformPoint(rig.Hip + lean * (rest - restHipCenter));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Pro Frame (LateUpdate, nach der Animation)
|
||||
|
||||
public void Animate(float speed)
|
||||
/// <summary>Drehung, die das Knochenstück from->to aus der Ruhelage in die gewünschte Richtung bringt.</summary>
|
||||
Quaternion AimDelta(int from, int to, Vector3 desired)
|
||||
{
|
||||
if (anim == null) return;
|
||||
bool walking = speed > 0.4f && walkClip != null;
|
||||
string clip = walking ? walkClip : (idleClip ?? walkClip);
|
||||
if (clip == null) return;
|
||||
if (!anim.IsPlaying(clip)) anim.CrossFade(clip, 0.25f);
|
||||
if (clip == walkClip) anim[walkClip].speed = walking ? Mathf.Clamp(speed / 1.4f, 0.6f, 2.2f) : 0f; // angehalten = stehen
|
||||
Vector3 rest = restPos[to] - restPos[from];
|
||||
if (desired.sqrMagnitude < 1e-6f || rest.sqrMagnitude < 1e-6f) return Quaternion.identity;
|
||||
return Quaternion.FromToRotation(rest, desired);
|
||||
}
|
||||
|
||||
public void Pose(Transform body, Vector3 headTarget, float pitch, bool seated, bool holding, Vector3 heldPos)
|
||||
void Place(int i, Vector3 pos, Quaternion rot)
|
||||
{
|
||||
Vector3 up = body.up, forward = body.forward, right = body.right;
|
||||
ResetUnanimatedBones();
|
||||
frames++;
|
||||
|
||||
// 1) Auf den Boden stellen: die (animierten) Füße stehen auf der Bodenhöhe des Spielers.
|
||||
// Die Grundhaltung eines NPCs passt oft nicht zur Animation - deshalb jedes Frame nachmessen.
|
||||
float lowestFoot = float.MaxValue;
|
||||
for (int i = 0; i < 2; i++)
|
||||
if (foot[i] != null) lowestFoot = Mathf.Min(lowestFoot, Vector3.Dot(foot[i].position - body.position, up));
|
||||
if (lowestFoot < float.MaxValue)
|
||||
{
|
||||
Vector3 lp = Root.localPosition;
|
||||
lp.y -= lowestFoot - AnkleHeight;
|
||||
Root.localPosition = lp;
|
||||
bones[i].position = pos;
|
||||
bones[i].rotation = rot;
|
||||
}
|
||||
|
||||
// 2) Größe einmal an die laufende Animation anpassen (Augenhöhe wie der Spieler)
|
||||
if (!calibrated && frames > 5 && !seated)
|
||||
public void Destroy()
|
||||
{
|
||||
calibrated = true;
|
||||
float eye = Vector3.Dot(head.position + up * EyeAboveHeadBone - body.position, up);
|
||||
if (eye > 0.5f) Root.localScale *= Mathf.Clamp(StandingEyeHeight / eye, 0.7f, 1.4f);
|
||||
RememberOutput();
|
||||
return; // nächstes Frame mit neuer Größe neu auf den Boden stellen
|
||||
}
|
||||
|
||||
// Fußpositionen der Animation merken (die sollen stehen bleiben)
|
||||
for (int i = 0; i < 2; i++)
|
||||
if (foot[i] != null) animatedFoot[i] = foot[i].position;
|
||||
|
||||
// 3) Becken: beim Ducken/Sitzen absenken, beim Lehnen nach hinten ausweichen
|
||||
Vector3 eyeNow = head.position + up * EyeAboveHeadBone;
|
||||
Vector3 offset = headTarget - eyeNow;
|
||||
Vector3 horizontal = offset - up * Vector3.Dot(offset, up);
|
||||
if (horizontal.magnitude > 0.6f) horizontal = horizontal.normalized * 0.6f;
|
||||
float drop = Mathf.Clamp(Vector3.Dot(offset, up), -1.1f, 0f);
|
||||
pelvis.position += up * drop - horizontal * 0.25f;
|
||||
|
||||
// Wirbelsäule so drehen, dass der Kopf an der Kamera sitzt (nur wenn nötig)
|
||||
if (horizontal.sqrMagnitude > 0.0025f || drop < -0.05f)
|
||||
Aim(spine, head.position + up * EyeAboveHeadBone, head.position + up * EyeAboveHeadBone + horizontal * 0.75f);
|
||||
// Kopf nicken
|
||||
head.rotation = Quaternion.AngleAxis(pitch, right) * head.rotation;
|
||||
|
||||
// Beine
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
if (thigh[i] == null || knee[i] == null || foot[i] == null) continue;
|
||||
Vector3 target = seated
|
||||
? thigh[i].position + forward * 0.45f - up * 0.4f
|
||||
: animatedFoot[i];
|
||||
SolveTwoBone(thigh[i], knee[i], foot[i], target, forward);
|
||||
}
|
||||
|
||||
// Arme: rechts zum gehaltenen Item, im Auto beide nach vorne (Lenkrad)
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
if (upperArm[i] == null || forearm[i] == null || hand[i] == null) continue;
|
||||
if (i == 1 && holding)
|
||||
SolveTwoBone(upperArm[i], forearm[i], hand[i], heldPos, -forward - up * 0.5f);
|
||||
else if (seated)
|
||||
SolveTwoBone(upperArm[i], forearm[i], hand[i], upperArm[i].position + forward * 0.38f - up * 0.28f, -up);
|
||||
}
|
||||
RememberOutput();
|
||||
}
|
||||
|
||||
/// <summary>Knochen so drehen, dass "child" in Richtung "target" zeigt - egal wie das Skelett ausgerichtet ist.</summary>
|
||||
static void Aim(Transform bone, Vector3 childPos, Vector3 target)
|
||||
{
|
||||
Vector3 from = childPos - bone.position;
|
||||
Vector3 to = target - bone.position;
|
||||
if (from.sqrMagnitude < 1e-6f || to.sqrMagnitude < 1e-6f) return;
|
||||
bone.rotation = Quaternion.FromToRotation(from, to) * bone.rotation;
|
||||
}
|
||||
|
||||
static void SolveTwoBone(Transform upper, Transform lower, Transform end, Vector3 target, Vector3 bendHint)
|
||||
{
|
||||
float a = (lower.position - upper.position).magnitude;
|
||||
float b = (end.position - lower.position).magnitude;
|
||||
if (a < 0.01f || b < 0.01f) return;
|
||||
|
||||
Vector3 root = upper.position;
|
||||
Vector3 toTarget = target - root;
|
||||
float d = Mathf.Clamp(toTarget.magnitude, 0.01f, a + b - 0.001f);
|
||||
Vector3 dir = toTarget.normalized;
|
||||
float along = (a * a - b * b + d * d) / (2f * d);
|
||||
float sideways = Mathf.Sqrt(Mathf.Max(0f, a * a - along * along));
|
||||
Vector3 bend = bendHint - dir * Vector3.Dot(bendHint, dir);
|
||||
if (bend.sqrMagnitude < 1e-6f) bend = Vector3.up - dir * Vector3.Dot(Vector3.up, dir);
|
||||
Vector3 joint = root + dir * along + bend.normalized * sideways;
|
||||
|
||||
Aim(upper, lower.position, joint);
|
||||
Aim(lower, end.position, root + dir * d);
|
||||
if (model != null) Object.Destroy(model);
|
||||
model = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
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 = 3f; // so lange nach einer Nachricht die Feder halten und selbst nicht senden
|
||||
// Steht das Fahrzeug, schickt nur, wer die Tür gerade selbst bewegt hat (Klick in der Nähe). Vorher schickte
|
||||
// jeder in der Nähe: setzte sich die Tür beim anderen nach dem Übernehmen etwas anders, schickte er zurück -
|
||||
// die Türen schaukelten hin und her.
|
||||
const float InteractRange = 2.5f;
|
||||
const float InteractAuthority = 4f; // so lange nach dem Klick (Tür schwingt aus)
|
||||
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 float LocalAuthorityUntil = -100f;
|
||||
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 = -1f; // erster Suchlauf verteilt, siehe Update
|
||||
}
|
||||
|
||||
public void Update(ObjectSync objects, int localId, Vector3 playerPos, float dt, Action<MsgType, byte[]> send)
|
||||
{
|
||||
if (nextScan < 0f) nextScan = Time.time + 1.7f; // nicht alle Sync-Teile im selben Frame suchen lassen
|
||||
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;
|
||||
bool mine = Time.time < h.LocalAuthorityUntil;
|
||||
if (!mine && Time.time - h.TargetTime < ApplyHold) continue; // gerade von anderen gesetzt
|
||||
int owner = objects.VehicleOwner(h.Root);
|
||||
// Fährt jemand: der Fahrer schickt (Türen schwingen während der Fahrt). Hat man selbst gerade an der Tür
|
||||
// geklickt, schickt man auch - sonst würde die Feder sie beim Fahrer gleich wieder zudrücken.
|
||||
if (!mine && owner != SyncedBody.NoOwner && owner != localId) continue;
|
||||
if (!mine && owner == SyncedBody.NoOwner) { h.SentAngle = SafeAngle(h, h.SentAngle); continue; } // stehendes Auto: nur wer klickt
|
||||
|
||||
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); }
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>Jedes Frame: Klick (linke Maustaste / Benutzen) in der Nähe einer Tür = wir bewegen sie gerade.</summary>
|
||||
public void NoteInteraction(Vector3 playerPos)
|
||||
{
|
||||
if (!Input.GetMouseButtonDown(0) && !Input.GetKeyDown(KeyCode.F)) return;
|
||||
foreach (Hinge h in hinges.Values)
|
||||
{
|
||||
if (h.Rb == null || (h.Rb.worldCenterOfMass - playerPos).sqrMagnitude > InteractRange * InteractRange) continue;
|
||||
h.LocalAuthorityUntil = Time.time + InteractAuthority;
|
||||
h.TargetTime = -100f; // eigene Bewegung: keine Feder mehr auf den alten Winkel
|
||||
}
|
||||
}
|
||||
|
||||
static float SafeAngle(Hinge h, float fallback)
|
||||
{
|
||||
float a = h.Joint.angle;
|
||||
return float.IsNaN(a) || float.IsInfinity(a) ? fallback : a;
|
||||
}
|
||||
|
||||
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;
|
||||
if (Time.time < h.LocalAuthorityUntil) continue; // wir bewegen sie gerade selbst
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-3
@@ -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>
|
||||
@@ -74,11 +74,15 @@ namespace MSCO
|
||||
Cursor.lockState = CursorLockMode.None;
|
||||
}
|
||||
|
||||
/// <summary>PlayMaker-Aktionen, die den Mauszeiger setzen, überspringen, solange ein Fenster offen ist.</summary>
|
||||
/// <summary>
|
||||
/// PlayMaker-Aktionen, die den Mauszeiger setzen, überspringen, solange ein Fenster offen ist. Außerdem die
|
||||
/// Tastenabfragen des Spiels (cInput) sperren: sonst löst Tippen im Chat Spielaktionen aus (Benutzen, Hupe ...).
|
||||
/// </summary>
|
||||
static void PatchCursorActions()
|
||||
{
|
||||
if (patched) return;
|
||||
patched = true;
|
||||
PatchGameInput();
|
||||
try
|
||||
{
|
||||
Type action = null;
|
||||
@@ -99,10 +103,50 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
static bool SkipWhileLocked() { return !locked; }
|
||||
|
||||
static void PatchGameInput()
|
||||
{
|
||||
try
|
||||
{
|
||||
Type input = typeof(cInput);
|
||||
HarmonyInstance harmony = HarmonyInstance.Create("mbm.msco.inputlock.keys");
|
||||
var boolPrefix = new HarmonyMethod(typeof(InputLock).GetMethod("NoKeyWhileLocked", BindingFlags.Static | BindingFlags.NonPublic));
|
||||
var floatPrefix = new HarmonyMethod(typeof(InputLock).GetMethod("NoAxisWhileLocked", BindingFlags.Static | BindingFlags.NonPublic));
|
||||
int count = 0;
|
||||
foreach (MethodInfo m in input.GetMethods(BindingFlags.Static | BindingFlags.Public))
|
||||
{
|
||||
bool keyQuery = m.Name == "GetKey" || m.Name == "GetKeyDown" || m.Name == "GetKeyUp" ||
|
||||
m.Name == "GetButton" || m.Name == "GetButtonDown" || m.Name == "GetButtonUp";
|
||||
bool axisQuery = m.Name == "GetAxis" || m.Name == "GetAxisRaw";
|
||||
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++; }
|
||||
}
|
||||
MscoLog.Info("[MSCO] Game input is paused while typing (" + count + " cInput queries)");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
MscoLog.Warning("[MSCO] Could not pause game input while typing: " + e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Harmony-Prefixe: solange ein Eingabefenster offen ist, meldet cInput "nichts gedrückt"
|
||||
static bool NoKeyWhileLocked(ref bool __result)
|
||||
{
|
||||
if (!locked) return true;
|
||||
__result = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool NoAxisWhileLocked(ref float __result)
|
||||
{
|
||||
if (!locked) return true;
|
||||
__result = 0f;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+120
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+640
-197
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,30 @@ namespace MSCO
|
||||
{
|
||||
public class MSCO : Mod
|
||||
{
|
||||
public const string ModVersion = "2.6";
|
||||
/// <summary>
|
||||
/// Version der Mod (MSCLoader / MSC Launcher). Kleine Updates: nur die dritte Stelle erhöhen (2.8.1, 2.8.2 ...) -
|
||||
/// sie bleiben mit 2.8-Servern und -Mitspielern kompatibel.
|
||||
/// </summary>
|
||||
public const string ModVersion = "2.8.1";
|
||||
/// <summary>
|
||||
/// Netzwerk-Version (die ersten beiden Stellen). Nur erhöhen, wenn sich das Protokoll inkompatibel ändert - dann
|
||||
/// auch GameServer.ModVersion im Server und eine neue Mod-Version 2.9.0 o.ä.
|
||||
/// </summary>
|
||||
public const string ProtocolVersion = "2.8";
|
||||
|
||||
/// <summary>Passt eine andere Version (Server, Host, Mitspieler) zu uns? Verglichen werden nur die ersten zwei Stellen.</summary>
|
||||
public static bool Compatible(string other)
|
||||
{
|
||||
return Protocol(other) == ProtocolVersion;
|
||||
}
|
||||
|
||||
/// <summary>"2.8.3" -> "2.8"</summary>
|
||||
public static string Protocol(string version)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version)) return "";
|
||||
string[] parts = version.Trim().Split('.');
|
||||
return parts.Length >= 2 ? parts[0] + "." + parts[1] : version.Trim();
|
||||
}
|
||||
|
||||
public override string ID => "MSCO"; // Your (unique) mod ID
|
||||
public override string Name => "MSCO"; // Your mod name
|
||||
@@ -32,7 +55,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 +64,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)
|
||||
|
||||
+13
-1
@@ -73,8 +73,20 @@
|
||||
</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" />
|
||||
<Compile Include="SafetyPatches.cs" />
|
||||
@@ -85,7 +97,6 @@
|
||||
<Compile Include="DiscordPresence.cs" />
|
||||
<Compile Include="DoorSync.cs" />
|
||||
<Compile Include="FsmSync.cs" />
|
||||
<Compile Include="GameCharacter.cs" />
|
||||
<Compile Include="JoinLinks.cs" />
|
||||
<Compile Include="LobbyUI.cs" />
|
||||
<Compile Include="MenuHelper.cs" />
|
||||
@@ -95,6 +106,7 @@
|
||||
<Compile Include="NetSession.cs" />
|
||||
<Compile Include="ObjectSync.cs" />
|
||||
<Compile Include="Passenger.cs" />
|
||||
<Compile Include="GameCharacter.cs" />
|
||||
<Compile Include="SaveSync.cs" />
|
||||
<Compile Include="ServerBrowser.cs" />
|
||||
<Compile Include="SteamLobby.cs" />
|
||||
|
||||
+97
-2
@@ -51,22 +51,117 @@ 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Geräusche und Musik des Spiel-Hauptmenüs stummschalten - der MSCO-Launcher deckt das ganze Menü ab.
|
||||
/// Gibt die Anzahl der stummgeschalteten Quellen zurück.
|
||||
/// </summary>
|
||||
public static int MuteMenuAudio()
|
||||
{
|
||||
int muted = 0;
|
||||
foreach (Object o in Object.FindObjectsOfType(typeof(AudioSource)))
|
||||
{
|
||||
var src = (AudioSource)o;
|
||||
if (src == null || src.mute || src.transform.root.name.StartsWith("MSCO")) continue;
|
||||
src.mute = true;
|
||||
mutedByUs.Add(src);
|
||||
muted++;
|
||||
}
|
||||
return muted;
|
||||
}
|
||||
|
||||
static readonly System.Collections.Generic.List<AudioSource> mutedByUs = new System.Collections.Generic.List<AudioSource>();
|
||||
|
||||
/// <summary>Launcher eingeklappt: die von uns stummgeschalteten Menü-Geräusche wieder an.</summary>
|
||||
public static void UnmuteMenuAudio()
|
||||
{
|
||||
foreach (AudioSource src in mutedByUs) if (src != null) src.mute = false;
|
||||
mutedByUs.Clear();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Neues Spiel starten (der Spielstand wurde vorher geleert): über die Logik des NEW-GAME-Knopfs des Spiels,
|
||||
/// sonst wie CONTINUE - ohne Spielstand legt das Spiel dann eine frische Welt an.
|
||||
/// </summary>
|
||||
public static void StartNewGame()
|
||||
{
|
||||
PlayMakerFSM fsm;
|
||||
FsmState state;
|
||||
if (switchState != null && FindLoadState("newgame", out fsm, out state))
|
||||
{
|
||||
MscoLog.Info("[MSCO] Starting a new game via NEW GAME (" + fsm.name + " / " + state.Name + ")");
|
||||
switchState.Invoke(fsm.Fsm, new object[] { state });
|
||||
return;
|
||||
}
|
||||
MscoLog.Info("[MSCO] NEW GAME button not found - starting with an empty save");
|
||||
StartGame();
|
||||
}
|
||||
|
||||
/// <summary>FSM unter einem Objekt, dessen Name "hint" enthält, mit einem Zustand, der eine Szene lädt.</summary>
|
||||
static bool FindLoadState(string hint, out PlayMakerFSM fsm, out FsmState state)
|
||||
{
|
||||
fsm = null;
|
||||
state = null;
|
||||
// Nur aktive Objekte der Menü-Szene (die Spiel-Knöpfe sind nur unsichtbar, nicht abgeschaltet)
|
||||
foreach (Object o in Object.FindObjectsOfType(typeof(PlayMakerFSM)))
|
||||
{
|
||||
var candidate = (PlayMakerFSM)o;
|
||||
if (!HierarchyContains(candidate.transform, hint)) continue;
|
||||
foreach (FsmState s in candidate.FsmStates)
|
||||
{
|
||||
if (!s.ActionsLoaded) s.LoadActions();
|
||||
if (s.Actions == null) continue;
|
||||
foreach (FsmStateAction action in s.Actions)
|
||||
{
|
||||
var load = action as LoadLevel;
|
||||
if (load != null && load.levelName != null && !string.IsNullOrEmpty(load.levelName.Value))
|
||||
{
|
||||
fsm = candidate;
|
||||
state = s;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool HierarchyContains(Transform t, string text)
|
||||
{
|
||||
for (; t != null; t = t.parent)
|
||||
|
||||
+2
-2
@@ -2,11 +2,11 @@ using MSCLoader;
|
||||
|
||||
namespace MSCO
|
||||
{
|
||||
/// <summary>MSCLoader-Konsole: "mp <befehl>" - gleiche Befehle wie in der F8-Konsole.</summary>
|
||||
/// <summary>MSCLoader-Konsole: "mp <befehl>" - 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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1159
-53
File diff suppressed because it is too large
Load Diff
+42
-4
@@ -34,10 +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]
|
||||
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)
|
||||
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
|
||||
@@ -62,6 +77,12 @@ namespace MSCO
|
||||
public Quaternion HeldLocalRot; // relativ zur Kamera
|
||||
public bool Seated; // fährt oder sitzt als Beifahrer
|
||||
public float FeetY; // Bodenhöhe unter dem Spieler (für Ducken/Lehnen der Figur)
|
||||
// Im Fahrzeug: Kopf und Blick relativ zum Fahrzeug (ID des SyncedBody, 0 = nicht im Fahrzeug). Damit sitzt die
|
||||
// Figur beim anderen fest auf dem Sitz, statt in Weltkoordinaten hinter dem fahrenden Auto herzuhinken.
|
||||
// Steht am Ende der Nachricht: ältere Versionen und der Server lesen es einfach nicht mit.
|
||||
public int SeatId;
|
||||
public Vector3 SeatHead;
|
||||
public Quaternion SeatView;
|
||||
|
||||
public byte[] Serialize()
|
||||
{
|
||||
@@ -75,9 +96,15 @@ namespace MSCO
|
||||
w.Write(s.Seated);
|
||||
w.Write(s.FeetY);
|
||||
w.Write(s.HeldId);
|
||||
if (s.HeldId == 0) return;
|
||||
if (s.HeldId != 0)
|
||||
{
|
||||
Write(w, s.HeldLocalPos);
|
||||
Write(w, s.HeldLocalRot);
|
||||
}
|
||||
w.Write(s.SeatId);
|
||||
if (s.SeatId == 0) return;
|
||||
Write(w, s.SeatHead);
|
||||
Write(w, s.SeatView);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,13 +119,24 @@ namespace MSCO
|
||||
Seated = r.ReadBoolean(),
|
||||
FeetY = r.ReadSingle(),
|
||||
HeldId = r.ReadInt32(),
|
||||
HeldLocalRot = Quaternion.identity
|
||||
HeldLocalRot = Quaternion.identity,
|
||||
SeatView = Quaternion.identity
|
||||
};
|
||||
if (s.HeldId != 0)
|
||||
{
|
||||
s.HeldLocalPos = ReadVector(r);
|
||||
s.HeldLocalRot = ReadQuaternion(r);
|
||||
}
|
||||
// Ältere Versionen senden keinen Sitz
|
||||
if (r.BaseStream.Length - r.BaseStream.Position >= 4)
|
||||
{
|
||||
s.SeatId = r.ReadInt32();
|
||||
if (s.SeatId != 0)
|
||||
{
|
||||
s.SeatHead = ReadVector(r);
|
||||
s.SeatView = ReadQuaternion(r);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
+477
-28
@@ -16,17 +16,43 @@ 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
|
||||
public RigidbodyInterpolation WasInterpolation;
|
||||
public bool InterpolationSet; // Fahrzeug eines anderen: bei uns interpoliert (siehe FreezeForRemote)
|
||||
// Gelenke der angebauten Teile, solange ein anderer das Fahrzeug fährt: unzerbrechlich (vorherige Bruchkraft/-moment)
|
||||
public Dictionary<Joint, Vector2> HeldJoints;
|
||||
public float NextPartScan;
|
||||
// Nach der Rückgabe erst so spät wieder zerbrechlich (bis dahin schwingen die Teile ein)
|
||||
public float JointRestoreAt = float.MaxValue;
|
||||
// Item liegt in diesem Fahrzeug (Kofferraum, Sitz ...): gehört dann dem, der das Fahrzeug fährt
|
||||
public SyncedBody Carrier;
|
||||
public float NextCarrierCheck;
|
||||
// Fahrzeug: Umriss in lokalen Koordinaten (für "liegt im Auto")
|
||||
public Bounds LocalBounds;
|
||||
public bool HasLocalBounds;
|
||||
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 (Physik-Zeit, Time.fixedTime)
|
||||
public float LastRecvTime;
|
||||
public float AvatarHoldTime = -100f; // zuletzt von der Hand eines Mitspielers platziert
|
||||
|
||||
@@ -53,7 +79,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;
|
||||
@@ -63,6 +90,13 @@ namespace MSCO
|
||||
const float ReleaseAfterStill = 1f;
|
||||
const float SnapDistance = 10f;
|
||||
const float RemoteTimeout = 3f; // Besitzer meldet sich nicht mehr (z.B. Teil wurde dort montiert)
|
||||
// Ein gefahrenes Auto NICHT nach 3 s freigeben: hängt das Spiel des Fahrers kurz (z.B. beim Speichern), blieb es
|
||||
// bei den anderen schlagartig stehen und die Teile rissen ab. Trennt der Fahrer, gibt ReleaseAllOf es frei.
|
||||
const float DrivenVehicleTimeout = 30f;
|
||||
const float JointSettleTime = 3f; // so lange nach der Rückgabe bleiben die Gelenke der Teile unzerbrechlich
|
||||
const float CarrierCheckInterval = 0.4f;
|
||||
const float CarrierMinSpeed = 1.5f; // ab dieser Geschwindigkeit nimmt ein Fahrzeug seine Ladung mit
|
||||
const float RunawaySpeed = 90f; // m/s - schneller fährt in MSC nichts; darüber hat die Physik es weggeschleudert
|
||||
const float HandoverCooldown = 3f; // nach Freigabe/Übergabe keine Übernahme durch Bewegung
|
||||
const float MaxHoldDistance = 1.5f; // gehaltene Items sind so nah an der Kamera
|
||||
const float VehicleClaimSpeed = 0.7f; // Fahrzeuge erst ab echter Bewegung übernehmen (nicht Federn)
|
||||
@@ -74,6 +108,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>();
|
||||
@@ -86,6 +165,8 @@ namespace MSCO
|
||||
|
||||
float loadTime;
|
||||
float nextScan;
|
||||
int frame;
|
||||
readonly List<SyncedBody> drivenList = new List<SyncedBody>();
|
||||
float sendTimer;
|
||||
|
||||
FsmGameObject pickedObject;
|
||||
@@ -102,6 +183,9 @@ 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;
|
||||
if (!b.Rb.isKinematic)
|
||||
@@ -121,6 +205,9 @@ namespace MSCO
|
||||
|
||||
public bool IsRemoteOwnedBody(SyncedBody b) { return IsRemoteOwned(b); }
|
||||
|
||||
/// <summary>Alle erfassten Fahrzeuge (Liste wird alle 2 s erneuert, nicht verändern).</summary>
|
||||
public List<SyncedBody> AllVehicles() { return Vehicles(); }
|
||||
|
||||
/// <summary>Für "mp vehicles": Zustand aller erfassten Fahrzeuge (zur Fehlersuche).</summary>
|
||||
public List<string> DescribeVehicles()
|
||||
{
|
||||
@@ -132,7 +219,8 @@ namespace MSCO
|
||||
if (!b.IsVehicle) continue;
|
||||
string owner = b.OwnerId == SyncedBody.NoOwner ? "free" : IsLocal(b) ? "ME" : "player " + b.OwnerId;
|
||||
lines.Add(b.RootName + " id=" + b.Id + " owner=" + owner + (b.OwnerActive ? " (active)" : "") +
|
||||
(b.Rb == null ? " [rigidbody gone]" : " kinematic=" + b.Rb.isKinematic + " speed=" + b.Rb.velocity.magnitude.ToString("0.0")));
|
||||
(b.Rb == null ? " [rigidbody gone]" : " kinematic=" + b.Rb.isKinematic + " speed=" + b.Rb.velocity.magnitude.ToString("0.0") +
|
||||
" pos=" + b.Rb.position.ToString("F0") + (b.Rb.gameObject.activeInHierarchy ? "" : " [inactive]")));
|
||||
}
|
||||
if (lines.Count == 1) lines.Add("No vehicles registered! (SATSUMA, HAYOSIKO, ... not found as root objects with a Rigidbody)");
|
||||
return lines;
|
||||
@@ -140,16 +228,30 @@ namespace MSCO
|
||||
|
||||
/// <summary>Fährt der lokale Spieler gerade selbst?</summary>
|
||||
public bool IsDriving { get; private set; }
|
||||
/// <summary>Das Fahrzeug, das der lokale Spieler gerade fährt (sonst null).</summary>
|
||||
public SyncedBody Driven { get; private set; }
|
||||
|
||||
/// <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()
|
||||
{
|
||||
@@ -173,10 +275,14 @@ namespace MSCO
|
||||
public void Reset()
|
||||
{
|
||||
foreach (SyncedBody b in byId.Values)
|
||||
if (IsRemoteOwned(b) && b.Rb != null) b.Rb.isKinematic = b.WasKinematic;
|
||||
{
|
||||
if (IsRemoteOwned(b) && b.Rb != null) RestoreKinematic(b);
|
||||
RestoreJoints(b);
|
||||
}
|
||||
byId.Clear();
|
||||
byRb.Clear();
|
||||
rejected.Clear();
|
||||
networkCreated.Clear();
|
||||
drivenName = null;
|
||||
drivenCache = null;
|
||||
ScanComplete = false;
|
||||
@@ -190,7 +296,7 @@ namespace MSCO
|
||||
foreach (SyncedBody b in byId.Values)
|
||||
{
|
||||
if (b.OwnerId != playerId || b.Rb == null) continue;
|
||||
b.Rb.isKinematic = b.WasKinematic;
|
||||
RestoreKinematic(b);
|
||||
b.OwnerId = SyncedBody.NoOwner;
|
||||
b.NoMotionClaimUntil = Time.time + HandoverCooldown;
|
||||
}
|
||||
@@ -199,6 +305,7 @@ namespace MSCO
|
||||
/// <summary>Besitzer eines Fahrzeugs (nach Wurzelname), NoOwner wenn frei oder unbekannt.</summary>
|
||||
public int VehicleOwner(string rootName)
|
||||
{
|
||||
if (rootName == null) return SyncedBody.NoOwner;
|
||||
foreach (SyncedBody b in byId.Values)
|
||||
if (b.IsVehicle && b.RootName == rootName) return b.OwnerId;
|
||||
return SyncedBody.NoOwner;
|
||||
@@ -222,6 +329,7 @@ namespace MSCO
|
||||
|
||||
public void Update(Transform player, float dt)
|
||||
{
|
||||
if (player != null) lastPlayerPos = player.position;
|
||||
if (Time.time >= nextScan)
|
||||
{
|
||||
nextScan = Time.time + ScanInterval;
|
||||
@@ -232,20 +340,44 @@ namespace MSCO
|
||||
|
||||
SyncedBody held = HeldBody();
|
||||
SyncedBody driven = DrivenBody();
|
||||
Driven = driven;
|
||||
IsDriving = driven != null;
|
||||
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
|
||||
if (b.HeldJoints != null && !IsRemoteOwned(b) && Time.time >= b.JointRestoreAt) RestoreJoints(b);
|
||||
|
||||
bool active = b == held || b == driven;
|
||||
if (!IsRemoteOwned(b)) StopRunaway(b);
|
||||
// Ladung eines fahrenden Autos gehört dessen Fahrer: nur bei ihm wird sie mit dem Auto simuliert.
|
||||
// Übernahm sie ein Mitfahrer, folgte sie beim Fahrer verspätet und kinematisch (unendlich schwer) und
|
||||
// schlug Sitze und Teile ab.
|
||||
SyncedBody carrier = active || b.IsVehicle ? null : CarrierOf(b);
|
||||
bool carrierRemote = carrier != null && IsRemoteOwned(carrier);
|
||||
bool carrierMine = carrier != null && IsLocal(carrier) && IsCarrying(carrier);
|
||||
if (b.OwnerId == SyncedBody.NoOwner)
|
||||
{
|
||||
if (active) Claim(b, true);
|
||||
else if (motionClaimAllowed && Time.time >= b.NoMotionClaimUntil && IsMoving(b) && IsNear(b, player)) Claim(b, false);
|
||||
else if (carrierMine && IsMoving(b)) Claim(b, false);
|
||||
else if (motionClaimAllowed && Time.time >= b.NoMotionClaimUntil && IsMoving(b) && IsNear(b, player) && !carrierRemote) Claim(b, false);
|
||||
}
|
||||
else if (IsLocal(b) && !active && carrierRemote && carrier.OwnerActive)
|
||||
{
|
||||
Release(b); // fährt ein anderer: der übernimmt die Ladung
|
||||
}
|
||||
else if (IsLocal(b))
|
||||
{
|
||||
@@ -264,10 +396,15 @@ 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
|
||||
else if (Time.time - b.LastRecvTime > RemoteTimeout) SetFree(b);
|
||||
// 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 (carrierMine && !b.OwnerActive) Claim(b, false); // liegt in unserem fahrenden Auto
|
||||
else if (Time.time - b.LastRecvTime > (b.IsVehicle && b.OwnerActive ? DrivenVehicleTimeout : RemoteTimeout)) SetFree(b);
|
||||
}
|
||||
}
|
||||
ReportConsumed(player);
|
||||
foreach (SyncedBody b in removeList) Unregister(b);
|
||||
Held = held != null && held.Rb != null && IsLocal(held) ? held : null;
|
||||
|
||||
@@ -284,17 +421,25 @@ namespace MSCO
|
||||
{
|
||||
foreach (SyncedBody b in byId.Values)
|
||||
{
|
||||
if (!IsRemoteOwned(b) || b.Rb == null) continue;
|
||||
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.
|
||||
// Beides in Physik-Zeit: mit der Bildzeit (Time.time beim Empfang) wurde das Alter in den ersten
|
||||
// Physik-Schritten danach negativ - das Auto sprang bei jedem Paket ein Stück zurück (Ruckeln)
|
||||
float age = Mathf.Clamp(Time.fixedTime - b.TargetTime, 0f, 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,7 +451,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) return;
|
||||
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;
|
||||
@@ -321,6 +466,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;
|
||||
}
|
||||
|
||||
@@ -328,7 +475,7 @@ namespace MSCO
|
||||
|
||||
void Claim(SyncedBody b, bool active)
|
||||
{
|
||||
if (IsRemoteOwned(b)) b.Rb.isKinematic = b.WasKinematic;
|
||||
if (IsRemoteOwned(b)) RestoreKinematic(b);
|
||||
b.OwnerId = LocalId;
|
||||
b.OwnerActive = active;
|
||||
b.StillTime = 0f;
|
||||
@@ -357,28 +504,162 @@ namespace MSCO
|
||||
if (b.OwnerId == ownerId) return;
|
||||
if (!IsRemoteOwned(b))
|
||||
{
|
||||
b.WasKinematic = b.Rb.isKinematic;
|
||||
b.Rb.isKinematic = true;
|
||||
FreezeForRemote(b);
|
||||
b.TargetPos = b.Rb.position;
|
||||
b.TargetRot = b.Rb.rotation;
|
||||
b.TargetVel = Vector3.zero;
|
||||
b.TargetTime = Time.time;
|
||||
}
|
||||
b.OwnerId = ownerId;
|
||||
}
|
||||
|
||||
void SetFree(SyncedBody b)
|
||||
{
|
||||
if (IsRemoteOwned(b)) b.Rb.isKinematic = b.WasKinematic;
|
||||
if (IsRemoteOwned(b)) RestoreKinematic(b);
|
||||
b.OwnerId = SyncedBody.NoOwner;
|
||||
b.NoMotionClaimUntil = Time.time + HandoverCooldown;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
b.JointRestoreAt = float.MaxValue;
|
||||
HoldParts(b);
|
||||
// Physik läuft mit 50 Hz, das Bild öfter: ohne Interpolation springt das Auto nur in Physik-Schritten
|
||||
// weiter - wer darin mitfährt, sieht die Welt ruckeln. Nur bei Fahrzeugen (Items setzt die Hand direkt).
|
||||
b.WasInterpolation = b.Rb.interpolation;
|
||||
b.InterpolationSet = true;
|
||||
b.Rb.interpolation = RigidbodyInterpolation.Interpolate;
|
||||
}
|
||||
}
|
||||
|
||||
void HoldParts(SyncedBody b)
|
||||
{
|
||||
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 (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 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.InterpolationSet) b.Rb.interpolation = b.WasInterpolation;
|
||||
// Mitten in der Fahrt zurückgegeben (Fahrer übergibt, steigt aus ...): mit seiner Geschwindigkeit
|
||||
// weiterrollen statt schlagartig stehenzubleiben - die Teile wollen sonst mit Tempo weiter und reißen ab
|
||||
if (b.IsVehicle && !b.Rb.isKinematic)
|
||||
{
|
||||
b.Rb.velocity = Time.fixedTime - b.TargetTime < 0.5f ? b.TargetVel : Vector3.zero;
|
||||
b.Rb.angularVelocity = Vector3.zero;
|
||||
}
|
||||
}
|
||||
b.InterpolationSet = false;
|
||||
if (b.HeldJoints == null) return;
|
||||
if (b.IsVehicle) { b.JointRestoreAt = Time.time + JointSettleTime; return; } // erst nach dem Einschwingen
|
||||
RestoreJoints(b);
|
||||
}
|
||||
|
||||
void RestoreJoints(SyncedBody b)
|
||||
{
|
||||
b.JointRestoreAt = float.MaxValue;
|
||||
if (b.HeldJoints == null) return;
|
||||
foreach (KeyValuePair<Joint, Vector2> kv in b.HeldJoints)
|
||||
{
|
||||
if (kv.Key == null) continue; // Teil wurde inzwischen abgebaut
|
||||
kv.Key.breakForce = kv.Value.x;
|
||||
kv.Key.breakTorque = kv.Value.y;
|
||||
}
|
||||
b.HeldJoints = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Ladung in Fahrzeugen
|
||||
|
||||
/// <summary>Fährt das Fahrzeug gerade (nimmt also seine Ladung mit)?</summary>
|
||||
bool IsCarrying(SyncedBody v)
|
||||
{
|
||||
if (v.Rb == null) return false;
|
||||
if (IsRemoteOwned(v)) return v.OwnerActive || v.TargetVel.sqrMagnitude > CarrierMinSpeed * CarrierMinSpeed;
|
||||
return v == Driven || (!v.Rb.isKinematic && v.Rb.velocity.sqrMagnitude > CarrierMinSpeed * CarrierMinSpeed);
|
||||
}
|
||||
|
||||
/// <summary>In welchem Fahrzeug liegt das Item gerade (null = in keinem)? Zwischengespeichert.</summary>
|
||||
SyncedBody CarrierOf(SyncedBody item)
|
||||
{
|
||||
if (Time.time < item.NextCarrierCheck) return item.Carrier != null && item.Carrier.Rb != null ? item.Carrier : null;
|
||||
item.NextCarrierCheck = Time.time + CarrierCheckInterval;
|
||||
item.Carrier = null;
|
||||
Vector3 p = item.Rb.worldCenterOfMass;
|
||||
foreach (SyncedBody v in Vehicles())
|
||||
{
|
||||
if (v.Rb == null || (v.Rb.position - p).sqrMagnitude > 15f * 15f) continue;
|
||||
if (!v.HasLocalBounds) ComputeLocalBounds(v);
|
||||
if (v.LocalBounds.Contains(v.Rb.transform.InverseTransformPoint(p))) { item.Carrier = v; break; }
|
||||
}
|
||||
return item.Carrier;
|
||||
}
|
||||
|
||||
readonly List<SyncedBody> vehicleList = new List<SyncedBody>();
|
||||
float nextVehicleList;
|
||||
|
||||
List<SyncedBody> Vehicles()
|
||||
{
|
||||
if (Time.time < nextVehicleList) return vehicleList;
|
||||
nextVehicleList = Time.time + 2f;
|
||||
vehicleList.Clear();
|
||||
foreach (SyncedBody b in byId.Values) if (b.IsVehicle && b.Rb != null) vehicleList.Add(b);
|
||||
return vehicleList;
|
||||
}
|
||||
|
||||
/// <summary>Umriss des Fahrzeugs (alle festen Collider) in seinen lokalen Koordinaten, etwas nach oben erweitert.</summary>
|
||||
static void ComputeLocalBounds(SyncedBody v)
|
||||
{
|
||||
Transform t = v.Rb.transform;
|
||||
bool any = false;
|
||||
var local = new Bounds();
|
||||
foreach (Collider c in v.Rb.GetComponentsInChildren<Collider>())
|
||||
{
|
||||
if (c == null || c.isTrigger || !c.enabled) continue;
|
||||
Bounds wb = c.bounds;
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
Vector3 corner = wb.center + Vector3.Scale(wb.extents, new Vector3((i & 1) == 0 ? -1 : 1, (i & 2) == 0 ? -1 : 1, (i & 4) == 0 ? -1 : 1));
|
||||
Vector3 lp = t.InverseTransformPoint(corner);
|
||||
if (!any) { local = new Bounds(lp, Vector3.zero); any = true; }
|
||||
else local.Encapsulate(lp);
|
||||
}
|
||||
}
|
||||
if (!any) local = new Bounds(Vector3.zero, new Vector3(2f, 1.5f, 4f));
|
||||
// Weltachsen-Umrisse sind bei schrägem Auto zu groß - etwas verkleinern, oben Platz für Ladung lassen
|
||||
local.Expand(new Vector3(-0.2f, 0f, -0.2f));
|
||||
local.max = new Vector3(local.max.x, local.max.y + 0.5f, local.max.z);
|
||||
v.LocalBounds = local;
|
||||
v.HasLocalBounds = true;
|
||||
}
|
||||
|
||||
void SendOwnedStates()
|
||||
{
|
||||
sendList.Clear();
|
||||
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;
|
||||
@@ -407,7 +688,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
|
||||
@@ -426,12 +707,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) 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.fixedTime;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -442,9 +725,10 @@ 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) || IsAttached(b)) { SetFree(b); return; }
|
||||
b.Rb.transform.position = pos;
|
||||
b.Rb.transform.rotation = rot;
|
||||
b.Rb.isKinematic = b.WasKinematic;
|
||||
RestoreKinematic(b);
|
||||
if (!b.Rb.isKinematic)
|
||||
{
|
||||
b.Rb.velocity = vel;
|
||||
@@ -457,6 +741,140 @@ namespace MSCO
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wird ein Objekt von der Physik weggeschleudert (z.B. nach dem Setzen einer Position, die im Boden lag),
|
||||
/// sofort anhalten und auf die letzte gute Position zurücksetzen - statt es quer über die Karte fliegen zu lassen.
|
||||
/// </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;
|
||||
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)
|
||||
{
|
||||
PlaceOnTop(rb);
|
||||
b.RunawayCount = 0;
|
||||
}
|
||||
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>
|
||||
static bool IsValidPose(Vector3 p, Quaternion q)
|
||||
{
|
||||
if (float.IsNaN(p.x) || float.IsNaN(p.y) || float.IsNaN(p.z) || float.IsInfinity(p.x) || float.IsInfinity(p.y) || float.IsInfinity(p.z))
|
||||
return false;
|
||||
if (Mathf.Abs(p.x) > 6000f || Mathf.Abs(p.z) > 6000f || p.y < -200f || p.y > 3000f) return false;
|
||||
float len = Mathf.Sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
|
||||
return len > 0.9f && len < 1.1f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liegt die Position im Boden, auf die Bodenoberfläche heben. Kurzer Strahl ab 2 m über dem Punkt
|
||||
/// (nicht aufs Garagendach), die eigenen Teile des Fahrzeugs werden ignoriert.
|
||||
/// </summary>
|
||||
static Vector3 AboveGround(Vector3 pos, Transform ignore)
|
||||
{
|
||||
float ground = float.MinValue;
|
||||
foreach (RaycastHit hit in Physics.RaycastAll(pos + Vector3.up * 2f, Vector3.down, 4f))
|
||||
{
|
||||
if (hit.collider.isTrigger || (ignore != null && hit.transform.IsChildOf(ignore))) continue;
|
||||
if (hit.transform.root.name == "PLAYER") continue;
|
||||
ground = Mathf.Max(ground, hit.point.y);
|
||||
}
|
||||
return ground > pos.y - 0.2f ? new Vector3(pos.x, ground + 0.6f, pos.z) : pos;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Admin: Fahrzeug (Wurzelname beginnt mit rootPrefix) an eine Position holen. Fährt es gerade ein
|
||||
/// anderer Spieler, geht das nicht. Gibt einen Statustext zurück.
|
||||
/// </summary>
|
||||
public string BringVehicle(string rootPrefix, Vector3 pos, Quaternion rot)
|
||||
{
|
||||
foreach (SyncedBody b in byId.Values)
|
||||
{
|
||||
if (!b.IsVehicle || b.Rb == null || !b.RootName.StartsWith(rootPrefix)) continue;
|
||||
if (IsRemoteOwned(b) && b.OwnerActive) return rootPrefix + " is being driven by another player";
|
||||
Claim(b, false); // wir besitzen es jetzt -> Position geht an alle und an den Server
|
||||
b.Rb.transform.position = AboveGround(pos, b.Rb.transform);
|
||||
b.Rb.transform.rotation = rot;
|
||||
if (!b.Rb.isKinematic)
|
||||
{
|
||||
b.Rb.velocity = Vector3.zero;
|
||||
b.Rb.angularVelocity = Vector3.zero;
|
||||
}
|
||||
b.StillTime = 0f;
|
||||
b.LastSentTime = -100f;
|
||||
return rootPrefix + " is next to you";
|
||||
}
|
||||
return rootPrefix + " not found (mp vehicles shows all vehicles)";
|
||||
}
|
||||
|
||||
static void WriteState(BinaryWriter w, SyncedBody b)
|
||||
{
|
||||
Vector3 p = b.Rb.position;
|
||||
@@ -521,20 +939,34 @@ 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)
|
||||
{
|
||||
Rigidbody rb = t.GetComponent<Rigidbody>();
|
||||
SyncedBody b;
|
||||
if (rb != null && byRb.TryGetValue(rb, out b)) return IsReallyInHand(b) ? b : null;
|
||||
// Fahrzeuge hält man nie in der Hand: im Auto zeigt PickedObject z.B. aufs Lenkrad, dessen
|
||||
// Physik-Körper ist das Auto selbst - das Auto würde sonst mit dem Kopf mitkippen
|
||||
if (rb != null && byRb.TryGetValue(rb, out b)) return !b.IsVehicle && IsReallyInHand(b) ? b : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -567,6 +999,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()
|
||||
{
|
||||
@@ -575,9 +1014,9 @@ namespace MSCO
|
||||
var candidates = new List<KeyValuePair<Rigidbody, bool>>();
|
||||
var rootsByName = new Dictionary<string, List<Transform>>();
|
||||
|
||||
foreach (UnityEngine.Object o in UnityEngine.Object.FindObjectsOfType(typeof(Rigidbody)))
|
||||
foreach (Rigidbody rb in SceneCache.Rigidbodies(1f))
|
||||
{
|
||||
var rb = (Rigidbody)o;
|
||||
if (rb == null) continue;
|
||||
Transform root = rb.transform.root;
|
||||
List<Transform> roots;
|
||||
if (!rootsByName.TryGetValue(root.name, out roots)) rootsByName[root.name] = roots = new List<Transform>();
|
||||
@@ -605,14 +1044,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)
|
||||
|
||||
+187
-11
@@ -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()
|
||||
{
|
||||
@@ -130,10 +136,11 @@ namespace MSCO
|
||||
{
|
||||
float width = Mathf.Min(Screen.width - 40, 960);
|
||||
var rect = new Rect((Screen.width - width) / 2f, 20, width, Mathf.Min(340, Screen.height * 0.5f));
|
||||
UiTheme.DrawShadow(rect);
|
||||
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,8 +270,9 @@ namespace MSCO
|
||||
|
||||
void DrawAdmin(MultiplayerManager mp)
|
||||
{
|
||||
float width = Mathf.Min(600, Screen.width - 40), height = Mathf.Min(460, 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);
|
||||
UiTheme.DrawShadow(rect);
|
||||
GUI.Box(rect, GUIContent.none, panel);
|
||||
|
||||
if (Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Escape)
|
||||
@@ -281,12 +289,47 @@ 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);
|
||||
|
||||
GUILayout.Label("REASON (optional)", caption);
|
||||
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.BeginHorizontal();
|
||||
if (GUILayout.Button("+1.000 MK", primary, GUILayout.Height(30))) mp.CheatMoney(1000f);
|
||||
GUILayout.Space(6);
|
||||
if (GUILayout.Button("+10.000 MK", primary, GUILayout.Height(30))) mp.CheatMoney(10000f);
|
||||
GUILayout.Space(6);
|
||||
if (GUILayout.Button("REFILL NEEDS", primary, GUILayout.Height(30))) mp.CheatNeeds();
|
||||
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 teleports you to a player, BRING teleports a player to you.", small);
|
||||
GUILayout.Space(8);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -304,12 +347,28 @@ namespace MSCO
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
bool self = p.Id == mp.LocalId;
|
||||
if (mp.CanCheat)
|
||||
{
|
||||
GUI.enabled = !self;
|
||||
if (GUILayout.Button("GOTO", secondary, GUILayout.Height(30), GUILayout.Width(70))) mp.GotoPlayer(p.Id);
|
||||
GUILayout.Space(6);
|
||||
if (GUILayout.Button("BRING", secondary, GUILayout.Height(30), GUILayout.Width(70))) mp.BringPlayer(p.Id);
|
||||
GUILayout.Space(12);
|
||||
}
|
||||
if (mp.IsDedicated)
|
||||
{
|
||||
// Admin geben/nehmen (braucht Steam; der Server meldet, wenn es nicht geht)
|
||||
GUI.enabled = !self;
|
||||
if (GUILayout.Button(p.IsAdmin ? "REMOVE ADMIN" : "MAKE ADMIN", secondary, GUILayout.Height(30), GUILayout.Width(120)))
|
||||
mp.SetAdmin(p.Id, !p.IsAdmin);
|
||||
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;
|
||||
@@ -320,7 +379,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
|
||||
@@ -328,18 +503,19 @@ namespace MSCO
|
||||
void EnsureStyles()
|
||||
{
|
||||
if (stylesReady && panel.normal.background != null) return;
|
||||
panel = UiTheme.Panel9(UiTheme.Background);
|
||||
panel = UiTheme.Card(UiTheme.Background, 10);
|
||||
rowStyle = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.72f));
|
||||
rowStyle.padding = new RectOffset(12, 12, 6, 6);
|
||||
field = UiTheme.Field(14);
|
||||
logText = UiTheme.Label(12, UiTheme.Text);
|
||||
logText.wordWrap = true;
|
||||
logText.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);
|
||||
title.wordWrap = false;
|
||||
small = UiTheme.Label(11, UiTheme.Muted);
|
||||
primary = UiTheme.Button(UiTheme.Accent, UiTheme.AccentHover, Color.white);
|
||||
primary = UiTheme.PrimaryButton();
|
||||
secondary = UiTheme.Button(UiTheme.Secondary, UiTheme.SecondaryHover, UiTheme.Text, 12);
|
||||
danger = UiTheme.Button(UiTheme.Danger, UiTheme.DangerHover, Color.white, 12);
|
||||
chatName = UiTheme.Label(13, UiTheme.AccentHover, FontStyle.Bold, TextAnchor.MiddleLeft);
|
||||
|
||||
+227
-26
@@ -4,14 +4,20 @@ using UnityEngine;
|
||||
namespace MSCO
|
||||
{
|
||||
/// <summary>
|
||||
/// Beifahrer: Neben einem Fahrzeug, das ein anderer Spieler fährt, per Taste einsteigen.
|
||||
/// Der Sitz ist die Fahrerposition, auf die andere Seite gespiegelt (bei Fahrzeugen mit
|
||||
/// Sitz in der Mitte, z.B. Traktor oder Moped: dahinter). Während der Fahrt wird der
|
||||
/// Spieler jedes Frame auf den Sitz gesetzt und dreht mit dem Fahrzeug mit; umschauen geht weiter.
|
||||
/// Mitfahren: Wer mit dem Kopf im Fahrzeug ist (Kabine, offene Tür, Ladefläche ...), wird genau dort festgehalten und
|
||||
/// fährt mit - egal ob schon jemand fährt oder das Auto noch steht. Aussteigen mit Leertaste oder der Beifahrer-Taste.
|
||||
/// Steht man per Taste neben dem Auto, kommt man auf den Beifahrersitz (Fahrerposition des Spiels, gespiegelt).
|
||||
/// Während der Fahrt wird der Spieler jedes Frame an seine Stelle im Fahrzeug gesetzt und dreht mit; umschauen geht.
|
||||
/// </summary>
|
||||
public class Passenger
|
||||
{
|
||||
const float EnterRange = 4f; // so nah muss man am Fahrzeug stehen
|
||||
const float EnterRange = 3f; // per Taste: so nah muss man an der Karosserie stehen
|
||||
const float AutoEnterRange = 1.0f; // automatisch: Kopf so nah am Beifahrersitz ...
|
||||
const float AutoEnterSide = 0.6f; // ... und seitlich höchstens so weit außerhalb des Sitzes - an der
|
||||
// geschlossenen Tür steht man weiter draußen, nur in der offenen Tür so nah
|
||||
const float AutoEnterDwell = 0.35f; // ... so lange (nicht beim bloßen Vorbeilaufen)
|
||||
const float AutoRearmRange = 2f; // nach dem Aussteigen erst wieder, wenn man so weit weg war
|
||||
const float AutoCheckInterval = 0.15f;
|
||||
const float CenterSeatThreshold = 0.2f;
|
||||
const float BehindCenterSeat = 0.7f;
|
||||
const float ExitSideOffset = 1.4f; // beim Aussteigen so weit neben den Sitz
|
||||
@@ -24,8 +30,23 @@ 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>();
|
||||
// Fahrerkopf je Fahrzeug (lokal), aus DriverHeadPivot - null = nicht gefunden
|
||||
readonly Dictionary<Rigidbody, Vector3?> driverHeads = new Dictionary<Rigidbody, Vector3?>();
|
||||
|
||||
float nextAutoCheck, dwellStart = -1f, lastDriving = -100f;
|
||||
const float AfterDrivingPause = 4f; // nach dem Fahren (Ein-/Aussteigen am Fahrersitz) nicht automatisch
|
||||
SyncedBody dwellVehicle;
|
||||
bool autoArmed = true;
|
||||
Vector3 lastExitSeat;
|
||||
|
||||
public bool IsRiding { get { return vehicle != null; } }
|
||||
/// <summary>Fahrzeug, in dem wir mitfahren (sonst null).</summary>
|
||||
public SyncedBody Vehicle { get { return vehicle; } }
|
||||
/// <summary>Kopf auf dem Sitz, relativ zum Fahrzeug (genau, nicht aus der Weltposition des letzten Frames).</summary>
|
||||
public Vector3 SeatHeadLocal { get { return seatHeadLocal; } }
|
||||
|
||||
/// <summary>Taste gedrückt: einsteigen oder aussteigen. Gibt einen Statustext zurück.</summary>
|
||||
public string Toggle(Transform player, Camera cam, ObjectSync objects, IDictionary<int, PlayerInfo> players)
|
||||
@@ -35,42 +56,191 @@ namespace MSCO
|
||||
Exit(player, cam);
|
||||
return "Got out";
|
||||
}
|
||||
if (cam == null) return null;
|
||||
if (cam == null || objects.IsDriving) return null;
|
||||
SyncedBody inside = VehicleAround(objects, cam.transform.position);
|
||||
if (inside != null)
|
||||
{
|
||||
Enter(inside, inside.Rb.transform.InverseTransformPoint(cam.transform.position), player);
|
||||
return "Riding along (press again or Space to get out)";
|
||||
}
|
||||
|
||||
SyncedBody best = null;
|
||||
RemotePlayer driver = null;
|
||||
float bestDist = EnterRange * EnterRange;
|
||||
foreach (SyncedBody b in objects.RemoteDrivenVehicles())
|
||||
Vector3 bestSeat = Vector3.zero;
|
||||
float bestDist = EnterRange * EnterRange, nearest = float.MaxValue;
|
||||
foreach (SyncedBody b in objects.AllVehicles())
|
||||
{
|
||||
if (b.Rb == null || (b.Rb.position - player.position).sqrMagnitude > 30f * 30f) continue;
|
||||
float d = DistanceToVehicleSq(b, player.position);
|
||||
Vector3? seat = SeatFor(b, objects, players);
|
||||
if (seat == null) continue;
|
||||
nearest = Mathf.Min(nearest, d);
|
||||
if (d < bestDist) { bestDist = d; best = b; bestSeat = seat.Value; }
|
||||
}
|
||||
if (best == null)
|
||||
{
|
||||
if (nearest == float.MaxValue) return "Passenger: no vehicle nearby";
|
||||
return "Passenger: get closer to the car (" + Mathf.Sqrt(nearest).ToString("0") + " m away, max " + EnterRange.ToString("0") + " m)";
|
||||
}
|
||||
Enter(best, bestSeat, player);
|
||||
return "Riding as passenger (press again or Space to get out)";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Jedes Frame (zu Fuß): ist man mit dem Kopf IM Fahrzeug (Kabine, Ladefläche ...), wird die aktuelle Position im
|
||||
/// Fahrzeug festgehalten - man fährt genau dort mit, wo man ist (kein Umsetzen auf einen berechneten Sitz).
|
||||
/// Gibt einen Statustext zurück, wenn man "eingestiegen" ist.
|
||||
/// </summary>
|
||||
public string AutoUpdate(Transform player, Camera cam, ObjectSync objects, IDictionary<int, PlayerInfo> players)
|
||||
{
|
||||
if (objects.IsDriving) lastDriving = Time.time;
|
||||
if (IsRiding || cam == null || player == null || objects.IsDriving) { dwellStart = -1f; return null; }
|
||||
// Beim Ein-/Aussteigen am Fahrersitz übernimmt das Spiel die Steuerung (CharacterController aus, Spieler an ein
|
||||
// Fahrzeug gehängt) - dann und kurz nach dem Fahren nicht festhalten
|
||||
if (Time.time - lastDriving < AfterDrivingPause) { dwellStart = -1f; return null; }
|
||||
CharacterController cc = player.GetComponent<CharacterController>();
|
||||
if ((cc != null && !cc.enabled) || player.parent != null) { lastDriving = Time.time; dwellStart = -1f; return null; }
|
||||
if (Time.time < nextAutoCheck) return null;
|
||||
nextAutoCheck = Time.time + AutoCheckInterval;
|
||||
Vector3 head = cam.transform.position;
|
||||
|
||||
// Nach dem Aussteigen erst einmal aus dem Fahrzeug heraus, sonst säße man sofort wieder drin
|
||||
if (!autoArmed)
|
||||
{
|
||||
if (VehicleAround(objects, head) == null) autoArmed = true;
|
||||
else return null;
|
||||
}
|
||||
|
||||
SyncedBody found = VehicleAround(objects, head);
|
||||
if (found == null || found != dwellVehicle)
|
||||
{
|
||||
dwellVehicle = found;
|
||||
dwellStart = found != null ? Time.time : -1f;
|
||||
return null;
|
||||
}
|
||||
if (Time.time - dwellStart < AutoEnterDwell) return null;
|
||||
dwellStart = -1f;
|
||||
dwellVehicle = null;
|
||||
Enter(found, found.Rb.transform.InverseTransformPoint(head), player); // genau hier festhalten
|
||||
return "Riding along (Space or the passenger key to get out)";
|
||||
}
|
||||
|
||||
/// <summary>Fahrzeug, in dessen Umriss der Kopf gerade ist (null = in keinem).</summary>
|
||||
SyncedBody VehicleAround(ObjectSync objects, Vector3 head)
|
||||
{
|
||||
foreach (SyncedBody b in objects.AllVehicles())
|
||||
{
|
||||
if (b.Rb == null || (b.Rb.position - head).sqrMagnitude > 10f * 10f) continue;
|
||||
Bounds inner = InnerBounds(b);
|
||||
if (inner.Contains(b.Rb.transform.InverseTransformPoint(head))) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
readonly Dictionary<Rigidbody, Bounds> innerBounds = new Dictionary<Rigidbody, Bounds>();
|
||||
|
||||
/// <summary>
|
||||
/// Innenraum des Fahrzeugs (lokal): Umriss aller festen Collider, seitlich und vorne/hinten etwas kleiner - neben
|
||||
/// der geschlossenen Tür steht man noch draußen, erst in der offenen Tür bzw. drin ist man "im Auto".
|
||||
/// </summary>
|
||||
Bounds InnerBounds(SyncedBody b)
|
||||
{
|
||||
Bounds result;
|
||||
if (innerBounds.TryGetValue(b.Rb, out result)) return result;
|
||||
Transform t = b.Rb.transform;
|
||||
bool any = false;
|
||||
foreach (Collider c in b.Rb.GetComponentsInChildren<Collider>())
|
||||
{
|
||||
if (c == null || c.isTrigger || !c.enabled) continue;
|
||||
Bounds wb = c.bounds;
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
Vector3 corner = wb.center + Vector3.Scale(wb.extents, new Vector3((i & 1) == 0 ? -1 : 1, (i & 2) == 0 ? -1 : 1, (i & 4) == 0 ? -1 : 1));
|
||||
Vector3 lp = t.InverseTransformPoint(corner);
|
||||
if (!any) { result = new Bounds(lp, Vector3.zero); any = true; }
|
||||
else result.Encapsulate(lp);
|
||||
}
|
||||
}
|
||||
if (!any) result = new Bounds(Vector3.zero, Vector3.zero);
|
||||
else
|
||||
{
|
||||
Vector3 size = result.size;
|
||||
// Weltachsen-Umrisse schräger Teile sind zu groß: seitlich 0.25 m, vorne/hinten 0.3 m kleiner,
|
||||
// oben 0.6 m Luft (Kopf über dem Dach der Ladefläche / im offenen Fahrzeug)
|
||||
result.size = new Vector3(Mathf.Max(0.1f, size.x - 0.5f), size.y + 0.6f, Mathf.Max(0.1f, size.z - 0.6f));
|
||||
result.center += new Vector3(0f, 0.3f, 0f);
|
||||
}
|
||||
innerBounds[b.Rb] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
void Enter(SyncedBody b, Vector3 seat, Transform player)
|
||||
{
|
||||
seatHeadLocal = seat;
|
||||
vehicle = b;
|
||||
lastYaw = b.Rb.transform.eulerAngles.y;
|
||||
SetMovementEnabled(player, false);
|
||||
}
|
||||
|
||||
/// <summary>Beifahrersitz (Kopfposition, lokal im Fahrzeug) - aus dem Fahrersitz des Spiels oder dem Fahrer.</summary>
|
||||
Vector3? SeatFor(SyncedBody b, ObjectSync objects, IDictionary<int, PlayerInfo> players)
|
||||
{
|
||||
Vector3? driverHead;
|
||||
if (!driverHeads.TryGetValue(b.Rb, out driverHead))
|
||||
{
|
||||
driverHead = null;
|
||||
foreach (Transform t in b.Rb.GetComponentsInChildren<Transform>(true))
|
||||
{
|
||||
if (t.name != "DriverHeadPivot") continue;
|
||||
driverHead = b.Rb.transform.InverseTransformPoint(t.position);
|
||||
break;
|
||||
}
|
||||
driverHeads[b.Rb] = driverHead;
|
||||
}
|
||||
// Ohne DriverHeadPivot: nur, wenn gerade jemand fährt (dessen Kopf ist die Fahrerposition)
|
||||
if (driverHead == null && objects.IsRemoteOwnedBody(b) && b.OwnerActive)
|
||||
{
|
||||
PlayerInfo owner;
|
||||
if (!players.TryGetValue(b.OwnerId, out owner) || owner.Avatar == null) continue;
|
||||
float d = (b.Rb.worldCenterOfMass - player.position).sqrMagnitude;
|
||||
if (d < bestDist) { bestDist = d; best = b; driver = owner.Avatar; }
|
||||
if (players.TryGetValue(b.OwnerId, out owner) && owner.Avatar != null)
|
||||
driverHead = b.Rb.transform.InverseTransformPoint(owner.Avatar.HeadPosition);
|
||||
}
|
||||
if (driverHead == null) return null;
|
||||
Vector3 d = driverHead.Value;
|
||||
return Mathf.Abs(d.x) < CenterSeatThreshold
|
||||
? d - new Vector3(0f, 0f, BehindCenterSeat) // Sitz in der Mitte: dahinter
|
||||
: new Vector3(-d.x, d.y, d.z); // gegenüber vom Fahrer
|
||||
}
|
||||
if (best == null) return "No vehicle driven by another player nearby";
|
||||
|
||||
Transform vt = best.Rb.transform;
|
||||
Vector3 driverHead = vt.InverseTransformPoint(driver.HeadPosition);
|
||||
seatHeadLocal = Mathf.Abs(driverHead.x) < CenterSeatThreshold
|
||||
? driverHead - new Vector3(0f, 0f, BehindCenterSeat) // Sitz in der Mitte: dahinter
|
||||
: new Vector3(-driverHead.x, driverHead.y, driverHead.z); // gegenüber vom Fahrer
|
||||
|
||||
vehicle = best;
|
||||
lastYaw = vt.eulerAngles.y;
|
||||
SetMovementEnabled(player, false);
|
||||
return "Riding as passenger (press again to get out)";
|
||||
/// <summary>
|
||||
/// 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)
|
||||
{
|
||||
if (!IsRiding) return;
|
||||
// Fahrer ausgestiegen oder Fahrzeug weg: automatisch aussteigen
|
||||
if (vehicle.Rb == null || cam == null || !objects.IsRemoteOwnedBody(vehicle))
|
||||
if (vehicle.Rb == null || cam == null)
|
||||
{
|
||||
Exit(player, cam);
|
||||
return;
|
||||
}
|
||||
// Selbst ans Steuer gesetzt (Fahrersitz des Spiels): Beifahrer-Modus ohne Umsetzen beenden
|
||||
if (objects.IsDriving)
|
||||
{
|
||||
Reset();
|
||||
return;
|
||||
}
|
||||
|
||||
Transform vt = vehicle.Rb.transform;
|
||||
float yaw = vt.eulerAngles.y;
|
||||
@@ -86,9 +256,15 @@ namespace MSCO
|
||||
if (!IsRiding) return;
|
||||
if (player != null && vehicle.Rb != null)
|
||||
{
|
||||
lastExitSeat = vehicle.Rb.transform.TransformPoint(seatHeadLocal);
|
||||
autoArmed = false;
|
||||
// Auf der Seite des Sitzes neben das Fahrzeug stellen
|
||||
// Zur näheren Seite neben das Fahrzeug (außerhalb seines Umrisses), auf Kopfhöhe von vorher
|
||||
float side = seatHeadLocal.x >= 0f ? 1f : -1f;
|
||||
Vector3 outside = vehicle.Rb.transform.TransformPoint(seatHeadLocal + new Vector3(side * ExitSideOffset, 0f, 0f));
|
||||
Bounds inner = InnerBounds(vehicle);
|
||||
float x = inner.size.sqrMagnitude > 0f ? inner.center.x + side * (inner.extents.x + 0.85f) : seatHeadLocal.x + side * ExitSideOffset;
|
||||
var local = new Vector3(x, seatHeadLocal.y, seatHeadLocal.z);
|
||||
Vector3 outside = vehicle.Rb.transform.TransformPoint(local);
|
||||
Vector3 cameraOffset = cam != null ? cam.transform.position - player.position : Vector3.up * 1.6f;
|
||||
player.position = outside - cameraOffset + Vector3.up * 0.1f;
|
||||
}
|
||||
@@ -100,6 +276,10 @@ namespace MSCO
|
||||
public void Reset()
|
||||
{
|
||||
vehicle = null;
|
||||
dwellVehicle = null;
|
||||
dwellStart = -1f;
|
||||
driverHeads.Clear();
|
||||
innerBounds.Clear();
|
||||
SetMovementEnabled(null, true);
|
||||
}
|
||||
|
||||
@@ -118,6 +298,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
|
||||
{
|
||||
@@ -125,6 +322,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ using System.Runtime.InteropServices;
|
||||
[assembly: AssemblyTitle("MSCO")]
|
||||
[assembly: AssemblyDescription("MSCLoader Mod file")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyCompany("MBM")]
|
||||
[assembly: AssemblyProduct("MSCO")]
|
||||
[assembly: AssemblyCopyright("Copyright © 2026 by MBM")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
@@ -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("1.0.0.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
||||
[assembly: AssemblyVersion("2.8.0.0")]
|
||||
[assembly: AssemblyFileVersion("2.8.0.0")]
|
||||
|
||||
@@ -10,7 +10,7 @@ Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern
|
||||
## Features
|
||||
|
||||
**Verbinden**
|
||||
- Launcher im Hauptmenü im Stil von FiveM: P2P-Seite und Server-Browser
|
||||
- Launcher im Hauptmenü: P2P-Seite und Server-Browser
|
||||
- **P2P über Steam:** HOST GAME, Freunde einladen, Beitritt über die Steam-Freundesliste (max. 3 Spieler)
|
||||
- **Server-Browser:** Suche, Filter (Steam / Non-Steam / volle ausblenden), Ping, Server-Profil
|
||||
(Besitzer, Version, Laufzeit, Spieler), lokale Server auf diesem PC werden automatisch gefunden
|
||||
@@ -20,7 +20,8 @@ Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern
|
||||
und nach dem Spielen wiederhergestellt
|
||||
|
||||
**Synchronisiert**
|
||||
- Spieler als Spielfigur (Modell aus den NPCs des Spiels) mit Blickrichtung, Ducken/Lehnen, Namensschild
|
||||
- Spieler als eigene Figur im MSC-Stil (Karohemd, Jeans, Schnurrbart, Kappe – je Spieler etwas anders) mit
|
||||
Blickrichtung, Laufen, Ducken/Lehnen, Sitzen, gehaltenem Item und Namensschild
|
||||
- Items, lose Teile, Schrauben und Fahrzeuge (auch mit G geschoben) mit Besitzer-System – inkl. was jemand in der Hand hält
|
||||
- Fahrzeuge: Position, Lichter, Motorgeräusch; als **Beifahrer** mitfahren (Taste P)
|
||||
- **Fahrzeugzustand:** Tank, Öl, Kühlwasser, Bremsflüssigkeit, Batterie, Schrauben-Festigkeit, Schäden, Verschleiß,
|
||||
@@ -30,11 +31,12 @@ Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern
|
||||
|
||||
**Im Spiel**
|
||||
- **T** – Chat
|
||||
- **F8** – MSCO-Konsole mit allen Befehlen
|
||||
- **/admin** – Admin-Menü (Spieler kicken/bannen) für Server-Admins bzw. den P2P-Host
|
||||
- **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
|
||||
- Alle Tasten sind in den MSCLoader-Einstellungen der Mod änderbar
|
||||
- Discord Rich Presence („In the Menus“, Server, Spielerzahl)
|
||||
- Rich Presence: zeigt Freunden deinen Status („In the Menus“, Server, Spielerzahl)
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -43,9 +45,24 @@ Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern
|
||||
Mods-Ordner kopieren (Standard: `Documents\MySummerCar\Mods`).
|
||||
3. Spiel **über Steam** starten – ohne Steam gehen nur Non-Steam-Server.
|
||||
|
||||
### Einen eigenen Server betreiben
|
||||
|
||||
Siehe [MSCO-Dedicated-Server](https://git.mbm.wtf/MrMaho/MSCO-Dedicated-Server) – Lizenzschlüssel gibt es kostenlos
|
||||
unter https://msco.mbm.wtf/keys.
|
||||
|
||||
## Probleme?
|
||||
|
||||
| Problem | Lösung |
|
||||
|---|---|
|
||||
| Server fehlt im Browser | REFRESH drücken; Filter (STEAM / NON-STEAM) prüfen; der Server muss von außen erreichbar sein |
|
||||
| „This server requires Steam“ | Spiel über Steam starten |
|
||||
| Warnung „Version mismatch“ | Mod und Server auf dieselbe Version bringen (Releases) |
|
||||
| Eigener Spielstand weg | Wird nach dem Verlassen automatisch wiederhergestellt; Sicherung liegt in `AppData\LocalLow\Amistech\My Summer Car\MSCO_Backup` |
|
||||
| Absturz / Hänger beim Beitreten | `output_log.txt` im Spielordner an den Server-Betreiber bzw. als Issue schicken |
|
||||
|
||||
## 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 |
|
||||
|---|---|
|
||||
@@ -53,15 +70,15 @@ In der F8-Konsole direkt, in der MSCLoader-Konsole mit `mp` davor (z.B. `mp conn
|
||||
| `disconnect` | Server / Spiel verlassen |
|
||||
| `host [port]` / `join <ip> [port]` | LAN-Testsitzung ohne Steam |
|
||||
| `save` | Spiel speichern (Host / Einzelspieler) |
|
||||
| `ping [on\|off]` | Ping zum Server oben rechts anzeigen (grün < 80 ms, gelb < 150 ms, rot darüber) |
|
||||
| `admin` bzw. `/admin` im Chat | Admin-Menü |
|
||||
| `vehicles`, `rig` | Diagnose: Fahrzeuge / Spielermodelle |
|
||||
| `debug` | Sync-Debugausgaben an/aus |
|
||||
| `master [url]` | Adresse der Serverliste anzeigen/ändern |
|
||||
| `clear` | Konsole leeren |
|
||||
|
||||
## Selbst bauen
|
||||
|
||||
- Visual Studio 2022 oder neuer, .NET Framework 3.5 (Unity 5.0 / Mono)
|
||||
- Eine C#-Entwicklungsumgebung, .NET Framework 3.5 (Unity 5.0 / Mono)
|
||||
- Die Verweise in `MSCO.csproj` zeigen auf `F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\`
|
||||
(UnityEngine, PlayMaker, Assembly-CSharp, Assembly-CSharp-firstpass, MSCLoader, 0Harmony, Newtonsoft.Json, cInput) –
|
||||
bei anderem Installationsort anpassen.
|
||||
@@ -71,6 +88,15 @@ In der F8-Konsole direkt, in der MSCLoader-Konsole mit `mp` davor (z.B. `mp conn
|
||||
msbuild MSCO.csproj -p:Configuration=Release
|
||||
```
|
||||
|
||||
### Updates veröffentlichen (MSC Launcher / MSCLoader)
|
||||
|
||||
- **Kleines Update** (Fehlerbehebungen, neue Funktionen, Netzwerk unverändert): in `MSCO.cs` nur die dritte Stelle von
|
||||
`ModVersion` erhöhen (`2.8.1` → `2.8.2`), Release bauen und `bin/Release/MSCO.dll` hochladen. Spieler mit 2.8.x
|
||||
spielen weiter zusammen, Server 2.8 müssen nicht aktualisiert werden.
|
||||
- **Großes Update** (Protokoll inkompatibel geändert): `ProtocolVersion` in `MSCO.cs` **und** `GameServer.ModVersion` im
|
||||
Server erhöhen (z.B. `2.9`), `ModVersion` auf `2.9.0`. Dann brauchen alle die neue Mod und den neuen Server.
|
||||
- Verglichen werden immer nur die ersten zwei Stellen; übers Netz geht nur die Netzwerk-Version.
|
||||
|
||||
## Aufbau
|
||||
|
||||
| Datei | Aufgabe |
|
||||
@@ -83,9 +109,9 @@ msbuild MSCO.csproj -p:Configuration=Release
|
||||
| `ObjectSync.cs` | Items/Teile/Fahrzeuge mit Besitzer-System |
|
||||
| `VehicleStateSync.cs`, `VehicleFx.cs`, `Passenger.cs` | Fahrzeugzustand, Lichter/Sound, Beifahrer |
|
||||
| `FsmSync.cs`, `DoorSync.cs`, `WorldSync.cs`, `MoneySync.cs` | Montage-Events, Türen, Zeit/Wetter, Geld |
|
||||
| `RemotePlayer.cs`, `GameCharacter.cs` | Spielfiguren der Mitspieler |
|
||||
| `RemotePlayer.cs` | Spielfiguren der Mitspieler |
|
||||
| `SaveSync.cs`, `MenuHelper.cs` | Spielstand übertragen/sichern, Laden aus dem Menü |
|
||||
| `SteamLobby.cs`, `JoinLinks.cs`, `DiscordPresence.cs` | Steam-Lobbys, `msco://`-Links, Discord |
|
||||
| `SteamLobby.cs`, `JoinLinks.cs`, `DiscordPresence.cs` | Steam-Lobbys, `msco://`-Links, Rich Presence |
|
||||
| `SafetyPatches.cs`, `InputLock.cs`, `SceneCache.cs` | Absturzschutz, Eingabesperre, geteilte FSM-Suche |
|
||||
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
+244
-68
@@ -3,10 +3,11 @@ using UnityEngine;
|
||||
namespace MSCO
|
||||
{
|
||||
/// <summary>
|
||||
/// Figur eines Mitspielers mit echter Körperhaltung. Aus Kopfposition (Kamera) und Bodenhöhe wird
|
||||
/// jedes Frame die Pose berechnet: Ducken beugt die Knie, nach vorne Lehnen neigt den Oberkörper,
|
||||
/// Laufen bewegt Beine und Arme, beim Halten greift der rechte Arm (mit Ellbogen) zum Item, im Auto
|
||||
/// sitzt die Figur. Die Körperteile sind abgerundete Kapseln zwischen den Gelenken.
|
||||
/// Figur eines Mitspielers im Stil der My-Summer-Car-Leute (selbst gebaut, keine NPC-Kopie):
|
||||
/// Karohemd/Arbeitsjacke, Jeans, Arbeitsschuhe, Schnurrbart, Kappe oder Haare, leichter Bauch.
|
||||
/// Aus Kopfposition (Kamera) und Bodenhöhe wird jedes Frame die Pose berechnet: Ducken beugt die Knie,
|
||||
/// nach vorne Lehnen neigt den Oberkörper, Laufen bewegt Beine und Arme, beim Halten greift der rechte Arm
|
||||
/// zum Item, im Auto sitzt die Figur.
|
||||
/// </summary>
|
||||
public class RemotePlayer : MonoBehaviour
|
||||
{
|
||||
@@ -15,22 +16,31 @@ namespace MSCO
|
||||
const float MaxHeadPitch = 55f;
|
||||
|
||||
// Körpermaße (Meter)
|
||||
const float ThighLength = 0.45f, ShinLength = 0.45f;
|
||||
const float ThighLength = 0.41f, ShinLength = 0.41f; // im Stehen fast gestreckt
|
||||
const float UpperArmLength = 0.3f, ForearmLength = 0.29f;
|
||||
const float TorsoLength = 0.55f; // Hüfte bis Hals
|
||||
const float HipWidth = 0.1f, ShoulderWidth = 0.21f;
|
||||
const float AnkleHeight = 0.07f;
|
||||
const float LegReach = ThighLength + ShinLength + AnkleHeight - 0.02f; // Hüfthöhe bei gestrecktem Bein
|
||||
|
||||
static readonly Color Skin = new Color(0.93f, 0.76f, 0.62f);
|
||||
static readonly Color Jeans = new Color(0.18f, 0.25f, 0.42f);
|
||||
static readonly Color Boots = new Color(0.24f, 0.16f, 0.1f);
|
||||
static readonly Color Skin = new Color(0.86f, 0.66f, 0.52f);
|
||||
static readonly Color Jeans = new Color(0.2f, 0.26f, 0.38f);
|
||||
static readonly Color Boots = new Color(0.22f, 0.15f, 0.09f);
|
||||
static readonly Color Sole = new Color(0.07f, 0.06f, 0.05f);
|
||||
static readonly Color Dark = new Color(0.08f, 0.08f, 0.08f);
|
||||
static readonly Color Metal = new Color(0.62f, 0.62f, 0.6f);
|
||||
static readonly Color[] HairColors =
|
||||
{
|
||||
new Color(0.23f, 0.15f, 0.09f), new Color(0.45f, 0.33f, 0.2f), new Color(0.12f, 0.1f, 0.08f), new Color(0.55f, 0.5f, 0.45f)
|
||||
};
|
||||
|
||||
Color jacket;
|
||||
int variant;
|
||||
Transform headPivot, view;
|
||||
Transform pelvis, torso, belt, neck;
|
||||
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], shoulderBall = new Transform[2];
|
||||
|
||||
Vector3 targetFeet;
|
||||
Vector3 targetHead;
|
||||
@@ -39,16 +49,31 @@ namespace MSCO
|
||||
Quaternion targetViewRot = Quaternion.identity;
|
||||
PlayerState last;
|
||||
|
||||
// Fahrzeug, in dem die Figur sitzt (setzt der Manager jedes Frame; null = läuft frei)
|
||||
public Transform Seat;
|
||||
Transform lastSeat;
|
||||
Vector3 seatHead;
|
||||
Quaternion seatView = Quaternion.identity;
|
||||
|
||||
Vector3 lastPosition;
|
||||
float walkPhase, walkAmount, speed;
|
||||
|
||||
// Echtes Spielmodell (NPC aus der Szene); null = eigene Figur als Rückfall
|
||||
GameCharacter model;
|
||||
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; } }
|
||||
public Vector3 HeadPosition { get { return model != null ? model.HeadPosition : headPivot.position; } }
|
||||
public string ModelDescription { get { return model != null ? "game model: " + model.Description : "fallback figure (no NPC template found)"; } }
|
||||
public Vector3 HeadPosition { get { return headPivot.position; } }
|
||||
public string ModelDescription { get { return model != null ? model.Description : "MSCO figure, variant " + variant; } }
|
||||
|
||||
/// <summary>Mitspieler als echte MSC-Menschen zeigen (Konsole "model game|classic", gespeichert).</summary>
|
||||
public static bool UseGameModels
|
||||
{
|
||||
get { return PlayerPrefs.GetInt("MSCO_GameModels", 1) == 1; }
|
||||
set { PlayerPrefs.SetInt("MSCO_GameModels", value ? 1 : 0); PlayerPrefs.Save(); }
|
||||
}
|
||||
|
||||
// Echte Spielfigur (null = eigene MSCO-Figur sichtbar); die MSCO-Figur liefert dann nur noch die Gelenkpunkte
|
||||
GameCharacter model;
|
||||
readonly GameCharacter.Rig rig = new GameCharacter.Rig();
|
||||
|
||||
// ---------------------------------------------------------------- Aufbau
|
||||
|
||||
@@ -57,43 +82,50 @@ namespace MSCO
|
||||
var root = new GameObject(name);
|
||||
var rp = root.AddComponent<RemotePlayer>();
|
||||
rp.jacket = jacketColor;
|
||||
rp.variant = Mathf.Abs(variant);
|
||||
rp.Build();
|
||||
if (UseGameModels) rp.UseGameModel();
|
||||
rp.SetTarget(state);
|
||||
root.transform.position = rp.targetFeet;
|
||||
root.transform.rotation = Quaternion.Euler(0f, rp.targetYaw, 0f);
|
||||
|
||||
// Wenn möglich einen echten NPC aus dem Spiel als Figur nehmen, sonst die eigene Figur
|
||||
try { rp.model = GameCharacter.TryCreate(root.transform, variant); }
|
||||
catch (System.Exception e) { MSCLoader.ModConsole.Warning("[MSCO] Game model failed: " + e.Message); }
|
||||
if (rp.model != null)
|
||||
foreach (Renderer r in root.GetComponentsInChildren<Renderer>(true))
|
||||
if (!r.transform.IsChildOf(rp.model.Root)) r.enabled = false;
|
||||
rp.headWorld = rp.targetHead;
|
||||
rp.pitch = rp.targetPitch;
|
||||
rp.view.rotation = rp.targetViewRot;
|
||||
rp.lastPosition = root.transform.position;
|
||||
if (rp.model == null) rp.Pose(0f);
|
||||
rp.Pose(0f);
|
||||
return rp;
|
||||
}
|
||||
|
||||
void Build()
|
||||
{
|
||||
Transform r = transform;
|
||||
Color jacketDark = jacket * 0.75f;
|
||||
Color jacketDark = jacket * 0.7f;
|
||||
jacketDark.a = 1f;
|
||||
Color hairColor = HairColors[variant % HairColors.Length];
|
||||
bool wearsCap = variant % 3 != 2;
|
||||
bool sunglasses = variant % 4 == 1;
|
||||
Texture2D plaid = Plaid(jacket);
|
||||
|
||||
// Körper: Karohemd/Jacke, Jeans, Gürtel, leichter Bauch (wie die Leute in MSC)
|
||||
pelvis = Capsule("Pelvis", r, Jeans);
|
||||
torso = Capsule("Torso", r, jacket);
|
||||
belt = Capsule("Belt", r, Dark);
|
||||
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);
|
||||
neck = Capsule("Neck", r, Skin);
|
||||
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
thigh[i] = Capsule("Thigh", r, Jeans);
|
||||
shin[i] = Capsule("Shin", r, Jeans);
|
||||
boot[i] = Box("Boot", r, Boots);
|
||||
upperArm[i] = Capsule("UpperArm", r, jacket);
|
||||
forearm[i] = Capsule("Forearm", r, jacketDark);
|
||||
shin[i] = Capsule("Shin", r, Jeans * 0.92f);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -101,31 +133,108 @@ namespace MSCO
|
||||
headPivot = new GameObject("Head").transform;
|
||||
headPivot.SetParent(r, false);
|
||||
Transform skull = Sphere("Skull", headPivot, Skin, 0.24f);
|
||||
skull.localScale = new Vector3(0.22f, 0.26f, 0.24f);
|
||||
Transform hair = Sphere("Hair", headPivot, new Color(0.25f, 0.17f, 0.1f), 0.24f);
|
||||
hair.localPosition = new Vector3(0f, 0.02f, -0.025f);
|
||||
hair.localScale = new Vector3(0.23f, 0.25f, 0.23f);
|
||||
Transform cap = Sphere("Cap", headPivot, jacketDark, 0.25f);
|
||||
cap.localPosition = new Vector3(0f, 0.07f, -0.005f);
|
||||
cap.localScale = new Vector3(0.245f, 0.16f, 0.255f);
|
||||
Transform brim = Box("CapBrim", headPivot, jacketDark);
|
||||
brim.localPosition = new Vector3(0f, 0.075f, 0.13f);
|
||||
brim.localScale = new Vector3(0.2f, 0.02f, 0.11f);
|
||||
brim.localRotation = Quaternion.Euler(-8f, 0f, 0f);
|
||||
skull.localScale = new Vector3(0.225f, 0.27f, 0.25f);
|
||||
Transform jaw = Sphere("Jaw", headPivot, Skin, 0.2f);
|
||||
jaw.localPosition = new Vector3(0f, -0.07f, 0.022f);
|
||||
jaw.localScale = new Vector3(0.19f, 0.14f, 0.2f);
|
||||
Transform hair = Sphere("Hair", headPivot, hairColor, 0.24f);
|
||||
hair.localPosition = new Vector3(0f, 0.028f, -0.032f);
|
||||
hair.localScale = new Vector3(0.235f, 0.255f, 0.235f);
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
Transform eye = Sphere("Eye", headPivot, Dark, 0.035f);
|
||||
eye.localPosition = new Vector3(i == 0 ? -0.045f : 0.045f, 0.015f, 0.105f);
|
||||
float side = i == 0 ? -1f : 1f;
|
||||
Transform ear = Sphere("Ear", headPivot, Skin * 0.96f, 0.05f);
|
||||
ear.localPosition = new Vector3(side * 0.112f, -0.005f, 0f);
|
||||
ear.localScale = new Vector3(0.035f, 0.065f, 0.05f);
|
||||
Transform eye = Sphere("Eye", headPivot, Dark, 0.026f);
|
||||
eye.localPosition = new Vector3(side * 0.045f, 0.018f, 0.112f);
|
||||
Transform brow = Box("Brow", headPivot, hairColor);
|
||||
brow.localPosition = new Vector3(side * 0.046f, 0.046f, 0.117f);
|
||||
brow.localScale = new Vector3(0.045f, 0.012f, 0.012f);
|
||||
brow.localRotation = Quaternion.Euler(0f, 0f, side * -8f);
|
||||
}
|
||||
Transform nose = Box("Nose", headPivot, Skin * 0.93f);
|
||||
nose.localPosition = new Vector3(0f, -0.012f, 0.128f);
|
||||
nose.localScale = new Vector3(0.035f, 0.055f, 0.045f);
|
||||
// Der typische Schnurrbart
|
||||
Transform mustache = Box("Mustache", headPivot, hairColor);
|
||||
mustache.localPosition = new Vector3(0f, -0.054f, 0.12f);
|
||||
mustache.localScale = new Vector3(0.085f, 0.024f, 0.03f);
|
||||
Transform mouth = Box("Mouth", headPivot, new Color(0.45f, 0.25f, 0.22f));
|
||||
mouth.localPosition = new Vector3(0f, -0.082f, 0.114f);
|
||||
mouth.localScale = new Vector3(0.05f, 0.01f, 0.01f);
|
||||
|
||||
if (sunglasses)
|
||||
{
|
||||
Transform glasses = Box("Sunglasses", headPivot, Dark);
|
||||
glasses.localPosition = new Vector3(0f, 0.018f, 0.121f);
|
||||
glasses.localScale = new Vector3(0.15f, 0.035f, 0.012f);
|
||||
}
|
||||
if (wearsCap)
|
||||
{
|
||||
// Kappe ("lippis") in der Jackenfarbe
|
||||
Transform cap = Sphere("Cap", headPivot, jacketDark, 0.25f);
|
||||
cap.localPosition = new Vector3(0f, 0.08f, -0.005f);
|
||||
cap.localScale = new Vector3(0.25f, 0.16f, 0.26f);
|
||||
Transform brim = Box("CapBrim", headPivot, jacketDark);
|
||||
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
|
||||
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);
|
||||
}
|
||||
Transform nose = Box("Nose", headPivot, Skin * 0.95f);
|
||||
nose.localPosition = new Vector3(0f, -0.02f, 0.12f);
|
||||
nose.localScale = new Vector3(0.035f, 0.05f, 0.04f);
|
||||
|
||||
// Unsichtbarer "Blick" mit der exakten Kameradrehung - daran hängt das gehaltene Item
|
||||
view = new GameObject("View").transform;
|
||||
view.SetParent(r, false);
|
||||
}
|
||||
|
||||
void UseGameModel()
|
||||
{
|
||||
model = GameCharacter.Create(transform, variant);
|
||||
if (model == null) return;
|
||||
// Eigene Figur unsichtbar (sie bewegt sich weiter und gibt die Gelenke vor)
|
||||
Transform modelRoot = transform.Find("MSCO_Model");
|
||||
foreach (Renderer r in GetComponentsInChildren<Renderer>(true))
|
||||
if (modelRoot == null || !r.transform.IsChildOf(modelRoot)) r.enabled = false;
|
||||
}
|
||||
|
||||
/// <summary>Kleines Karomuster (Flanellhemd) in der Grundfarbe; wird auf die Kapseln gekachelt.</summary>
|
||||
static Texture2D Plaid(Color baseColor)
|
||||
{
|
||||
const int size = 16;
|
||||
var tex = new Texture2D(size, size, TextureFormat.RGB24, false);
|
||||
// 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.25f); // dünne dunkle Linien
|
||||
c.a = 1f;
|
||||
tex.SetPixel(x, y, c);
|
||||
}
|
||||
}
|
||||
tex.filterMode = FilterMode.Bilinear;
|
||||
tex.wrapMode = TextureWrapMode.Repeat;
|
||||
tex.Apply();
|
||||
return tex;
|
||||
}
|
||||
|
||||
static Transform Textured(Transform part, Texture2D tex, Vector2 tiling)
|
||||
{
|
||||
Material m = part.GetComponent<Renderer>().material;
|
||||
m.mainTexture = tex;
|
||||
m.mainTextureScale = tiling;
|
||||
return part;
|
||||
}
|
||||
|
||||
static Transform Capsule(string name, Transform parent, Color color) { return Primitive(PrimitiveType.Capsule, name, parent, color); }
|
||||
|
||||
static Transform Box(string name, Transform parent, Color color) { return Primitive(PrimitiveType.Cube, name, parent, color); }
|
||||
@@ -144,6 +253,9 @@ namespace MSCO
|
||||
Destroy(go.GetComponent<Collider>()); // stört sonst Physik/Trigger
|
||||
var renderer = go.GetComponent<Renderer>();
|
||||
renderer.material.color = color;
|
||||
// Matt wie Stoff/Haut statt glänzendem Plastik (Standard-Shader)
|
||||
if (renderer.material.HasProperty("_Glossiness")) renderer.material.SetFloat("_Glossiness", 0.08f);
|
||||
if (renderer.material.HasProperty("_Metallic")) renderer.material.SetFloat("_Metallic", 0f);
|
||||
renderer.receiveShadows = true;
|
||||
go.transform.SetParent(parent, false);
|
||||
return go.transform;
|
||||
@@ -174,6 +286,15 @@ namespace MSCO
|
||||
|
||||
public void Tick(float dt)
|
||||
{
|
||||
if (Seat != null && last.SeatId != 0)
|
||||
{
|
||||
TickSeated(dt);
|
||||
UpdateWalk(dt);
|
||||
Pose(dt);
|
||||
return;
|
||||
}
|
||||
lastSeat = null;
|
||||
|
||||
// Bei großen Sprüngen (Teleport, Laden) direkt setzen statt gleiten
|
||||
bool snap = (transform.position - targetFeet).sqrMagnitude > 100f;
|
||||
float t = snap ? 1f : Mathf.Clamp01(dt * Smoothing);
|
||||
@@ -186,18 +307,33 @@ namespace MSCO
|
||||
view.rotation = Quaternion.Slerp(view.rotation, targetViewRot, t);
|
||||
|
||||
UpdateWalk(dt);
|
||||
if (model != null) model.Animate(speed); // Spiel-Animation (Idle/Walk) läuft vor LateUpdate
|
||||
else Pose(dt);
|
||||
Pose(dt);
|
||||
}
|
||||
|
||||
/// <summary>Spielmodell: nach der Animation Ducken/Lehnen/Kopf/Arme anpassen.</summary>
|
||||
void LateUpdate()
|
||||
/// <summary>
|
||||
/// Im Fahrzeug: relativ zum Fahrzeug glätten und jedes Frame an dessen aktuelle Lage setzen. In Weltkoordinaten
|
||||
/// hinkt die Figur einem fahrenden Auto um Tempo / Glättung hinterher (bei 90 km/h fast 2 m) und ruckelt.
|
||||
/// </summary>
|
||||
void TickSeated(float dt)
|
||||
{
|
||||
if (model == null) return;
|
||||
Vector3 heldPos;
|
||||
Quaternion heldRot;
|
||||
bool holding = TryGetHeldPose(out heldPos, out heldRot);
|
||||
model.Pose(transform, headWorld, pitch, last.Seated, holding, heldPos);
|
||||
bool fresh = lastSeat != Seat; // gerade eingestiegen: direkt auf den Sitz
|
||||
lastSeat = Seat;
|
||||
float t = fresh ? 1f : Mathf.Clamp01(dt * Smoothing);
|
||||
seatHead = Vector3.Lerp(seatHead, last.SeatHead, t);
|
||||
seatView = Quaternion.Slerp(seatView, last.SeatView, t);
|
||||
|
||||
Vector3 up = Seat.up;
|
||||
Quaternion viewRot = Seat.rotation * seatView;
|
||||
Vector3 forward = viewRot * Vector3.forward;
|
||||
Vector3 flat = forward - up * Vector3.Dot(forward, up);
|
||||
if (flat.sqrMagnitude < 0.0001f) flat = Seat.forward;
|
||||
|
||||
headWorld = Seat.TransformPoint(seatHead);
|
||||
transform.rotation = Quaternion.LookRotation(flat, up); // neigt sich mit dem Auto (Berg, Kurve)
|
||||
transform.position = headWorld - up * 1.2f;
|
||||
pitch = Mathf.Clamp(-Mathf.Asin(Mathf.Clamp(Vector3.Dot(forward.normalized, up), -1f, 1f)) * Mathf.Rad2Deg, -MaxHeadPitch, MaxHeadPitch);
|
||||
view.position = headWorld;
|
||||
view.rotation = viewRot;
|
||||
}
|
||||
|
||||
void UpdateWalk(float dt)
|
||||
@@ -222,6 +358,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);
|
||||
@@ -232,24 +369,45 @@ 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;
|
||||
Vector3 forward = Vector3.Cross(right, up).normalized;
|
||||
|
||||
// Rumpf
|
||||
Segment(torso, hip + up * 0.08f, neckBase, 0.38f, 0.24f);
|
||||
Segment(belt, hip + up * 0.06f, hip + up * 0.1f, 0.37f, 0.23f);
|
||||
Segment(pelvis, hip - right * HipWidth, hip + right * HipWidth, 0.2f, 0.22f);
|
||||
Segment(neck, neckBase, neckTop, 0.09f, 0.09f);
|
||||
Quaternion torsoRot = Quaternion.LookRotation(forward, up);
|
||||
// Brust (breit oben) und Bauch (etwas tiefer = leichter Bauch) - fließend statt Klotz mit Kugel
|
||||
Segment(torso, hip + up * 0.26f, neckBase - up * 0.04f, 0.37f, 0.22f);
|
||||
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.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;
|
||||
placket.localRotation = torsoRot;
|
||||
placket.localScale = new Vector3(0.022f, 0.3f, 0.01f);
|
||||
Segment(pelvis, hip - right * (HipWidth * 0.8f) - up * 0.02f, hip + right * (HipWidth * 0.8f) - up * 0.02f, 0.2f, 0.22f);
|
||||
Segment(collar, neckBase - up * 0.02f, neckBase + up * 0.035f, 0.17f, 0.15f);
|
||||
Segment(neck, neckBase, neckTop, 0.1f, 0.1f);
|
||||
|
||||
// Kopf
|
||||
headPivot.localPosition = head;
|
||||
headPivot.localRotation = Quaternion.Euler(pitch, 0f, 0f);
|
||||
rig.Hip = hip;
|
||||
rig.NeckBase = neckBase;
|
||||
rig.Head = head;
|
||||
rig.Up = up;
|
||||
rig.Pitch = pitch;
|
||||
|
||||
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;
|
||||
@@ -261,16 +419,27 @@ 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.17f, 0.17f);
|
||||
Segment(shin[i], knee, ankle, 0.14f, 0.14f);
|
||||
boot[i].localPosition = ankle + new Vector3(0f, -0.02f, 0.05f);
|
||||
boot[i].localRotation = Quaternion.identity;
|
||||
boot[i].localScale = new Vector3(0.12f, 0.1f, 0.27f);
|
||||
Segment(thigh[i], hipJoint, knee, 0.15f, 0.16f);
|
||||
rig.HipJoint[i] = hipJoint;
|
||||
rig.Knee[i] = knee;
|
||||
rig.Ankle[i] = ankle;
|
||||
Segment(shin[i], knee, ankle, 0.12f, 0.13f);
|
||||
// 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;
|
||||
@@ -279,7 +448,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.03f, -(UpperArmLength + ForearmLength) + 0.04f, -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;
|
||||
@@ -287,10 +456,17 @@ namespace MSCO
|
||||
if (reach.magnitude > maxReach) handTarget = shoulder + reach.normalized * maxReach;
|
||||
|
||||
Vector3 elbow = Joint(shoulder, handTarget, UpperArmLength, ForearmLength, new Vector3(side * 0.4f, -0.2f, -1f));
|
||||
Segment(upperArm[i], shoulder, elbow, 0.12f, 0.12f);
|
||||
Segment(forearm[i], elbow, handTarget, 0.11f, 0.11f);
|
||||
Segment(upperArm[i], shoulder, elbow, 0.11f, 0.11f);
|
||||
rig.Shoulder[i] = shoulder;
|
||||
rig.Elbow[i] = elbow;
|
||||
rig.Hand[i] = handTarget;
|
||||
Segment(forearm[i], elbow, handTarget, 0.095f, 0.095f);
|
||||
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.085f, 0.125f, 0.11f);
|
||||
}
|
||||
if (model != null) model.Apply(rig);
|
||||
}
|
||||
|
||||
/// <summary>Zwei-Knochen-Gelenk (Knie/Ellbogen): Punkt zwischen a und b, zur Seite "bend" geknickt.</summary>
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+51
-3
@@ -24,6 +24,54 @@ namespace MSCO
|
||||
|
||||
public static bool HasBackup { get { return File.Exists(Marker); } }
|
||||
|
||||
static string ArchiveDir { get { return Path.Combine(SaveDir, "MSCO_Saves"); } }
|
||||
const int KeepArchives = 10;
|
||||
|
||||
/// <summary>Gibt es einen eigenen Spielstand zum Weiterspielen?</summary>
|
||||
public static bool HasLocalSave { get { return File.Exists(Path.Combine(SaveDir, "defaultES2File.txt")); } }
|
||||
|
||||
/// <summary>Wann der eigene Spielstand zuletzt gespeichert wurde (null = keiner).</summary>
|
||||
public static System.DateTime? LocalSaveTime
|
||||
{
|
||||
get
|
||||
{
|
||||
string file = Path.Combine(SaveDir, "defaultES2File.txt");
|
||||
return File.Exists(file) ? File.GetLastWriteTime(file) : (System.DateTime?)null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ordner mit den Spielständen, die vor einem neuen Spiel gesichert wurden.</summary>
|
||||
public static string ArchiveFolder { get { return ArchiveDir; } }
|
||||
|
||||
/// <summary>
|
||||
/// Neues Spiel: den bisherigen Spielstand nach MSCO_Saves/Datum kopieren (nichts geht verloren) und die
|
||||
/// Spielstand-Dateien leeren, damit das Spiel eine frische Welt anlegt. Gibt den Sicherungsordner zurück (oder null).
|
||||
/// </summary>
|
||||
public static string ArchiveForNewGame()
|
||||
{
|
||||
// War noch der Spielstand eines anderen Hosts aktiv, erst den eigenen zurückholen
|
||||
if (HasBackup) RestoreBackup();
|
||||
string target = null;
|
||||
if (HasLocalSave)
|
||||
{
|
||||
target = Path.Combine(ArchiveDir, System.DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"));
|
||||
Directory.CreateDirectory(target);
|
||||
foreach (string name in Files)
|
||||
{
|
||||
string path = Path.Combine(SaveDir, name);
|
||||
if (File.Exists(path)) File.Copy(path, Path.Combine(target, name), true);
|
||||
}
|
||||
MscoLog.Info("[MSCO] Your previous save game was backed up to " + target);
|
||||
// Nur die letzten Sicherungen behalten
|
||||
string[] all = Directory.GetDirectories(ArchiveDir);
|
||||
System.Array.Sort(all);
|
||||
for (int i = 0; i < all.Length - KeepArchives; i++)
|
||||
try { Directory.Delete(all[i], true); } catch { }
|
||||
}
|
||||
DeleteSaveFiles();
|
||||
return target;
|
||||
}
|
||||
|
||||
public static List<KeyValuePair<string, byte[]>> ReadLocalSave()
|
||||
{
|
||||
var result = new List<KeyValuePair<string, byte[]>>();
|
||||
@@ -48,7 +96,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 +106,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 +122,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()
|
||||
|
||||
@@ -14,6 +14,27 @@ namespace MSCO
|
||||
static float builtAt = -1000f;
|
||||
static int builtForLevel = -1;
|
||||
|
||||
static Rigidbody[] bodies = new Rigidbody[0];
|
||||
static float bodiesAt = -1000f;
|
||||
static int bodiesForLevel = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Alle Rigidbodies; höchstens maxAge Sekunden alt (Objekt-Sync und Verkehr teilen sich die teure Suche).
|
||||
/// Einträge können inzwischen zerstört sein (== null prüfen).
|
||||
/// </summary>
|
||||
public static Rigidbody[] Rigidbodies(float maxAge)
|
||||
{
|
||||
if (Time.realtimeSinceStartup - bodiesAt > maxAge || bodiesForLevel != Application.loadedLevel)
|
||||
{
|
||||
Object[] found = Object.FindObjectsOfType(typeof(Rigidbody));
|
||||
bodies = new Rigidbody[found.Length];
|
||||
for (int i = 0; i < found.Length; i++) bodies[i] = (Rigidbody)found[i];
|
||||
bodiesAt = Time.realtimeSinceStartup;
|
||||
bodiesForLevel = Application.loadedLevel;
|
||||
}
|
||||
return bodies;
|
||||
}
|
||||
|
||||
/// <summary>Alle FSMs; höchstens maxAge Sekunden alt. Einträge können inzwischen zerstört sein (== null prüfen).</summary>
|
||||
public static PlayMakerFSM[] Fsms(float maxAge)
|
||||
{
|
||||
|
||||
+5
-10
@@ -23,6 +23,8 @@ namespace MSCO
|
||||
public string Version;
|
||||
public bool RequireSteam = true; // false = Non-Steam-Server
|
||||
public string Owner; // Besitzer des Lizenzschlüssels
|
||||
public bool Featured; // vom Keymaster-Admin hervorgehoben (steht in der Liste oben)
|
||||
public List<string> Tags = new List<string>(); // Admin-Tags (z.B. "official") und eigene Tags des Servers (Web-Panel)
|
||||
|
||||
[JsonIgnore] public int UptimeSeconds = -1;
|
||||
[JsonIgnore] public List<string> PlayerNames = new List<string>();
|
||||
@@ -37,11 +39,11 @@ namespace MSCO
|
||||
/// </summary>
|
||||
public class ServerBrowser
|
||||
{
|
||||
public const string DefaultMasterUrl = "https://msco.mbm.wtf";
|
||||
const string PrefMasterUrl = "MSCO_MasterUrl";
|
||||
/// <summary>Offizieller Master (Serverliste) - fest eingebaut.</summary>
|
||||
public const string MasterUrl = "https://msco.mbm.wtf";
|
||||
const int QueryTimeoutMs = 3000;
|
||||
|
||||
// Wie bei FiveM: ein Server auf diesem PC erscheint automatisch oben in der Liste
|
||||
// Ein Server auf diesem PC erscheint automatisch oben in der Liste
|
||||
static readonly int[] LocalPorts = { 7777, 7778, 7779, 7780 };
|
||||
public const float AutoRefreshSeconds = 15f;
|
||||
|
||||
@@ -54,13 +56,6 @@ namespace MSCO
|
||||
/// <summary>Zeitpunkt der letzten Aktualisierung (Time.realtimeSinceStartup), für das Auto-Refresh.</summary>
|
||||
public float LastRefresh { get; private set; }
|
||||
|
||||
/// <summary>Adresse des Master-Servers (änderbar mit "mp master <url>", z.B. zum lokalen Testen).</summary>
|
||||
public static string MasterUrl
|
||||
{
|
||||
get { return PlayerPrefs.GetString(PrefMasterUrl, DefaultMasterUrl).TrimEnd('/'); }
|
||||
set { PlayerPrefs.SetString(PrefMasterUrl, string.IsNullOrEmpty(value) ? DefaultMasterUrl : value.TrimEnd('/')); }
|
||||
}
|
||||
|
||||
/// <summary>Lokale Server zuerst, dann die öffentliche Liste.</summary>
|
||||
public List<ServerListEntry> Servers
|
||||
{
|
||||
|
||||
+3
-3
@@ -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;
|
||||
}
|
||||
@@ -164,7 +164,7 @@ namespace MSCO
|
||||
return;
|
||||
}
|
||||
LobbyId = new CSteamID(result.m_ulSteamIDLobby);
|
||||
SteamMatchmaking.SetLobbyData(LobbyId, VersionKey, MSCO.ModVersion);
|
||||
SteamMatchmaking.SetLobbyData(LobbyId, VersionKey, MSCO.ProtocolVersion); // ältere Mods vergleichen genau
|
||||
if (Created != null) Created(LobbyId);
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ namespace MSCO
|
||||
LobbyId = new CSteamID(result.m_ulSteamIDLobby);
|
||||
|
||||
string hostVersion = SteamMatchmaking.GetLobbyData(LobbyId, VersionKey);
|
||||
if (!string.IsNullOrEmpty(hostVersion) && hostVersion != MSCO.ModVersion)
|
||||
if (!string.IsNullOrEmpty(hostVersion) && !MSCO.Compatible(hostVersion))
|
||||
{
|
||||
Leave();
|
||||
Fail("Version mismatch (host " + hostVersion + ", you " + MSCO.ModVersion + ")");
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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);
|
||||
UiTheme.DrawShadow(r);
|
||||
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.Card(new Color(0.06f, 0.06f, 0.07f, 0.92f), 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
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 = -1f; // erster Suchlauf verteilt, siehe Update
|
||||
}
|
||||
|
||||
public void Update(bool authority, float dt, Action<MsgType, byte[]> send)
|
||||
{
|
||||
if (nextScan < 0f) nextScan = Time.time + 4.5f; // nicht alle Sync-Teile im selben Frame suchen lassen
|
||||
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.fixedTime; // Physik-Zeit wie in FixedUpdate (sonst negatives Alter = Rucken)
|
||||
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.Clamp(Time.fixedTime - c.TargetTime, 0f, 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 SceneCache.Rigidbodies(5f)) // teilt sich die Suche mit dem Objekt-Sync
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
-1
@@ -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.
|
||||
@@ -26,8 +26,13 @@ namespace MSCO
|
||||
public static readonly Color Muted = Hex(0x8e, 0x8e, 0x99);
|
||||
public static readonly Color Online = Hex(0x3d, 0xd6, 0x8c);
|
||||
public static readonly Color Pending = Hex(0xf2, 0xc2, 0x1d);
|
||||
public static readonly Color Gold = Hex(0xff, 0xb4, 0x5a);
|
||||
/// <summary>Feiner heller Rand um Fenster und Karten (wie auf der Website).</summary>
|
||||
public static readonly Color Border = new Color(1f, 1f, 1f, 0.08f);
|
||||
|
||||
public const int Radius = 6;
|
||||
const int ShadowBlur = 16;
|
||||
static GUIStyle shadowStyle;
|
||||
|
||||
public static Color Hex(int r, int g, int b, float a = 1f)
|
||||
{
|
||||
@@ -58,6 +63,92 @@ namespace MSCO
|
||||
return tex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wie Rounded, aber mit senkrechtem Verlauf (oben -> unten) und optional 1 px Rand. Die Textur ist höher als
|
||||
/// breit, damit der Verlauf beim 9-Slice-Strecken im Mittelteil erhalten bleibt.
|
||||
/// </summary>
|
||||
public static Texture2D RoundedEx(Color top, Color bottom, Color border, bool withBorder, int radius = Radius, int height = 48)
|
||||
{
|
||||
int width = radius * 2 + 4;
|
||||
height = Mathf.Max(height, width);
|
||||
var tex = new Texture2D(width, height, TextureFormat.ARGB32, false);
|
||||
tex.wrapMode = TextureWrapMode.Clamp;
|
||||
tex.filterMode = FilterMode.Bilinear;
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
// Texturzeile 0 ist unten
|
||||
Color fill = Color.Lerp(bottom, top, (y + 0.5f) / height);
|
||||
// Rand deckend auf die Fläche mischen (ein durchsichtiger Rand ließe das Spiel durchscheinen)
|
||||
Color edge = Color.Lerp(fill, new Color(border.r, border.g, border.b, fill.a), border.a);
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
float cx = Mathf.Clamp(x + 0.5f, radius, width - radius);
|
||||
float cy = Mathf.Clamp(y + 0.5f, radius, height - radius);
|
||||
float d = Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy));
|
||||
Color c = withBorder ? Color.Lerp(edge, fill, Mathf.Clamp01(radius - 1f - d + 0.5f)) : fill;
|
||||
c.a *= Mathf.Clamp01(radius - d + 0.5f);
|
||||
tex.SetPixel(x, y, c);
|
||||
}
|
||||
}
|
||||
tex.Apply();
|
||||
tex.hideFlags = HideFlags.HideAndDontSave;
|
||||
return tex;
|
||||
}
|
||||
|
||||
/// <summary>Fenster/Karte mit feinem Rand; mit GUI.Box zeichnen (vorher DrawShadow für Tiefe).</summary>
|
||||
public static GUIStyle Card(Color color, int radius = Radius)
|
||||
{
|
||||
var s = new GUIStyle();
|
||||
s.normal.background = RoundedEx(color, color, Border, true, radius, radius * 2 + 4);
|
||||
s.border = new RectOffset(radius + 2, radius + 2, radius + 2, radius + 2);
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>Orange Hauptknopf mit leichtem Verlauf (wie .btn auf der Website).</summary>
|
||||
public static GUIStyle PrimaryButton(int fontSize = 13)
|
||||
{
|
||||
GUIStyle s = Button(Accent, AccentHover, Color.white, fontSize);
|
||||
ApplyPrimaryGradient(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>Verlauf auf einen vorhandenen Knopf-Stil legen (Rundung und Rand bleiben).</summary>
|
||||
public static void ApplyPrimaryGradient(GUIStyle s)
|
||||
{
|
||||
s.normal.background = s.focused.background = RoundedEx(AccentHover, Accent, Color.clear, false);
|
||||
s.hover.background = s.active.background = s.onNormal.background =
|
||||
RoundedEx(Color.Lerp(AccentHover, Color.white, 0.12f), AccentHover, Color.clear, false);
|
||||
}
|
||||
|
||||
/// <summary>Weicher Schatten unter einem Fenster (etwas nach unten versetzt). Vor dem Fenster zeichnen.</summary>
|
||||
public static void DrawShadow(Rect r)
|
||||
{
|
||||
if (shadowStyle == null || shadowStyle.normal.background == null)
|
||||
{
|
||||
const int radius = 10;
|
||||
int reach = radius + ShadowBlur;
|
||||
int size = reach * 2 + 4;
|
||||
var tex = new Texture2D(size, size, TextureFormat.ARGB32, false);
|
||||
tex.wrapMode = TextureWrapMode.Clamp;
|
||||
tex.filterMode = FilterMode.Bilinear;
|
||||
for (int y = 0; y < size; y++)
|
||||
for (int x = 0; x < size; x++)
|
||||
{
|
||||
float cx = Mathf.Clamp(x + 0.5f, reach, size - reach);
|
||||
float cy = Mathf.Clamp(y + 0.5f, reach, size - reach);
|
||||
float outside = Mathf.Max(0f, Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy)) - radius);
|
||||
float fade = 1f - Mathf.Clamp01(outside / ShadowBlur);
|
||||
tex.SetPixel(x, y, new Color(0f, 0f, 0f, 0.45f * fade * fade));
|
||||
}
|
||||
tex.Apply();
|
||||
tex.hideFlags = HideFlags.HideAndDontSave;
|
||||
shadowStyle = new GUIStyle();
|
||||
shadowStyle.normal.background = tex;
|
||||
shadowStyle.border = new RectOffset(reach + 2, reach + 2, reach + 2, reach + 2);
|
||||
}
|
||||
GUI.Box(new Rect(r.x - ShadowBlur, r.y - ShadowBlur + 6, r.width + ShadowBlur * 2, r.height + ShadowBlur * 2), GUIContent.none, shadowStyle);
|
||||
}
|
||||
|
||||
public static Texture2D Solid(Color color)
|
||||
{
|
||||
var tex = new Texture2D(1, 1);
|
||||
|
||||
+119
-12
@@ -7,14 +7,23 @@ 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 CacheRefresh = 5f; // Teile können an-/abgebaut werden
|
||||
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 = 15f; // Teile können an-/abgebaut werden (Nachschlagen ist teuer: ganzes Fahrzeug)
|
||||
const float ApplyTimeout = 2f; // ohne neue Daten nichts mehr erzwingen
|
||||
|
||||
struct AudioState
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+194
-27
@@ -21,13 +21,29 @@ namespace MSCO
|
||||
static readonly string[] Keywords =
|
||||
{
|
||||
"fuel", "oil", "coolant", "water", "fluid", "brake", "clutchfluid", "battery", "charge",
|
||||
"bolt", "tight", "damage", "wear", "condition", "odo", "km", "dirt", "paint", "color", "colour",
|
||||
"damage", "wear", "condition", "odo", "km", "dirt", "paint", "color", "colour",
|
||||
"tune", "tuning", "ratio", "broken", "fixed", "flat", "pressure", "level", "mixture", "timing",
|
||||
"idle", "air", "jet", "spark", "gap", "rust", "health", "burn", "cool", "temp"
|
||||
};
|
||||
|
||||
// Montiert-Flags nicht direkt setzen: das echte Anbauen läuft über FsmSync (Events)
|
||||
static readonly string[] Excluded = { "install", "rpm", "speed", "steer", "throttle", "gear", "raycast", "distance" };
|
||||
// Schrauben/Festigkeit laufen über FsmSync (Events) - ein falscher Wert hier würde Teile lockern und abfallen lassen
|
||||
// Vom Spiel selbst berechnete Werte ebenfalls nicht: z.B. LeakOilpan/LeakOilfilter (leckt Öl?) ergibt sich aus
|
||||
// angebauten/festgezogenen Teilen - ein übertragener alter Wert ließ beim Beitreten Öl auslaufen. Raten (…Rate)
|
||||
// rechnet das Spiel laufend neu aus.
|
||||
static readonly string[] Excluded =
|
||||
{
|
||||
"install", "bolt", "tight", "detach", "attach", "rpm", "speed", "steer", "throttle", "gear", "raycast", "distance",
|
||||
"leak", "rate",
|
||||
// Spieler-Werte (Database/PlayerDatabase: Schmutz, Verbrennungen ...) sind pro Spieler; Fluch-Geräusche,
|
||||
// Ton-Verzerrung und Anzeige-Hilfswerte (GUI) rechnet jeder selbst
|
||||
"player", "swear", "distortion", "gui"
|
||||
};
|
||||
|
||||
// Wurzeln außer den Fahrzeugen: Satsuma-Teile (lose und angebaut) und ihre Datenbank (Verschleiß, beschädigt,
|
||||
// Lack, Füllstände ...). Maßgeblich ist dort wie beim Satsuma selbst, wer den Satsuma gerade fährt.
|
||||
static readonly string[] PartRoots = { "CARPARTS", "Database" };
|
||||
const string PartOwnerPrefix = "SATSUMA";
|
||||
|
||||
const float ScanInterval = 10f;
|
||||
const float CheckInterval = 0.5f;
|
||||
@@ -36,9 +52,12 @@ namespace MSCO
|
||||
class Var
|
||||
{
|
||||
public int Key;
|
||||
public string Name; // Pfad|FSM|Variable (für die Diagnose "vars")
|
||||
public string Root;
|
||||
public NamedVariable Variable;
|
||||
public byte Type; // 1 float, 2 int, 3 bool
|
||||
public byte Type; // 1 float, 2 int, 3 bool (übertragen); Farben als 4 einzelne Kanäle vom Typ 1
|
||||
public int Channel = -1; // FsmColor: 0-3 = r/g/b/a
|
||||
public PlayMakerFSM Fsm;
|
||||
public float LastF;
|
||||
public int LastI;
|
||||
public bool LastB;
|
||||
@@ -47,21 +66,47 @@ namespace MSCO
|
||||
readonly Dictionary<int, Var> vars = new Dictionary<int, Var>();
|
||||
readonly Dictionary<int, byte[]> pending = new Dictionary<int, byte[]>(); // noch nicht gefundene Werte
|
||||
readonly List<Var> outgoing = new List<Var>();
|
||||
readonly HashSet<PlayMakerFSM> scanned = new HashSet<PlayMakerFSM>();
|
||||
// Erfasste FSMs mit dem Elternobjekt beim Erfassen: wird ein Teil an-/abgebaut, ändert sich sein Pfad (und damit
|
||||
// die Schlüssel) - dann neu erfassen. FSMs außerhalb der Fahrzeuge/Teile werden bei jedem Suchlauf neu geprüft
|
||||
// (vorher einmal als "nicht Fahrzeug" gemerkt und nie wieder angesehen: später angebaute Teile fehlten)
|
||||
readonly Dictionary<PlayMakerFSM, Transform> scanned = new Dictionary<PlayMakerFSM, Transform>();
|
||||
readonly Dictionary<PlayMakerFSM, List<int>> keysByFsm = new Dictionary<PlayMakerFSM, List<int>>();
|
||||
string satsumaRoot;
|
||||
float nextScan, nextCheck;
|
||||
|
||||
public int Count { get { return vars.Count; } }
|
||||
|
||||
/// <summary>Diagnose (Konsole "vars <filter>"): synchronisierte Fahrzeugwerte mit aktuellem Wert.</summary>
|
||||
public List<string> Describe(string filter, int max)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
string f = (filter ?? "").ToLower();
|
||||
foreach (Var v in vars.Values)
|
||||
{
|
||||
if (f.Length > 0 && (v.Name == null || !v.Name.ToLower().Contains(f))) continue;
|
||||
string value = v.Type == 1 ? GetFloat(v).ToString("0.###")
|
||||
: v.Type == 2 ? ((FsmInt)v.Variable).Value.ToString()
|
||||
: ((FsmBool)v.Variable).Value.ToString();
|
||||
lines.Add(" " + v.Name + " = " + value);
|
||||
if (lines.Count >= max) { lines.Add(" ... (more - use a narrower filter)"); break; }
|
||||
}
|
||||
if (lines.Count == 0) lines.Add(" no synced vehicle value matches '" + filter + "'");
|
||||
return lines;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
vars.Clear();
|
||||
pending.Clear();
|
||||
scanned.Clear();
|
||||
nextScan = 0f;
|
||||
keysByFsm.Clear();
|
||||
satsumaRoot = null;
|
||||
nextScan = -1f; // erster Suchlauf verteilt, siehe Update
|
||||
}
|
||||
|
||||
public void Update(ObjectSync objects, int localId, Action<MsgType, byte[]> send)
|
||||
{
|
||||
if (nextScan < 0f) nextScan = Time.time + 3.1f; // nicht alle Sync-Teile im selben Frame suchen lassen
|
||||
if (Time.time >= nextScan)
|
||||
{
|
||||
nextScan = Time.time + ScanInterval;
|
||||
@@ -71,13 +116,19 @@ namespace MSCO
|
||||
nextCheck = Time.time + CheckInterval;
|
||||
|
||||
outgoing.Clear();
|
||||
ResolveSatsuma(objects);
|
||||
foreach (Var v in vars.Values)
|
||||
{
|
||||
if (!Changed(v)) continue;
|
||||
// Fährt/schiebt ein anderer das Fahrzeug, ist seine Version maßgeblich - nichts senden
|
||||
int owner = objects.VehicleOwner(OwnerRoot(v.Root));
|
||||
if (owner != SyncedBody.NoOwner && owner != localId) { if (Changed(v, false)) Remember(v); continue; }
|
||||
|
||||
// Eigenes Fahrzeug: jede Änderung. Freies Fahrzeug: nur deutliche Sprünge (Öl nachfüllen, tanken ...) -
|
||||
// langsames Abdriften (z.B. Abkühlen) rechnet jeder selbst, sonst überschreiben sich alle gegenseitig
|
||||
bool big = owner == SyncedBody.NoOwner;
|
||||
if (!Changed(v, big)) continue;
|
||||
Remember(v);
|
||||
// Fährt/schiebt ein anderer das Fahrzeug, ist seine Version maßgeblich
|
||||
int owner = objects.VehicleOwner(v.Root);
|
||||
if (owner == SyncedBody.NoOwner || owner == localId) outgoing.Add(v);
|
||||
outgoing.Add(v);
|
||||
}
|
||||
|
||||
for (int start = 0; start < outgoing.Count; start += MaxPerMessage)
|
||||
@@ -101,7 +152,7 @@ namespace MSCO
|
||||
byte[] value = r.ReadBytes(type == 3 ? 1 : 4);
|
||||
Var v;
|
||||
if (!vars.TryGetValue(key, out v)) { pending[key] = Combine(type, value); continue; }
|
||||
if (from >= 0 && objects.VehicleOwner(v.Root) == localId) continue; // ich fahre gerade: meine Werte gelten
|
||||
if (from >= 0 && objects.VehicleOwner(OwnerRoot(v.Root)) == localId) continue; // ich fahre gerade: meine Werte gelten
|
||||
Apply(v, type, value);
|
||||
}
|
||||
}
|
||||
@@ -126,30 +177,72 @@ namespace MSCO
|
||||
void Scan()
|
||||
{
|
||||
int before = vars.Count;
|
||||
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
|
||||
foreach (PlayMakerFSM fsm in SceneCache.Fsms(20f))
|
||||
{
|
||||
// Schon erfasste FSMs nicht jedes Mal neu durchgehen (Pfad + Hash kosten)
|
||||
if (fsm == null || !scanned.Add(fsm)) continue;
|
||||
if (fsm == null) continue;
|
||||
// Schon erfasst und nicht umgehängt (an-/abgebaut): nichts zu tun (Pfad + Hash kosten)
|
||||
Transform parent;
|
||||
if (scanned.TryGetValue(fsm, out parent) && parent == fsm.transform.parent) continue;
|
||||
string root = fsm.transform.root.name;
|
||||
if (!IsVehicle(root)) continue;
|
||||
if (!IsTracked(root)) { Forget(fsm); continue; }
|
||||
scanned[fsm] = fsm.transform.parent;
|
||||
Forget(fsm);
|
||||
// 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;
|
||||
if (fsm.transform.root.name == "Database" && FsmSync.PathOf(fsm.transform).Contains("PlayerDatabase")) 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);
|
||||
foreach (FsmFloat f in fv.FloatVariables) Register(fsm, prefix, root, f, 1, -1);
|
||||
foreach (FsmInt n in fv.IntVariables) Register(fsm, prefix, root, n, 2, -1);
|
||||
foreach (FsmBool b in fv.BoolVariables) Register(fsm, prefix, root, b, 3, -1);
|
||||
// Lack-/Farbwerte (FsmColor) als vier Zahlen: alte Server/Mods kennen nur float/int/bool
|
||||
foreach (FsmColor c in fv.ColorVariables)
|
||||
{
|
||||
if (c == null || !IsColorName(c.Name)) continue;
|
||||
for (int ch = 0; ch < 4; ch++) Register(fsm, prefix, root, c, 1, ch);
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
void Register(string prefix, string root, NamedVariable variable, byte type)
|
||||
static readonly string[] TransformActionWords = { "Scale", "Position", "Rotate", "Rotation", "Translate", "Parent" };
|
||||
|
||||
static bool TouchesTransform(PlayMakerFSM fsm)
|
||||
{
|
||||
if (variable == null || string.IsNullOrEmpty(variable.Name) || !Wanted(variable.Name)) return;
|
||||
int key = Hash(prefix + variable.Name);
|
||||
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(PlayMakerFSM fsm, string prefix, string root, NamedVariable variable, byte type, int channel)
|
||||
{
|
||||
if (variable == null || string.IsNullOrEmpty(variable.Name)) return;
|
||||
if (channel < 0 && !Wanted(variable.Name)) return;
|
||||
string name = prefix + variable.Name + (channel >= 0 ? "#" + "rgba"[channel] : "");
|
||||
int key = Hash(name);
|
||||
Var existing;
|
||||
if (vars.TryGetValue(key, out existing) && existing.Variable == variable) return;
|
||||
var v = new Var { Key = key, Root = root, Variable = variable, Type = type };
|
||||
var v = new Var { Key = key, Name = name, Root = root, Variable = variable, Type = type, Channel = channel, Fsm = fsm };
|
||||
Remember(v);
|
||||
vars[key] = v;
|
||||
List<int> keys;
|
||||
if (!keysByFsm.TryGetValue(fsm, out keys)) keysByFsm[fsm] = keys = new List<int>();
|
||||
keys.Add(key);
|
||||
|
||||
byte[] stored;
|
||||
if (pending.TryGetValue(key, out stored))
|
||||
@@ -175,13 +268,83 @@ namespace MSCO
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool Changed(Var v)
|
||||
static bool IsTracked(string root)
|
||||
{
|
||||
return IsVehicle(root) || Array.IndexOf(PartRoots, root) >= 0;
|
||||
}
|
||||
|
||||
static bool IsColorName(string name)
|
||||
{
|
||||
string n = name.ToLower();
|
||||
return n.Contains("paint") || n.Contains("color") || n.Contains("colour");
|
||||
}
|
||||
|
||||
/// <summary>Werte einer umgehängten/verschwundenen FSM vergessen (Pfad und damit Schlüssel ändern sich).</summary>
|
||||
void Forget(PlayMakerFSM fsm)
|
||||
{
|
||||
List<int> keys;
|
||||
if (!keysByFsm.TryGetValue(fsm, out keys)) return;
|
||||
foreach (int k in keys)
|
||||
{
|
||||
Var v;
|
||||
if (vars.TryGetValue(k, out v) && v.Fsm == fsm) vars.Remove(k);
|
||||
}
|
||||
keysByFsm.Remove(fsm);
|
||||
}
|
||||
|
||||
/// <summary>Satsuma-Teile gehören dem, der den Satsuma fährt.</summary>
|
||||
string OwnerRoot(string root)
|
||||
{
|
||||
return Array.IndexOf(PartRoots, root) >= 0 ? satsumaRoot : root;
|
||||
}
|
||||
|
||||
void ResolveSatsuma(ObjectSync objects)
|
||||
{
|
||||
if (satsumaRoot != null) return;
|
||||
foreach (SyncedBody b in objects.AllVehicles())
|
||||
if (b.RootName != null && b.RootName.StartsWith(PartOwnerPrefix)) { satsumaRoot = b.RootName; return; }
|
||||
}
|
||||
|
||||
static float GetFloat(Var v)
|
||||
{
|
||||
if (v.Channel < 0) return ((FsmFloat)v.Variable).Value;
|
||||
Color c = ((FsmColor)v.Variable).Value;
|
||||
return c[v.Channel];
|
||||
}
|
||||
|
||||
static void SetFloat(Var v, float f)
|
||||
{
|
||||
if (v.Channel < 0) { ((FsmFloat)v.Variable).Value = f; return; }
|
||||
var cv = (FsmColor)v.Variable;
|
||||
Color c = cv.Value;
|
||||
c[v.Channel] = Mathf.Clamp(f, 0f, 4f);
|
||||
cv.Value = c;
|
||||
ShowPaint(v, c);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lack sichtbar machen: Die Lackier-FSM eines Teils setzt beim Lackieren die Materialfarbe ihres eigenen Meshes.
|
||||
/// Beim Mitspieler kommt nur der Farbwert an - also dieselbe Farbe aufs Mesh des Teils legen.
|
||||
/// </summary>
|
||||
static void ShowPaint(Var v, Color c)
|
||||
{
|
||||
if (v.Fsm == null || v.Fsm.FsmName.IndexOf("paint", StringComparison.OrdinalIgnoreCase) < 0) return;
|
||||
Renderer r = v.Fsm.GetComponent<Renderer>();
|
||||
if (r == null || r.material == null) return;
|
||||
r.material.color = c;
|
||||
}
|
||||
|
||||
static bool Changed(Var v, bool bigOnly)
|
||||
{
|
||||
switch (v.Type)
|
||||
{
|
||||
case 1:
|
||||
float f = ((FsmFloat)v.Variable).Value;
|
||||
return Mathf.Abs(f - v.LastF) > Mathf.Max(0.01f, Mathf.Abs(v.LastF) * 0.002f);
|
||||
float f = GetFloat(v);
|
||||
float delta = Mathf.Abs(f - v.LastF);
|
||||
if (v.Channel >= 0) return delta > 0.002f; // Farben: jede Änderung (Lackieren)
|
||||
return bigOnly
|
||||
? delta > Mathf.Max(0.5f, Mathf.Abs(v.LastF) * 0.05f)
|
||||
: delta > Mathf.Max(0.01f, Mathf.Abs(v.LastF) * 0.002f);
|
||||
case 2: return ((FsmInt)v.Variable).Value != v.LastI;
|
||||
default: return ((FsmBool)v.Variable).Value != v.LastB;
|
||||
}
|
||||
@@ -191,7 +354,7 @@ namespace MSCO
|
||||
{
|
||||
switch (v.Type)
|
||||
{
|
||||
case 1: v.LastF = ((FsmFloat)v.Variable).Value; break;
|
||||
case 1: v.LastF = GetFloat(v); break;
|
||||
case 2: v.LastI = ((FsmInt)v.Variable).Value; break;
|
||||
default: v.LastB = ((FsmBool)v.Variable).Value; break;
|
||||
}
|
||||
@@ -202,7 +365,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
|
||||
SetFloat(v, f);
|
||||
break;
|
||||
case 2: ((FsmInt)v.Variable).Value = BitConverter.ToInt32(value, 0); break;
|
||||
default: ((FsmBool)v.Variable).Value = value[0] != 0; break;
|
||||
}
|
||||
|
||||
+103
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -163,7 +163,7 @@ namespace MSCO
|
||||
{
|
||||
nextDiscover = Time.time + DiscoverRetry;
|
||||
tracked.Clear();
|
||||
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
|
||||
foreach (PlayMakerFSM fsm in SceneCache.Fsms(20f))
|
||||
{
|
||||
if (fsm == null) continue;
|
||||
if (Array.IndexOf(TrackedFsmNames, fsm.FsmName) < 0) continue;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
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",
|
||||
// Weitere Events/Orte: Dragrace (Zeiten, Anmeldung), Kilju-Käufer, Theater, Fußball, Dorf, Hütte
|
||||
"DRAGRACE", "KILJUGUY", "THEATRE", "SOCCER", "PERAJARVI", "CABIN"
|
||||
};
|
||||
|
||||
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>();
|
||||
// Werte für noch nicht gefundene FSMs (Server-Snapshot vor dem ersten Suchlauf): [typ, wert]
|
||||
readonly Dictionary<int, KeyValuePair<byte, int>> pending = new Dictionary<int, KeyValuePair<byte, int>>();
|
||||
float nextScan, nextCheck;
|
||||
|
||||
public int Count { get { return vars.Count; } }
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
vars.Clear();
|
||||
scanned.Clear();
|
||||
pending.Clear();
|
||||
nextScan = -1f; // erster Suchlauf verteilt, siehe Update
|
||||
}
|
||||
|
||||
public void Update(Action<MsgType, byte[]> send)
|
||||
{
|
||||
if (nextScan < 0f) nextScan = Time.time + 3.8f; // nicht alle Sync-Teile im selben Frame suchen lassen
|
||||
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))
|
||||
{
|
||||
if (pending.Count < 20000) pending[key] = new KeyValuePair<byte, int>(type, type == 2 ? iv : (bv ? 1 : 0));
|
||||
continue;
|
||||
}
|
||||
if (v.Type != type) continue;
|
||||
Set(v, type == 2 ? iv : (bv ? 1 : 0));
|
||||
}
|
||||
}
|
||||
|
||||
void Scan()
|
||||
{
|
||||
int before = vars.Count;
|
||||
foreach (PlayMakerFSM fsm in SceneCache.Fsms(20f))
|
||||
{
|
||||
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;
|
||||
KeyValuePair<byte, int> waiting;
|
||||
if (pending.TryGetValue(key, out waiting))
|
||||
{
|
||||
pending.Remove(key);
|
||||
if (waiting.Key == type) Set(v, waiting.Value);
|
||||
}
|
||||
}
|
||||
|
||||
static void Set(Var v, int value)
|
||||
{
|
||||
if (v.Type == 2) ((FsmInt)v.Variable).Value = value; else ((FsmBool)v.Variable).Value = value != 0;
|
||||
Remember(v); // nicht zurückschicken
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user