21 Commits
Author SHA1 Message Date
MrMahoandClaude Opus 5.5 e125ff9d74 Sync more events: dragrace, kilju buyer, theatre, soccer, Perajarvi, cabin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:57:13 +02:00
MrMahoandClaude Opus 5.5 56c3990650 Admin menu: make admin / remove admin for players on dedicated servers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:52:49 +02:00
MrMahoandClaude Opus 5.5 ea1b0f74d9 Mod 2.8.1: separate mod version from network protocol version
ModVersion (2.8.1, shown in MSCLoader/MSC Launcher) and ProtocolVersion
(2.8) are separate; only the first two digits are compared (P2P host, Steam
lobby, server info, server browser) and only the protocol version is sent,
so minor updates stay compatible with 2.8 servers, hosts and players.
README: how to publish minor and major updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:48:07 +02:00
MrMahoandClaude Opus 5.5 f707e67e5c Doors on all vehicles: no more part-logic FSMs in DoorSync, single sender for hinges
- DoorSync matched every FSM with door/hood/lid/trunk in its name, including
  the door parts' removal/assembly/bolt/paint FSMs and trigger_ objects, and
  forced their states at the other players: doors hung crooked, snapped back
  or came off. Those are excluded now (assembly runs via FsmSync).
- HingeSync: on a parked vehicle everyone nearby sent the angle, so doors
  ping-ponged. Now only the player who clicked near the door (4 s) sends;
  on a driven vehicle the driver sends; received angles are held 3 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:44:03 +02:00
MrMahoandClaude Opus 5.5 dd31587d61 Riding along: hold the current position inside the vehicle
Instead of snapping to a computed passenger seat, a player whose head is
inside a vehicle's (shrunk) outline is held exactly there and moves with it;
works for cabins, open doors and beds, with or without a driver. Getting out
puts the player beside the vehicle on the nearer side. P next to the car
still uses the passenger seat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:40:13 +02:00
MrMahoandClaude Opus 5.5 5f8062c649 Server new game: start a fresh world when the server says so
StartGame carries a second flag (new game); the client then starts MSC's new
game instead of loading the (empty) received save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:36:14 +02:00
MrMahoandClaude Opus 5.5 52b1219934 Launcher can be minimized to a small bar in the start menu
A '-' button collapses the fullscreen launcher into a small MSCO bar at the
top; the game's start menu is visible and clickable again and its music is
unmuted. Clicking the bar opens the launcher; prompts (disconnected,
reconnecting, join confirmation, version warning) open it automatically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:22:24 +02:00
MrMahoandClaude Opus 5.5 3b83644ce9 Satsuma: sync part state from CARPARTS/Database, paint, late-installed parts, more controls
- VehicleStateSync also tracks the CARPARTS and Database roots (wear, damaged
  parts, tire wear, oilpan oil, battery charge, carb/ignition settings,
  suspension, odometer); the Satsuma's driver is authoritative for them
- Paint colors (FsmColor) as four float channels (compatible with old servers)
  and applied to the part's material
- FSMs are re-examined when a part is installed/removed (path changes);
  before, anything not on the car at the first scan was never synced
- ControlSync: handbrake, sun visors, seat recline, seat belt, CD player,
  interior light; pending states for controls not found yet
- Late joiners: job/shop values and switch states from the server snapshot;
  P2P host resends all switch states every 15 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:10:42 +02:00
MrMahoandClaude Opus 5.5 0b2834282d Passenger: no automatic seat when getting in on the driver's side
Getting in on the driver's side moves the head past the passenger seat while
the game puts the player into the driver's seat. Auto-boarding now only
triggers from the passenger side (head on the seat's side, not further inside
than the seat), not while the game controls the player (CharacterController
off or parented to a vehicle) and not within 4 s after driving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:17:59 +02:00
MrMahoandClaude Opus 5.5 cb116da2b8 Player models: MSC body mesh with own skeleton and normal clothes
All MSC humans share one body mesh (20 bones, same order, different bone
names) and differ only by shirt/pants/face materials. Instead of cloning NPCs
(their thighs hang off 'skeleton', the head off a HeadPivot outside the
skeleton - legs and torso stretched like a dress), MSCO now takes just the
most used body mesh, builds its own flat skeleton from the bind pose and sets
every bone directly from the MSCO figure's joints. Clothes: char_shirt*,
char_pants*, char_face* (no women/ghost/curtain/flower), chosen per player id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:15:22 +02:00
MrMahoandClaude Opus 5.5 cd8c1293e0 Other players as real MSC characters
GameCharacter clones a MSC human (bodymesh + skeleton, no scripts, physics or
animation), puts it into its bind pose, aligns and scales it, and drives the
bones every frame from the joint points of the existing MSCO figure (hip,
knee, ankle, shoulder, elbow, hand, head pitch, torso lean) by rotating each
limb from its rest direction. Limb bones are picked geometrically, so bone
naming conventions do not matter. Variant per player id; console 'model
classic|game' switches, the MSCO figure is the automatic fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:36:50 +02:00
MrMahoandClaude Opus 5.5 983291a4b3 Satsuma no longer falls apart when driving together; automatic passenger seat
- A driven vehicle is no longer released after 3 s without data (a short hitch
  of the driver, e.g. saving, stopped the car instantly on the passenger's side
  and ripped the parts off, which then synced to everyone): 30 s timeout, on
  release it keeps the driver's velocity and part joints stay unbreakable for
  3 more seconds
- Cargo in a moving vehicle belongs to its driver (passengers no longer claim
  items in the car, which then followed late and kinematic at the driver's end
  and knocked seats and parts off)
- Passenger: sit down automatically by stepping into the open passenger door,
  also without a driver (seat from the game's DriverHeadPivot); Space or P to
  get out

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:23:25 +02:00
MrMahoandClaude Opus 5.5 73004340ce Server browser: highlight the admin-only OFFICIAL tag, own server tags as grey pills
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:19:32 +02:00
MrMahoandClaude Opus 5.5 2faf03035a Fewer hitches: FSM scene scans less often, damage safety check one mesh at a time; hitch detector (per-subsystem timing, reports MSCO part or game) in the F7 console
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:36:33 +02:00
MrMahoandClaude Opus 5.5 4801539b9d Fix oil leaking after joining: don't sync game-computed leak states/rates (LeakOilpan, LeakOilfilter, ...); vars <filter> diagnostic command
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:29:34 +02:00
MrMahoandClaude Opus 5.5 7d341126e8 Crisp launcher (sizes and fonts scaled in screen pixels instead of a GUI matrix), fps command and display, fewer hitches (staggered scans, shared rigidbody scan, crash-damage mesh reads only after impacts, rarer vehicle fx lookups, cached display prefs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:26:43 +02:00
MrMahoandClaude Opus 5.5 0b55d270e5 Launcher fullscreen and auto-scaled (GUI matrix, lists grow with the screen), game menu fully covered and muted, no minimize
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:06:34 +02:00
MrMahoandClaude Opus 5.5 e844446a21 P2P host lobby: CONTINUE own save or NEW GAME (previous save backed up to MSCO_Saves, new world saved and sent to waiting players)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:44:25 +02:00
Nico MeisterandClaude Opus 5.5 ba4a3d1b9a Docs: neutral wording instead of third-party product names (reverse proxies, Netbird, social networks, IDE)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:17:20 +02:00
Nico MeisterandClaude Opus 5.5 ce2fbdfc10 Passenger/driver sit still in moving cars: seat pose sent relative to the vehicle (backward-compatible), no backward pull; remote cars/traffic extrapolate in physics time and interpolate; UI polish (shadows, borders, gradient buttons, server list hover, fill bars, colored ping)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:52:56 +02:00
Nico MeisterandClaude Opus 5.5 33a415139b Server browser: featured servers (gold row + FEATURED badge, pinned to the top by the master) and key tags in the server profile; search matches tags
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:55:46 +02:00
29 changed files with 2087 additions and 352 deletions
+95
View File
@@ -1,9 +1,104 @@
# Changelog # 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 ## 2.8
Server und Mod müssen beide 2.8 sein (Server: `git pull && docker compose up -d --build`). 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** **Mehr Sync**
- **Einkäufe**: gekaufte/ausgepackte Items tauchen bei allen auf (auch bei später Beitretenden) - **Einkäufe**: gekaufte/ausgepackte Items tauchen bei allen auf (auch bei später Beitretenden)
- **Verbrauchte Items**: Gegessenes, Getrunkenes und Verbrauchtes verschwindet bei allen - **Verbrauchte Items**: Gegessenes, Getrunkenes und Verbrauchtes verschwindet bei allen
+20
View File
@@ -18,8 +18,11 @@ namespace MSCO
"host [port] / join <ip> [port] - direct LAN game (no Steam)\n" + "host [port] / join <ip> [port] - direct LAN game (no Steam)\n" +
"save - save the game (host / singleplayer)\n" + "save - save the game (host / singleplayer)\n" +
"ping [on|off] - show your ping to the server (toggle)\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" + "/admin - admin menu (kick / ban / teleport / cheats)\n" +
"vehicles | rig - debug info about vehicles / player models\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" + "debug - toggle sync debug output\n" +
"clear - clear this console"; "clear - clear this console";
@@ -112,6 +115,12 @@ namespace MSCO
else mp.ShowPing = !mp.ShowPing; else mp.ShowPing = !mp.ShowPing;
print("Ping: " + mp.PingText + " (display " + (mp.ShowPing ? "on" : "off") + ")"); print("Ping: " + mp.PingText + " (display " + (mp.ShowPing ? "on" : "off") + ")");
break; 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":
case "/admin": case "/admin":
mp.OpenAdminMenu(); mp.OpenAdminMenu();
@@ -119,9 +128,20 @@ namespace MSCO
case "rig": case "rig":
foreach (string line in mp.DescribeRigs()) print(line); foreach (string line in mp.DescribeRigs()) print(line);
break; 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": case "vehicles":
foreach (string line in mp.DescribeVehicles()) print(line); foreach (string line in mp.DescribeVehicles()) print(line);
break; 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": case "debug":
FsmSync.Debug = !FsmSync.Debug; FsmSync.Debug = !FsmSync.Debug;
print("Sync debug output " + (FsmSync.Debug ? "ON" : "OFF")); print("Sync debug output " + (FsmSync.Debug ? "ON" : "OFF"));
+52 -10
View File
@@ -21,7 +21,9 @@ namespace MSCO
static readonly string[] Keywords = static readonly string[] Keywords =
{ {
"ignition", "starter", "engine", "light", "choke", "wiper", "hazard", "indicator", "turnsignal", "blinker", "ignition", "starter", "engine", "light", "choke", "wiper", "hazard", "indicator", "turnsignal", "blinker",
"horn", "heater", "fan", "radio", "key", "switch", "knob", "button" "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 // Montage/Speichern/Schrauben - läuft über den Objekt- bzw. FSM-Sync, hier nicht anfassen
static readonly string[] Excluded = static readonly string[] Excluded =
@@ -51,6 +53,8 @@ namespace MSCO
readonly Dictionary<int, Control> controls = new Dictionary<int, Control>(); readonly Dictionary<int, Control> controls = new Dictionary<int, Control>();
readonly HashSet<PlayMakerFSM> scanned = new HashSet<PlayMakerFSM>(); readonly HashSet<PlayMakerFSM> scanned = new HashSet<PlayMakerFSM>();
readonly List<Control> outgoing = new List<Control>(); 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; float nextScan, nextCheck;
public int Count { get { return controls.Count; } } public int Count { get { return controls.Count; } }
@@ -60,11 +64,13 @@ namespace MSCO
{ {
controls.Clear(); controls.Clear();
scanned.Clear(); scanned.Clear();
nextScan = 0f; pending.Clear();
nextScan = -1f; // erster Suchlauf verteilt, siehe Update
} }
public void Update(ObjectSync objects, int localId, Vector3 playerPos, Action<MsgType, byte[]> send) 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) if (Time.time >= nextScan)
{ {
nextScan = Time.time + ScanInterval; nextScan = Time.time + ScanInterval;
@@ -114,32 +120,68 @@ namespace MSCO
int key = r.ReadInt32(); int key = r.ReadInt32();
string state = r.ReadString(); string state = r.ReadString();
Control c; Control c;
if (!controls.TryGetValue(key, out c) || c.Fsm == null) { nextScan = 0f; continue; } 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 if (objects.VehicleOwner(c.Root) == localId) continue; // ich fahre: meine Schalter gelten
if (c.Fsm.ActiveStateName == state) { c.LastState = state; continue; } 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); FsmState target = c.Fsm.Fsm.GetState(state);
if (target == null) continue; if (target == null) return;
c.LastState = state; // nicht als eigene Änderung zurückschicken c.LastState = state; // nicht als eigene Änderung zurückschicken
FsmSync.SwitchStateSilently(c.Fsm, target); FsmSync.SwitchStateSilently(c.Fsm, target);
c.LastState = c.Fsm.ActiveStateName; // FSM kann sofort weiterschalten (z.B. "Start" -> "Running") c.LastState = c.Fsm.ActiveStateName; // FSM kann sofort weiterschalten (z.B. "Start" -> "Running")
} }
}
void Scan() void Scan()
{ {
int before = controls.Count; int before = controls.Count;
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f)) foreach (PlayMakerFSM fsm in SceneCache.Fsms(20f))
{ {
if (fsm == null || !scanned.Add(fsm)) continue; if (fsm == null || scanned.Contains(fsm)) continue;
string root = fsm.transform.root.name; string root = fsm.transform.root.name;
if (!IsVehicle(root) || !Wanted(fsm.gameObject.name, fsm.FsmName)) continue; // 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); int key = Hash(FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName);
if (controls.ContainsKey(key)) continue; if (controls.ContainsKey(key)) continue;
controls[key] = new Control { Key = key, Root = root, Fsm = fsm, LastState = fsm.ActiveStateName, WindowStart = Time.time }; 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"); 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) static bool Wanted(string objectName, string fsmName)
{ {
string o = objectName.ToLower(), f = fsmName.ToLower(); string o = objectName.ToLower(), f = fsmName.ToLower();
+38 -8
View File
@@ -14,8 +14,10 @@ namespace MSCO
public class DamageSync public class DamageSync
{ {
const float ScanInterval = 10f; const float ScanInterval = 10f;
const float CheckInterval = 1f; // Fahrzeuge, die wir fahren const float CheckInterval = 0.5f; // so oft nach einem Aufprall prüfen
const float FreeCheckInterval = 5f; // geparkte Fahrzeuge (Unfall nur durch Anstoßen) seltener 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 float MinMove = 0.002f;
const int MaxPerMessage = 4000; const int MaxPerMessage = 4000;
@@ -26,13 +28,19 @@ namespace MSCO
public int Key; public int Key;
public string Root; public string Root;
public MeshFilter Filter; public MeshFilter Filter;
public Rigidbody Body; // Fahrzeug, zu dem das Mesh gehört
public Vector3[] Known; // zuletzt gesendet/empfangen public Vector3[] Known; // zuletzt gesendet/empfangen
} }
readonly Dictionary<int, DamageMesh> meshes = new Dictionary<int, DamageMesh>(); readonly Dictionary<int, DamageMesh> meshes = new Dictionary<int, DamageMesh>();
readonly HashSet<MeshFilter> seen = new HashSet<MeshFilter>(); readonly HashSet<MeshFilter> seen = new HashSet<MeshFilter>();
readonly List<int> changed = new List<int>(); readonly List<int> changed = new List<int>();
float nextScan, nextCheck, nextFreeCheck; 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; static List<Type> types;
public int Count { get { return meshes.Count; } } public int Count { get { return meshes.Count; } }
@@ -41,27 +49,47 @@ namespace MSCO
{ {
meshes.Clear(); meshes.Clear();
seen.Clear(); seen.Clear();
nextScan = 0f; lastVelocity.Clear();
lastImpact.Clear();
bodies.Clear();
nextScan = -1f; // erster Suchlauf verteilt, siehe Update
} }
public void Update(ObjectSync objects, int localId, Action<MsgType, byte[]> send) 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) if (Time.time >= nextScan)
{ {
nextScan = Time.time + ScanInterval; nextScan = Time.time + ScanInterval;
Scan(); 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; if (Time.time < nextCheck) return;
nextCheck = Time.time + CheckInterval; nextCheck = Time.time + CheckInterval;
bool checkFree = Time.time >= nextFreeCheck; bool safety = Time.time >= nextSafetyCheck;
if (checkFree) nextFreeCheck = Time.time + FreeCheckInterval; if (safety) { nextSafetyCheck = Time.time + SafetyCheckInterval; safetyIndex++; }
int index = -1;
foreach (DamageMesh m in meshes.Values) foreach (DamageMesh m in meshes.Values)
{ {
index++;
if (m.Filter == null) continue; if (m.Filter == null) continue;
bool safetyThis = safety && meshes.Count > 0 && index == safetyIndex % meshes.Count;
int owner = objects.VehicleOwner(m.Root); int owner = objects.VehicleOwner(m.Root);
if (owner != SyncedBody.NoOwner && owner != localId) continue; // der Fahrer schickt die Schäden if (owner != SyncedBody.NoOwner && owner != localId) continue; // der Fahrer schickt die Schäden
if (owner == SyncedBody.NoOwner && !checkFree) continue; 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; Mesh mesh = m.Filter.sharedMesh;
if (mesh == null) continue; if (mesh == null) continue;
@@ -164,7 +192,9 @@ namespace MSCO
if (mesh == null || mesh.vertexCount == 0 || mesh.vertexCount > 65000) return; if (mesh == null || mesh.vertexCount == 0 || mesh.vertexCount > 65000) return;
int key = Hash(FsmSync.PathOf(mf.transform)); int key = Hash(FsmSync.PathOf(mf.transform));
if (meshes.ContainsKey(key)) return; // nicht eindeutig if (meshes.ContainsKey(key)) return; // nicht eindeutig
meshes[key] = new DamageMesh { Key = key, Root = mf.transform.root.name, Filter = mf, Known = mesh.vertices }; 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) static bool IsVehicle(string root)
+17 -1
View File
@@ -16,6 +16,13 @@ namespace MSCO
public class DoorSync public class DoorSync
{ {
static readonly string[] Keywords = { "door", "gate", "hood", "bonnet", "trunk", "lid", "hatch" }; 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 Cooldown = 1.5f; // Folge-Zustände der Animation nicht nochmal senden
const float RescanInterval = 10f; // neue Objekte (z.B. angebaute Autotüren) aufnehmen const float RescanInterval = 10f; // neue Objekte (z.B. angebaute Autotüren) aufnehmen
@@ -96,12 +103,13 @@ namespace MSCO
void Scan() void Scan()
{ {
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f)) foreach (PlayMakerFSM fsm in SceneCache.Fsms(20f))
{ {
if (fsm == null) continue; if (fsm == null) continue;
string root = fsm.transform.root.name; string root = fsm.transform.root.name;
if (root == "PLAYER" || root.StartsWith("MSCO_")) continue; 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 (!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; string key = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName;
Tracked t; 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) static bool Matches(string name)
{ {
if (string.IsNullOrEmpty(name)) return false; if (string.IsNullOrEmpty(name)) return false;
+242
View File
@@ -0,0 +1,242 @@
using System.Collections.Generic;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// 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
{
/// <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];
}
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 };
// ---------------------------------------------------------------- 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; }
/// <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)
{
if (!FindAssets()) return null;
var c = new GameCharacter();
try
{
c.Build(owner, Mathf.Abs(variant));
return c;
}
catch (System.Exception e)
{
MscoLog.Warning("[MSCO] Player model could not be built: " + e.Message);
c.Destroy();
return null;
}
}
void Build(Transform owner, int variant)
{
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++)
{
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;
}
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 + ")";
}
// ---------------------------------------------------------------- jedes Frame
public void Apply(Rig rig)
{
if (model == null) return;
Quaternion root = owner.rotation;
Quaternion lean = Quaternion.FromToRotation(Vector3.up, rig.Up);
Quaternion torso = root * lean;
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++)
{
// 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]]);
}
}
/// <summary>Punkt des Oberkörpers: an der Hüfte der MSCO-Figur, mit ihr geneigt.</summary>
Vector3 TorsoPoint(Rig rig, Quaternion lean, Vector3 rest)
{
return owner.TransformPoint(rig.Hip + lean * (rest - restHipCenter));
}
/// <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)
{
Vector3 rest = restPos[to] - restPos[from];
if (desired.sqrMagnitude < 1e-6f || rest.sqrMagnitude < 1e-6f) return Quaternion.identity;
return Quaternion.FromToRotation(rest, desired);
}
void Place(int i, Vector3 pos, Quaternion rot)
{
bones[i].position = pos;
bones[i].rotation = rot;
}
public void Destroy()
{
if (model != null) Object.Destroy(model);
model = null;
}
}
}
+34 -5
View File
@@ -19,7 +19,12 @@ namespace MSCO
const float SendInterval = 0.1f; const float SendInterval = 0.1f;
const float MinAngle = 2f; // Grad - kleiner ist Zittern 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 NearRange = 4f; // so nah muss man an einer Tür stehen, um sie zu bewegen
const float ApplyHold = 1.5f; // so lange nach einer Nachricht die Feder halten und selbst nicht senden const float 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 SpringForce = 120f;
const float SpringDamper = 12f; const float SpringDamper = 12f;
@@ -34,6 +39,7 @@ namespace MSCO
public float TargetAngle; public float TargetAngle;
public float TargetTime = -100f; public float TargetTime = -100f;
public bool Driving; // Feder gerade von uns gesetzt public bool Driving; // Feder gerade von uns gesetzt
public float LocalAuthorityUntil = -100f;
public bool OrigUseSpring; public bool OrigUseSpring;
public JointSpring OrigSpring; public JointSpring OrigSpring;
} }
@@ -50,11 +56,12 @@ namespace MSCO
foreach (Hinge h in hinges.Values) StopDriving(h); foreach (Hinge h in hinges.Values) StopDriving(h);
hinges.Clear(); hinges.Clear();
seen.Clear(); seen.Clear();
nextScan = 0f; nextScan = -1f; // erster Suchlauf verteilt, siehe Update
} }
public void Update(ObjectSync objects, int localId, Vector3 playerPos, float dt, Action<MsgType, byte[]> send) 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) if (Time.time >= nextScan)
{ {
nextScan = Time.time + ScanInterval; nextScan = Time.time + ScanInterval;
@@ -68,10 +75,13 @@ namespace MSCO
foreach (Hinge h in hinges.Values) foreach (Hinge h in hinges.Values)
{ {
if (h.Joint == null || h.Rb == null || !h.Rb.gameObject.activeInHierarchy) continue; if (h.Joint == null || h.Rb == null || !h.Rb.gameObject.activeInHierarchy) continue;
if (Time.time - h.TargetTime < ApplyHold) continue; // gerade von anderen gesetzt bool mine = Time.time < h.LocalAuthorityUntil;
if (!mine && Time.time - h.TargetTime < ApplyHold) continue; // gerade von anderen gesetzt
int owner = objects.VehicleOwner(h.Root); int owner = objects.VehicleOwner(h.Root);
if (owner != SyncedBody.NoOwner && owner != localId) continue; // der Fahrer schickt // Fährt jemand: der Fahrer schickt (Türen schwingen während der Fahrt). Hat man selbst gerade an der Tür
if (owner == SyncedBody.NoOwner && (h.Rb.position - playerPos).sqrMagnitude > NearRange * NearRange) continue; // 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; float angle = h.Joint.angle;
if (float.IsNaN(angle) || float.IsInfinity(angle)) continue; if (float.IsNaN(angle) || float.IsInfinity(angle)) continue;
@@ -89,6 +99,24 @@ namespace MSCO
})); }));
} }
/// <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) public void Handle(BinaryReader r)
{ {
int count = r.ReadInt32(); int count = r.ReadInt32();
@@ -99,6 +127,7 @@ namespace MSCO
Hinge h; Hinge h;
if (!hinges.TryGetValue(key, out h)) { nextScan = 0f; continue; } if (!hinges.TryGetValue(key, out h)) { nextScan = 0f; continue; }
if (float.IsNaN(angle) || float.IsInfinity(angle) || Mathf.Abs(angle) > 180f) 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.TargetAngle = angle;
h.TargetTime = Time.time; h.TargetTime = Time.time;
h.SentAngle = angle; // nicht als eigene Bewegung zurückschicken h.SentAngle = angle; // nicht als eigene Bewegung zurückschicken
+374 -224
View File
File diff suppressed because it is too large Load Diff
+24 -1
View File
@@ -5,7 +5,30 @@ namespace MSCO
{ {
public class MSCO : Mod public class MSCO : Mod
{ {
public const string ModVersion = "2.8"; /// <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 ID => "MSCO"; // Your (unique) mod ID
public override string Name => "MSCO"; // Your mod name public override string Name => "MSCO"; // Your mod name
+1
View File
@@ -106,6 +106,7 @@
<Compile Include="NetSession.cs" /> <Compile Include="NetSession.cs" />
<Compile Include="ObjectSync.cs" /> <Compile Include="ObjectSync.cs" />
<Compile Include="Passenger.cs" /> <Compile Include="Passenger.cs" />
<Compile Include="GameCharacter.cs" />
<Compile Include="SaveSync.cs" /> <Compile Include="SaveSync.cs" />
<Compile Include="ServerBrowser.cs" /> <Compile Include="ServerBrowser.cs" />
<Compile Include="SteamLobby.cs" /> <Compile Include="SteamLobby.cs" />
+74
View File
@@ -72,6 +72,33 @@ namespace MSCO
return 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() public static void StartGame()
{ {
PlayMakerFSM fsm; PlayMakerFSM fsm;
@@ -88,6 +115,53 @@ namespace MSCO
} }
} }
/// <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) static bool HierarchyContains(Transform t, string text)
{ {
for (; t != null; t = t.parent) for (; t != null; t = t.parent)
+310 -18
View File
@@ -65,6 +65,8 @@ namespace MSCO
readonly TrafficSync traffic = new TrafficSync(); readonly TrafficSync traffic = new TrafficSync();
readonly HingeSync hinges = new HingeSync(); readonly HingeSync hinges = new HingeSync();
readonly ControlSync controls = new ControlSync(); readonly ControlSync controls = new ControlSync();
bool lobbyWasMinimized;
float nextControlResync;
readonly Toasts toasts = new Toasts(); readonly Toasts toasts = new Toasts();
float nextNeedsSend; float nextNeedsSend;
const float NeedsInterval = 10f; const float NeedsInterval = 10f;
@@ -183,6 +185,7 @@ namespace MSCO
public void Host(int port) public void Host(int port)
{ {
Disconnect(); Disconnect();
PartStart();
try try
{ {
session = NetSession.StartHost(port); session = NetSession.StartHost(port);
@@ -210,6 +213,15 @@ namespace MSCO
return lines; return lines;
} }
/// <summary>Konsole "model": Figuren neu bauen (kommen mit dem nächsten Spielerzustand wieder).</summary>
public void RebuildAvatars()
{
foreach (PlayerInfo p in players.Values) DestroyAvatar(p);
}
/// <summary>Konsole "vars <filter>": synchronisierte Fahrzeugwerte (Diagnose).</summary>
public List<string> DescribeVehicleValues(string filter) { return vehicleState.Describe(filter, 60); }
public List<string> DescribeVehicles() public List<string> DescribeVehicles()
{ {
var lines = objects.DescribeVehicles(); var lines = objects.DescribeVehicles();
@@ -279,8 +291,84 @@ namespace MSCO
/// <summary>Ping-Anzeige oben rechts (Konsole: "ping"), bleibt gespeichert.</summary> /// <summary>Ping-Anzeige oben rechts (Konsole: "ping"), bleibt gespeichert.</summary>
public bool ShowPing public bool ShowPing
{ {
get { return PlayerPrefs.GetInt(PrefShowPing, 0) == 1; } get
set { PlayerPrefs.SetInt(PrefShowPing, value ? 1 : 0); } {
if (showPing < 0) showPing = PlayerPrefs.GetInt(PrefShowPing, 0); // einmal lesen, nicht bei jedem Zeichnen
return showPing == 1;
}
set
{
showPing = value ? 1 : 0;
PlayerPrefs.SetInt(PrefShowPing, showPing);
}
}
int showPing = -1, showFps = -1;
// ---------------------------------------------------------------- FPS-Anzeige (Konsole: "fps")
const string PrefShowFps = "MSCO_ShowFps";
float fpsTimer, fpsValue = -1f, frameMsWorst;
int fpsFrames;
float frameMsWorstWindow;
/// <summary>FPS-Anzeige oben rechts, bleibt gespeichert.</summary>
public bool ShowFps
{
get
{
if (showFps < 0) showFps = PlayerPrefs.GetInt(PrefShowFps, 0);
return showFps == 1;
}
set
{
showFps = value ? 1 : 0;
PlayerPrefs.SetInt(PrefShowFps, showFps);
}
}
/// <summary>Bilder pro Sekunde (Mittel über eine halbe Sekunde) und längstes Bild darin (Ruckler).</summary>
public string FpsText
{
get
{
if (fpsValue < 0f) return "measuring...";
return Mathf.RoundToInt(fpsValue) + " FPS (" + (1000f / Mathf.Max(1f, fpsValue)).ToString("0.0") + " ms, worst " +
frameMsWorst.ToString("0") + " ms)";
}
}
void MeasureFps()
{
float dt = Time.unscaledDeltaTime;
fpsFrames++;
fpsTimer += dt;
frameMsWorstWindow = Mathf.Max(frameMsWorstWindow, dt * 1000f);
if (fpsTimer < 0.5f) return;
fpsValue = fpsFrames / fpsTimer;
frameMsWorst = frameMsWorstWindow;
fpsFrames = 0;
fpsTimer = 0f;
frameMsWorstWindow = 0f;
}
GUIStyle fpsStyle;
/// <summary>Oben rechts, links neben dem Ping.</summary>
void DrawFps(float rightEdge)
{
if (!ShowFps) return;
if (fpsStyle == null)
{
fpsStyle = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.85f), 6);
fpsStyle.fontSize = 12;
fpsStyle.fontStyle = FontStyle.Bold;
fpsStyle.alignment = TextAnchor.MiddleCenter;
fpsStyle.padding = new RectOffset(10, 10, 0, 0);
}
// grün ab 50, gelb ab 30, sonst rot
fpsStyle.normal.textColor = fpsValue < 0f ? UiTheme.Muted : fpsValue >= 50f ? UiTheme.Online : fpsValue >= 30f ? UiTheme.Pending : UiTheme.Danger;
var content = new GUIContent(fpsValue < 0f ? "FPS ..." : "FPS " + Mathf.RoundToInt(fpsValue));
float width = fpsStyle.CalcSize(content).x;
GUI.Box(new Rect(rightEdge - width, 8, width, 24), content, fpsStyle);
} }
/// <summary>Text für Anzeige und Konsole.</summary> /// <summary>Text für Anzeige und Konsole.</summary>
@@ -311,7 +399,9 @@ namespace MSCO
var content = new GUIContent("PING " + PingText); var content = new GUIContent("PING " + PingText);
float width = pingStyle.CalcSize(content).x; float width = pingStyle.CalcSize(content).x;
GUI.Box(new Rect(Screen.width - width - 12, 8, width, 24), content, pingStyle); GUI.Box(new Rect(Screen.width - width - 12, 8, width, 24), content, pingStyle);
pingWidth = width + 8;
} }
float pingWidth; // Platz, den die Ping-Anzeige belegt (FPS steht links daneben)
// ---------------------------------------------------------------- Cheats (Admin-Menü) // ---------------------------------------------------------------- Cheats (Admin-Menü)
@@ -471,6 +561,18 @@ namespace MSCO
SendGame(MsgType.Chat, NetSession.Build(w => w.Write("* " + text))); SendGame(MsgType.Chat, NetSession.Build(w => w.Write("* " + text)));
} }
/// <summary>Admin (dedizierter Server): einem Spieler Admin-Rechte geben (9) bzw. nehmen (10).</summary>
public void SetAdmin(int playerId, bool admin)
{
if (session == null || !dedicated) return;
session.SendTo(NetSession.HostId, MsgType.AdminAction, NetSession.Build(w =>
{
w.Write((byte)(admin ? 9 : 10));
w.Write(playerId);
w.Write("");
}));
}
public void AdminAction(bool ban, int playerId, string reason) public void AdminAction(bool ban, int playerId, string reason)
{ {
if (session == null) return; if (session == null) return;
@@ -585,7 +687,7 @@ namespace MSCO
string name = string.IsNullOrEmpty(displayName) ? info.Name : displayName; string name = string.IsNullOrEmpty(displayName) ? info.Name : displayName;
int max = info.MaxPlayers > 0 ? info.MaxPlayers : maxPlayers; int max = info.MaxPlayers > 0 ? info.MaxPlayers : maxPlayers;
if (info.Version != MSCO.ModVersion) if (!MSCO.Compatible(info.Version))
{ {
PendingVersionWarning = new VersionWarning { Host = host, Port = port, Name = name, MaxPlayers = max, ServerVersion = info.Version }; PendingVersionWarning = new VersionWarning { Host = host, Port = port, Name = name, MaxPlayers = max, ServerVersion = info.Version };
SetStatus("Version mismatch: server v" + info.Version + ", you v" + MSCO.ModVersion); SetStatus("Version mismatch: server v" + info.Version + ", you v" + MSCO.ModVersion);
@@ -790,11 +892,57 @@ namespace MSCO
public void StartHostGame() public void StartHostGame()
{ {
if (!IsHost || !MenuHelper.InMenu) return; if (!IsHost || !MenuHelper.InMenu) return;
newGamePending = false;
SendSaveToWaitingPlayers(); SendSaveToWaitingPlayers();
SetStatus("Starting game..."); SetStatus("Starting game...");
MenuHelper.StartGame(); MenuHelper.StartGame();
} }
// Host hat ein neues Spiel gestartet: es gibt noch keinen Spielstand zum Verschicken - erst im Spiel speichern
bool newGamePending;
/// <summary>
/// Host: neues Spiel statt des eigenen Spielstands. Der bisherige Spielstand wird gesichert (MSCO_Saves), die
/// Welt frisch angelegt; die Mitspieler bekommen sie, sobald sie einmal gespeichert ist.
/// </summary>
public void StartHostNewGame()
{
if (!IsHost || !MenuHelper.InMenu) return;
string archived;
try { archived = SaveSync.ArchiveForNewGame(); }
catch (System.Exception e)
{
// Lieber gar nicht starten, als den Spielstand ungesichert zu löschen
MscoLog.Error("[MSCO] Could not back up your save game - new game cancelled: " + e.Message);
SetStatus("Could not back up your save game - new game cancelled");
return;
}
newGamePending = true;
if (archived != null) overlays.Log("Your previous save game was backed up to " + archived);
SetStatus("Starting a new game...");
MenuHelper.StartNewGame();
}
/// <summary>Neues Spiel geladen: warten, bis die Welt steht, speichern und an die wartenden Mitspieler schicken.</summary>
System.Collections.IEnumerator NewGameSaveAndSend()
{
float give = Time.realtimeSinceStartup + 60f;
while (localPlayer == null && Time.realtimeSinceStartup < give) yield return new WaitForSeconds(0.5f);
yield return new WaitForSeconds(3f); // Spielwelt erst fertig aufbauen lassen
if (!newGamePending || session == null || !session.IsHost || !MenuHelper.InGame) yield break;
SaveGame();
yield return new WaitForSeconds(LateJoinSaveDelay);
newGamePending = false;
var targets = new List<PlayerInfo>();
foreach (PlayerInfo p in players.Values)
if (!p.IsLocal && !p.SaveSent) targets.Add(p);
if (targets.Count > 0)
{
SendSave(targets, true);
SetStatus("New game sent to " + targets.Count + " player(s)");
}
}
/// <summary> /// <summary>
/// Speichert wie die Toilette: das Spiel schickt allen FSMs das globale Event "SAVEGAME". /// Speichert wie die Toilette: das Spiel schickt allen FSMs das globale Event "SAVEGAME".
/// Nur der Host speichert, Clients haben nur eine Kopie seines Spielstands. /// Nur der Host speichert, Clients haben nur eine Kopie seines Spielstands.
@@ -817,6 +965,7 @@ namespace MSCO
return; return;
} }
savingMessageUntil = Time.realtimeSinceStartup + 2.5f; // "SAVING GAME..." in der Bildschirmmitte savingMessageUntil = Time.realtimeSinceStartup + 2.5f; // "SAVING GAME..." in der Bildschirmmitte
lastSaveTriggered = Time.realtimeSinceStartup;
PlayMakerFSM.BroadcastEvent("SAVEGAME"); PlayMakerFSM.BroadcastEvent("SAVEGAME");
MscoLog.Info("[MSCO] Game saved."); MscoLog.Info("[MSCO] Game saved.");
} }
@@ -825,6 +974,8 @@ namespace MSCO
void Update() void Update()
{ {
CheckHitch();
MeasureFps();
// Menü-Buttons des Spiels nicht durch unser Fenster hindurch auslösen (z.B. Changelog) // Menü-Buttons des Spiels nicht durch unser Fenster hindurch auslösen (z.B. Changelog)
// Solange das Multiplayer-Fenster offen ist, reagiert das Spielmenü (CONTINUE, QUIT ...) gar nicht; // Solange das Multiplayer-Fenster offen ist, reagiert das Spielmenü (CONTINUE, QUIT ...) gar nicht;
// erst nach MINIMIZE wieder. Über der eingeklappten Leiste ebenfalls nicht (sonst öffnet sich z.B. der Changelog). // erst nach MINIMIZE wieder. Über der eingeklappten Leiste ebenfalls nicht (sonst öffnet sich z.B. der Changelog).
@@ -834,6 +985,13 @@ namespace MSCO
{ {
nextMenuButtonCheck = Time.realtimeSinceStartup + 0.5f; nextMenuButtonCheck = Time.realtimeSinceStartup + 0.5f;
menuButtonsHidden = MenuHelper.HideGameMenuButtons() > 0; menuButtonsHidden = MenuHelper.HideGameMenuButtons() > 0;
if (!lobby.Minimized) MenuHelper.MuteMenuAudio(); // Launcher im Vollbild: Menü-Musik/-Geräusche aus
}
// Launcher eingeklappt: Menü-Musik wieder an; aufgeklappt: wieder aus
if (MenuHelper.InMenu && lobby.Minimized != lobbyWasMinimized)
{
lobbyWasMinimized = lobby.Minimized;
if (lobby.Minimized) MenuHelper.UnmuteMenuAudio(); else MenuHelper.MuteMenuAudio();
} }
UpdateOverlayKeys(); UpdateOverlayKeys();
PersistPendingState(); // Kick-Grund / Neu-Verbinden über das Neuladen der Mods hinweg merken PersistPendingState(); // Kick-Grund / Neu-Verbinden über das Neuladen der Mods hinweg merken
@@ -918,7 +1076,7 @@ namespace MSCO
byte[] ticket = SteamLobby.CreateAuthTicket(); byte[] ticket = SteamLobby.CreateAuthTicket();
session.SendTo(NetSession.HostId, MsgType.Hello, NetSession.Build(w => session.SendTo(NetSession.HostId, MsgType.Hello, NetSession.Build(w =>
{ {
w.Write(MSCO.ModVersion); w.Write(MSCO.ProtocolVersion); // nur die Netzwerk-Version: ältere Hosts vergleichen genau
w.Write(name); w.Write(name);
// Steam-Identität: dedizierte Server lassen nur Spieler mit Steam rein // Steam-Identität: dedizierte Server lassen nur Spieler mit Steam rein
w.Write(SteamLobby.OwnSteamId); w.Write(SteamLobby.OwnSteamId);
@@ -1246,7 +1404,7 @@ namespace MSCO
string version = r.ReadString(); string version = r.ReadString();
string name = r.ReadString(); string name = r.ReadString();
if (version != MSCO.ModVersion) if (!MSCO.Compatible(version))
{ {
Reject(peerId, "Version mismatch (host " + MSCO.ModVersion + ", you " + version + ")"); Reject(peerId, "Version mismatch (host " + MSCO.ModVersion + ", you " + version + ")");
return; return;
@@ -1450,6 +1608,15 @@ namespace MSCO
receivingSave = false; receivingSave = false;
// Später beigetreten: nach dem Laden neben den Host setzen // Später beigetreten: nach dem Laden neben den Host setzen
teleportToHost = r.BaseStream.Length > 0 && r.ReadBoolean(); teleportToHost = r.BaseStream.Length > 0 && r.ReadBoolean();
// Server mit "neues Spiel" (Panel): kein Spielstand - hier ein neues Spiel starten (Server ab 2.9.3)
bool serverNewGame = r.BaseStream.Length - r.BaseStream.Position >= 1 && r.ReadBoolean();
if (serverNewGame)
{
SetStatus("The server starts a new game - creating a fresh world...");
MscoLog.Info("[MSCO] Server has a new game - starting a fresh world");
MenuHelper.StartNewGame();
break;
}
SetStatus(dedicated ? "Loading server world..." : "Loading host's game..."); SetStatus(dedicated ? "Loading server world..." : "Loading host's game...");
MenuHelper.StartGame(); MenuHelper.StartGame();
break; break;
@@ -1582,6 +1749,7 @@ namespace MSCO
void SendSaveToWaitingPlayers() void SendSaveToWaitingPlayers()
{ {
if (newGamePending) return; // neues Spiel: es gibt noch keinen Spielstand - kommt nach dem ersten Speichern
var targets = new List<PlayerInfo>(); var targets = new List<PlayerInfo>();
foreach (PlayerInfo p in players.Values) foreach (PlayerInfo p in players.Values)
if (!p.IsLocal && !p.SaveSent) targets.Add(p); if (!p.IsLocal && !p.SaveSent) targets.Add(p);
@@ -1630,8 +1798,12 @@ namespace MSCO
nextPresenceUpdate = 0f; // Discord gleich nach dem Szenenwechsel aktualisieren nextPresenceUpdate = 0f; // Discord gleich nach dem Szenenwechsel aktualisieren
if (MenuHelper.InGame) if (MenuHelper.InGame)
{ {
// Falls der CONTINUE-Klick nicht erkannt wurde: jetzt nachholen // Falls der CONTINUE-Klick nicht erkannt wurde: jetzt nachholen. Neues Spiel: erst speichern, dann senden
if (session != null && session.IsHost) SendSaveToWaitingPlayers(); if (session != null && session.IsHost)
{
if (newGamePending) StartCoroutine(NewGameSaveAndSend());
else SendSaveToWaitingPlayers();
}
passenger.Reset(); passenger.Reset();
localPlayer = null; localPlayer = null;
objects.OnGameLoaded(); objects.OnGameLoaded();
@@ -1653,6 +1825,7 @@ namespace MSCO
DestroyAvatar(p); DestroyAvatar(p);
} }
continueFsm = null; continueFsm = null;
newGamePending = false;
passenger.Reset(); passenger.Reset();
localPlayer = null; localPlayer = null;
objects.Reset(); objects.Reset();
@@ -1712,14 +1885,23 @@ namespace MSCO
StartCoroutine(ApplyWorldSnapshot(snapshot)); StartCoroutine(ApplyWorldSnapshot(snapshot));
} }
string passengerResult = null;
if (MSCO.PassengerKey != null && !overlays.AnyOpen && MSCO.PassengerKey.GetKeybindDown()) if (MSCO.PassengerKey != null && !overlays.AnyOpen && MSCO.PassengerKey.GetKeybindDown())
passengerResult = passenger.Toggle(localPlayer, playerCamera, objects, players);
else if (passenger.IsRiding && !overlays.AnyOpen && Input.GetKeyDown(KeyCode.Space))
{ {
string result = passenger.Toggle(localPlayer, playerCamera, objects, players); passenger.Exit(localPlayer, playerCamera); // aussteigen wie zu Fuß losspringen
if (result != null) passengerResult = "Got out";
{
SetStatus(result);
overlays.AddChat("", result, true); // im Spiel sichtbar, nicht nur in der Konsole
} }
else if (IsPeerReady && MenuHelper.InGame && localPlayer != null && !overlays.AnyOpen)
passengerResult = passenger.AutoUpdate(localPlayer, playerCamera, objects, players); // in die Beifahrertür treten
// Klick an einer Fahrzeugtür: wir bewegen sie (nur dann wird ihr Winkel geschickt, siehe HingeSync)
if (MenuHelper.InGame && localPlayer != null && !overlays.AnyOpen)
hinges.NoteInteraction(playerCamera != null ? playerCamera.transform.position : localPlayer.position);
if (passengerResult != null)
{
SetStatus(passengerResult);
overlays.AddChat("", passengerResult, true); // im Spiel sichtbar, nicht nur in der Konsole
} }
bool ready = IsPeerReady; bool ready = IsPeerReady;
@@ -1731,64 +1913,104 @@ namespace MSCO
if (sendTimer >= SendInterval) if (sendTimer >= SendInterval)
{ {
sendTimer = 0f; sendTimer = 0f;
PartStart();
try { SendGame(MsgType.Player, BuildLocalState().Serialize()); } try { SendGame(MsgType.Player, BuildLocalState().Serialize()); }
catch (System.Exception e) { ReportError("player state", e); } catch (System.Exception e) { ReportError("player state", e); }
PartEnd("player state");
} }
// Eigene Bedürfnisse (Hunger, Durst ...) pro Spieler auf dem Server sichern // Eigene Bedürfnisse (Hunger, Durst ...) pro Spieler auf dem Server sichern
if (dedicated && worldSynced && Time.realtimeSinceStartup >= nextNeedsSend) if (dedicated && worldSynced && Time.realtimeSinceStartup >= nextNeedsSend)
{ {
nextNeedsSend = Time.realtimeSinceStartup + NeedsInterval; nextNeedsSend = Time.realtimeSinceStartup + NeedsInterval;
PartStart();
try try
{ {
byte[] needs = PlayerNeeds.Build(); byte[] needs = PlayerNeeds.Build();
if (needs != null) session.SendTo(NetSession.HostId, MsgType.PlayerNeeds, needs); if (needs != null) session.SendTo(NetSession.HostId, MsgType.PlayerNeeds, needs);
} }
catch (System.Exception e) { ReportError("player needs", e); } catch (System.Exception e) { ReportError("player needs", e); }
PartEnd("player needs");
} }
PartStart();
try { objects.Update(localPlayer, Time.deltaTime); } try { objects.Update(localPlayer, Time.deltaTime); }
catch (System.Exception e) { ReportError("object sync", e); } catch (System.Exception e) { ReportError("object sync", e); }
PartStart();
PartEnd("object sync");
try { vehicleFx.Update(objects, SendGame, Time.deltaTime, IsWorldAuthority); } try { vehicleFx.Update(objects, SendGame, Time.deltaTime, IsWorldAuthority); }
catch (System.Exception e) { ReportError("vehicle lights/sound", e); } catch (System.Exception e) { ReportError("vehicle lights/sound", e); }
PartStart();
PartEnd("vehicle lights/sound");
try { worldSync.Update(IsWorldAuthority, SendGame, Time.unscaledDeltaTime); } try { worldSync.Update(IsWorldAuthority, SendGame, Time.unscaledDeltaTime); }
catch (System.Exception e) { ReportError("time/weather sync", e); } catch (System.Exception e) { ReportError("time/weather sync", e); }
PartEnd("time/weather sync");
// Geld erst beobachten, wenn das Spiel fertig geladen ist // Geld erst beobachten, wenn das Spiel fertig geladen ist
PartStart();
try try
{ {
if (objects.ScanComplete) moneySync.Begin(); if (objects.ScanComplete) moneySync.Begin();
moneySync.Update(SendGame); moneySync.Update(SendGame);
} }
catch (System.Exception e) { ReportError("money sync", e); } catch (System.Exception e) { ReportError("money sync", e); }
PartEnd("money sync");
// Türen, Tore, Hauben (z.B. Garagentor) // Türen, Tore, Hauben (z.B. Garagentor)
PartStart();
try { if (objects.ScanComplete) doorSync.Update(SendGame); } try { if (objects.ScanComplete) doorSync.Update(SendGame); }
catch (System.Exception e) { ReportError("door sync", e); } catch (System.Exception e) { ReportError("door sync", e); }
PartEnd("door sync");
// Gesamtzustand der Fahrzeuge (Tank, Öl, Verschleiß ...) // Gesamtzustand der Fahrzeuge (Tank, Öl, Verschleiß ...)
PartStart();
try { if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame); } try { if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame); }
catch (System.Exception e) { ReportError("vehicle state sync", e); } catch (System.Exception e) { ReportError("vehicle state sync", e); }
PartEnd("vehicle state sync");
// Unfallschäden (verbeulte Karosserie) // Unfallschäden (verbeulte Karosserie)
PartStart();
try { if (objects.ScanComplete) damageSync.Update(objects, localId, SendGame); } try { if (objects.ScanComplete) damageSync.Update(objects, localId, SendGame); }
catch (System.Exception e) { ReportError("crash damage sync", e); } catch (System.Exception e) { ReportError("crash damage sync", e); }
PartEnd("crash damage sync");
// Zündung, Motor, Licht, Choke ... der Fahrzeuge // Zündung, Motor, Licht, Choke ... der Fahrzeuge
try { if (objects.ScanComplete && localPlayer != null) controls.Update(objects, localId, localPlayer.position, SendGame); } PartStart();
try
{
if (objects.ScanComplete && localPlayer != null) controls.Update(objects, localId, localPlayer.position, SendGame);
// P2P: der Host gleicht regelmäßig alle Schalter ab (später Beigetretene, verlorene Nachrichten)
if (objects.ScanComplete && IsHost && !dedicated && Time.time >= nextControlResync)
{
nextControlResync = Time.time + 15f;
controls.ResendAll(objects, localId, SendGame);
}
}
catch (System.Exception e) { ReportError("vehicle controls sync", e); } catch (System.Exception e) { ReportError("vehicle controls sync", e); }
PartEnd("vehicle controls sync");
// Türen, Motorhaube, Kofferraum der Fahrzeuge // Türen, Motorhaube, Kofferraum der Fahrzeuge
PartStart();
try { if (objects.ScanComplete && localPlayer != null) hinges.Update(objects, localId, localPlayer.position, Time.deltaTime, SendGame); } try { if (objects.ScanComplete && localPlayer != null) hinges.Update(objects, localId, localPlayer.position, Time.deltaTime, SendGame); }
catch (System.Exception e) { ReportError("car door sync", e); } catch (System.Exception e) { ReportError("car door sync", e); }
PartEnd("car door sync");
// Jobs, Aufträge, Laden // Jobs, Aufträge, Laden
PartStart();
try { if (objects.ScanComplete) worldVars.Update(SendGame); } try { if (objects.ScanComplete) worldVars.Update(SendGame); }
catch (System.Exception e) { ReportError("jobs/shop sync", e); } catch (System.Exception e) { ReportError("jobs/shop sync", e); }
PartEnd("jobs/shop sync");
// Verkehr: die Welt-Autorität simuliert, alle anderen fahren nach // Verkehr: die Welt-Autorität simuliert, alle anderen fahren nach
PartStart();
try { if (objects.ScanComplete) traffic.Update(IsWorldAuthority, Time.deltaTime, SendGame); } try { if (objects.ScanComplete) traffic.Update(IsWorldAuthority, Time.deltaTime, SendGame); }
catch (System.Exception e) { ReportError("traffic sync", e); } catch (System.Exception e) { ReportError("traffic sync", e); }
PartEnd("traffic sync");
} }
foreach (PlayerInfo p in players.Values) foreach (PlayerInfo p in players.Values)
{ {
if (p.Avatar == null) continue; if (p.Avatar == null) continue;
PartStart();
try try
{ {
// Sitzt der Mitspieler in einem Fahrzeug, die Figur an unsere Kopie dieses Fahrzeugs hängen
int seatId = p.Avatar.LastState.SeatId;
SyncedBody seat = seatId != 0 ? objects.Get(seatId) : null;
p.Avatar.Seat = seat != null && seat.Rb != null && seat.IsVehicle ? seat.Rb.transform : null;
p.Avatar.Tick(Time.unscaledDeltaTime); p.Avatar.Tick(Time.unscaledDeltaTime);
// Gehaltenes Item an die Hand der Figur setzen // Gehaltenes Item an die Hand der Figur setzen
@@ -1798,6 +2020,7 @@ namespace MSCO
objects.ApplyAvatarHold(p.Id, p.Avatar.HeldId, pos, rot); objects.ApplyAvatarHold(p.Id, p.Avatar.HeldId, pos, rot);
} }
catch (System.Exception e) { ReportError("player figure", e); } catch (System.Exception e) { ReportError("player figure", e); }
PartEnd("player figure");
} }
// Abgefangene Schraub-/Montage-Aktionen verschicken // Abgefangene Schraub-/Montage-Aktionen verschicken
@@ -1808,12 +2031,52 @@ namespace MSCO
foreach (FsmEventMsg msg in FsmSync.Outbox) SendGame(MsgType.FsmEvent, msg.Serialize()); foreach (FsmEventMsg msg in FsmSync.Outbox) SendGame(MsgType.FsmEvent, msg.Serialize());
} }
catch (System.Exception e) { ReportError("part/bolt sync", e); } catch (System.Exception e) { ReportError("part/bolt sync", e); }
PartEnd("part/bolt sync");
FsmSync.Outbox.Clear(); FsmSync.Outbox.Clear();
} }
readonly Dictionary<string, float> lastErrorReport = new Dictionary<string, float>(); readonly Dictionary<string, float> lastErrorReport = new Dictionary<string, float>();
/// <summary>Fehler eines Teilsystems melden (höchstens alle 30 s pro Teilsystem, damit das Log nicht vollläuft).</summary> /// <summary>Fehler eines Teilsystems melden (höchstens alle 30 s pro Teilsystem, damit das Log nicht vollläuft).</summary>
// ---------------------------------------------------------------- Hänger erkennen
// Jeder Sync-Teil wird gemessen. Dauert ein Bild sehr lange, steht in der F7-Konsole, ob MSCO schuld war
// (und welcher Teil) oder das Spiel selbst (z.B. beim Speichern).
readonly System.Diagnostics.Stopwatch partWatch = System.Diagnostics.Stopwatch.StartNew();
long partStart;
double frameMsco, slowestMs;
string slowestPart;
float nextHitchReport, lastSaveTriggered = -100f;
const float HitchSeconds = 0.2f;
void PartStart() { partStart = partWatch.ElapsedTicks; }
void PartEnd(string name)
{
double ms = (partWatch.ElapsedTicks - partStart) * 1000.0 / System.Diagnostics.Stopwatch.Frequency;
frameMsco += ms;
if (ms > slowestMs) { slowestMs = ms; slowestPart = name; }
}
/// <summary>Zu Beginn jedes Bilds: war das letzte Bild ein Hänger? Dann Ursache melden (höchstens alle 20 s).</summary>
void CheckHitch()
{
float dt = Time.unscaledDeltaTime;
if (dt > HitchSeconds && MenuHelper.InGame && Time.realtimeSinceStartup >= nextHitchReport)
{
nextHitchReport = Time.realtimeSinceStartup + 20f;
int total = Mathf.RoundToInt(dt * 1000f);
if (frameMsco > dt * 1000f * 0.4f)
MscoLog.Warning("[MSCO] Hitch " + total + " ms - caused by MSCO: " + slowestPart + " " + slowestMs.ToString("0") +
" ms (MSCO total " + frameMsco.ToString("0") + " ms)");
else
MscoLog.Info("[MSCO] Hitch " + total + " ms - not MSCO (MSCO used " + frameMsco.ToString("0") + " ms)" +
(Time.realtimeSinceStartup - lastSaveTriggered < 5f ? " - the game was saving" : " - the game itself (loading, saving, physics)"));
}
frameMsco = 0;
slowestMs = 0;
slowestPart = null;
}
void ReportError(string what, System.Exception e) void ReportError(string what, System.Exception e)
{ {
float now = Time.realtimeSinceStartup, last; float now = Time.realtimeSinceStartup, last;
@@ -1826,25 +2089,37 @@ namespace MSCO
void FixedUpdate() void FixedUpdate()
{ {
if (!IsPeerReady || !MenuHelper.InGame) return; if (!IsPeerReady || !MenuHelper.InGame) return;
PartStart();
try { objects.FixedUpdate(); } try { objects.FixedUpdate(); }
catch (System.Exception e) { ReportError("object movement", e); } catch (System.Exception e) { ReportError("object movement", e); }
PartEnd("object movement");
PartStart();
try { traffic.FixedUpdate(); } try { traffic.FixedUpdate(); }
catch (System.Exception e) { ReportError("traffic movement", e); } catch (System.Exception e) { ReportError("traffic movement", e); }
PartEnd("traffic movement");
PartStart();
try { hinges.FixedUpdate(); } try { hinges.FixedUpdate(); }
catch (System.Exception e) { ReportError("car door movement", e); } catch (System.Exception e) { ReportError("car door movement", e); }
PartEnd("car door movement");
} }
void LateUpdate() void LateUpdate()
{ {
InputLock.KeepCursorFree(); // das Spiel sperrt den Zeiger im selben Frame evtl. wieder InputLock.KeepCursorFree(); // das Spiel sperrt den Zeiger im selben Frame evtl. wieder
// Nach den Spiel-FSMs, damit Lichter/Motorgeräusch der Fahrzeuge anderer gewinnen // Nach den Spiel-FSMs, damit Lichter/Motorgeräusch der Fahrzeuge anderer gewinnen
PartStart();
try { if (IsPeerReady && MenuHelper.InGame) vehicleFx.Apply(objects); } try { if (IsPeerReady && MenuHelper.InGame) vehicleFx.Apply(objects); }
catch (System.Exception e) { ReportError("vehicle lights/sound", e); } catch (System.Exception e) { ReportError("vehicle lights/sound", e); }
PartEnd("vehicle lights/sound");
PartStart();
try { if (IsPeerReady && MenuHelper.InGame) wheelSpin.Apply(objects, Time.deltaTime); } try { if (IsPeerReady && MenuHelper.InGame) wheelSpin.Apply(objects, Time.deltaTime); }
catch (System.Exception e) { ReportError("wheels", e); } catch (System.Exception e) { ReportError("wheels", e); }
PartEnd("wheels");
// Beifahrer nach der Fahrzeugbewegung auf den Sitz setzen // Beifahrer nach der Fahrzeugbewegung auf den Sitz setzen
PartStart();
try { if (localPlayer != null && MenuHelper.InGame) passenger.LateUpdate(localPlayer, playerCamera, objects); } try { if (localPlayer != null && MenuHelper.InGame) passenger.LateUpdate(localPlayer, playerCamera, objects); }
catch (System.Exception e) { ReportError("passenger", e); } catch (System.Exception e) { ReportError("passenger", e); }
PartEnd("passenger");
} }
/// <summary> /// <summary>
@@ -1954,6 +2229,9 @@ namespace MSCO
int needs = PlayerNeeds.Apply(r); int needs = PlayerNeeds.Apply(r);
if (needs > 0) MscoLog.Info("[MSCO] Your own hunger/thirst/... restored (" + needs + " values)"); if (needs > 0) MscoLog.Info("[MSCO] Your own hunger/thirst/... restored (" + needs + " values)");
} }
// Jobs/Laden und Schalter (Licht, Zündung, Motor ...) - Server ab 2.9.2
if (r.BaseStream.Length - r.BaseStream.Position >= 4) worldVars.Handle(r);
if (r.BaseStream.Length - r.BaseStream.Position >= 4) controls.Handle(r, objects, localId);
// Abgebaute Teile und gespawnte Items sind jetzt eigene Objekte: erfassen, dann alle Positionen setzen // Abgebaute Teile und gespawnte Items sind jetzt eigene Objekte: erfassen, dann alle Positionen setzen
yield return null; yield return null;
@@ -1983,6 +2261,7 @@ namespace MSCO
System.Collections.IEnumerator UploadSave(int requestId) System.Collections.IEnumerator UploadSave(int requestId)
{ {
if (!MenuHelper.InGame || !dedicated || session == null) yield break; if (!MenuHelper.InGame || !dedicated || session == null) yield break;
lastSaveTriggered = Time.realtimeSinceStartup;
PlayMakerFSM.BroadcastEvent("SAVEGAME"); PlayMakerFSM.BroadcastEvent("SAVEGAME");
float until = Time.realtimeSinceStartup + 3f; // das Spiel schreibt die Dateien über mehrere Frames float until = Time.realtimeSinceStartup + 3f; // das Spiel schreibt die Dateien über mehrere Frames
while (Time.realtimeSinceStartup < until) yield return null; while (Time.realtimeSinceStartup < until) yield return null;
@@ -2057,9 +2336,22 @@ namespace MSCO
CamRot = head.rotation, CamRot = head.rotation,
Seated = passenger.IsRiding || objects.IsDriving, Seated = passenger.IsRiding || objects.IsDriving,
FeetY = GroundBelowPlayer(head.position), FeetY = GroundBelowPlayer(head.position),
HeldLocalRot = Quaternion.identity HeldLocalRot = Quaternion.identity,
SeatView = Quaternion.identity
}; };
// Im Fahrzeug: Kopf und Blick relativ zum Fahrzeug mitschicken
SyncedBody seat = passenger.IsRiding ? passenger.Vehicle : objects.Driven;
if (seat != null && seat.Rb != null)
{
Transform vt = seat.Rb.transform;
state.SeatId = seat.Id;
// Beifahrer: der Sitz ist bekannt - die Weltposition stammt noch vom letzten Frame, das Auto ist seitdem
// weitergefahren (bei Tempo ein halber Meter)
state.SeatHead = passenger.IsRiding ? passenger.SeatHeadLocal : vt.InverseTransformPoint(head.position);
state.SeatView = Quaternion.Inverse(vt.rotation) * head.rotation;
}
SyncedBody held = objects.Held; SyncedBody held = objects.Held;
if (held != null) if (held != null)
{ {
@@ -2077,9 +2369,9 @@ namespace MSCO
if (!players.TryGetValue(from, out p)) return; if (!players.TryGetValue(from, out p)) return;
if (p.Avatar == null) if (p.Avatar == null)
{ {
// Platzhalter-Figur: Kapsel mit Kopf und Hand // Echter MSC-Mensch (oder die eigene MSCO-Figur, wenn keiner passt / "model classic")
p.Avatar = RemotePlayer.Create("MSCO_Player_" + from, PlayerColor(from), state, from); p.Avatar = RemotePlayer.Create("MSCO_Player_" + from, PlayerColor(from), state, from);
MscoLog.Info("[MSCO] " + p.Name + " is in the game"); MscoLog.Info("[MSCO] " + p.Name + " is in the game (" + p.Avatar.ModelDescription + ")");
} }
p.Avatar.SetTarget(state); p.Avatar.SetTarget(state);
} }
@@ -2109,7 +2401,7 @@ namespace MSCO
if (MenuHelper.InMenu) if (MenuHelper.InMenu)
{ {
lobby.Draw(this); lobby.Draw(this);
DrawPing(); toasts.Draw(); pingWidth = 0f; DrawPing(); DrawFps(Screen.width - 12 - pingWidth); toasts.Draw();
overlays.Draw(this, false); overlays.Draw(this, false);
return; return;
} }
@@ -2126,7 +2418,7 @@ namespace MSCO
} }
if (session != null) DrawHud(); if (session != null) DrawHud();
DrawPing(); toasts.Draw(); pingWidth = 0f; DrawPing(); DrawFps(Screen.width - 12 - pingWidth); toasts.Draw();
overlays.Draw(this, session != null); overlays.Draw(this, session != null);
} }
+25 -2
View File
@@ -77,6 +77,12 @@ namespace MSCO
public Quaternion HeldLocalRot; // relativ zur Kamera public Quaternion HeldLocalRot; // relativ zur Kamera
public bool Seated; // fährt oder sitzt als Beifahrer public bool Seated; // fährt oder sitzt als Beifahrer
public float FeetY; // Bodenhöhe unter dem Spieler (für Ducken/Lehnen der Figur) 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() public byte[] Serialize()
{ {
@@ -90,9 +96,15 @@ namespace MSCO
w.Write(s.Seated); w.Write(s.Seated);
w.Write(s.FeetY); w.Write(s.FeetY);
w.Write(s.HeldId); w.Write(s.HeldId);
if (s.HeldId == 0) return; if (s.HeldId != 0)
{
Write(w, s.HeldLocalPos); Write(w, s.HeldLocalPos);
Write(w, s.HeldLocalRot); Write(w, s.HeldLocalRot);
}
w.Write(s.SeatId);
if (s.SeatId == 0) return;
Write(w, s.SeatHead);
Write(w, s.SeatView);
}); });
} }
@@ -107,13 +119,24 @@ namespace MSCO
Seated = r.ReadBoolean(), Seated = r.ReadBoolean(),
FeetY = r.ReadSingle(), FeetY = r.ReadSingle(),
HeldId = r.ReadInt32(), HeldId = r.ReadInt32(),
HeldLocalRot = Quaternion.identity HeldLocalRot = Quaternion.identity,
SeatView = Quaternion.identity
}; };
if (s.HeldId != 0) if (s.HeldId != 0)
{ {
s.HeldLocalPos = ReadVector(r); s.HeldLocalPos = ReadVector(r);
s.HeldLocalRot = ReadQuaternion(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; return s;
} }
+143 -9
View File
@@ -25,9 +25,19 @@ namespace MSCO
public int OwnerId = NoOwner; public int OwnerId = NoOwner;
public bool OwnerActive; // Besitzer hält/fährt es gerade aktiv public bool OwnerActive; // Besitzer hält/fährt es gerade aktiv
public bool WasKinematic; // Zustand bevor ein anderer Spieler es übernommen hat 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) // Gelenke der angebauten Teile, solange ein anderer das Fahrzeug fährt: unzerbrechlich (vorherige Bruchkraft/-moment)
public Dictionary<Joint, Vector2> HeldJoints; public Dictionary<Joint, Vector2> HeldJoints;
public float NextPartScan; 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 StillTime;
public float NoMotionClaimUntil; // kurz nach einer Übergabe nicht wegen Nachfedern/Nachrollen übernehmen 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, // Am Auto angebautes Teil (Gelenk zum Fahrzeug oder unter einem anderen Rigidbody): bewegt sich mit dem Auto,
@@ -42,7 +52,7 @@ namespace MSCO
public Vector3 TargetPos; public Vector3 TargetPos;
public Quaternion TargetRot; public Quaternion TargetRot;
public Vector3 TargetVel; // Geschwindigkeit beim Besitzer: damit wird die Position vorausberechnet public Vector3 TargetVel; // Geschwindigkeit beim Besitzer: damit wird die Position vorausberechnet
public float TargetTime; // wann TargetPos ankam public float TargetTime; // wann TargetPos ankam (Physik-Zeit, Time.fixedTime)
public float LastRecvTime; public float LastRecvTime;
public float AvatarHoldTime = -100f; // zuletzt von der Hand eines Mitspielers platziert public float AvatarHoldTime = -100f; // zuletzt von der Hand eines Mitspielers platziert
@@ -80,6 +90,12 @@ namespace MSCO
const float ReleaseAfterStill = 1f; const float ReleaseAfterStill = 1f;
const float SnapDistance = 10f; const float SnapDistance = 10f;
const float RemoteTimeout = 3f; // Besitzer meldet sich nicht mehr (z.B. Teil wurde dort montiert) 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 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 HandoverCooldown = 3f; // nach Freigabe/Übergabe keine Übernahme durch Bewegung
const float MaxHoldDistance = 1.5f; // gehaltene Items sind so nah an der Kamera const float MaxHoldDistance = 1.5f; // gehaltene Items sind so nah an der Kamera
@@ -189,6 +205,9 @@ namespace MSCO
public bool IsRemoteOwnedBody(SyncedBody b) { return IsRemoteOwned(b); } 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> /// <summary>Für "mp vehicles": Zustand aller erfassten Fahrzeuge (zur Fehlersuche).</summary>
public List<string> DescribeVehicles() public List<string> DescribeVehicles()
{ {
@@ -209,6 +228,8 @@ namespace MSCO
/// <summary>Fährt der lokale Spieler gerade selbst?</summary> /// <summary>Fährt der lokale Spieler gerade selbst?</summary>
public bool IsDriving { get; private set; } 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> /// <summary>Fahrzeuge, die gerade ein anderer Spieler aktiv fährt.</summary>
public List<SyncedBody> RemoteDrivenVehicles() public List<SyncedBody> RemoteDrivenVehicles()
@@ -254,7 +275,10 @@ namespace MSCO
public void Reset() public void Reset()
{ {
foreach (SyncedBody b in byId.Values) foreach (SyncedBody b in byId.Values)
{
if (IsRemoteOwned(b) && b.Rb != null) RestoreKinematic(b); if (IsRemoteOwned(b) && b.Rb != null) RestoreKinematic(b);
RestoreJoints(b);
}
byId.Clear(); byId.Clear();
byRb.Clear(); byRb.Clear();
rejected.Clear(); rejected.Clear();
@@ -281,6 +305,7 @@ namespace MSCO
/// <summary>Besitzer eines Fahrzeugs (nach Wurzelname), NoOwner wenn frei oder unbekannt.</summary> /// <summary>Besitzer eines Fahrzeugs (nach Wurzelname), NoOwner wenn frei oder unbekannt.</summary>
public int VehicleOwner(string rootName) public int VehicleOwner(string rootName)
{ {
if (rootName == null) return SyncedBody.NoOwner;
foreach (SyncedBody b in byId.Values) foreach (SyncedBody b in byId.Values)
if (b.IsVehicle && b.RootName == rootName) return b.OwnerId; if (b.IsVehicle && b.RootName == rootName) return b.OwnerId;
return SyncedBody.NoOwner; return SyncedBody.NoOwner;
@@ -315,6 +340,7 @@ namespace MSCO
SyncedBody held = HeldBody(); SyncedBody held = HeldBody();
SyncedBody driven = DrivenBody(); SyncedBody driven = DrivenBody();
Driven = driven;
IsDriving = driven != null; IsDriving = driven != null;
bool motionClaimAllowed = Time.time - loadTime > MotionClaimGrace; bool motionClaimAllowed = Time.time - loadTime > MotionClaimGrace;
@@ -333,13 +359,25 @@ namespace MSCO
b.LastPos = b.Rb.position; b.LastPos = b.Rb.position;
if (!b.Rb.gameObject.activeInHierarchy) continue; if (!b.Rb.gameObject.activeInHierarchy) continue;
if (UpdateAttached(b)) continue; // angebaut: bewegt sich mit dem Auto 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; bool active = b == held || b == driven;
if (!IsRemoteOwned(b)) StopRunaway(b); 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 (b.OwnerId == SyncedBody.NoOwner)
{ {
if (active) Claim(b, true); 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)) else if (IsLocal(b))
{ {
@@ -362,7 +400,8 @@ namespace MSCO
// zeigt oft noch auf ein altes Item; trägt es jemand an unserem Kopf vorbei, würden wir es ihm sonst entreißen // 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; 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 if (active && grab && Wins(LocalId, true, b.OwnerId, b.OwnerActive)) Claim(b, true); // Spieler greift sich das Objekt
else if (Time.time - b.LastRecvTime > RemoteTimeout) SetFree(b); 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); ReportConsumed(player);
@@ -387,7 +426,9 @@ namespace MSCO
if (Time.time - b.AvatarHoldTime < 0.2f) continue; // sitzt gerade in der Hand eines Mitspielers if (Time.time - b.AvatarHoldTime < 0.2f) continue; // sitzt gerade in der Hand eines Mitspielers
// Dort, wo das Objekt beim Besitzer JETZT ungefähr ist: letzte Position + Geschwindigkeit * Alter. // 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. // Ohne das hinken schnelle Objekte (fahrende Autos) immer einen Paketabstand hinterher und ruckeln.
float age = Mathf.Min(Time.time - b.TargetTime, MaxExtrapolation); // 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; Vector3 predicted = b.TargetPos + b.TargetVel * age;
if ((b.Rb.position - predicted).sqrMagnitude > SnapDistance * SnapDistance) if ((b.Rb.position - predicted).sqrMagnitude > SnapDistance * SnapDistance)
{ {
@@ -489,7 +530,16 @@ namespace MSCO
{ {
b.WasKinematic = b.Rb.isKinematic; b.WasKinematic = b.Rb.isKinematic;
b.Rb.isKinematic = true; b.Rb.isKinematic = true;
if (b.IsVehicle) HoldParts(b); 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) void HoldParts(SyncedBody b)
@@ -508,7 +558,27 @@ namespace MSCO
/// <summary>Wieder selbst simulieren: Auto zurück auf seinen Zustand, Gelenke wieder mit ihrer Bruchkraft.</summary> /// <summary>Wieder selbst simulieren: Auto zurück auf seinen Zustand, Gelenke wieder mit ihrer Bruchkraft.</summary>
void RestoreKinematic(SyncedBody b) void RestoreKinematic(SyncedBody b)
{ {
if (b.Rb != null) b.Rb.isKinematic = b.WasKinematic; 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; if (b.HeldJoints == null) return;
foreach (KeyValuePair<Joint, Vector2> kv in b.HeldJoints) foreach (KeyValuePair<Joint, Vector2> kv in b.HeldJoints)
{ {
@@ -519,6 +589,70 @@ namespace MSCO
b.HeldJoints = null; 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() void SendOwnedStates()
{ {
sendList.Clear(); sendList.Clear();
@@ -580,7 +714,7 @@ namespace MSCO
b.TargetPos = pos; b.TargetPos = pos;
b.TargetRot = rot; b.TargetRot = rot;
b.TargetVel = vel.sqrMagnitude < 90f * 90f ? vel : Vector3.zero; b.TargetVel = vel.sqrMagnitude < 90f * 90f ? vel : Vector3.zero;
b.TargetTime = Time.time; b.TargetTime = Time.fixedTime;
} }
break; break;
} }
@@ -880,9 +1014,9 @@ namespace MSCO
var candidates = new List<KeyValuePair<Rigidbody, bool>>(); var candidates = new List<KeyValuePair<Rigidbody, bool>>();
var rootsByName = new Dictionary<string, List<Transform>>(); 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; Transform root = rb.transform.root;
List<Transform> roots; List<Transform> roots;
if (!rootsByName.TryGetValue(root.name, out roots)) rootsByName[root.name] = roots = new List<Transform>(); if (!rootsByName.TryGetValue(root.name, out roots)) rootsByName[root.name] = roots = new List<Transform>();
+12 -2
View File
@@ -136,6 +136,7 @@ namespace MSCO
{ {
float width = Mathf.Min(Screen.width - 40, 960); 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)); 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.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.x + 16, rect.y + 10, 300, 20), "MSCO CONSOLE", caption);
@@ -271,6 +272,7 @@ namespace MSCO
{ {
float width = Mathf.Min(720, Screen.width - 40), height = Mathf.Min(580, 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); var rect = new Rect((Screen.width - width) / 2f, (Screen.height - height) / 2f, width, height);
UiTheme.DrawShadow(rect);
GUI.Box(rect, GUIContent.none, panel); GUI.Box(rect, GUIContent.none, panel);
if (Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Escape) if (Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Escape)
@@ -353,6 +355,14 @@ namespace MSCO
if (GUILayout.Button("BRING", secondary, GUILayout.Height(30), GUILayout.Width(70))) mp.BringPlayer(p.Id); if (GUILayout.Button("BRING", secondary, GUILayout.Height(30), GUILayout.Width(70))) mp.BringPlayer(p.Id);
GUILayout.Space(12); 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; GUI.enabled = !self && !p.IsAdmin;
if (GUILayout.Button("KICK", secondary, GUILayout.Height(30), GUILayout.Width(70))) if (GUILayout.Button("KICK", secondary, GUILayout.Height(30), GUILayout.Width(70)))
mp.AdminAction(false, p.Id, adminReason); mp.AdminAction(false, p.Id, adminReason);
@@ -493,7 +503,7 @@ namespace MSCO
void EnsureStyles() void EnsureStyles()
{ {
if (stylesReady && panel.normal.background != null) return; 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 = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.72f));
rowStyle.padding = new RectOffset(12, 12, 6, 6); rowStyle.padding = new RectOffset(12, 12, 6, 6);
field = UiTheme.Field(14); field = UiTheme.Field(14);
@@ -505,7 +515,7 @@ namespace MSCO
title = UiTheme.Label(22, UiTheme.Text, FontStyle.Bold); title = UiTheme.Label(22, UiTheme.Text, FontStyle.Bold);
title.wordWrap = false; title.wordWrap = false;
small = UiTheme.Label(11, UiTheme.Muted); 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); secondary = UiTheme.Button(UiTheme.Secondary, UiTheme.SecondaryHover, UiTheme.Text, 12);
danger = UiTheme.Button(UiTheme.Danger, UiTheme.DangerHover, Color.white, 12); danger = UiTheme.Button(UiTheme.Danger, UiTheme.DangerHover, Color.white, 12);
chatName = UiTheme.Label(13, UiTheme.AccentHover, FontStyle.Bold, TextAnchor.MiddleLeft); chatName = UiTheme.Label(13, UiTheme.AccentHover, FontStyle.Bold, TextAnchor.MiddleLeft);
+180 -27
View File
@@ -4,14 +4,20 @@ using UnityEngine;
namespace MSCO namespace MSCO
{ {
/// <summary> /// <summary>
/// Beifahrer: Neben einem Fahrzeug, das ein anderer Spieler fährt, per Taste einsteigen. /// Mitfahren: Wer mit dem Kopf im Fahrzeug ist (Kabine, offene Tür, Ladefläche ...), wird genau dort festgehalten und
/// Der Sitz ist die Fahrerposition, auf die andere Seite gespiegelt (bei Fahrzeugen mit /// fährt mit - egal ob schon jemand fährt oder das Auto noch steht. Aussteigen mit Leertaste oder der Beifahrer-Taste.
/// Sitz in der Mitte, z.B. Traktor oder Moped: dahinter). Während der Fahrt wird der /// Steht man per Taste neben dem Auto, kommt man auf den Beifahrersitz (Fahrerposition des Spiels, gespiegelt).
/// Spieler jedes Frame auf den Sitz gesetzt und dreht mit dem Fahrzeug mit; umschauen geht weiter. /// Während der Fahrt wird der Spieler jedes Frame an seine Stelle im Fahrzeug gesetzt und dreht mit; umschauen geht.
/// </summary> /// </summary>
public class Passenger public class Passenger
{ {
const float EnterRange = 3f; // so nah muss man an der Karosserie 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 CenterSeatThreshold = 0.2f;
const float BehindCenterSeat = 0.7f; const float BehindCenterSeat = 0.7f;
const float ExitSideOffset = 1.4f; // beim Aussteigen so weit neben den Sitz const float ExitSideOffset = 1.4f; // beim Aussteigen so weit neben den Sitz
@@ -27,8 +33,20 @@ namespace MSCO
// Feste Collider des Spielers, die während der Fahrt aus sind (sonst stecken sie in Sitzen und Teilen des Autos) // 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<Collider> disabledColliders = new List<Collider>();
readonly List<Rigidbody> frozenBodies = new List<Rigidbody>(); 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; } } 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> /// <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) public string Toggle(Transform player, Camera cam, ObjectSync objects, IDictionary<int, PlayerInfo> players)
@@ -38,38 +56,158 @@ namespace MSCO
Exit(player, cam); Exit(player, cam);
return "Got out"; 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; SyncedBody best = null;
RemotePlayer driver = null; Vector3 bestSeat = Vector3.zero;
float bestDist = EnterRange * EnterRange; float bestDist = EnterRange * EnterRange, nearest = float.MaxValue;
List<SyncedBody> driven = objects.RemoteDrivenVehicles(); foreach (SyncedBody b in objects.AllVehicles())
float nearest = float.MaxValue;
foreach (SyncedBody b in driven)
{ {
if (b.Rb == null || (b.Rb.position - player.position).sqrMagnitude > 30f * 30f) continue;
float d = DistanceToVehicleSq(b, player.position); float d = DistanceToVehicleSq(b, player.position);
Vector3? seat = SeatFor(b, objects, players);
if (seat == null) continue;
nearest = Mathf.Min(nearest, d); nearest = Mathf.Min(nearest, d);
PlayerInfo owner; if (d < bestDist) { bestDist = d; best = b; bestSeat = seat.Value; }
if (!players.TryGetValue(b.OwnerId, out owner) || owner.Avatar == null) continue;
if (d < bestDist) { bestDist = d; best = b; driver = owner.Avatar; }
} }
if (best == null) if (best == null)
{ {
// Genau sagen, woran es liegt if (nearest == float.MaxValue) return "Passenger: no vehicle nearby";
if (driven.Count == 0) return "Passenger: nobody is driving a vehicle right now (the driver must sit in the driver's seat)";
return "Passenger: get closer to the car (" + Mathf.Sqrt(nearest).ToString("0") + " m away, max " + EnterRange.ToString("0") + " m)"; 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)";
}
Transform vt = best.Rb.transform; /// <summary>
Vector3 driverHead = vt.InverseTransformPoint(driver.HeadPosition); /// Jedes Frame (zu Fuß): ist man mit dem Kopf IM Fahrzeug (Kabine, Ladefläche ...), wird die aktuelle Position im
seatHeadLocal = Mathf.Abs(driverHead.x) < CenterSeatThreshold /// Fahrzeug festgehalten - man fährt genau dort mit, wo man ist (kein Umsetzen auf einen berechneten Sitz).
? driverHead - new Vector3(0f, 0f, BehindCenterSeat) // Sitz in der Mitte: dahinter /// Gibt einen Statustext zurück, wenn man "eingestiegen" ist.
: new Vector3(-driverHead.x, driverHead.y, driverHead.z); // gegenüber vom Fahrer /// </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;
vehicle = best; // Nach dem Aussteigen erst einmal aus dem Fahrzeug heraus, sonst säße man sofort wieder drin
lastYaw = vt.eulerAngles.y; 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); SetMovementEnabled(player, false);
return "Riding as passenger (press again to get out)"; }
/// <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)
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
} }
/// <summary> /// <summary>
@@ -92,12 +230,17 @@ namespace MSCO
public void LateUpdate(Transform player, Camera cam, ObjectSync objects) public void LateUpdate(Transform player, Camera cam, ObjectSync objects)
{ {
if (!IsRiding) return; if (!IsRiding) return;
// Fahrer ausgestiegen oder Fahrzeug weg: automatisch aussteigen if (vehicle.Rb == null || cam == null)
if (vehicle.Rb == null || cam == null || !objects.IsRemoteOwnedBody(vehicle))
{ {
Exit(player, cam); Exit(player, cam);
return; return;
} }
// Selbst ans Steuer gesetzt (Fahrersitz des Spiels): Beifahrer-Modus ohne Umsetzen beenden
if (objects.IsDriving)
{
Reset();
return;
}
Transform vt = vehicle.Rb.transform; Transform vt = vehicle.Rb.transform;
float yaw = vt.eulerAngles.y; float yaw = vt.eulerAngles.y;
@@ -113,9 +256,15 @@ namespace MSCO
if (!IsRiding) return; if (!IsRiding) return;
if (player != null && vehicle.Rb != null) if (player != null && vehicle.Rb != null)
{ {
lastExitSeat = vehicle.Rb.transform.TransformPoint(seatHeadLocal);
autoArmed = false;
// Auf der Seite des Sitzes neben das Fahrzeug stellen // 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; 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; Vector3 cameraOffset = cam != null ? cam.transform.position - player.position : Vector3.up * 1.6f;
player.position = outside - cameraOffset + Vector3.up * 0.1f; player.position = outside - cameraOffset + Vector3.up * 0.1f;
} }
@@ -127,6 +276,10 @@ namespace MSCO
public void Reset() public void Reset()
{ {
vehicle = null; vehicle = null;
dwellVehicle = null;
dwellStart = -1f;
driverHeads.Clear();
innerBounds.Clear();
SetMovementEnabled(null, true); SetMovementEnabled(null, true);
} }
+12 -3
View File
@@ -36,7 +36,7 @@ Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern
Cheats (+1.000 / +10.000 MK, Bedürfnisse auffüllen, Satsuma zu sich holen) – Cheats werden im Chat angezeigt Cheats (+1.000 / +10.000 MK, Bedürfnisse auffüllen, Satsuma zu sich holen) – Cheats werden im Chat angezeigt
- **P** – als Beifahrer ein-/aussteigen - **P** – als Beifahrer ein-/aussteigen
- Alle Tasten sind in den MSCLoader-Einstellungen der Mod änderbar - 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 ## Installation
@@ -78,7 +78,7 @@ In der F7-Konsole direkt, in der MSCLoader-Konsole mit `mp` davor (z.B. `mp conn
## Selbst bauen ## 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\` - 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) – (UnityEngine, PlayMaker, Assembly-CSharp, Assembly-CSharp-firstpass, MSCLoader, 0Harmony, Newtonsoft.Json, cInput) –
bei anderem Installationsort anpassen. bei anderem Installationsort anpassen.
@@ -88,6 +88,15 @@ In der F7-Konsole direkt, in der MSCLoader-Konsole mit `mp` davor (z.B. `mp conn
msbuild MSCO.csproj -p:Configuration=Release 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 ## Aufbau
| Datei | Aufgabe | | Datei | Aufgabe |
@@ -102,7 +111,7 @@ msbuild MSCO.csproj -p:Configuration=Release
| `FsmSync.cs`, `DoorSync.cs`, `WorldSync.cs`, `MoneySync.cs` | Montage-Events, Türen, Zeit/Wetter, Geld | | `FsmSync.cs`, `DoorSync.cs`, `WorldSync.cs`, `MoneySync.cs` | Montage-Events, Türen, Zeit/Wetter, Geld |
| `RemotePlayer.cs` | Spielfiguren der Mitspieler | | `RemotePlayer.cs` | Spielfiguren der Mitspieler |
| `SaveSync.cs`, `MenuHelper.cs` | Spielstand übertragen/sichern, Laden aus dem Menü | | `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 | | `SafetyPatches.cs`, `InputLock.cs`, `SceneCache.cs` | Absturzschutz, Eingabesperre, geteilte FSM-Suche |
## Bekannte Einschränkungen ## Bekannte Einschränkungen
+76 -1
View File
@@ -49,6 +49,12 @@ namespace MSCO
Quaternion targetViewRot = Quaternion.identity; Quaternion targetViewRot = Quaternion.identity;
PlayerState last; 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; Vector3 lastPosition;
float walkPhase, walkAmount, speed; float walkPhase, walkAmount, speed;
float airAmount; // 0 = steht am Boden, 1 = in der Luft (Sprung, Fallen) float airAmount; // 0 = steht am Boden, 1 = in der Luft (Sprung, Fallen)
@@ -56,7 +62,18 @@ namespace MSCO
public int HeldId { get { return last.HeldId; } } public int HeldId { get { return last.HeldId; } }
public PlayerState LastState { get { return last; } } public PlayerState LastState { get { return last; } }
public Vector3 HeadPosition { get { return headPivot.position; } } public Vector3 HeadPosition { get { return headPivot.position; } }
public string ModelDescription { get { return "MSCO figure, variant " + variant; } } 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 // ---------------------------------------------------------------- Aufbau
@@ -67,6 +84,7 @@ namespace MSCO
rp.jacket = jacketColor; rp.jacket = jacketColor;
rp.variant = Mathf.Abs(variant); rp.variant = Mathf.Abs(variant);
rp.Build(); rp.Build();
if (UseGameModels) rp.UseGameModel();
rp.SetTarget(state); rp.SetTarget(state);
root.transform.position = rp.targetFeet; root.transform.position = rp.targetFeet;
root.transform.rotation = Quaternion.Euler(0f, rp.targetYaw, 0f); root.transform.rotation = Quaternion.Euler(0f, rp.targetYaw, 0f);
@@ -174,6 +192,16 @@ namespace MSCO
view.SetParent(r, false); 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> /// <summary>Kleines Karomuster (Flanellhemd) in der Grundfarbe; wird auf die Kapseln gekachelt.</summary>
static Texture2D Plaid(Color baseColor) static Texture2D Plaid(Color baseColor)
{ {
@@ -258,6 +286,15 @@ namespace MSCO
public void Tick(float dt) 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 // Bei großen Sprüngen (Teleport, Laden) direkt setzen statt gleiten
bool snap = (transform.position - targetFeet).sqrMagnitude > 100f; bool snap = (transform.position - targetFeet).sqrMagnitude > 100f;
float t = snap ? 1f : Mathf.Clamp01(dt * Smoothing); float t = snap ? 1f : Mathf.Clamp01(dt * Smoothing);
@@ -273,6 +310,32 @@ namespace MSCO
Pose(dt); Pose(dt);
} }
/// <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)
{
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) void UpdateWalk(float dt)
{ {
Vector3 moved = transform.position - lastPosition; Vector3 moved = transform.position - lastPosition;
@@ -338,6 +401,11 @@ namespace MSCO
// Kopf // Kopf
headPivot.localPosition = head; headPivot.localPosition = head;
headPivot.localRotation = Quaternion.Euler(pitch, 0f, 0f); 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 * (1f - airAmount); float swing = Mathf.Sin(walkPhase) * walkAmount * (1f - airAmount);
for (int i = 0; i < 2; i++) for (int i = 0; i < 2; i++)
@@ -359,6 +427,9 @@ namespace MSCO
} }
Vector3 knee = Joint(hipJoint, ankle, ThighLength, ShinLength, Vector3.forward); Vector3 knee = Joint(hipJoint, ankle, ThighLength, ShinLength, Vector3.forward);
Segment(thigh[i], hipJoint, knee, 0.15f, 0.16f); 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); Segment(shin[i], knee, ankle, 0.12f, 0.13f);
// Stiefel: Kapsel von der Ferse zur Spitze, darunter eine flache Sohle // 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(boot[i], ankle + new Vector3(0f, -0.025f, -0.06f), ankle + new Vector3(0f, -0.03f, 0.14f), 0.12f, 0.1f);
@@ -386,12 +457,16 @@ namespace MSCO
Vector3 elbow = Joint(shoulder, handTarget, UpperArmLength, ForearmLength, new Vector3(side * 0.4f, -0.2f, -1f)); Vector3 elbow = Joint(shoulder, handTarget, UpperArmLength, ForearmLength, new Vector3(side * 0.4f, -0.2f, -1f));
Segment(upperArm[i], shoulder, elbow, 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(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); 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].localPosition = handTarget;
hand[i].localRotation = Quaternion.LookRotation(Vector3.forward, (handTarget - elbow).sqrMagnitude > 0f ? elbow - handTarget : Vector3.up); 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); 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> /// <summary>Zwei-Knochen-Gelenk (Knie/Ellbogen): Punkt zwischen a und b, zur Seite "bend" geknickt.</summary>
+48
View File
@@ -24,6 +24,54 @@ namespace MSCO
public static bool HasBackup { get { return File.Exists(Marker); } } 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() public static List<KeyValuePair<string, byte[]>> ReadLocalSave()
{ {
var result = new List<KeyValuePair<string, byte[]>>(); var result = new List<KeyValuePair<string, byte[]>>();
+21
View File
@@ -14,6 +14,27 @@ namespace MSCO
static float builtAt = -1000f; static float builtAt = -1000f;
static int builtForLevel = -1; 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> /// <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) public static PlayMakerFSM[] Fsms(float maxAge)
{ {
+2
View File
@@ -23,6 +23,8 @@ namespace MSCO
public string Version; public string Version;
public bool RequireSteam = true; // false = Non-Steam-Server public bool RequireSteam = true; // false = Non-Steam-Server
public string Owner; // Besitzer des Lizenzschlüssels 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 int UptimeSeconds = -1;
[JsonIgnore] public List<string> PlayerNames = new List<string>(); [JsonIgnore] public List<string> PlayerNames = new List<string>();
+2 -2
View File
@@ -164,7 +164,7 @@ namespace MSCO
return; return;
} }
LobbyId = new CSteamID(result.m_ulSteamIDLobby); 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); if (Created != null) Created(LobbyId);
} }
@@ -178,7 +178,7 @@ namespace MSCO
LobbyId = new CSteamID(result.m_ulSteamIDLobby); LobbyId = new CSteamID(result.m_ulSteamIDLobby);
string hostVersion = SteamMatchmaking.GetLobbyData(LobbyId, VersionKey); string hostVersion = SteamMatchmaking.GetLobbyData(LobbyId, VersionKey);
if (!string.IsNullOrEmpty(hostVersion) && hostVersion != MSCO.ModVersion) if (!string.IsNullOrEmpty(hostVersion) && !MSCO.Compatible(hostVersion))
{ {
Leave(); Leave();
Fail("Version mismatch (host " + hostVersion + ", you " + MSCO.ModVersion + ")"); Fail("Version mismatch (host " + hostVersion + ", you " + MSCO.ModVersion + ")");
+2 -1
View File
@@ -53,6 +53,7 @@ namespace MSCO
var r = new Rect(Screen.width - 12 - Width + (1f - slide) * (Width + 20f), y, Width, Height); var r = new Rect(Screen.width - 12 - Width + (1f - slide) * (Width + 20f), y, Width, Height);
GUI.color = new Color(1f, 1f, 1f, alpha); GUI.color = new Color(1f, 1f, 1f, alpha);
UiTheme.DrawShadow(r);
GUI.Box(r, GUIContent.none, box); GUI.Box(r, GUIContent.none, box);
GUI.color = new Color(t.Dot.r, t.Dot.g, t.Dot.b, alpha); GUI.color = new Color(t.Dot.r, t.Dot.g, t.Dot.b, alpha);
@@ -73,7 +74,7 @@ namespace MSCO
void EnsureStyles() void EnsureStyles()
{ {
if (box != null && box.normal.background != null) return; if (box != null && box.normal.background != null) return;
box = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.88f), 6); 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 = new GUIStyle(GUI.skin.label) { fontSize = 13, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft };
title.normal.textColor = Color.white; title.normal.textColor = Color.white;
title.wordWrap = false; title.wordWrap = false;
+5 -4
View File
@@ -42,11 +42,12 @@ namespace MSCO
foreach (Car c in cars.Values) StopFollowing(c); foreach (Car c in cars.Values) StopFollowing(c);
cars.Clear(); cars.Clear();
seen.Clear(); seen.Clear();
nextScan = 0f; nextScan = -1f; // erster Suchlauf verteilt, siehe Update
} }
public void Update(bool authority, float dt, Action<MsgType, byte[]> send) 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) if (Time.time >= nextScan)
{ {
nextScan = Time.time + ScanInterval; nextScan = Time.time + ScanInterval;
@@ -100,7 +101,7 @@ namespace MSCO
c.TargetPos = p; c.TargetPos = p;
c.TargetRot = q; c.TargetRot = q;
c.TargetVel = v; c.TargetVel = v;
c.TargetTime = Time.time; c.TargetTime = Time.fixedTime; // Physik-Zeit wie in FixedUpdate (sonst negatives Alter = Rucken)
if (!c.Following) if (!c.Following)
{ {
c.WasKinematic = c.Rb.isKinematic; c.WasKinematic = c.Rb.isKinematic;
@@ -117,7 +118,7 @@ namespace MSCO
foreach (Car c in cars.Values) foreach (Car c in cars.Values)
{ {
if (!c.Following || c.Rb == null) continue; if (!c.Following || c.Rb == null) continue;
float ahead = Mathf.Min(Time.time - c.TargetTime, MaxExtrapolation); float ahead = Mathf.Clamp(Time.fixedTime - c.TargetTime, 0f, MaxExtrapolation);
Vector3 goal = c.TargetPos + c.TargetVel * ahead; Vector3 goal = c.TargetPos + c.TargetVel * ahead;
float t = Mathf.Clamp01(Time.fixedDeltaTime * Smoothing); float t = Mathf.Clamp01(Time.fixedDeltaTime * Smoothing);
if ((c.Rb.position - goal).sqrMagnitude > 25f) { c.Rb.position = goal; c.Rb.rotation = c.TargetRot; continue; } if ((c.Rb.position - goal).sqrMagnitude > 25f) { c.Rb.position = goal; c.Rb.rotation = c.TargetRot; continue; }
@@ -136,7 +137,7 @@ namespace MSCO
void Scan() void Scan()
{ {
int before = cars.Count; int before = cars.Count;
foreach (Rigidbody rb in UnityEngine.Object.FindObjectsOfType<Rigidbody>()) foreach (Rigidbody rb in SceneCache.Rigidbodies(5f)) // teilt sich die Suche mit dem Objekt-Sync
{ {
if (rb == null || !seen.Add(rb)) continue; if (rb == null || !seen.Add(rb)) continue;
if (Array.IndexOf(Roots, rb.transform.root.name) < 0) continue; if (Array.IndexOf(Roots, rb.transform.root.name) < 0) continue;
+91
View File
@@ -26,8 +26,13 @@ namespace MSCO
public static readonly Color Muted = Hex(0x8e, 0x8e, 0x99); public static readonly Color Muted = Hex(0x8e, 0x8e, 0x99);
public static readonly Color Online = Hex(0x3d, 0xd6, 0x8c); public static readonly Color Online = Hex(0x3d, 0xd6, 0x8c);
public static readonly Color Pending = Hex(0xf2, 0xc2, 0x1d); 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; 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) public static Color Hex(int r, int g, int b, float a = 1f)
{ {
@@ -58,6 +63,92 @@ namespace MSCO
return tex; 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) public static Texture2D Solid(Color color)
{ {
var tex = new Texture2D(1, 1); var tex = new Texture2D(1, 1);
+1 -1
View File
@@ -23,7 +23,7 @@ namespace MSCO
string n = name.ToLower(); string n = name.ToLower();
return n.Contains("needle") || n.Contains("steeringwheel"); return n.Contains("needle") || n.Contains("steeringwheel");
} }
const float CacheRefresh = 5f; // Teile können an-/abgebaut werden 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 const float ApplyTimeout = 2f; // ohne neue Daten nichts mehr erzwingen
struct AudioState struct AudioState
+148 -20
View File
@@ -28,7 +28,22 @@ namespace MSCO
// Montiert-Flags nicht direkt setzen: das echte Anbauen läuft über FsmSync (Events) // Montiert-Flags nicht direkt setzen: das echte Anbauen läuft über FsmSync (Events)
// Schrauben/Festigkeit laufen über FsmSync (Events) - ein falscher Wert hier würde Teile lockern und abfallen lassen // Schrauben/Festigkeit laufen über FsmSync (Events) - ein falscher Wert hier würde Teile lockern und abfallen lassen
static readonly string[] Excluded = { "install", "bolt", "tight", "detach", "attach", "rpm", "speed", "steer", "throttle", "gear", "raycast", "distance" }; // 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 ScanInterval = 10f;
const float CheckInterval = 0.5f; const float CheckInterval = 0.5f;
@@ -37,9 +52,12 @@ namespace MSCO
class Var class Var
{ {
public int Key; public int Key;
public string Name; // Pfad|FSM|Variable (für die Diagnose "vars")
public string Root; public string Root;
public NamedVariable Variable; 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 float LastF;
public int LastI; public int LastI;
public bool LastB; public bool LastB;
@@ -48,21 +66,47 @@ namespace MSCO
readonly Dictionary<int, Var> vars = new Dictionary<int, Var>(); readonly Dictionary<int, Var> vars = new Dictionary<int, Var>();
readonly Dictionary<int, byte[]> pending = new Dictionary<int, byte[]>(); // noch nicht gefundene Werte readonly Dictionary<int, byte[]> pending = new Dictionary<int, byte[]>(); // noch nicht gefundene Werte
readonly List<Var> outgoing = new List<Var>(); 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; float nextScan, nextCheck;
public int Count { get { return vars.Count; } } 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() public void Reset()
{ {
vars.Clear(); vars.Clear();
pending.Clear(); pending.Clear();
scanned.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) 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) if (Time.time >= nextScan)
{ {
nextScan = Time.time + ScanInterval; nextScan = Time.time + ScanInterval;
@@ -72,10 +116,11 @@ namespace MSCO
nextCheck = Time.time + CheckInterval; nextCheck = Time.time + CheckInterval;
outgoing.Clear(); outgoing.Clear();
ResolveSatsuma(objects);
foreach (Var v in vars.Values) foreach (Var v in vars.Values)
{ {
// Fährt/schiebt ein anderer das Fahrzeug, ist seine Version maßgeblich - nichts senden // Fährt/schiebt ein anderer das Fahrzeug, ist seine Version maßgeblich - nichts senden
int owner = objects.VehicleOwner(v.Root); int owner = objects.VehicleOwner(OwnerRoot(v.Root));
if (owner != SyncedBody.NoOwner && owner != localId) { if (Changed(v, false)) Remember(v); continue; } 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 ...) - // Eigenes Fahrzeug: jede Änderung. Freies Fahrzeug: nur deutliche Sprünge (Öl nachfüllen, tanken ...) -
@@ -107,7 +152,7 @@ namespace MSCO
byte[] value = r.ReadBytes(type == 3 ? 1 : 4); byte[] value = r.ReadBytes(type == 3 ? 1 : 4);
Var v; Var v;
if (!vars.TryGetValue(key, out v)) { pending[key] = Combine(type, value); continue; } 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); Apply(v, type, value);
} }
} }
@@ -132,20 +177,31 @@ namespace MSCO
void Scan() void Scan()
{ {
int before = vars.Count; 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) continue;
if (fsm == null || !scanned.Add(fsm)) 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; 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 // 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") // kann dort eine Größe 0 ergeben - Unity stürzt dann in der Physik ab ("cleaning the mesh failed")
if (TouchesTransform(fsm)) continue; 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 + "|"; string prefix = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName + "|";
FsmVariables fv = fsm.FsmVariables; FsmVariables fv = fsm.FsmVariables;
foreach (FsmFloat f in fv.FloatVariables) Register(prefix, root, f, 1); foreach (FsmFloat f in fv.FloatVariables) Register(fsm, prefix, root, f, 1, -1);
foreach (FsmInt n in fv.IntVariables) Register(prefix, root, n, 2); foreach (FsmInt n in fv.IntVariables) Register(fsm, prefix, root, n, 2, -1);
foreach (FsmBool b in fv.BoolVariables) Register(prefix, root, b, 3); 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) MscoLog.Info("[MSCO] Vehicle state: " + vars.Count + " values synced"); if (vars.Count != before) MscoLog.Info("[MSCO] Vehicle state: " + vars.Count + " values synced");
} }
@@ -173,15 +229,20 @@ namespace MSCO
return false; return false;
} }
void Register(string prefix, string root, NamedVariable variable, byte type) void Register(PlayMakerFSM fsm, string prefix, string root, NamedVariable variable, byte type, int channel)
{ {
if (variable == null || string.IsNullOrEmpty(variable.Name) || !Wanted(variable.Name)) return; if (variable == null || string.IsNullOrEmpty(variable.Name)) return;
int key = Hash(prefix + variable.Name); if (channel < 0 && !Wanted(variable.Name)) return;
string name = prefix + variable.Name + (channel >= 0 ? "#" + "rgba"[channel] : "");
int key = Hash(name);
Var existing; Var existing;
if (vars.TryGetValue(key, out existing) && existing.Variable == variable) return; 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); Remember(v);
vars[key] = v; vars[key] = v;
List<int> keys;
if (!keysByFsm.TryGetValue(fsm, out keys)) keysByFsm[fsm] = keys = new List<int>();
keys.Add(key);
byte[] stored; byte[] stored;
if (pending.TryGetValue(key, out stored)) if (pending.TryGetValue(key, out stored))
@@ -207,13 +268,80 @@ namespace MSCO
return false; return false;
} }
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) static bool Changed(Var v, bool bigOnly)
{ {
switch (v.Type) switch (v.Type)
{ {
case 1: case 1:
float f = ((FsmFloat)v.Variable).Value; float f = GetFloat(v);
float delta = Mathf.Abs(f - v.LastF); float delta = Mathf.Abs(f - v.LastF);
if (v.Channel >= 0) return delta > 0.002f; // Farben: jede Änderung (Lackieren)
return bigOnly return bigOnly
? delta > Mathf.Max(0.5f, Mathf.Abs(v.LastF) * 0.05f) ? delta > Mathf.Max(0.5f, Mathf.Abs(v.LastF) * 0.05f)
: delta > Mathf.Max(0.01f, Mathf.Abs(v.LastF) * 0.002f); : delta > Mathf.Max(0.01f, Mathf.Abs(v.LastF) * 0.002f);
@@ -226,7 +354,7 @@ namespace MSCO
{ {
switch (v.Type) 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; case 2: v.LastI = ((FsmInt)v.Variable).Value; break;
default: v.LastB = ((FsmBool)v.Variable).Value; break; default: v.LastB = ((FsmBool)v.Variable).Value; break;
} }
@@ -240,7 +368,7 @@ namespace MSCO
case 1: case 1:
float f = BitConverter.ToSingle(value, 0); float f = BitConverter.ToSingle(value, 0);
if (float.IsNaN(f) || float.IsInfinity(f)) return; // nie ungültige Werte übernehmen if (float.IsNaN(f) || float.IsInfinity(f)) return; // nie ungültige Werte übernehmen
((FsmFloat)v.Variable).Value = f; SetFloat(v, f);
break; break;
case 2: ((FsmInt)v.Variable).Value = BitConverter.ToInt32(value, 0); break; case 2: ((FsmInt)v.Variable).Value = BitConverter.ToInt32(value, 0); break;
default: ((FsmBool)v.Variable).Value = value[0] != 0; break; default: ((FsmBool)v.Variable).Value = value[0] != 0; break;
+1 -1
View File
@@ -163,7 +163,7 @@ namespace MSCO
{ {
nextDiscover = Time.time + DiscoverRetry; nextDiscover = Time.time + DiscoverRetry;
tracked.Clear(); tracked.Clear();
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f)) foreach (PlayMakerFSM fsm in SceneCache.Fsms(20f))
{ {
if (fsm == null) continue; if (fsm == null) continue;
if (Array.IndexOf(TrackedFsmNames, fsm.FsmName) < 0) continue; if (Array.IndexOf(TrackedFsmNames, fsm.FsmName) < 0) continue;
+31 -6
View File
@@ -16,7 +16,12 @@ namespace MSCO
public class WorldVarSync public class WorldVarSync
{ {
/// <summary>Wurzelobjekte mit Jobs/Aufträgen/Laden.</summary> /// <summary>Wurzelobjekte mit Jobs/Aufträgen/Laden.</summary>
public static readonly string[] Roots = { "JOBS", "STORE", "REPAIRSHOP", "INSPECTION", "LANDFILL", "DANCEHALL", "COTTAGE", "RALLY" }; 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 ScanInterval = 15f;
const float CheckInterval = 0.5f; const float CheckInterval = 0.5f;
@@ -41,6 +46,8 @@ namespace MSCO
readonly Dictionary<int, Var> vars = new Dictionary<int, Var>(); readonly Dictionary<int, Var> vars = new Dictionary<int, Var>();
readonly HashSet<PlayMakerFSM> scanned = new HashSet<PlayMakerFSM>(); readonly HashSet<PlayMakerFSM> scanned = new HashSet<PlayMakerFSM>();
readonly List<Var> outgoing = new List<Var>(); 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; float nextScan, nextCheck;
public int Count { get { return vars.Count; } } public int Count { get { return vars.Count; } }
@@ -49,11 +56,13 @@ namespace MSCO
{ {
vars.Clear(); vars.Clear();
scanned.Clear(); scanned.Clear();
nextScan = 0f; pending.Clear();
nextScan = -1f; // erster Suchlauf verteilt, siehe Update
} }
public void Update(Action<MsgType, byte[]> send) 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) if (Time.time >= nextScan)
{ {
nextScan = Time.time + ScanInterval; nextScan = Time.time + ScanInterval;
@@ -101,16 +110,20 @@ namespace MSCO
int iv = 0; bool bv = false; int iv = 0; bool bv = false;
if (type == 2) iv = r.ReadInt32(); else if (type == 3) bv = r.ReadBoolean(); else return; if (type == 2) iv = r.ReadInt32(); else if (type == 3) bv = r.ReadBoolean(); else return;
Var v; Var v;
if (!vars.TryGetValue(key, out v) || v.Type != type) continue; if (!vars.TryGetValue(key, out v))
if (type == 2) ((FsmInt)v.Variable).Value = iv; else ((FsmBool)v.Variable).Value = bv; {
Remember(v); // nicht zurückschicken 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() void Scan()
{ {
int before = vars.Count; int before = vars.Count;
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f)) foreach (PlayMakerFSM fsm in SceneCache.Fsms(20f))
{ {
if (fsm == null || !scanned.Add(fsm)) continue; if (fsm == null || !scanned.Add(fsm)) continue;
if (Array.IndexOf(Roots, fsm.transform.root.name) < 0) continue; if (Array.IndexOf(Roots, fsm.transform.root.name) < 0) continue;
@@ -130,6 +143,18 @@ namespace MSCO
var v = new Var { Key = key, Variable = variable, Type = type }; var v = new Var { Key = key, Variable = variable, Type = type };
Remember(v); Remember(v);
vars[key] = 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) static bool Changed(Var v)