61 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
MrMahoandClaude Opus 5.5 8e00640732 Favorites: heart toggle, Favorites tab with nicknames, /switch <nickname> quick server switch, fav command
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:24:00 +02:00
MrMahoandClaude Opus 5.5 8dae3ac13d Per-player needs, jumping figure, round belt/shoulders/boots, reset-point UI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:48:16 +02:00
MrMahoandClaude Opus 5.5 09c85e3d96 Keep kick reason and reconnect intent across MSCLoader's mod reload on returning to the menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:31:47 +02:00
MrMahoandClaude Opus 5.5 526f9e9c77 Door/hood sync via hinge spring instead of teleport (fixes NaN crash and parts falling off); join/leave toasts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:59:01 +02:00
MrMahoandClaude Opus 5.5 097d1e87de Sync vehicle controls (ignition, engine, lights ...), stop forced starter sound, auto-reconnect after server restart or lost connection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:51:39 +02:00
MrMahoandClaude Opus 5.5 bddc1efe76 Admin WORLD tab: reset world, backup now, list and restore save backups
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:43:40 +02:00
MrMahoandClaude Opus 5.5 31e630dccb Disconnect/kick prompt with reason, reconnect after world reset with retries; stop mass 'consumed' deletes on scene reload
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:31:40 +02:00
MrMahoandClaude Opus 5.5 90c1af4513 Sync car doors, hood and trunk angles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:13:08 +02:00
MrMahoandClaude Opus 5.5 9b8c64d0c6 Performance: time-sliced checks for idle objects, fewer allocations, rarer damage checks for parked cars
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:03:45 +02:00
MrMahoandClaude Opus 5.5 2eb15f2166 2.8: sync bought/consumed items, wheel spin, crash damage, jobs/shop, traffic
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:58:38 +02:00
MrMahoandClaude Opus 5.5 96e8790f0b Stuck bodies are placed on top instead of being reset into the same spot (no fling loop); reset message shown in-game before reconnecting; server: backups / restore-save
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:27:20 +02:00
MrMahoandClaude Opus 5.5 a6e5abfc05 Crash safety: vehicle state sync skips FSMs that set scale/position (value could make a size 0 -> PhysX crash), never applies NaN; light/effect sync no longer activates parent objects
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:25:29 +02:00
MrMahoandClaude Opus 5.5 b3405d3752 Server gets the current save game from a player in the game (on join and every few minutes) instead of replaying actions; vehicle fire/smoke synced, also for parked cars
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:15:18 +02:00
MrMahoandClaude Opus 5.5 bdaedcc1e8 Server closes connections gracefully so kick/ban/reset/shutdown messages arrive; status dot and HUD yellow until really connected (also while reconnecting)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:59:50 +02:00
MrMahoandClaude Opus 5.5 0ae4dae28d Show why you were disconnected in the main menu; reset-world / reload-save make all players reconnect automatically
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:46:34 +02:00
MrMahoandClaude Opus 5.5 bba20cf991 Own main menu (game's Continue/New Game/Credits/Changelog/Quit hidden, QUIT in the launcher); new server profile layout; admin items: ratchet set, battery, shop items; session shows server player limit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:41:54 +02:00
MrMahoandClaude Opus 5.5 9f55646123 Session view: show the server's player limit (not the P2P limit of 3) and a server-appropriate status text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:34:47 +02:00
MrMahoandClaude Opus 5.5 5f1f524bee Server profile: JOIN button (and name field without Steam) at the top so it never slides out of the window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:31:17 +02:00
MrMahoandClaude Opus 5.5 2000fc677f Admin: spawn items (saved on the server), unban (menu tab + console), tabbed admin menu; dashboard needles and steering wheel synced; server browser row opens the profile
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:15:01 +02:00
MrMahoandClaude Opus 5.5 0c15459302 All mod messages go to the MSCO console (F7) instead of the MSCLoader console; still written to output_log.txt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:53:40 +02:00
MrMahoandClaude Opus 5.5 1dc65ac553 Passenger: player colliders off while riding (they pushed seats/parts out, physics crashed); last position: not overwritten by the save's start point, invalid positions ignored, saved when a player leaves
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:51:25 +02:00
MrMahoandClaude Opus 5.5 b853425037 Smoother sync: predict remote objects/cars from their velocity, 30 updates/s for objects and players
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:22:07 +02:00
MrMahoandClaude Opus 5.5 bdd86336c2 Remote-driven car: keep attached parts on unbreakable joints instead of switching them kinematic (that broke the joints and the car fell apart); passenger range measured to the car body
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:18:39 +02:00
MrMahoandClaude Opus 5.5 ce4aa7e0a6 MSCO console on F7 (F8 opens the game's own test menu in the pause menu)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:13:33 +02:00
MrMahoandClaude Opus 5.5 be9abd262f Server browser: keep the list stable between GUI passes (fixes 'Getting control 1's position' error)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:55:11 +02:00
MrMahoandClaude Opus 5.5 647acda46c Join: replay part actions first, then rescan and place objects (detached parts no longer stuck in the car); rescan right after remote disassembly; lift out bodies that keep getting flung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:52:53 +02:00
MrMahoandClaude Opus 5.5 8346cd9b09 Don't snatch an item another player is carrying just because it passes in front of your face
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:42:50 +02:00
MrMahoandClaude Opus 5.5 477949e559 Don't sync parts that are attached to a vehicle (they were flung away / car fell apart); softer plaid, calmer colors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:40:46 +02:00
MrMahoandClaude Opus 5.5 3970ef130b Changelog for 2.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 00:06:28 +02:00
MrMahoandClaude Opus 5.5 c3f84895a7 Nicer player figure: chest + belly instead of box and ball, bigger head, straight legs, flat belt, cap brim down, matte materials; free vehicles only sync big value jumps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 00:04:02 +02:00
MrMahoandClaude Opus 5.5 dfdb78db17 Version 2.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:58:05 +02:00
MrMahoandClaude Opus 5.5 d7a9479baa Pause game input while typing (chat/console/admin); isolate sync subsystems so one error doesn't stop everything
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:56:14 +02:00
MrMahoandClaude Opus 5.5 f4d154d4a5 Freeze attached parts of remote-driven vehicles (joints broke, car fell apart); don't sync bolt tightness
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:39:43 +02:00
MrMahoandClaude Opus 5.5 e050712c0d Own MSC-style player figure instead of cloned NPCs; stop objects flung away by physics
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:28:43 +02:00
MrMahoandClaude Opus 5.5 24cd485bcc Validate stored vehicle/object poses, show positions in 'vehicles', admin: Satsuma to me
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:25:00 +02:00
MrMahoandClaude Opus 5.5 b33cd82e68 Fix: vehicles were treated as held items (car tilted with the driver's head)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:53:53 +02:00
MrMahoandClaude Opus 5.5 d32cdd5693 Player models: bind pose + procedural animation (no more ragdoll poses); passenger feedback in chat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:50:52 +02:00
MrMahoandClaude Opus 5.5 6d349399ab Ping measurement and display (console: ping)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:45:11 +02:00
MrMahoandClaude Opus 5.5 b408be7559 Fixed master address, admin cheats (money, needs, goto/bring), docs page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:56:17 +02:00
MrMahoandClaude Opus 5.5 bf01651ccd Production ready: hardening, limits, docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:55:29 +02:00
42 changed files with 5811 additions and 879 deletions
+215
View File
@@ -0,0 +1,215 @@
# Changelog
## 2.8.1
**Mehr Events synchron**
- Zusätzlich zu Jobs, Laden, Werkstatt, Prüfstelle, Müllkippe, Tanzhalle, Ferienhaus und Rallye: Dragrace, Kilju-Käufer,
Theater, Fußball, Peräjärvi (Dorf) und die Hütte - ständig wechselnde interne Zähler werden weiterhin automatisch
ausgelassen
**Admin-Menü**
- Im Admin-Menü (Spieler-Reiter) gibt es **MAKE ADMIN / REMOVE ADMIN**: Admins können anderen Spielern Admin-Rechte
geben oder nehmen (dedizierter Server ab 2.9.4; der Spieler braucht Steam)
**Kleine Updates**
- Mod-Version (2.8.1 für MSCLoader/MSC Launcher) und Netzwerk-Version (2.8) sind getrennt: Updates an der dritten Stelle
passen zu allen 2.8-Servern und -Mitspielern. Anleitung im README („Updates veröffentlichen“)
**Server-Browser**
- **Hervorgehobene Server**: vom Keymaster-Admin markierte Server (Tag `featured`) stehen ganz oben, mit goldener
Zeile und **FEATURED**-Abzeichen (Liste und Profil)
- **Tags** der Server erscheinen im Server-Profil; die Suche findet auch Tags. Serverbetreiber setzen eigene Tags im
Web-Panel (Einstellungen), **OFFICIAL** vergeben nur die MSCO-Admins und wird hervorgehoben
- Braucht keinen neuen Server: die Infos kommen vom Master (msco.mbm.wtf) und funktionieren mit 2.8-Servern
**Mitspieler als echte MSC-Menschen**
- Andere Spieler sehen aus wie die Leute im Spiel: derselbe Körper wie alle MSC-Menschen, mit einem normalen Hemd,
einer Hose und einem Gesicht aus dem Spiel (keine Kleider/Vorhänge/Geister). Bewegt wird ein eigenes Skelett mit der
bisherigen Animation - laufen, springen, ducken, sitzen, Items halten
- Jeder Spieler bekommt (nach Spieler-Nummer) eine andere Kombination; alle Mitspieler sehen dieselbe
- Konsole (F7): `model classic` zeigt wieder die MSCO-Figur, `model game` die MSC-Menschen; `rig` zeigt, wer welches
Modell hat. Findet MSCO den Körper nicht, bleibt automatisch die MSCO-Figur
**Türen an allen Fahrzeugen**
- Türen hingen schief, sprangen zurück oder lösten sich: der Tür-Sync erfasste auch die FSMs zum An-/Abbauen,
Festschrauben und Lackieren der Tür-Teile und erzwang deren Zustände beim Mitspieler. Jetzt nur noch Öffnen/Schließen
- Türen schaukelten hin und her: an einem stehenden Auto schickte jeder in der Nähe den Türwinkel zurück. Jetzt schickt
nur, wer an der Tür geklickt hat (bzw. beim fahrenden Auto der Fahrer); empfangene Winkel werden länger gehalten
**Mitfahren**
- **Wer im Fahrzeug ist, fährt mit - genau an seiner Stelle**: Kopf in der Kabine, in der offenen Tür oder auf der
Ladefläche -> die Position im Fahrzeug wird festgehalten, kein Umsetzen auf einen berechneten Sitz. Geht auch, wenn
noch niemand fährt. Aussteigen mit Leertaste oder der Beifahrer-Taste (P) - man steht dann auf der näheren Seite
neben dem Fahrzeug. P neben dem Auto setzt einen wie bisher auf den Beifahrersitz
- Beim Ein-/Aussteigen am Fahrersitz und kurz nach dem Fahren wird man nicht festgehalten
**Satsuma zerfällt nicht mehr beim gemeinsamen Fahren**
- Hing das Spiel des Fahrers kurz (z.B. beim Speichern für den Server), gab der Mitfahrer das Auto nach 3 s frei: es
blieb bei ihm schlagartig stehen, die Teile wollten mit Tempo weiter und rissen ab - und das „Teil abgefallen“ ging an
alle. Ein gefahrenes Auto wird jetzt erst nach 30 s ohne Daten freigegeben, rollt dann mit der Geschwindigkeit des
Fahrers weiter, und die Teile bleiben noch 3 s fest, bis alles eingeschwungen ist
- Ladung im Auto (Kisten, Werkzeug, Teile im Kofferraum oder auf dem Sitz) gehört jetzt immer dem Fahrer. Vorher
übernahm sie oft der Mitfahrer; beim Fahrer folgte sie dann verspätet und „unendlich schwer“ und schlug Sitze und Teile ab
**Mitfahren ohne Ruckeln**
- Fahrer und Beifahrer sitzen beim anderen fest auf dem Sitz: die Figur wird relativ zum Auto übertragen und geglättet,
statt in Weltkoordinaten hinter dem fahrenden Auto herzuhinken (vorher wurde der Beifahrer bei Tempo nach hinten gezogen)
- Der Beifahrersitz wird jetzt aus der richtigen Fahrerposition berechnet (vorher aus der hinterherhinkenden Figur)
- Autos anderer Spieler ruckeln nicht mehr bei jedem Netzwerkpaket zurück (Vorausberechnung in Physik-Zeit) und werden
zwischen den Physik-Schritten flüssig bewegt – auch der NPC-Verkehr
- Kompatibel: ältere Mods und der Server ignorieren die neuen Sitz-Daten
**Schönere Oberfläche**
- Fenster (Launcher, Konsole, Admin-Menü, Hinweise) mit weichem Schatten und feinem Rand, orange Knöpfe mit Verlauf
- Server-Browser: mehr Platz für die Liste, Zeilen leuchten beim Überfahren auf, Füllbalken für die Spielerzahl,
Ping farbig (grün/gelb/rot), goldener Streifen bei hervorgehobenen Servern
- Seitenleiste mit Trennlinie, Menüpunkte werden beim Überfahren hell
## 2.8
Server und Mod müssen beide 2.8 sein (Server: `git pull && docker compose up -d --build`).
**Weniger Hänger**
- Der Server holt sich den Spielstand nur noch, wenn sich seit dem letzten etwas geändert hat (Speichern lässt das Spiel
kurz hängen), Standard für neue Server alle 10 statt 5 Minuten
- Die teure Suche über alle PlayMaker-FSMs läuft seltener; die Schadens-Prüfung liest ab und zu nur noch ein Mesh
- Hänger-Erkennung: dauert ein Bild länger als 0,2 s, steht in der F7-Konsole, ob MSCO schuld war (und welcher Teil)
oder das Spiel selbst (z.B. beim Speichern)
**Behoben: Öl lief aus dem Satsuma**
- Beim Beitreten lief Öl aus, obwohl der Spielstand lokal dicht war: die vom Spiel selbst berechneten Werte
`LeakOilpan`, `LeakOilfilter`, `LeakRockerCover`, `LeakHeadgasket` (und Raten wie `…LeakRate`) wurden wie echte
Fahrzeugwerte synchronisiert und überschrieben den Stand aus dem Spielstand. Werden jetzt nicht mehr übertragen
- Diagnose: `vars <filter>` in der F7-Konsole zeigt die synchronisierten Fahrzeugwerte (z.B. `vars oil`)
**Scharfe Oberfläche, FPS, Leistung**
- Launcher wird in voller Auflösung gezeichnet (Schrift und Kanten scharf statt hochgezogen/verpixelt)
- `fps` in der F7-Konsole: FPS-Anzeige oben rechts (grün/gelb/rot), Konsole zeigt auch das längste Bild (Ruckler)
- Weniger Ruckler: Sync-Teile suchen nach dem Laden nicht mehr alle im selben Frame, gemeinsame Suche nach Rigidbodys,
Unfallschäden werden nur noch nach einem Aufprall geprüft (vorher jede Sekunde große Mesh-Kopien), Fahrzeug-Effekte
schlagen ihre Bauteile seltener nach, Ping-/FPS-Anzeige ohne Registry-Zugriff pro Bild
**Launcher im Vollbild**
- Der MSCO-Launcher füllt das ganze Hauptmenü und **skaliert automatisch** mit der Auflösung; Listen wachsen mit dem
Platz (keine abgeschnittenen Knöpfe mehr). Das Spielmenü dahinter (Logo, Farbauswahl, MP3 …) ist komplett verdeckt
und gesperrt, die Menü-Musik ist stumm. MINIMIZE entfällt
**P2P: Weiterspielen oder neues Spiel**
- In der Host-Lobby: **CONTINUE** (eigenen Spielstand weiterspielen, mit Datum) oder **NEW GAME** (frische Welt für
alle). Der bisherige Spielstand wird vorher nach `MSCO_Saves/<Datum>` gesichert (die letzten 10 bleiben), mit
Sicherheitsabfrage. Mitspieler bekommen die neue Welt automatisch, sobald sie einmal gespeichert ist
**Mehr Sync**
- **Einkäufe**: gekaufte/ausgepackte Items tauchen bei allen auf (auch bei später Beitretenden)
- **Verbrauchte Items**: Gegessenes, Getrunkenes und Verbrauchtes verschwindet bei allen
- **Räder** drehen sich bei Autos, die ein anderer Spieler fährt
- **Unfallschäden**: verbeulte Karosserie wird an alle übertragen
- **Jobs, Aufträge und Laden**: Fortschritt (Auftrag angenommen, erledigt, bestellt ...) wird synchronisiert
- **Zündung, Motor, Licht, Choke, Blinker, Wischer**: die Schalterstellungen werden übertragen – beim anderen laufen
Motor und Scheinwerfer dadurch wirklich (nicht nur das Geräusch)
- **Autotüren, Motorhaube, Kofferraum**: stehen bei allen im selben Winkel offen
- **Verkehr**: NPC-Autos fahren bei allen an derselben Stelle (der erste Spieler simuliert, die anderen folgen)
- `vehicles` in der F7-Konsole zeigt, wie viele Karosserie-Meshes, Job-Werte und Verkehrsautos synchronisiert werden
**Admin: Welt verwalten (neuer Reiter WORLD im Admin-Menü)**
- **RESET WORLD**: die ganze Welt zurück auf den Spielstand, wie er auf den Server hochgeladen wurde (Reset-Punkt) –
das automatische Hochladen alle 5 Minuten ändert den Reset-Punkt nicht; der Stand davor wird gesichert
- **SET RESET POINT**: den aktuellen Spielstand als neuen Reset-Punkt nehmen (Server-Befehl: `set-reset-point`)
- **BACKUP NOW**: aktuellen Server-Spielstand sichern (Server-Befehl: `backup`)
- **Backups** mit Datum/Uhrzeit, **RESTORE** holt einen älteren Stand zurück (der aktuelle wird vorher gesichert)
- Jede Aktion mit Sicherheitsabfrage; nur für Admins (vom Server geprüft)
**Favoriten**
- Herz neben jedem Server (Liste und Profil) merkt ihn als Favorit
- Neuer Tab **Favorites**: jedem Server einen eigenen Kurznamen geben, beitreten, entfernen, per Adresse hinzufügen
- Schnell wechseln: `/switch <kurzname>` im Chat oder `switch <kurzname>` in der F7-Konsole – trennt und verbindet
direkt mit dem anderen Server; `fav` listet alle, `fav add <name>` merkt den aktuellen Server
**Pro Spieler**
- Hunger, Durst, Stress, Harndrang, Müdigkeit und Schmutz werden für jeden Spieler einzeln auf dem Server gespeichert
und beim Beitreten zurückgegeben (nicht mehr die Werte aus dem gemeinsamen Spielstand)
**Spielerfiguren**
- Springen: die ganze Figur hebt ab (vorher ging nur der Oberkörper nach oben), Knie werden angezogen
- Runder Gürtel, runde Schultern, abgerundete Stiefel mit Sohle
**Hinweise**
- Kleine Benachrichtigung oben rechts, wenn jemand beitritt oder das Spiel verlässt
**Trennen & Neu-Verbinden**
- Gekickt, gebannt, Server beendet, Verbindung verloren: eigenes Fenster in der Bildmitte mit **Grund** und OK
(im Spiel wird der Grund vorher 2,5 s groß angezeigt)
- `reset-world` / `reload-save` / **Server-Neustart** (z.B. Update per `docker compose up`) / **Verbindung verloren**:
Fenster „Reconnecting“ – verbindet automatisch neu, bis zu 10 Versuche (ca. 1 Minute), mit CANCEL-Knopf.
Nach Kick, Ban oder eigenem Trennen wird nicht neu verbunden
**Behoben**
- Nach Kick/Reset kam weder das Fenster mit dem Grund noch das automatische Neu-Verbinden: MSCLoader lädt beim Wechsel
ins Hauptmenü alle Mods neu und damit ging der Zustand verloren. Er wird jetzt vorher gesichert und danach fortgesetzt
- Absturz („NaN … collider“) und abfallende Teile am Satsuma: Türen/Haube/Kofferraum wurden gegen das Scharnier an
ihre Position teleportiert – das erzeugte riesige Kräfte. Jetzt zieht die Feder des Scharniers sie sanft auf den Winkel
- Beim anderen Spieler „orgelte“ der Anlasser endlos: erzwungene Geräusche/Lichter werden losgelassen, sobald keine
Daten mehr vom Fahrer kommen
- Beim Neuladen der Welt wurden herumliegende Objekte als „verbraucht“ gemeldet und bei allen gelöscht. Jetzt werden
nur einzelne echte Items gemeldet, Fahrzeuge/Teile nie gelöscht, und der Server nimmt höchstens 20 pro Minute an
**Leistung**
- Mod: herumliegende Objekte werden nur noch jedes 4. Frame geprüft (eigene/gehaltene/gefahrene weiterhin jedes Frame),
weniger Speicher-Allokationen pro Frame, Gelenke fremd gefahrener Autos seltener geprüft
- Server: Nachrichten an alle werden nur einmal verpackt; der Server wartet auf eingehende Daten statt fest zu schlafen
(geringere Verzögerung, fast keine CPU-Last im Leerlauf)
## 2.7
Server und Mod müssen beide 2.7 sein (Server: `git pull && docker compose up -d --build`).
**Neu**
- Eigene Spielerfigur im MSC-Stil (Karohemd, Jeans, Arbeitsschuhe, Schnurrbart, Kappe oder Haare, je Spieler etwas
anders) statt kopierter NPCs – keine liegenden/verbuggten Figuren mehr
- Ping-Anzeige oben rechts: `ping` in der F7-Konsole (grün/gelb/rot)
- MSCO-Konsole jetzt auf **F7** (F8 öffnet im Pausenmenü ein Test-Menü des Spiels)
- Admin-Menü: Cheats (+1.000 / +10.000 MK, Bedürfnisse auffüllen, Satsuma zu sich holen), GOTO / BRING (teleportieren)
- Fahrzeugzustand-Sync: Tank, Öl, Kühlwasser, Bremsflüssigkeit, Batterie, Schäden, Verschleiß, Kilometerstand, Lack
- `vehicles` in der Konsole zeigt die Position jedes Fahrzeugs
- Admin-Menü mit Reitern: **PLAYERS** (Kick/Ban/GOTO/BRING, Cheats), **ITEMS** (Items spawnen – alle sehen sie, auf dem
Server gespeichert), **BANS** (Sperren aufheben); Server-Befehle `bans` / `unban`
- Tacho, Drehzahlmesser, Tank-/Temperaturanzeige und Lenkrad werden beim Beifahrer mitbewegt
- Flüssigerer Sync: Position wird aus der Geschwindigkeit vorausberechnet, 30 Updates/s
- Letzte Position wird zuverlässig gespeichert (nicht mehr immer an der Toilette spawnen)
- Server-Browser: Klick auf den Server öffnet das Profil (JOIN-Knopf in der Liste entfernt)
- Alle Mod-Meldungen nur noch in der MSCO-Konsole (F7)
- Eigenes Hauptmenü: Continue, New Game, Credits, Changelog und Quit des Spiels sind ausgeblendet, der MSCO-Launcher
ist das Hauptmenü (mit eigenem QUIT)
- Server-Profil neu: JOIN oben, Spielerliste rechts, Namensfeld ohne Steam in eigenem Kasten
- Admin-ITEMS: Werkzeuge und Laden-Artikel (Ratschenkasten, Batterie, Motoröl, Kühlmittel, Bremsflüssigkeit ...)
- Nach dem Trennen (Kick, Ban, Shutdown, Timeout ...) zeigt das Hauptmenü den Grund
- `reset-world` / `reload-save` auf dem Server: alle Spieler bekommen eine Meldung und verbinden sich automatisch neu
- **Server holt sich den aktuellen Spielstand vom Spiel** (beim Beitreten und alle 5 Minuten): neue Spieler bekommen
exakt die aktuelle Welt – Schrauben fest, Teile dran – statt nachgespielter Aktionen; Backups der letzten 5 Stände
- Feuer, Rauch, Dampf und Funken an Fahrzeugen werden synchronisiert, auch bei geparkten Autos
- Kick-, Ban-, Reset- und Shutdown-Meldungen kommen zuverlässig an (Server trennt sauber)
- Status-Punkt gelb, bis man wirklich verbunden ist
**Behoben**
- Auto fiel beim Fahren eines anderen Spielers auseinander (angebaute Teile rissen an ihren Gelenken ab)
- Auto kippte mit dem Kopf des Fahrers mit und zitterte (Fahrzeug wurde als „in der Hand gehalten“ erkannt)
- Satsuma wurde weggeschleudert und war „verschwunden“ – ungültige/im Boden liegende Positionen werden jetzt
verworfen bzw. angehoben, weggeschleuderte Objekte sofort gestoppt
- Items und Autos zitterten nach dem Loslassen (Ownership-Pingpong)
- Absturz beim Beitreten durch einen Kreis in der Objekt-Hierarchie (nachgespielte Montage-Aktion)
- Tippen im Chat/in der Konsole löste Spieltasten aus (Benutzen, Beifahrer-Taste P …)
- Maus sprang bei offenem Admin-Menü/Chat immer in die Bildmitte
- Ein Fehler in einem Sync-Teil legt nicht mehr den ganzen Sync lahm
- Freie Fahrzeuge: nur noch deutliche Wertänderungen werden abgeglichen (kein gegenseitiges Überschreiben)
**Sicherheit (Server)**
- Admin-Rechte nur noch für von Steam bestätigte SteamIDs – der Server braucht dafür einen `SteamWebApiKey`
(sonst könnte sich jeder als Admin ausgeben). Unbestätigte SteamIDs können niemanden mehr vom Server drängen.
- Ungültige Positionen werden nicht gespeichert, Paketgröße und Weltzustand sind begrenzt
**Server & Keymaster**
- Master-Adresse fest (`msco.mbm.wtf`), nicht mehr änderbar
- Docker läuft als dein Linux-Benutzer (kein „Permission denied“ in `data/` mehr), klare Meldungen statt Absturz
- `docker exec msco-server msco <befehl>` für Server-Befehle
- Keymaster: Rate-Limits, Security-Header, Healthcheck, Docs-Seite
- Automatische Tests: Server `dotnet run --project Tests` (30 Checks), Keymaster `npm run smoke` (24 Checks)
+55
View File
@@ -0,0 +1,55 @@
using System.Collections.Generic;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Admin-Cheats für den eigenen Spieler (Geld, Bedürfnisse). Nur aus dem Admin-Menü erreichbar, also nur für
/// Server-Admins bzw. den P2P-Host. Geld wird über MoneySync automatisch an alle weitergegeben.
/// </summary>
public static class Cheats
{
// Globale PlayMaker-Variablen des Spielers: Wert, auf den "auffüllen" sie setzt
static readonly KeyValuePair<string, float>[] Needs =
{
new KeyValuePair<string, float>("PlayerHunger", 0f),
new KeyValuePair<string, float>("PlayerThirst", 0f),
new KeyValuePair<string, float>("PlayerStress", 0f),
new KeyValuePair<string, float>("PlayerUrine", 0f),
new KeyValuePair<string, float>("PlayerFatigue", 0f),
new KeyValuePair<string, float>("PlayerDirtiness", 0f),
};
public static string AddMoney(float amount)
{
FsmFloat money = FsmVariables.GlobalVariables.FindFsmFloat("PlayerMoney");
if (money == null) return "Money not found (only works in the game)";
money.Value += amount;
return "+" + amount.ToString("0") + " MK (now " + money.Value.ToString("0") + " MK)";
}
public static string FillNeeds()
{
int done = 0;
foreach (KeyValuePair<string, float> need in Needs)
{
FsmFloat v = FsmVariables.GlobalVariables.FindFsmFloat(need.Key);
if (v == null) continue;
v.Value = need.Value;
done++;
}
return done > 0 ? "Hunger, thirst, stress, fatigue, bladder and dirt reset" : "Player needs not found (only works in the game)";
}
/// <summary>Eigenen Spieler an eine Position setzen (etwas darüber, damit er nicht im Boden steckt).</summary>
public static bool Teleport(Transform player, Vector3 position)
{
if (player == null) return false;
player.position = position + Vector3.up * 0.3f;
Rigidbody rb = player.GetComponent<Rigidbody>();
if (rb != null && !rb.isKinematic) rb.velocity = Vector3.zero;
return true;
}
}
}
+73 -7
View File
@@ -3,22 +3,60 @@ using System;
namespace MSCO
{
/// <summary>
/// Alle MSCO-Befehle an einer Stelle - genutzt von der eigenen F8-Konsole (ohne "mp" davor)
/// Alle MSCO-Befehle an einer Stelle - genutzt von der eigenen F7-Konsole (ohne "mp" davor)
/// und von der MSCLoader-Konsole ("mp ...").
/// </summary>
public static class Commands
{
public const string Help =
"connect <host[:port]> - join a dedicated server\n" +
"switch <nickname> - quickly switch to a favorite server (also /switch in chat)\n" +
"fav - list your favorite servers\n" +
"fav add <nick> [host:port] - add a favorite (without address: the server you are on)\n" +
"fav remove <nick> - remove a favorite\n" +
"disconnect - leave the server / game\n" +
"host [port] / join <ip> [port] - LAN test session\n" +
"host [port] / join <ip> [port] - direct LAN game (no Steam)\n" +
"save - save the game (host / singleplayer)\n" +
"/admin - admin menu (kick / ban)\n" +
"ping [on|off] - show your ping to the server (toggle)\n" +
"fps [on|off] - show the frames per second (toggle)\n" +
"/admin - admin menu (kick / ban / teleport / cheats)\n" +
"vehicles | rig - debug info about vehicles / player models\n" +
"model [game|classic] - other players as MSC characters or the MSCO figure\n" +
"vars <filter> - synced vehicle values, e.g. vars oil (debug)\n" +
"debug - toggle sync debug output\n" +
"master [url] - show / change the server list address\n" +
"clear - clear this console";
static void Favorite(MultiplayerManager mp, string[] args, Action<string> print)
{
string sub = args.Length > 1 ? args[1].ToLower() : "list";
if (sub == "list")
{
if (Favorites.All.Count == 0) { print("No favorites yet - open a server in the browser and press FAVORITE, or: fav add <nick>"); return; }
foreach (Favorites.Favorite f in Favorites.All)
print(" " + f.Nickname + " -> " + f.Address + (string.IsNullOrEmpty(f.ServerName) ? "" : " (" + f.ServerName + ")"));
print("switch <nickname> to join one");
return;
}
if (sub == "add" || sub == "set")
{
if (args.Length < 3) { print("fav add <nickname> [host:port]"); return; }
string host = mp.CurrentServerHost, name = mp.CurrentServerName;
int port = mp.CurrentServerPort;
if (args.Length > 3 && !ServerBrowser.TryParseAddress(args[3], out host, out port)) { print("Invalid address: " + args[3]); return; }
if (host == null) { print("You are not on a server - use: fav add <nickname> <host:port>"); return; }
string error = Favorites.Set(args[2], host, port, name);
print(error ?? "Favorite '" + Favorites.CleanNickname(args[2]) + "' saved (" + host + ":" + port + ")");
return;
}
if (sub == "remove" || sub == "del" || sub == "delete")
{
Favorites.Favorite f = args.Length > 2 ? Favorites.Find(args[2]) : null;
print(f != null && Favorites.Remove(f) ? "Favorite '" + f.Nickname + "' removed" : "No such favorite");
return;
}
print("fav | fav add <nick> [host:port] | fav remove <nick>");
}
public static void Execute(string[] args, Action<string> print)
{
var mp = MultiplayerManager.Instance;
@@ -59,13 +97,30 @@ namespace MSCO
mp.JoinServer(host, serverPort);
break;
}
case "master":
if (args.Length > 1) ServerBrowser.MasterUrl = args[1];
print("Master server: " + ServerBrowser.MasterUrl);
case "switch":
case "sw":
print(mp.SwitchServer(args.Length > 1 ? args[1] : ""));
break;
case "fav":
case "favs":
case "favorites":
Favorite(mp, args, print);
break;
case "save":
mp.SaveGame();
break;
case "ping":
// "ping" schaltet die Anzeige um, "ping on/off" setzt sie
if (args.Length > 1) mp.ShowPing = args[1].ToLower() == "on" || args[1] == "1";
else mp.ShowPing = !mp.ShowPing;
print("Ping: " + mp.PingText + " (display " + (mp.ShowPing ? "on" : "off") + ")");
break;
case "fps":
// "fps" schaltet die Anzeige um, "fps on/off" setzt sie
if (args.Length > 1) mp.ShowFps = args[1].ToLower() == "on" || args[1] == "1";
else mp.ShowFps = !mp.ShowFps;
print("FPS: " + mp.FpsText + " (display " + (mp.ShowFps ? "on" : "off") + ")");
break;
case "admin":
case "/admin":
mp.OpenAdminMenu();
@@ -73,9 +128,20 @@ namespace MSCO
case "rig":
foreach (string line in mp.DescribeRigs()) print(line);
break;
case "model":
// Mitspieler als echte MSC-Menschen ("game") oder als MSCO-Figur ("classic")
if (args.Length > 1) RemotePlayer.UseGameModels = args[1].ToLower() != "classic";
else RemotePlayer.UseGameModels = !RemotePlayer.UseGameModels;
mp.RebuildAvatars();
print("Player models: " + (RemotePlayer.UseGameModels ? "MSC characters (model classic = MSCO figure)" : "MSCO figure (model game = MSC characters)"));
break;
case "vehicles":
foreach (string line in mp.DescribeVehicles()) print(line);
break;
case "vars":
// Diagnose: welche Fahrzeugwerte synchronisiert werden (z.B. "vars oil")
foreach (string line in mp.DescribeVehicleValues(args.Length > 1 ? args[1] : "")) print(line);
break;
case "debug":
FsmSync.Debug = !FsmSync.Debug;
print("Sync debug output " + (FsmSync.Debug ? "ON" : "OFF"));
+209
View File
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Bedienelemente der Fahrzeuge: Zündschloss, Anlasser, Motor, Lichtschalter, Choke, Blinker, Scheibenwischer ...
/// Das Spiel schaltet damit Motor und Licht über PlayMaker-FSMs (Zustände wie "Off" / "Lights" / "Running").
/// Übertragen wird der aktive Zustand; beim anderen Spieler wird dieselbe FSM in denselben Zustand geschaltet,
/// sodass sein Spiel Motor, Geräusch, Scheinwerfer und Armaturen selbst richtig anschaltet.
/// Wer sendet: der Fahrer; ist das Fahrzeug frei, wer daneben steht bzw. drin sitzt (Schalter betätigt).
/// FSMs, die ständig den Zustand wechseln (interne Schleifen), werden nach kurzer Zeit nicht mehr übertragen.
/// </summary>
public class ControlSync
{
// Objekt- oder FSM-Name enthält eines davon
static readonly string[] Keywords =
{
"ignition", "starter", "engine", "light", "choke", "wiper", "hazard", "indicator", "turnsignal", "blinker",
"horn", "heater", "fan", "radio", "key", "switch", "knob", "button",
// Handbremse, Sonnenblenden, Sitzlehne, Gurt, CD-Spieler, Innenlicht, Tankdeckel
"handbrake", "sunvisor", "seattop", "seatbelt", "cdplayer", "interiorlight", "opencap"
};
// Montage/Speichern/Schrauben - läuft über den Objekt- bzw. FSM-Sync, hier nicht anfassen
static readonly string[] Excluded =
{
"removal", "assembly", "assemble", "bolt", "screw", "data", "save", "load", "paint", "detach", "attach",
"install", "trigger", "tighten", "wear", "damage", "oil", "coolant", "fuel", "inspection"
};
const float ScanInterval = 10f;
const float CheckInterval = 0.1f;
const float NearRange = 5f;
const int FlipLimit = 12; // so viele Zustandswechsel ...
const float FlipWindow = 10f; // ... in dieser Zeit = interne Schleife, nicht übertragen
const int MaxPerMessage = 100;
class Control
{
public int Key;
public string Root;
public PlayMakerFSM Fsm;
public string LastState;
public float WindowStart;
public int Flips;
public bool Muted;
}
readonly Dictionary<int, Control> controls = new Dictionary<int, Control>();
readonly HashSet<PlayMakerFSM> scanned = new HashSet<PlayMakerFSM>();
readonly List<Control> outgoing = new List<Control>();
// Zustände für Schalter, die wir noch nicht kennen (Teil noch nicht angebaut/aktiv, Server-Snapshot vor dem Suchlauf)
readonly Dictionary<int, string> pending = new Dictionary<int, string>();
float nextScan, nextCheck;
public int Count { get { return controls.Count; } }
public int MutedCount { get { int n = 0; foreach (Control c in controls.Values) if (c.Muted) n++; return n; } }
public void Reset()
{
controls.Clear();
scanned.Clear();
pending.Clear();
nextScan = -1f; // erster Suchlauf verteilt, siehe Update
}
public void Update(ObjectSync objects, int localId, Vector3 playerPos, Action<MsgType, byte[]> send)
{
if (nextScan < 0f) nextScan = Time.time + 2.4f; // nicht alle Sync-Teile im selben Frame suchen lassen
if (Time.time >= nextScan)
{
nextScan = Time.time + ScanInterval;
Scan();
}
if (Time.time < nextCheck) return;
nextCheck = Time.time + CheckInterval;
outgoing.Clear();
foreach (Control c in controls.Values)
{
if (c.Fsm == null || c.Muted) continue;
string state = c.Fsm.ActiveStateName;
if (state == c.LastState || string.IsNullOrEmpty(state)) continue;
c.LastState = state;
if (Time.time - c.WindowStart > FlipWindow) { c.WindowStart = Time.time; c.Flips = 0; }
if (++c.Flips > FlipLimit)
{
c.Muted = true;
MscoLog.Info("[MSCO] Control not synced (changes all the time): " + c.Fsm.gameObject.name + " [" + c.Fsm.FsmName + "]");
continue;
}
int owner = objects.VehicleOwner(c.Root);
if (owner != SyncedBody.NoOwner && owner != localId) continue; // der Fahrer gibt vor
if (owner == SyncedBody.NoOwner && (c.Fsm.transform.position - playerPos).sqrMagnitude > NearRange * NearRange) continue;
outgoing.Add(c);
}
for (int start = 0; start < outgoing.Count; start += MaxPerMessage)
{
int from = start, count = Math.Min(MaxPerMessage, outgoing.Count - start);
send(MsgType.VehicleControls, NetSession.Build(w =>
{
w.Write(count);
for (int i = from; i < from + count; i++) { w.Write(outgoing[i].Key); w.Write(outgoing[i].LastState); }
}));
}
}
public void Handle(BinaryReader r, ObjectSync objects, int localId)
{
int count = r.ReadInt32();
for (int i = 0; i < count; i++)
{
int key = r.ReadInt32();
string state = r.ReadString();
Control c;
if (!controls.TryGetValue(key, out c) || c.Fsm == null) { pending[key] = state; nextScan = 0f; continue; }
if (objects.VehicleOwner(c.Root) == localId) continue; // ich fahre: meine Schalter gelten
SwitchTo(c, state);
}
}
void SwitchTo(Control c, string state)
{
if (c.Fsm.ActiveStateName == state) { c.LastState = state; return; }
FsmState target = c.Fsm.Fsm.GetState(state);
if (target == null) return;
c.LastState = state; // nicht als eigene Änderung zurückschicken
FsmSync.SwitchStateSilently(c.Fsm, target);
c.LastState = c.Fsm.ActiveStateName; // FSM kann sofort weiterschalten (z.B. "Start" -> "Running")
}
void Scan()
{
int before = controls.Count;
foreach (PlayMakerFSM fsm in SceneCache.Fsms(20f))
{
if (fsm == null || scanned.Contains(fsm)) continue;
string root = fsm.transform.root.name;
// Noch nicht am Fahrzeug (z.B. Radio liegt in CARPARTS): beim nächsten Suchlauf nochmal ansehen
if (!IsVehicle(root)) continue;
scanned.Add(fsm);
if (!Wanted(fsm.gameObject.name, fsm.FsmName)) continue;
int key = Hash(FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName);
if (controls.ContainsKey(key)) continue;
var control = new Control { Key = key, Root = root, Fsm = fsm, LastState = fsm.ActiveStateName, WindowStart = Time.time };
controls[key] = control;
string waiting;
if (pending.TryGetValue(key, out waiting)) { pending.Remove(key); SwitchTo(control, waiting); }
}
if (controls.Count != before) MscoLog.Info("[MSCO] Vehicle controls (ignition, lights, engine ...): " + controls.Count + " synced");
}
/// <summary>
/// P2P-Host: alle Schalterzustände nochmal schicken (Licht an, Motor läuft ...) - für später Beigetretene und
/// falls eine Nachricht verloren ging. Fahrzeuge, die ein anderer fährt, gibt dieser vor.
/// </summary>
public void ResendAll(ObjectSync objects, int localId, Action<MsgType, byte[]> send)
{
outgoing.Clear();
foreach (Control c in controls.Values)
{
if (c.Fsm == null || c.Muted || string.IsNullOrEmpty(c.Fsm.ActiveStateName)) continue;
int owner = objects.VehicleOwner(c.Root);
if (owner != SyncedBody.NoOwner && owner != localId) continue;
outgoing.Add(c);
}
for (int start = 0; start < outgoing.Count; start += MaxPerMessage)
{
int from = start, count = Math.Min(MaxPerMessage, outgoing.Count - start);
send(MsgType.VehicleControls, NetSession.Build(w =>
{
w.Write(count);
for (int i = from; i < from + count; i++) { w.Write(outgoing[i].Key); w.Write(outgoing[i].Fsm.ActiveStateName); }
}));
}
}
static bool Wanted(string objectName, string fsmName)
{
string o = objectName.ToLower(), f = fsmName.ToLower();
foreach (string x in Excluded) if (o.Contains(x) || f.Contains(x)) return false;
foreach (string k in Keywords) if (o.Contains(k) || f.Contains(k)) return true;
return false;
}
static bool IsVehicle(string root)
{
foreach (string v in ObjectSync.VehicleRoots) if (root.StartsWith(v)) return true;
return false;
}
static int Hash(string s)
{
unchecked
{
uint h = 2166136261;
foreach (byte c in Encoding.UTF8.GetBytes(s)) { h ^= c; h *= 16777619; }
return (int)h;
}
}
}
}
+216
View File
@@ -0,0 +1,216 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Unfallschäden (verbeulte Karosserie): Das Spiel verformt die Meshes der Fahrzeuge bei Kollisionen
/// (CarDamage, CarDamage2, Deformable). Das lässt sich nicht nachspielen, also übertragen wir das Ergebnis:
/// Wer das Fahrzeug fährt (oder bei freien Fahrzeugen: bei wem es passiert), schickt die geänderten Eckpunkte.
/// </summary>
public class DamageSync
{
const float ScanInterval = 10f;
const float CheckInterval = 0.5f; // so oft nach einem Aufprall prüfen
const float ImpactWindow = 4f; // so lange nach einem Aufprall prüfen (Verformung läuft ein paar Frames)
const float ImpactDeltaV = 2.5f; // plötzliche Geschwindigkeitsänderung (m/s pro Frame) = Aufprall
const float SafetyCheckInterval = 2f; // sonst nur ab und zu EIN Mesh (z.B. Reparatur in der Werkstatt) - verteilt statt alle auf einmal
const float MinMove = 0.002f;
const int MaxPerMessage = 4000;
static readonly string[] DamageTypes = { "CarDamage", "CarDamage2", "Deformable" };
class DamageMesh
{
public int Key;
public string Root;
public MeshFilter Filter;
public Rigidbody Body; // Fahrzeug, zu dem das Mesh gehört
public Vector3[] Known; // zuletzt gesendet/empfangen
}
readonly Dictionary<int, DamageMesh> meshes = new Dictionary<int, DamageMesh>();
readonly HashSet<MeshFilter> seen = new HashSet<MeshFilter>();
readonly List<int> changed = new List<int>();
float nextScan, nextCheck, nextSafetyCheck;
int safetyIndex;
// Mesh-Daten lesen ist teuer (große Arrays, Speicher-Müll) - nur nach einem Aufprall
readonly Dictionary<Rigidbody, Vector3> lastVelocity = new Dictionary<Rigidbody, Vector3>();
readonly Dictionary<Rigidbody, float> lastImpact = new Dictionary<Rigidbody, float>();
readonly List<Rigidbody> bodies = new List<Rigidbody>();
static List<Type> types;
public int Count { get { return meshes.Count; } }
public void Reset()
{
meshes.Clear();
seen.Clear();
lastVelocity.Clear();
lastImpact.Clear();
bodies.Clear();
nextScan = -1f; // erster Suchlauf verteilt, siehe Update
}
public void Update(ObjectSync objects, int localId, Action<MsgType, byte[]> send)
{
if (nextScan < 0f) nextScan = Time.time + 1.0f; // nicht alle Sync-Teile im selben Frame suchen lassen
if (Time.time >= nextScan)
{
nextScan = Time.time + ScanInterval;
Scan();
}
// Aufprall erkennen: Geschwindigkeit ändert sich schlagartig (billig, jedes Frame)
for (int i = 0; i < bodies.Count; i++)
{
Rigidbody rb = bodies[i];
if (rb == null || rb.isKinematic) continue;
Vector3 v = rb.velocity, before;
if (lastVelocity.TryGetValue(rb, out before) && (v - before).sqrMagnitude > ImpactDeltaV * ImpactDeltaV)
lastImpact[rb] = Time.time;
lastVelocity[rb] = v;
}
if (Time.time < nextCheck) return;
nextCheck = Time.time + CheckInterval;
bool safety = Time.time >= nextSafetyCheck;
if (safety) { nextSafetyCheck = Time.time + SafetyCheckInterval; safetyIndex++; }
int index = -1;
foreach (DamageMesh m in meshes.Values)
{
index++;
if (m.Filter == null) continue;
bool safetyThis = safety && meshes.Count > 0 && index == safetyIndex % meshes.Count;
int owner = objects.VehicleOwner(m.Root);
if (owner != SyncedBody.NoOwner && owner != localId) continue; // der Fahrer schickt die Schäden
float impact;
bool recentImpact = m.Body != null && lastImpact.TryGetValue(m.Body, out impact) && Time.time - impact < ImpactWindow;
if (!recentImpact && !safetyThis) continue;
Mesh mesh = m.Filter.sharedMesh;
if (mesh == null) continue;
Vector3[] now = mesh.vertices;
if (now.Length != m.Known.Length) { m.Known = now; continue; }
changed.Clear();
for (int i = 0; i < now.Length; i++)
if ((now[i] - m.Known[i]).sqrMagnitude > MinMove * MinMove) changed.Add(i);
if (changed.Count == 0) continue;
for (int start = 0; start < changed.Count; start += MaxPerMessage)
{
int from = start, count = Math.Min(MaxPerMessage, changed.Count - start);
int key = m.Key, total = now.Length;
send(MsgType.VehicleDamage, NetSession.Build(w =>
{
w.Write(key);
w.Write(total);
w.Write(count);
for (int i = from; i < from + count; i++)
{
int idx = changed[i];
Vector3 v = now[idx];
w.Write(idx); w.Write(v.x); w.Write(v.y); w.Write(v.z);
}
}));
}
m.Known = now;
}
}
public void Handle(BinaryReader r)
{
int key = r.ReadInt32();
int total = r.ReadInt32();
int count = r.ReadInt32();
DamageMesh m;
if (!meshes.TryGetValue(key, out m) || m.Filter == null) { nextScan = 0f; return; }
Mesh mesh = m.Filter.mesh; // eigene Kopie, falls das Mesh (noch) mit anderen geteilt ist
if (mesh == null) return;
Vector3[] verts = mesh.vertices;
if (verts.Length != total) return; // anderes Mesh (anderes Teil verbaut)
for (int i = 0; i < count; i++)
{
int idx = r.ReadInt32();
var v = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
if (idx < 0 || idx >= verts.Length || float.IsNaN(v.x) || float.IsNaN(v.y) || float.IsNaN(v.z)) continue;
if (v.sqrMagnitude > 1e6f) continue; // Unsinn nie übernehmen
verts[idx] = v;
}
mesh.vertices = verts;
mesh.RecalculateNormals();
mesh.RecalculateBounds();
m.Known = verts;
}
void Scan()
{
if (types == null)
{
types = new List<Type>();
foreach (Assembly a in AppDomain.CurrentDomain.GetAssemblies())
{
if (a.GetName().Name != "Assembly-CSharp") continue;
foreach (string n in DamageTypes)
{
Type t = a.GetType(n);
if (t != null) types.Add(t);
}
}
}
int before = meshes.Count;
foreach (Type t in types)
{
foreach (UnityEngine.Object o in UnityEngine.Object.FindObjectsOfType(t))
{
var c = o as Component;
if (c == null || !IsVehicle(c.transform.root.name)) continue;
foreach (FieldInfo f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance))
{
if (f.FieldType == typeof(MeshFilter)) Register(f.GetValue(c) as MeshFilter);
else if (f.FieldType == typeof(MeshFilter[]))
{
var arr = f.GetValue(c) as MeshFilter[];
if (arr != null) foreach (MeshFilter mf in arr) Register(mf);
}
}
}
}
if (meshes.Count != before) MscoLog.Info("[MSCO] Crash damage: " + meshes.Count + " body meshes synced");
}
void Register(MeshFilter mf)
{
if (mf == null || !seen.Add(mf)) return;
Mesh mesh = mf.sharedMesh;
if (mesh == null || mesh.vertexCount == 0 || mesh.vertexCount > 65000) return;
int key = Hash(FsmSync.PathOf(mf.transform));
if (meshes.ContainsKey(key)) return; // nicht eindeutig
Rigidbody body = mf.transform.root.GetComponent<Rigidbody>();
meshes[key] = new DamageMesh { Key = key, Root = mf.transform.root.name, Filter = mf, Body = body, Known = mesh.vertices };
if (body != null && !bodies.Contains(body)) bodies.Add(body);
}
static bool IsVehicle(string root)
{
foreach (string v in ObjectSync.VehicleRoots) if (root.StartsWith(v)) return true;
return false;
}
static int Hash(string s)
{
unchecked
{
uint h = 2166136261;
foreach (char c in s) { h ^= c; h *= 16777619; }
return (int)h;
}
}
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ namespace MSCO
if (running) return;
if (!Configured)
{
ModConsole.Print("[MSCO] Discord Rich Presence: no application id set (DiscordPresence.ApplicationId)");
MscoLog.Info("[MSCO] Discord Rich Presence: no application id set (DiscordPresence.ApplicationId)");
return;
}
if (Application.platform != RuntimePlatform.WindowsPlayer && Application.platform != RuntimePlatform.WindowsEditor) return;
+20 -4
View File
@@ -16,6 +16,13 @@ namespace MSCO
public class DoorSync
{
static readonly string[] Keywords = { "door", "gate", "hood", "bonnet", "trunk", "lid", "hatch" };
// Nicht die FSMs zum An-/Abbauen, Festschrauben, Lackieren, Speichern ... der Tür-TEILE: deren Zustände wurden
// beim Mitspieler erzwungen - Türen hingen schief, lösten sich oder sprangen zurück. Montage läuft über FsmSync.
static readonly string[] ExcludedFsms =
{
"removal", "assembly", "assemble", "boltcheck", "bolt", "screw", "paint", "data", "save", "load", "wiring",
"setup", "damage", "tightness", "detach", "attach", "install"
};
const float Cooldown = 1.5f; // Folge-Zustände der Animation nicht nochmal senden
const float RescanInterval = 10f; // neue Objekte (z.B. angebaute Autotüren) aufnehmen
@@ -62,7 +69,7 @@ namespace MSCO
t.QuietUntil = now + Cooldown;
string key = t.Key;
send(MsgType.DoorState, NetSession.Build(w => { w.Write(key); w.Write(state); }));
if (FsmSync.Debug) ModConsole.Print("[MSCO] >> DOOR " + key + " -> " + state);
if (FsmSync.Debug) MscoLog.Info("[MSCO] >> DOOR " + key + " -> " + state);
}
}
@@ -80,7 +87,7 @@ namespace MSCO
Scan(); // Objekt vielleicht erst seit kurzem aktiv
if (!byKey.TryGetValue(key, out t) || t.Fsm == null)
{
if (FsmSync.Debug) ModConsole.Warning("[MSCO] << DOOR not found: " + key);
if (FsmSync.Debug) MscoLog.Warning("[MSCO] << DOOR not found: " + key);
return;
}
}
@@ -91,17 +98,18 @@ namespace MSCO
t.QuietUntil = Time.realtimeSinceStartup + Cooldown; // Folge-Zustände nicht zurückschicken
t.LastState = state;
FsmSync.SwitchStateSilently(t.Fsm, target);
if (FsmSync.Debug) ModConsole.Print("[MSCO] << DOOR " + key + " -> " + state);
if (FsmSync.Debug) MscoLog.Info("[MSCO] << DOOR " + key + " -> " + state);
}
void Scan()
{
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
foreach (PlayMakerFSM fsm in SceneCache.Fsms(20f))
{
if (fsm == null) continue;
string root = fsm.transform.root.name;
if (root == "PLAYER" || root.StartsWith("MSCO_")) continue;
if (!Matches(fsm.gameObject.name) && !Matches(fsm.FsmName) && !Matches(fsm.transform.parent != null ? fsm.transform.parent.name : "")) continue;
if (IsPartLogic(fsm)) continue;
string key = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName;
Tracked t;
@@ -110,6 +118,14 @@ namespace MSCO
}
}
static bool IsPartLogic(PlayMakerFSM fsm)
{
string f = fsm.FsmName.ToLower(), o = fsm.gameObject.name.ToLower();
if (o.StartsWith("trigger_") || o.StartsWith("bolt")) return true;
foreach (string x in ExcludedFsms) if (f.Contains(x)) return true;
return false;
}
static bool Matches(string name)
{
if (string.IsNullOrEmpty(name)) return false;
+114
View File
@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Lieblings-Server mit eigenem Kurznamen (z.B. "mbm"). Gespeichert in den PlayerPrefs, eine Zeile pro Server:
/// Kurzname, Adresse, Port, Servername (Tab-getrennt). Mit "switch <kurzname>" wechselt man schnell den Server.
/// </summary>
public static class Favorites
{
public class Favorite
{
public string Nickname;
public string Host;
public int Port;
public string ServerName;
public string Address { get { return Host + ":" + Port; } }
}
const string Pref = "MSCO_Favorites";
const int MaxFavorites = 30;
static List<Favorite> list;
public static List<Favorite> All { get { Load(); return list; } }
static void Load()
{
if (list != null) return;
list = new List<Favorite>();
string raw = PlayerPrefs.GetString(Pref, "");
foreach (string line in raw.Split('\n'))
{
string[] p = line.Split('\t');
int port;
if (p.Length < 3 || p[0].Length == 0 || p[1].Length == 0 || !int.TryParse(p[2], out port)) continue;
list.Add(new Favorite { Nickname = p[0], Host = p[1], Port = port, ServerName = p.Length > 3 ? p[3] : "" });
}
}
static void Save()
{
var sb = new StringBuilder();
foreach (Favorite f in list)
sb.Append(Clean(f.Nickname)).Append('\t').Append(Clean(f.Host)).Append('\t').Append(f.Port).Append('\t').Append(Clean(f.ServerName)).Append('\n');
PlayerPrefs.SetString(Pref, sb.ToString());
PlayerPrefs.Save();
}
static string Clean(string s) { return (s ?? "").Replace("\t", " ").Replace("\n", " ").Trim(); }
/// <summary>Kurzname: Buchstaben, Zahlen, - und _ (ohne Leerzeichen, damit "switch name" eindeutig ist).</summary>
public static string CleanNickname(string nickname)
{
var sb = new StringBuilder();
foreach (char c in (nickname ?? "").Trim())
if (char.IsLetterOrDigit(c) || c == '-' || c == '_') sb.Append(c);
string s = sb.ToString();
return s.Length > 20 ? s.Substring(0, 20) : s;
}
public static Favorite Find(string nickname)
{
Load();
return list.Find(f => string.Equals(f.Nickname, nickname, StringComparison.OrdinalIgnoreCase));
}
public static Favorite FindByAddress(string host, int port)
{
Load();
return list.Find(f => f.Port == port && string.Equals(f.Host, host, StringComparison.OrdinalIgnoreCase));
}
/// <summary>Hinzufügen oder (gleiche Adresse) umbenennen. Gibt einen Fehler zurück oder null.</summary>
public static string Set(string nickname, string host, int port, string serverName)
{
Load();
nickname = CleanNickname(nickname);
if (nickname.Length == 0) return "Nickname: letters, numbers, - and _";
if (string.IsNullOrEmpty(host) || port <= 0 || port > 65535) return "Invalid server address";
Favorite sameName = Find(nickname);
Favorite existing = FindByAddress(host, port);
if (sameName != null && sameName != existing) return "The nickname '" + nickname + "' is already used for " + sameName.Address;
if (existing == null)
{
if (list.Count >= MaxFavorites) return "Too many favorites (max " + MaxFavorites + ")";
existing = new Favorite { Host = host, Port = port };
list.Add(existing);
}
existing.Nickname = nickname;
if (!string.IsNullOrEmpty(serverName)) existing.ServerName = serverName;
Save();
return null;
}
public static bool Remove(Favorite f)
{
Load();
if (f == null || !list.Remove(f)) return false;
Save();
return true;
}
/// <summary>Vorschlag für den Kurznamen aus dem Servernamen (erstes Wort, klein).</summary>
public static string SuggestNickname(string serverName)
{
string first = (serverName ?? "").Trim().Split(' ')[0];
string nick = CleanNickname(first).ToLower();
return nick.Length > 0 ? nick : "server";
}
}
}
+10 -10
View File
@@ -84,11 +84,11 @@ namespace MSCO
// Klick-Interaktionen (Lichtschalter, Knöpfe, Radio ...) über Zustandswechsel erkennen
MethodInfo switchPrefix = typeof(FsmSync).GetMethod("SwitchStatePrefix", BindingFlags.NonPublic | BindingFlags.Static);
harmony.Patch(switchState, new HarmonyMethod(switchPrefix), null);
ModConsole.Print("[MSCO] FSM sync ready");
MscoLog.Info("[MSCO] FSM sync ready");
}
catch (Exception e)
{
ModConsole.Error("[MSCO] FSM sync patch failed: " + e);
MscoLog.Error("[MSCO] FSM sync patch failed: " + e);
}
}
@@ -163,7 +163,7 @@ namespace MSCO
}
catch (Exception e)
{
ModConsole.Error("[MSCO] Click capture error: " + e.Message);
MscoLog.Error("[MSCO] Click capture error: " + e.Message);
}
}
@@ -212,7 +212,7 @@ namespace MSCO
Position = go.transform.position
};
Outbox.Add(msg);
if (Debug) ModConsole.Print("[MSCO] >> CLICK " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
if (Debug) MscoLog.Info("[MSCO] >> CLICK " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
}
/// <summary>Prüft der Zustand nur Maus/Tasten/Raycast (z.B. "Maus zeigt auf den Griff")?</summary>
@@ -262,11 +262,11 @@ namespace MSCO
Position = go.transform.position
};
Outbox.Add(msg);
if (Debug) ModConsole.Print("[MSCO] >> " + msg.EventName + " " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
if (Debug) MscoLog.Info("[MSCO] >> " + msg.EventName + " " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
}
catch (Exception e)
{
ModConsole.Error("[MSCO] FSM capture error: " + e.Message);
MscoLog.Error("[MSCO] FSM capture error: " + e.Message);
}
}
@@ -288,17 +288,17 @@ namespace MSCO
PlayMakerFSM fsm = Resolve(msg);
if (fsm == null)
{
if (Debug) ModConsole.Warning("[MSCO] << not found: " + msg.Path + " [" + msg.FsmName + "]");
if (Debug) MscoLog.Warning("[MSCO] << not found: " + msg.Path + " [" + msg.FsmName + "]");
return;
}
FsmState state = fsm.Fsm.GetState(msg.State);
if (state == null || switchState == null) return;
if (Debug) ModConsole.Print("[MSCO] << " + msg.EventName + " " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
if (Debug) MscoLog.Info("[MSCO] << " + msg.EventName + " " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
applying++;
lastApplyTime = Time.realtimeSinceStartup;
try { switchState.Invoke(fsm.Fsm, new object[] { state }); }
catch (Exception e) { ModConsole.Error("[MSCO] FSM apply error: " + e.Message); }
catch (Exception e) { MscoLog.Error("[MSCO] FSM apply error: " + e.Message); }
finally { applying--; }
}
@@ -309,7 +309,7 @@ namespace MSCO
applying++;
lastApplyTime = Time.realtimeSinceStartup;
try { switchState.Invoke(fsm.Fsm, new object[] { state }); }
catch (Exception e) { ModConsole.Error("[MSCO] FSM switch error: " + e.Message); }
catch (Exception e) { MscoLog.Error("[MSCO] FSM switch error: " + e.Message); }
finally { applying--; }
}
+202 -365
View File
@@ -1,405 +1,242 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Mitspieler-Figur aus einem echten My-Summer-Car-NPC (Körper-Mesh "bodymesh" + Skelett mit "pelvis").
/// Der NPC wird kopiert und von seiner Logik befreit; es bleiben Aussehen und die Spiel-Animationen
/// (Idle/Walk). Nach der Animation werden die Knochen für Ducken, Lehnen, Kopf-Nicken, Sitzen und
/// das Greifen zum Item verbogen (Zwei-Knochen-IK, unabhängig von den Achsen des Skeletts).
/// Mitspieler als MSC-Mensch: Alle Leute im Spiel benutzen denselben Körper ("bodymesh", 20 Knochen) und
/// unterscheiden sich nur durch drei Materialien - Hemd (char_shirtXX), Hose (char_pantsXX) und Gesicht
/// (char_faceXX). MSCO nimmt nur dieses Mesh, baut ein eigenes Skelett aus seiner Bind-Pose und gibt jedem
/// Spieler ein normales Hemd/Hose/Gesicht (keine Kleider, Vorhänge oder Geister). Die Knochen werden jedes Frame
/// direkt aus den Gelenkpunkten der MSCO-Figur gesetzt (laufen, springen, ducken, sitzen, Items halten).
///
/// Knochen-Reihenfolge ist bei allen MSC-Menschen gleich (nur die Namen unterscheiden sich):
/// 0 Becken, 1-2 Wirbelsäule, 3 Kopf, 4/9 Schlüsselbein, 5/10 Schulter, 6/11 Ellbogen, 7/12 Hand, 8/13 Finger,
/// 14/17 Hüfte, 15/18 Knie, 16/19 Knöchel (links/rechts). Bind-Pose: Z oben, X vorne, -Y links.
/// </summary>
public class GameCharacter
{
const float StandingEyeHeight = 1.65f;
const float EyeAboveHeadBone = 0.1f; // Augen liegen etwas über dem Kopf-Knochen
const float AnkleHeight = 0.07f; // Fuß-Knochen über dem Boden
/// <summary>Gelenkpunkte der MSCO-Figur im lokalen Raum des Spielers (jedes Frame von RemotePlayer.Pose).</summary>
public class Rig
{
public Vector3 Hip, NeckBase, Head;
public Vector3 Up = Vector3.up;
public float Pitch;
public readonly Vector3[] HipJoint = new Vector3[2], Knee = new Vector3[2], Ankle = new Vector3[2];
public readonly Vector3[] Shoulder = new Vector3[2], Elbow = new Vector3[2], Hand = new Vector3[2];
}
int frames;
bool calibrated;
const int BoneCount = 20;
const int Pelvis = 0, Head = 3;
static readonly int[] Torso = { 0, 1, 2, 4, 9 };
static readonly int[] Shoulder = { 5, 10 }, Elbow = { 6, 11 }, Wrist = { 7, 12 }, Finger = { 8, 13 };
static readonly int[] HipBone = { 14, 17 }, Knee = { 15, 18 }, Ankle = { 16, 19 };
public Transform Root { get; private set; }
// ---------------------------------------------------------------- Mesh und Kleidung aus dem Spiel
static Mesh bodyMesh;
static List<Material> shirts, pants, faces;
static float nextSearch;
static bool FindAssets()
{
if (bodyMesh != null && shirts.Count > 0 && pants.Count > 0 && faces.Count > 0) return true;
if (Time.realtimeSinceStartup < nextSearch) return false;
nextSearch = Time.realtimeSinceStartup + 10f;
var users = new Dictionary<Mesh, int>();
var shirtByName = new Dictionary<string, Material>();
var pantsByName = new Dictionary<string, Material>();
var faceByName = new Dictionary<string, Material>();
foreach (SkinnedMeshRenderer smr in Resources.FindObjectsOfTypeAll<SkinnedMeshRenderer>())
{
if (smr == null || smr.name != "bodymesh") continue;
Mesh m = smr.sharedMesh;
if (m == null || m.bindposes == null || m.bindposes.Length != BoneCount || m.subMeshCount != 3) continue;
int n;
users.TryGetValue(m, out n);
users[m] = n + 1;
Material[] mats = smr.sharedMaterials;
if (mats == null || mats.Length != 3) continue;
Add(shirtByName, mats[0], "char_shirt");
Add(pantsByName, mats[1], "char_pants");
if (mats[2] != null && !mats[2].name.Contains("woman")) Add(faceByName, mats[2], "char_face");
}
// Das Mesh, das die meisten Leute benutzen = der normale Mann
Mesh best = null;
int bestUsers = 0;
foreach (KeyValuePair<Mesh, int> kv in users)
if (kv.Value > bestUsers && LooksHuman(kv.Key)) { best = kv.Key; bestUsers = kv.Value; }
bodyMesh = best;
shirts = Sorted(shirtByName);
pants = Sorted(pantsByName);
faces = Sorted(faceByName);
bool ok = bodyMesh != null && shirts.Count > 0 && pants.Count > 0 && faces.Count > 0;
if (ok) MscoLog.Info("[MSCO] Player models: MSC body with " + shirts.Count + " shirts, " + pants.Count + " trousers, " + faces.Count + " faces");
return ok;
}
static void Add(Dictionary<string, Material> into, Material m, string prefix)
{
if (m != null && m.name.StartsWith(prefix) && !into.ContainsKey(m.name)) into[m.name] = m;
}
static List<Material> Sorted(Dictionary<string, Material> d)
{
var names = new List<string>(d.Keys);
names.Sort(string.CompareOrdinal); // bei allen Spielern gleiche Reihenfolge -> gleiches Aussehen
return names.ConvertAll(n => d[n]);
}
/// <summary>Prüft die erwartete Knochen-Reihenfolge an der Bind-Pose (Kopf oben, Knöchel unten, Beine lang).</summary>
static bool LooksHuman(Mesh m)
{
Vector3[] p = BindPositions(m);
if (p[Head].z < p[Pelvis].z + 0.4f) return false;
for (int i = 0; i < 2; i++)
{
if (p[Ankle[i]].z > p[Knee[i]].z || p[Knee[i]].z > p[HipBone[i]].z) return false;
if (p[Wrist[i]].z > p[Shoulder[i]].z) return false;
}
return p[Shoulder[0]].y < 0f && p[Shoulder[1]].y > 0f; // links = -Y
}
static Vector3[] BindPositions(Mesh m)
{
var p = new Vector3[BoneCount];
for (int i = 0; i < BoneCount; i++) p[i] = m.bindposes[i].inverse.GetColumn(3);
return p;
}
// ---------------------------------------------------------------- Instanz
GameObject model;
Transform owner;
readonly Transform[] bones = new Transform[BoneCount];
readonly Vector3[] restPos = new Vector3[BoneCount]; // relativ zum Spieler
readonly Quaternion[] restRot = new Quaternion[BoneCount];
Vector3 restHipCenter;
public string Description { get; private set; }
Animation anim;
string walkClip, idleClip;
Transform pelvis, spine, head;
readonly Transform[] thigh = new Transform[2], knee = new Transform[2], foot = new Transform[2];
readonly Transform[] upperArm = new Transform[2], forearm = new Transform[2], hand = new Transform[2];
readonly Vector3[] animatedFoot = new Vector3[2];
/// <summary>
/// Knochen, die wir verbiegen. Bewegt die Animation einen davon nicht selbst, würden sich unsere
/// Änderungen jedes Frame aufsummieren - dann wird er vorher auf die Grundhaltung zurückgesetzt.
/// </summary>
class BoneState
/// <summary>Figur bauen (variant wählt Hemd, Hose und Gesicht). null, wenn das Spiel-Mesh nicht gefunden wird.</summary>
public static GameCharacter Create(Transform owner, int variant)
{
public Transform Bone;
public Vector3 RestPos, OutPos;
public Quaternion RestRot, OutRot;
public bool HasOut;
}
readonly List<BoneState> posed = new List<BoneState>();
public Vector3 HeadPosition { get { return head.position + Root.up * EyeAboveHeadBone; } }
// ---------------------------------------------------------------- Vorlage finden und kopieren
static List<GameObject> templates;
/// <summary>Kopiert einen NPC aus der Spielszene als Figur. null, wenn keine passende Vorlage da ist.</summary>
public static GameCharacter TryCreate(Transform parent, int variant)
{
List<GameObject> candidates = FindTemplates();
if (candidates.Count == 0) return null;
GameObject template = candidates[Mathf.Abs(variant) % candidates.Count];
GameObject clone = (GameObject)UnityEngine.Object.Instantiate(template);
clone.name = "Model";
Strip(clone);
clone.SetActive(true);
clone.transform.SetParent(parent, false);
clone.transform.localPosition = Vector3.zero;
clone.transform.localRotation = Quaternion.identity;
clone.transform.localScale = Vector3.one;
var c = new GameCharacter { Root = clone.transform };
if (!c.FindBones())
if (!FindAssets()) return null;
var c = new GameCharacter();
try
{
UnityEngine.Object.Destroy(clone);
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;
}
c.Normalize();
c.SetupAnimation();
c.RememberRestPose();
c.Description = template.name + " (" + candidates.Count + " templates) - " + c.BoneReport();
return c;
}
/// <summary>NPC-Körper in der Szene (auch inaktive): SkinnedMeshRenderer "bodymesh" mit "pelvis"-Knochen.</summary>
static List<GameObject> FindTemplates()
void Build(Transform owner, int variant)
{
if (templates != null)
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++)
{
templates.RemoveAll(t => t == null);
if (templates.Count > 0) return templates;
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;
}
var scored = new List<KeyValuePair<GameObject, int>>();
var seen = new HashSet<string>();
foreach (UnityEngine.Object o in Resources.FindObjectsOfTypeAll(typeof(SkinnedMeshRenderer)))
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++)
{
var smr = (SkinnedMeshRenderer)o;
if (smr == null || !smr.name.ToLower().Contains("bodymesh") || smr.transform.parent == null) continue;
bool hasPelvis = false;
if (smr.bones != null)
foreach (Transform b in smr.bones)
if (b != null && b.name == "pelvis") { hasPelvis = true; break; }
if (!hasPelvis) continue;
// 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
GameObject root = smr.transform.parent.gameObject; // Körper + Skelett liegen gemeinsam darunter
if (root.transform.root.name == "PLAYER" || root.name.StartsWith("MSCO")) continue;
// Keine Sammelgruppen mit mehreren Personen kopieren (nur eine Figur, evtl. mit Varianten dünn/normal/dick)
if (PelvisCount(root.transform) != 1 || root.GetComponentsInChildren<Transform>(true).Length > 600) continue;
int score = AnimationScore(ref root);
if (!seen.Add(root.name + "/" + smr.sharedMesh)) continue; // gleiche Figur nicht mehrfach
scored.Add(new KeyValuePair<GameObject, int>(root, score));
}
// Nur die besten: am liebsten NPCs mit Lauf- UND Steh-Animation (ohne Gesten wie Anhalter/Betrunken)
int best = 0;
foreach (var kv in scored) best = Mathf.Max(best, kv.Value);
templates = new List<GameObject>();
foreach (var kv in scored) if (kv.Value == best) templates.Add(kv.Key);
templates.Sort((a, b) => string.CompareOrdinal(a.name, b.name));
return templates;
}
static int PelvisCount(Transform root)
{
int count = 0;
foreach (Transform t in root.GetComponentsInChildren<Transform>(true)) if (t.name == "pelvis") count++;
return count;
}
// Animationen, die keine normale Steh-/Laufhaltung sind
static readonly string[] GestureWords = { "hitch", "drunk", "fat", "lift", "sit", "wave", "point", "dance", "angry", "back", "theatre", "car" };
static bool IsGesture(string clip)
{
string n = clip.ToLower();
foreach (string g in GestureWords) if (n.Contains(g)) return true;
return false;
}
/// <summary>
/// Bewertet die Animationen eines NPCs (liegt die Animation eine Ebene höher, wird dieser Knoten
/// die Vorlage). 3 = normales Laufen + Stehen, 2 = nur Laufen, 1 = nur Stehen, 0 = keine.
/// </summary>
static int AnimationScore(ref GameObject root)
{
Animation[] found = root.GetComponentsInChildren<Animation>(true); // Unity 5.0: auch inaktive finden
Animation anim = found.Length > 0 ? found[0] : null;
if (anim == null)
{
Transform p = root.transform.parent;
for (int i = 0; i < 2 && p != null && anim == null; i++, p = p.parent)
{
var candidate = p.GetComponent<Animation>();
if (candidate != null && PelvisCount(p) == 1)
{
anim = candidate;
root = p.gameObject;
}
}
}
if (anim == null) return 0;
bool walk = false, idle = false;
foreach (AnimationState s in anim)
{
if (IsGesture(s.name)) continue;
string n = s.name.ToLower();
if (n.Contains("walk")) walk = true;
if (n.Contains("idle")) idle = true;
}
return (walk ? 2 : 0) + (idle ? 1 : 0);
}
/// <summary>Alles außer Aussehen und Animation entfernen (KI-FSMs, Physik, Sounds, Trigger ...).</summary>
static void Strip(GameObject go)
{
foreach (Joint j in go.GetComponentsInChildren<Joint>(true)) UnityEngine.Object.DestroyImmediate(j);
foreach (Rigidbody r in go.GetComponentsInChildren<Rigidbody>(true)) UnityEngine.Object.DestroyImmediate(r);
foreach (Collider c in go.GetComponentsInChildren<Collider>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (Behaviour b in go.GetComponentsInChildren<Behaviour>(true))
{
if (b is Animation || b is Animator) continue;
UnityEngine.Object.DestroyImmediate(b);
}
foreach (AudioSource a in go.GetComponentsInChildren<AudioSource>(true)) UnityEngine.Object.DestroyImmediate(a);
}
bool FindBones()
{
pelvis = Find("pelvis");
spine = Find("spine_upper") ?? Find("spine_middle") ?? Find("spine_mid");
head = Find("head");
if (pelvis == null || spine == null || head == null) return false;
string[] sides = { "left", "right" };
for (int i = 0; i < 2; i++)
{
knee[i] = Find("knee_" + sides[i]);
if (knee[i] != null)
{
thigh[i] = knee[i].parent;
foot[i] = knee[i].childCount > 0 ? knee[i].GetChild(0) : null;
}
hand[i] = Find("hand_" + sides[i]);
if (hand[i] != null && hand[i].parent != null)
{
forearm[i] = hand[i].parent;
upperArm[i] = forearm[i].parent;
}
}
return true;
}
Transform Find(string name)
{
foreach (Transform t in Root.GetComponentsInChildren<Transform>(true))
if (string.Equals(t.name, name, StringComparison.OrdinalIgnoreCase)) return t;
return null;
}
/// <summary>Größe auf Spielergröße bringen, Füße auf den Boden, Blickrichtung nach +Z.</summary>
void Normalize()
{
// Blickrichtung: Zehen (Kind des Fußes) liegen vor dem Fuß
Transform toe = foot[0] != null && foot[0].childCount > 0 ? foot[0].GetChild(0) : null;
if (toe != null && Root.parent.InverseTransformPoint(toe.position).z < Root.parent.InverseTransformPoint(foot[0].position).z - 0.01f)
Root.localRotation = Quaternion.Euler(0f, 180f, 0f);
float eye = Root.parent.InverseTransformPoint(head.position).y + EyeAboveHeadBone;
if (eye > 0.5f) Root.localScale = Vector3.one * Mathf.Clamp(StandingEyeHeight / eye, 0.6f, 1.6f);
float lowest = float.MaxValue;
for (int i = 0; i < 2; i++)
if (foot[i] != null) lowest = Mathf.Min(lowest, Root.parent.InverseTransformPoint(foot[i].position).y);
if (lowest < float.MaxValue) Root.localPosition = new Vector3(0f, -lowest + 0.06f, 0f);
}
void SetupAnimation()
{
anim = Root.GetComponentInChildren<Animation>();
if (anim == null) return;
foreach (AnimationState s in anim)
{
if (IsGesture(s.name)) continue;
string n = s.name.ToLower();
if (walkClip == null && n.Contains("walk")) walkClip = s.name;
if (idleClip == null && n.Contains("idle")) idleClip = s.name;
}
// Keine Steh-Animation: im Stand die Laufanimation angehalten zeigen (statt einer Geste)
if (idleClip == null && walkClip == null && anim.clip != null && !IsGesture(anim.clip.name)) idleClip = anim.clip.name;
if (walkClip != null) anim[walkClip].wrapMode = WrapMode.Loop;
if (idleClip != null) anim[idleClip].wrapMode = WrapMode.Loop;
if (idleClip != null) anim.Play(idleClip);
}
void RememberRestPose()
{
var bones = new List<Transform> { pelvis, spine, head };
bones.AddRange(thigh);
bones.AddRange(knee);
bones.AddRange(upperArm);
bones.AddRange(forearm);
foreach (Transform b in bones)
if (b != null)
posed.Add(new BoneState { Bone = b, RestPos = b.localPosition, RestRot = b.localRotation });
}
void ResetUnanimatedBones()
{
foreach (BoneState s in posed)
{
if (s.HasOut && s.Bone.localPosition == s.OutPos && s.Bone.localRotation == s.OutRot)
{
s.Bone.localPosition = s.RestPos;
s.Bone.localRotation = s.RestRot;
}
// 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]]);
}
}
void RememberOutput()
/// <summary>Punkt des Oberkörpers: an der Hüfte der MSCO-Figur, mit ihr geneigt.</summary>
Vector3 TorsoPoint(Rig rig, Quaternion lean, Vector3 rest)
{
foreach (BoneState s in posed)
{
s.OutPos = s.Bone.localPosition;
s.OutRot = s.Bone.localRotation;
s.HasOut = true;
}
return owner.TransformPoint(rig.Hip + lean * (rest - restHipCenter));
}
string BoneReport()
/// <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)
{
var sb = new StringBuilder();
sb.Append("legs ").Append(knee[0] != null && foot[0] != null ? "ok" : "missing");
sb.Append(", arms ").Append(hand[1] != null && upperArm[1] != null ? "ok" : "missing");
sb.Append(", anim idle=").Append(idleClip ?? "-").Append(" walk=").Append(walkClip ?? "-");
return sb.ToString();
Vector3 rest = restPos[to] - restPos[from];
if (desired.sqrMagnitude < 1e-6f || rest.sqrMagnitude < 1e-6f) return Quaternion.identity;
return Quaternion.FromToRotation(rest, desired);
}
// ---------------------------------------------------------------- Pro Frame (LateUpdate, nach der Animation)
public void Animate(float speed)
void Place(int i, Vector3 pos, Quaternion rot)
{
if (anim == null) return;
bool walking = speed > 0.4f && walkClip != null;
string clip = walking ? walkClip : (idleClip ?? walkClip);
if (clip == null) return;
if (!anim.IsPlaying(clip)) anim.CrossFade(clip, 0.25f);
if (clip == walkClip) anim[walkClip].speed = walking ? Mathf.Clamp(speed / 1.4f, 0.6f, 2.2f) : 0f; // angehalten = stehen
bones[i].position = pos;
bones[i].rotation = rot;
}
public void Pose(Transform body, Vector3 headTarget, float pitch, bool seated, bool holding, Vector3 heldPos)
public void Destroy()
{
Vector3 up = body.up, forward = body.forward, right = body.right;
ResetUnanimatedBones();
frames++;
// 1) Auf den Boden stellen: die (animierten) Füße stehen auf der Bodenhöhe des Spielers.
// Die Grundhaltung eines NPCs passt oft nicht zur Animation - deshalb jedes Frame nachmessen.
float lowestFoot = float.MaxValue;
for (int i = 0; i < 2; i++)
if (foot[i] != null) lowestFoot = Mathf.Min(lowestFoot, Vector3.Dot(foot[i].position - body.position, up));
if (lowestFoot < float.MaxValue)
{
Vector3 lp = Root.localPosition;
lp.y -= lowestFoot - AnkleHeight;
Root.localPosition = lp;
}
// 2) Größe einmal an die laufende Animation anpassen (Augenhöhe wie der Spieler)
if (!calibrated && frames > 5 && !seated)
{
calibrated = true;
float eye = Vector3.Dot(head.position + up * EyeAboveHeadBone - body.position, up);
if (eye > 0.5f) Root.localScale *= Mathf.Clamp(StandingEyeHeight / eye, 0.7f, 1.4f);
RememberOutput();
return; // nächstes Frame mit neuer Größe neu auf den Boden stellen
}
// Fußpositionen der Animation merken (die sollen stehen bleiben)
for (int i = 0; i < 2; i++)
if (foot[i] != null) animatedFoot[i] = foot[i].position;
// 3) Becken: beim Ducken/Sitzen absenken, beim Lehnen nach hinten ausweichen
Vector3 eyeNow = head.position + up * EyeAboveHeadBone;
Vector3 offset = headTarget - eyeNow;
Vector3 horizontal = offset - up * Vector3.Dot(offset, up);
if (horizontal.magnitude > 0.6f) horizontal = horizontal.normalized * 0.6f;
float drop = Mathf.Clamp(Vector3.Dot(offset, up), -1.1f, 0f);
pelvis.position += up * drop - horizontal * 0.25f;
// Wirbelsäule so drehen, dass der Kopf an der Kamera sitzt (nur wenn nötig)
if (horizontal.sqrMagnitude > 0.0025f || drop < -0.05f)
Aim(spine, head.position + up * EyeAboveHeadBone, head.position + up * EyeAboveHeadBone + horizontal * 0.75f);
// Kopf nicken
head.rotation = Quaternion.AngleAxis(pitch, right) * head.rotation;
// Beine
for (int i = 0; i < 2; i++)
{
if (thigh[i] == null || knee[i] == null || foot[i] == null) continue;
Vector3 target = seated
? thigh[i].position + forward * 0.45f - up * 0.4f
: animatedFoot[i];
SolveTwoBone(thigh[i], knee[i], foot[i], target, forward);
}
// Arme: rechts zum gehaltenen Item, im Auto beide nach vorne (Lenkrad)
for (int i = 0; i < 2; i++)
{
if (upperArm[i] == null || forearm[i] == null || hand[i] == null) continue;
if (i == 1 && holding)
SolveTwoBone(upperArm[i], forearm[i], hand[i], heldPos, -forward - up * 0.5f);
else if (seated)
SolveTwoBone(upperArm[i], forearm[i], hand[i], upperArm[i].position + forward * 0.38f - up * 0.28f, -up);
}
RememberOutput();
}
/// <summary>Knochen so drehen, dass "child" in Richtung "target" zeigt - egal wie das Skelett ausgerichtet ist.</summary>
static void Aim(Transform bone, Vector3 childPos, Vector3 target)
{
Vector3 from = childPos - bone.position;
Vector3 to = target - bone.position;
if (from.sqrMagnitude < 1e-6f || to.sqrMagnitude < 1e-6f) return;
bone.rotation = Quaternion.FromToRotation(from, to) * bone.rotation;
}
static void SolveTwoBone(Transform upper, Transform lower, Transform end, Vector3 target, Vector3 bendHint)
{
float a = (lower.position - upper.position).magnitude;
float b = (end.position - lower.position).magnitude;
if (a < 0.01f || b < 0.01f) return;
Vector3 root = upper.position;
Vector3 toTarget = target - root;
float d = Mathf.Clamp(toTarget.magnitude, 0.01f, a + b - 0.001f);
Vector3 dir = toTarget.normalized;
float along = (a * a - b * b + d * d) / (2f * d);
float sideways = Mathf.Sqrt(Mathf.Max(0f, a * a - along * along));
Vector3 bend = bendHint - dir * Vector3.Dot(bendHint, dir);
if (bend.sqrMagnitude < 1e-6f) bend = Vector3.up - dir * Vector3.Dot(Vector3.up, dir);
Vector3 joint = root + dir * along + bend.normalized * sideways;
Aim(upper, lower.position, joint);
Aim(lower, end.position, root + dir * d);
if (model != null) Object.Destroy(model);
model = null;
}
}
}
+216
View File
@@ -0,0 +1,216 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Türen, Motorhaube und Kofferraum an Fahrzeugen: hängen mit einem Scharnier (HingeJoint) an der Karosserie
/// und werden deshalb nicht vom Objekt-Sync bewegt. Übertragen wird der Öffnungswinkel des Scharniers.
/// Beim Empfänger wird die Tür NICHT hingesetzt (Teleportieren gegen das Gelenk erzeugt riesige Kräfte - Teile
/// reißen ab, im schlimmsten Fall stürzt die Physik ab), sondern die Feder des Scharniers zieht sie sanft auf
/// den Winkel. Danach bekommt das Scharnier seine ursprüngliche Einstellung zurück.
/// Wer sendet: Fährt jemand das Auto, der Fahrer. Ist es frei, wer gerade daneben steht (Tür auf/zu gemacht).
/// </summary>
public class HingeSync
{
const float ScanInterval = 10f;
const float SendInterval = 0.1f;
const float MinAngle = 2f; // Grad - kleiner ist Zittern
const float NearRange = 4f; // so nah muss man an einer Tür stehen, um sie zu bewegen
const float ApplyHold = 3f; // so lange nach einer Nachricht die Feder halten und selbst nicht senden
// Steht das Fahrzeug, schickt nur, wer die Tür gerade selbst bewegt hat (Klick in der Nähe). Vorher schickte
// jeder in der Nähe: setzte sich die Tür beim anderen nach dem Übernehmen etwas anders, schickte er zurück -
// die Türen schaukelten hin und her.
const float InteractRange = 2.5f;
const float InteractAuthority = 4f; // so lange nach dem Klick (Tür schwingt aus)
const float SpringForce = 120f;
const float SpringDamper = 12f;
class Hinge
{
public int Key;
public string Root;
public HingeJoint Joint;
public Rigidbody Rb;
public float SentAngle;
public bool HasSent;
public float TargetAngle;
public float TargetTime = -100f;
public bool Driving; // Feder gerade von uns gesetzt
public float LocalAuthorityUntil = -100f;
public bool OrigUseSpring;
public JointSpring OrigSpring;
}
readonly Dictionary<int, Hinge> hinges = new Dictionary<int, Hinge>();
readonly HashSet<HingeJoint> seen = new HashSet<HingeJoint>();
readonly List<Hinge> outgoing = new List<Hinge>();
float nextScan, sendTimer;
public int Count { get { return hinges.Count; } }
public void Reset()
{
foreach (Hinge h in hinges.Values) StopDriving(h);
hinges.Clear();
seen.Clear();
nextScan = -1f; // erster Suchlauf verteilt, siehe Update
}
public void Update(ObjectSync objects, int localId, Vector3 playerPos, float dt, Action<MsgType, byte[]> send)
{
if (nextScan < 0f) nextScan = Time.time + 1.7f; // nicht alle Sync-Teile im selben Frame suchen lassen
if (Time.time >= nextScan)
{
nextScan = Time.time + ScanInterval;
Scan();
}
sendTimer += dt;
if (sendTimer < SendInterval) return;
sendTimer = 0f;
outgoing.Clear();
foreach (Hinge h in hinges.Values)
{
if (h.Joint == null || h.Rb == null || !h.Rb.gameObject.activeInHierarchy) continue;
bool mine = Time.time < h.LocalAuthorityUntil;
if (!mine && Time.time - h.TargetTime < ApplyHold) continue; // gerade von anderen gesetzt
int owner = objects.VehicleOwner(h.Root);
// Fährt jemand: der Fahrer schickt (Türen schwingen während der Fahrt). Hat man selbst gerade an der Tür
// geklickt, schickt man auch - sonst würde die Feder sie beim Fahrer gleich wieder zudrücken.
if (!mine && owner != SyncedBody.NoOwner && owner != localId) continue;
if (!mine && owner == SyncedBody.NoOwner) { h.SentAngle = SafeAngle(h, h.SentAngle); continue; } // stehendes Auto: nur wer klickt
float angle = h.Joint.angle;
if (float.IsNaN(angle) || float.IsInfinity(angle)) continue;
if (h.HasSent && Mathf.Abs(angle - h.SentAngle) < MinAngle) continue;
h.SentAngle = angle;
h.HasSent = true;
outgoing.Add(h);
}
if (outgoing.Count == 0) return;
send(MsgType.HingeStates, NetSession.Build(w =>
{
w.Write(outgoing.Count);
foreach (Hinge h in outgoing) { w.Write(h.Key); w.Write(h.SentAngle); }
}));
}
/// <summary>Jedes Frame: Klick (linke Maustaste / Benutzen) in der Nähe einer Tür = wir bewegen sie gerade.</summary>
public void NoteInteraction(Vector3 playerPos)
{
if (!Input.GetMouseButtonDown(0) && !Input.GetKeyDown(KeyCode.F)) return;
foreach (Hinge h in hinges.Values)
{
if (h.Rb == null || (h.Rb.worldCenterOfMass - playerPos).sqrMagnitude > InteractRange * InteractRange) continue;
h.LocalAuthorityUntil = Time.time + InteractAuthority;
h.TargetTime = -100f; // eigene Bewegung: keine Feder mehr auf den alten Winkel
}
}
static float SafeAngle(Hinge h, float fallback)
{
float a = h.Joint.angle;
return float.IsNaN(a) || float.IsInfinity(a) ? fallback : a;
}
public void Handle(BinaryReader r)
{
int count = r.ReadInt32();
for (int i = 0; i < count; i++)
{
int key = r.ReadInt32();
float angle = r.ReadSingle();
Hinge h;
if (!hinges.TryGetValue(key, out h)) { nextScan = 0f; continue; }
if (float.IsNaN(angle) || float.IsInfinity(angle) || Mathf.Abs(angle) > 180f) continue;
if (Time.time < h.LocalAuthorityUntil) continue; // wir bewegen sie gerade selbst
h.TargetAngle = angle;
h.TargetTime = Time.time;
h.SentAngle = angle; // nicht als eigene Bewegung zurückschicken
h.HasSent = true;
}
}
/// <summary>Feder des Scharniers auf den empfangenen Winkel stellen bzw. wieder zurückgeben.</summary>
public void FixedUpdate()
{
foreach (Hinge h in hinges.Values)
{
if (h.Joint == null || h.Rb == null) continue;
bool fresh = Time.time - h.TargetTime < ApplyHold && !h.Rb.isKinematic;
if (!fresh) { StopDriving(h); continue; }
if (!h.Driving)
{
h.OrigUseSpring = h.Joint.useSpring;
h.OrigSpring = h.Joint.spring;
h.Driving = true;
}
float target = h.TargetAngle;
if (h.Joint.useLimits)
{
JointLimits l = h.Joint.limits;
target = Mathf.Clamp(target, Mathf.Min(l.min, l.max), Mathf.Max(l.min, l.max));
}
JointSpring s = h.Joint.spring;
s.spring = SpringForce;
s.damper = SpringDamper;
s.targetPosition = target;
h.Joint.spring = s;
h.Joint.useSpring = true;
h.Rb.WakeUp();
}
}
static void StopDriving(Hinge h)
{
if (!h.Driving) return;
h.Driving = false;
if (h.Joint == null) return;
h.Joint.spring = h.OrigSpring;
h.Joint.useSpring = h.OrigUseSpring;
}
void Scan()
{
int before = hinges.Count;
foreach (HingeJoint j in UnityEngine.Object.FindObjectsOfType<HingeJoint>())
{
if (j == null || !seen.Add(j)) continue;
Rigidbody rb = j.GetComponent<Rigidbody>();
Rigidbody body = j.connectedBody;
if (rb == null || body == null || !IsVehicle(body.transform.root.name)) continue;
int key = Hash(FsmSync.PathOf(j.transform) + "|" + FsmSync.PathOf(body.transform));
if (hinges.ContainsKey(key)) continue;
hinges[key] = new Hinge { Key = key, Root = body.transform.root.name, Joint = j, Rb = rb };
}
// Abgebaute Teile (Scharnier weg) vergessen
var gone = new List<int>();
foreach (KeyValuePair<int, Hinge> kv in hinges)
if (kv.Value.Joint == null || kv.Value.Rb == null) gone.Add(kv.Key);
foreach (int k in gone) hinges.Remove(k);
if (gone.Count > 0) seen.RemoveWhere(x => x == null);
if (hinges.Count != before) MscoLog.Info("[MSCO] Vehicle doors/hood/trunk: " + hinges.Count + " synced");
}
static bool IsVehicle(string root)
{
foreach (string v in ObjectSync.VehicleRoots) if (root.StartsWith(v)) return true;
return false;
}
static int Hash(string s)
{
unchecked
{
uint h = 2166136261;
foreach (char c in s) { h ^= c; h *= 16777619; }
return (int)h;
}
}
}
}
+47 -3
View File
@@ -8,7 +8,7 @@ using UnityEngine;
namespace MSCO
{
/// <summary>
/// Solange ein Eingabefenster offen ist (F8-Konsole, Chat, Admin-Menü): Spieler bewegt sich nicht,
/// Solange ein Eingabefenster offen ist (F7-Konsole, Chat, Admin-Menü): Spieler bewegt sich nicht,
/// Kamera dreht nicht mit, Mauszeiger ist frei. Danach wird genau das wieder eingeschaltet,
/// was hier ausgeschaltet wurde.
/// </summary>
@@ -74,11 +74,15 @@ namespace MSCO
Cursor.lockState = CursorLockMode.None;
}
/// <summary>PlayMaker-Aktionen, die den Mauszeiger setzen, überspringen, solange ein Fenster offen ist.</summary>
/// <summary>
/// PlayMaker-Aktionen, die den Mauszeiger setzen, überspringen, solange ein Fenster offen ist. Außerdem die
/// Tastenabfragen des Spiels (cInput) sperren: sonst löst Tippen im Chat Spielaktionen aus (Benutzen, Hupe ...).
/// </summary>
static void PatchCursorActions()
{
if (patched) return;
patched = true;
PatchGameInput();
try
{
Type action = null;
@@ -99,10 +103,50 @@ namespace MSCO
}
catch (Exception e)
{
ModConsole.Warning("[MSCO] Could not patch cursor actions: " + e.Message);
MscoLog.Warning("[MSCO] Could not patch cursor actions: " + e.Message);
}
}
static bool SkipWhileLocked() { return !locked; }
static void PatchGameInput()
{
try
{
Type input = typeof(cInput);
HarmonyInstance harmony = HarmonyInstance.Create("mbm.msco.inputlock.keys");
var boolPrefix = new HarmonyMethod(typeof(InputLock).GetMethod("NoKeyWhileLocked", BindingFlags.Static | BindingFlags.NonPublic));
var floatPrefix = new HarmonyMethod(typeof(InputLock).GetMethod("NoAxisWhileLocked", BindingFlags.Static | BindingFlags.NonPublic));
int count = 0;
foreach (MethodInfo m in input.GetMethods(BindingFlags.Static | BindingFlags.Public))
{
bool keyQuery = m.Name == "GetKey" || m.Name == "GetKeyDown" || m.Name == "GetKeyUp" ||
m.Name == "GetButton" || m.Name == "GetButtonDown" || m.Name == "GetButtonUp";
bool axisQuery = m.Name == "GetAxis" || m.Name == "GetAxisRaw";
if (keyQuery && m.ReturnType == typeof(bool)) { harmony.Patch(m, boolPrefix, null); count++; }
else if (axisQuery && m.ReturnType == typeof(float)) { harmony.Patch(m, floatPrefix, null); count++; }
}
MscoLog.Info("[MSCO] Game input is paused while typing (" + count + " cInput queries)");
}
catch (Exception e)
{
MscoLog.Warning("[MSCO] Could not pause game input while typing: " + e.Message);
}
}
// Harmony-Prefixe: solange ein Eingabefenster offen ist, meldet cInput "nichts gedrückt"
static bool NoKeyWhileLocked(ref bool __result)
{
if (!locked) return true;
__result = false;
return false;
}
static bool NoAxisWhileLocked(ref float __result)
{
if (!locked) return true;
__result = 0f;
return false;
}
}
}
+120
View File
@@ -0,0 +1,120 @@
using System.Collections.Generic;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Admin: Items spawnen. Kopiert ein vorhandenes Item des Spiels (z.B. "beer case(itemx)" aus dem Laden) und
/// setzt die Kopie an die gewünschte Stelle. Die Kopie behält den Namen des Originals, damit sich die Spiel-Logik
/// (Trinken, Essen, Tanken ...) wie beim echten Item verhält und der Objekt-Sync sie bei allen gleich benennt.
/// Werkzeuge wie der Ratschenkasten sind im Spiel keine Gegenstände, sondern ein Schalter ("hat der Spieler") -
/// die werden dem Admin direkt gegeben.
/// </summary>
public static class ItemSpawner
{
/// <summary>Werkzeug statt Gegenstand: globale Spielvariable, die "gekauft" bedeutet.</summary>
public const string RatchetSet = "ratchet set";
/// <summary>Auswahl im Admin-Menü (Anzeigename).</summary>
public static readonly string[] Items =
{
// Werkzeug / Auto
RatchetSet, "spanner set", "battery", "car jack", "floor jack", "motor hoist", "flashlight", "lantern",
"fire extinguisher", "spray can", "sledgehammer", "ax", "digging bar",
// Flüssigkeiten / Kraftstoff
"motor oil", "coolant", "brake fluid", "two stroke fuel", "gasoline", "diesel", "n2o bottle",
// Essen / Trinken / Laden
"beer case", "beer", "booze", "vodka shot", "milk", "juice", "coffee", "ground coffee", "sugar", "yeast",
"sausages", "pizza", "macaron box", "potato chips", "cigarettes", "mosquito spray", "grill charcoal",
// Sonstiges
"grill", "radio", "camera", "helmet", "bucket", "water bucket", "fireworks bag", "parts magazine"
};
// Items, die im Spiel nicht "(itemx)" heißen
static readonly Dictionary<string, string> objectNames = new Dictionary<string, string>
{
{ "battery", "battery(Clone)" }
};
static readonly Dictionary<string, GameObject> templates = new Dictionary<string, GameObject>();
public static bool IsKnown(string item) { return System.Array.IndexOf(Items, item) >= 0; }
static string ObjectName(string item)
{
string name;
return objectNames.TryGetValue(item, out name) ? name : item + "(itemx)";
}
static GameObject FindTemplate(string item) { return FindTemplateByObjectName(ObjectName(item)); }
static GameObject FindTemplateByObjectName(string name)
{
GameObject cached;
if (templates.TryGetValue(name, out cached) && cached != null) return cached;
GameObject best = null;
foreach (Object o in Resources.FindObjectsOfTypeAll(typeof(GameObject)))
{
var go = (GameObject)o;
if (go.name != name || go.GetComponent<Rigidbody>() == null) continue;
// Keine eingebauten Teile kopieren (z.B. die Batterie im Auto): nicht unter einem anderen Rigidbody
if (go.transform.parent != null && go.transform.parent.GetComponentInParent<Rigidbody>() != null) continue;
if (best == null || (go.activeInHierarchy && !best.activeInHierarchy)) best = go; // am liebsten eins aus der Welt
}
templates[name] = best;
return best;
}
/// <summary>Zuletzt erzeugtes Objekt (damit der Objekt-Sync es nicht als "neu gekauft" meldet).</summary>
public static GameObject LastSpawned { get; private set; }
/// <summary>Item nach seinem Objektnamen erzeugen (z.B. "beer case(itemx)") - für Einkäufe anderer Spieler.</summary>
public static bool SpawnObjectNamed(string objectName, Vector3 position, Quaternion rotation)
{
if (string.IsNullOrEmpty(objectName) || !objectName.EndsWith("(itemx)") || objectName.Length > 100) return false;
return SpawnTemplate(FindTemplateByObjectName(objectName), objectName, position, rotation);
}
/// <summary>Item erzeugen. Gibt false zurück, wenn es im Spiel kein solches Item gibt.</summary>
public static bool Spawn(string item, Vector3 position, Quaternion rotation)
{
return SpawnTemplate(FindTemplate(item), item, position, rotation);
}
static bool SpawnTemplate(GameObject template, string item, Vector3 position, Quaternion rotation)
{
LastSpawned = null;
if (template == null)
{
MscoLog.Warning("Cannot spawn '" + item + "' - no such item in this game");
return false;
}
var clone = (GameObject)Object.Instantiate(template, position, rotation);
LastSpawned = clone;
clone.name = template.name;
clone.transform.SetParent(null, true);
clone.SetActive(true);
Rigidbody rb = clone.GetComponent<Rigidbody>();
if (rb != null)
{
rb.isKinematic = false;
rb.velocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
}
return true;
}
/// <summary>Ratschenkasten "kaufen": der Spieler hat ihn dann wie nach dem Kauf im Laden.</summary>
public static bool GiveRatchetSet()
{
FsmBool has = FsmVariables.GlobalVariables.FindFsmBool("PlayerHasRatchet");
if (has == null) return false;
has.Value = true;
return true;
}
public static void ClearCache() { templates.Clear(); }
}
}
+2 -2
View File
@@ -26,7 +26,7 @@ namespace MSCO
string steamExe = SteamExePath();
if (steamExe == null)
{
ModConsole.Warning("[MSCO] Steam not found - JOIN links from the website are not available.");
MscoLog.Warning("[MSCO] Steam not found - JOIN links from the website are not available.");
return;
}
string command = "\"" + steamExe + "\" -applaunch " + MySummerCarAppId + " " + Argument + " \"%1\"";
@@ -41,7 +41,7 @@ namespace MSCO
}
catch (Exception e)
{
ModConsole.Warning("[MSCO] Could not register msco:// links: " + e.Message);
MscoLog.Warning("[MSCO] Could not register msco:// links: " + e.Message);
}
}
+643 -200
View File
File diff suppressed because it is too large Load Diff
+28 -4
View File
@@ -5,7 +5,30 @@ namespace MSCO
{
public class MSCO : Mod
{
public const string ModVersion = "2.6";
/// <summary>
/// Version der Mod (MSCLoader / MSC Launcher). Kleine Updates: nur die dritte Stelle erhöhen (2.8.1, 2.8.2 ...) -
/// sie bleiben mit 2.8-Servern und -Mitspielern kompatibel.
/// </summary>
public const string ModVersion = "2.8.1";
/// <summary>
/// Netzwerk-Version (die ersten beiden Stellen). Nur erhöhen, wenn sich das Protokoll inkompatibel ändert - dann
/// auch GameServer.ModVersion im Server und eine neue Mod-Version 2.9.0 o.ä.
/// </summary>
public const string ProtocolVersion = "2.8";
/// <summary>Passt eine andere Version (Server, Host, Mitspieler) zu uns? Verglichen werden nur die ersten zwei Stellen.</summary>
public static bool Compatible(string other)
{
return Protocol(other) == ProtocolVersion;
}
/// <summary>"2.8.3" -> "2.8"</summary>
public static string Protocol(string version)
{
if (string.IsNullOrEmpty(version)) return "";
string[] parts = version.Trim().Split('.');
return parts.Length >= 2 ? parts[0] + "." + parts[1] : version.Trim();
}
public override string ID => "MSCO"; // Your (unique) mod ID
public override string Name => "MSCO"; // Your mod name
@@ -32,7 +55,7 @@ namespace MSCO
/// <summary>Taste zum Ein-/Aussteigen als Beifahrer (in den MSCLoader-Einstellungen änderbar).</summary>
internal static SettingsKeybind PassengerKey;
/// <summary>MSCO-Konsole (Standard F8) und Chat (Standard T), ebenfalls änderbar.</summary>
/// <summary>MSCO-Konsole (Standard F7) und Chat (Standard T), ebenfalls änderbar.</summary>
internal static SettingsKeybind ConsoleKey, ChatKey;
private void Mod_Settings()
@@ -41,12 +64,13 @@ namespace MSCO
// DO NOT put anything that isn't settings or keybinds in here!
PassengerKey = Keybind.Add("passenger", "Ride as passenger (toggle)", KeyCode.P);
ChatKey = Keybind.Add("chat", "Open chat", KeyCode.T);
ConsoleKey = Keybind.Add("console", "Open MSCO console", KeyCode.F8);
// Nicht F8: im Pausenmenü öffnet F8 ein Test-Menü des Spiels. Neue ID, damit alte F8-Belegungen nicht übernommen werden
ConsoleKey = Keybind.Add("console_f7", "Open MSCO console", KeyCode.F7);
}
private void Mod_OnMenuLoad()
{
ModConsole.Print("Welcome to MSCO by mbm.wtf");
MscoLog.Info("Welcome to MSCO by mbm.wtf");
// Netzwerk + Lobby leben in einem eigenen Objekt, das Szenenwechsel überlebt
MultiplayerManager.Ensure();
if (!commandAdded)
+13 -1
View File
@@ -73,8 +73,20 @@
</ItemGroup>
<ItemGroup>
<Compile Include="MoneySync.cs" />
<Compile Include="WheelSpin.cs" />
<Compile Include="DamageSync.cs" />
<Compile Include="WorldVarSync.cs" />
<Compile Include="TrafficSync.cs" />
<Compile Include="HingeSync.cs" />
<Compile Include="ControlSync.cs" />
<Compile Include="Toasts.cs" />
<Compile Include="PlayerNeeds.cs" />
<Compile Include="Favorites.cs" />
<Compile Include="MpCommand.cs" />
<Compile Include="Commands.cs" />
<Compile Include="MscoLog.cs" />
<Compile Include="ItemSpawner.cs" />
<Compile Include="Cheats.cs" />
<Compile Include="InputLock.cs" />
<Compile Include="Overlays.cs" />
<Compile Include="SafetyPatches.cs" />
@@ -85,7 +97,6 @@
<Compile Include="DiscordPresence.cs" />
<Compile Include="DoorSync.cs" />
<Compile Include="FsmSync.cs" />
<Compile Include="GameCharacter.cs" />
<Compile Include="JoinLinks.cs" />
<Compile Include="LobbyUI.cs" />
<Compile Include="MenuHelper.cs" />
@@ -95,6 +106,7 @@
<Compile Include="NetSession.cs" />
<Compile Include="ObjectSync.cs" />
<Compile Include="Passenger.cs" />
<Compile Include="GameCharacter.cs" />
<Compile Include="SaveSync.cs" />
<Compile Include="ServerBrowser.cs" />
<Compile Include="SteamLobby.cs" />
+97 -2
View File
@@ -51,22 +51,117 @@ namespace MSCO
}
/// <summary>Startet das Spiel wie ein Klick auf CONTINUE.</summary>
// Knöpfe des Spiel-Hauptmenüs, die MSCO ersetzt (der MSCO-Launcher ist das Hauptmenü)
static readonly string[] HiddenMenuButtons = { "ButtonContinue", "ButtonNewgame", "ButtonCredits", "ButtonQuit", "ButtonChangelog" };
/// <summary>
/// Continue, New Game, Credits, Changelog und Quit des Spiels unsichtbar und unklickbar machen. Nicht abschalten:
/// die Logik hinter CONTINUE lädt beim Beitreten den übertragenen Spielstand (StartGame). Gibt die Anzahl zurück.
/// </summary>
public static int HideGameMenuButtons()
{
int hidden = 0;
foreach (string name in HiddenMenuButtons)
{
GameObject button = GameObject.Find(name);
if (button == null) continue;
foreach (Renderer r in button.GetComponentsInChildren<Renderer>(true)) r.enabled = false;
foreach (Collider c in button.GetComponentsInChildren<Collider>(true)) c.enabled = false;
hidden++;
}
return hidden;
}
/// <summary>
/// Geräusche und Musik des Spiel-Hauptmenüs stummschalten - der MSCO-Launcher deckt das ganze Menü ab.
/// Gibt die Anzahl der stummgeschalteten Quellen zurück.
/// </summary>
public static int MuteMenuAudio()
{
int muted = 0;
foreach (Object o in Object.FindObjectsOfType(typeof(AudioSource)))
{
var src = (AudioSource)o;
if (src == null || src.mute || src.transform.root.name.StartsWith("MSCO")) continue;
src.mute = true;
mutedByUs.Add(src);
muted++;
}
return muted;
}
static readonly System.Collections.Generic.List<AudioSource> mutedByUs = new System.Collections.Generic.List<AudioSource>();
/// <summary>Launcher eingeklappt: die von uns stummgeschalteten Menü-Geräusche wieder an.</summary>
public static void UnmuteMenuAudio()
{
foreach (AudioSource src in mutedByUs) if (src != null) src.mute = false;
mutedByUs.Clear();
}
public static void StartGame()
{
PlayMakerFSM fsm;
FsmState state;
if (switchState != null && FindContinueLoadState(out fsm, out state))
{
ModConsole.Print("[MSCO] Loading via CONTINUE (" + fsm.name + " / " + state.Name + ")");
MscoLog.Info("[MSCO] Loading via CONTINUE (" + fsm.name + " / " + state.Name + ")");
switchState.Invoke(fsm.Fsm, new object[] { state });
}
else
{
ModConsole.Warning("[MSCO] CONTINUE button not found, loading GAME directly");
MscoLog.Warning("[MSCO] CONTINUE button not found, loading GAME directly");
Application.LoadLevel(GameScene);
}
}
/// <summary>
/// Neues Spiel starten (der Spielstand wurde vorher geleert): über die Logik des NEW-GAME-Knopfs des Spiels,
/// sonst wie CONTINUE - ohne Spielstand legt das Spiel dann eine frische Welt an.
/// </summary>
public static void StartNewGame()
{
PlayMakerFSM fsm;
FsmState state;
if (switchState != null && FindLoadState("newgame", out fsm, out state))
{
MscoLog.Info("[MSCO] Starting a new game via NEW GAME (" + fsm.name + " / " + state.Name + ")");
switchState.Invoke(fsm.Fsm, new object[] { state });
return;
}
MscoLog.Info("[MSCO] NEW GAME button not found - starting with an empty save");
StartGame();
}
/// <summary>FSM unter einem Objekt, dessen Name "hint" enthält, mit einem Zustand, der eine Szene lädt.</summary>
static bool FindLoadState(string hint, out PlayMakerFSM fsm, out FsmState state)
{
fsm = null;
state = null;
// Nur aktive Objekte der Menü-Szene (die Spiel-Knöpfe sind nur unsichtbar, nicht abgeschaltet)
foreach (Object o in Object.FindObjectsOfType(typeof(PlayMakerFSM)))
{
var candidate = (PlayMakerFSM)o;
if (!HierarchyContains(candidate.transform, hint)) continue;
foreach (FsmState s in candidate.FsmStates)
{
if (!s.ActionsLoaded) s.LoadActions();
if (s.Actions == null) continue;
foreach (FsmStateAction action in s.Actions)
{
var load = action as LoadLevel;
if (load != null && load.levelName != null && !string.IsNullOrEmpty(load.levelName.Value))
{
fsm = candidate;
state = s;
return true;
}
}
}
}
return false;
}
static bool HierarchyContains(Transform t, string text)
{
for (; t != null; t = t.parent)
+2 -2
View File
@@ -2,11 +2,11 @@ using MSCLoader;
namespace MSCO
{
/// <summary>MSCLoader-Konsole: "mp &lt;befehl&gt;" - gleiche Befehle wie in der F8-Konsole.</summary>
/// <summary>MSCLoader-Konsole: "mp &lt;befehl&gt;" - gleiche Befehle wie in der F7-Konsole.</summary>
public class MpCommand : ConsoleCommand
{
public override string Name => "mp";
public override string Help => "MSCO commands, e.g. mp help | mp connect <host[:port]> | mp save (or press F8)";
public override string Help => "MSCO commands, e.g. mp help | mp connect <host[:port]> | mp save (or press F7)";
public override void Run(string[] args)
{
+46
View File
@@ -0,0 +1,46 @@
using System.Collections.Generic;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Zentrales Log der Mod: alle Meldungen landen in der MSCO-Konsole (F7), nicht in der MSCLoader-Konsole.
/// Zusätzlich gehen sie per Debug.Log in die output_log.txt des Spiels (zur Fehlersuche), ohne dass sich die
/// MSCLoader-Konsole öffnet. Thread-sicher: Meldungen aus Hintergrund-Threads werden im Hauptthread übernommen.
/// </summary>
public static class MscoLog
{
const string Prefix = "[MSCO] ";
static readonly Queue<string> pending = new Queue<string>();
static readonly object gate = new object();
public static void Info(string text) { Add(text, ""); }
public static void Warning(string text) { Add(text, "<color=#f2c21d>! </color>"); }
public static void Error(string text) { Add(text, "<color=#ee5a5e>ERROR </color>"); }
static void Add(string text, string marker)
{
string line = Strip(text ?? "");
Debug.Log(Prefix + line); // output_log.txt
lock (gate)
{
pending.Enqueue(marker + line);
while (pending.Count > 500) pending.Dequeue(); // falls die Konsole (noch) nicht abgeholt hat
}
}
/// <summary>Neue Meldungen abholen (Hauptthread, vom MultiplayerManager jedes Frame).</summary>
public static void Drain(System.Action<string> sink)
{
lock (gate)
{
while (pending.Count > 0) sink(pending.Dequeue());
}
}
static string Strip(string text)
{
return text.StartsWith(Prefix) ? text.Substring(Prefix.Length) : text;
}
}
}
+1170 -64
View File
File diff suppressed because it is too large Load Diff
+44 -6
View File
@@ -34,10 +34,25 @@ namespace MSCO
AdminStatus = 23, // Server -> Client: bool, ist Admin
AdminListRequest = 24, // Client -> Server: Spielerliste fürs Admin-Menü
AdminList = 25, // Server -> Client: int Anzahl, je [int id, string name, string steamId, bool admin]
AdminAction = 26, // Client -> Server: byte (1 = kick, 2 = ban), int Spieler-ID, string Grund
AdminAction = 26, // Client -> Server: byte (1 kick, 2 ban, 3 bring, 4 unban, 5 reset world, 6 backup, 7 restore backup, 8 set reset point), int Spieler-ID, string Grund/Name
AdminResult = 27, // Server -> Client: string Meldung
Chat = 28, // Chatnachricht (string)
VarSync = 29 // Fahrzeugzustand: int n, je [int key, byte typ, wert]
VarSync = 29, // Fahrzeugzustand: int n, je [int key, byte typ, wert]
Teleport = 30, // Admin holt einen Spieler zu sich: 3 float Position
Pong = 31, // Antwort auf Ping mit demselben Zeitstempel (Laufzeit messen)
SpawnItem = 32, // Admin spawnt ein Item: string Item-Name, 3 float Position, 4 float Drehung
Reconnect = 33, // Server -> Client: string Meldung; ins Menü und neu verbinden (z.B. Welt zurückgesetzt)
SaveRequest = 34, // Server -> Client: int Anfrage; im Spiel speichern und den Spielstand hochladen
SaveUploadPart = 35, // Client -> Server: int Anfrage, string Datei, int Gesamtgröße, int Offset, int Länge, bytes
SaveUploadDone = 36, // Client -> Server: int Anfrage, int Anzahl Dateien
ItemAppeared = 37, // neues Item bei einem Spieler (Einkauf, Tüte ausgepackt ...): int Objekt-ID, string Objektname, 3 float Pos, 4 float Rot
ObjDestroy = 38, // Item wurde verbraucht/zerstört (gegessen, getrunken ...): int Objekt-ID
VehicleDamage = 39, // Unfallschaden: int Mesh-Schlüssel, int Eckpunkte gesamt, int n, je [int Index, 3 float]
TrafficStates = 40, // Verkehr (nur Welt-Autorität): int n, je [int Schlüssel, 3 float Pos, 4 float Rot, 3 float Geschw.]
WorldVars = 41, // Jobs/Laden: int n, je [int Schlüssel, byte Typ (2 int, 3 bool), Wert]
HingeStates = 42, // Autotüren/Haube/Kofferraum: int n, je [int Schlüssel, float Scharnier-Winkel]
VehicleControls = 43, // Zündung/Motor/Licht ...: int n, je [int FSM-Schlüssel, string Zustand]
PlayerNeeds = 44 // Client -> Server: eigene Bedürfnisse (Hunger, Durst ...): int n, je [string Name, float Wert]
}
public struct Message
@@ -62,6 +77,12 @@ namespace MSCO
public Quaternion HeldLocalRot; // relativ zur Kamera
public bool Seated; // fährt oder sitzt als Beifahrer
public float FeetY; // Bodenhöhe unter dem Spieler (für Ducken/Lehnen der Figur)
// Im Fahrzeug: Kopf und Blick relativ zum Fahrzeug (ID des SyncedBody, 0 = nicht im Fahrzeug). Damit sitzt die
// Figur beim anderen fest auf dem Sitz, statt in Weltkoordinaten hinter dem fahrenden Auto herzuhinken.
// Steht am Ende der Nachricht: ältere Versionen und der Server lesen es einfach nicht mit.
public int SeatId;
public Vector3 SeatHead;
public Quaternion SeatView;
public byte[] Serialize()
{
@@ -75,9 +96,15 @@ namespace MSCO
w.Write(s.Seated);
w.Write(s.FeetY);
w.Write(s.HeldId);
if (s.HeldId == 0) return;
Write(w, s.HeldLocalPos);
Write(w, s.HeldLocalRot);
if (s.HeldId != 0)
{
Write(w, s.HeldLocalPos);
Write(w, s.HeldLocalRot);
}
w.Write(s.SeatId);
if (s.SeatId == 0) return;
Write(w, s.SeatHead);
Write(w, s.SeatView);
});
}
@@ -92,13 +119,24 @@ namespace MSCO
Seated = r.ReadBoolean(),
FeetY = r.ReadSingle(),
HeldId = r.ReadInt32(),
HeldLocalRot = Quaternion.identity
HeldLocalRot = Quaternion.identity,
SeatView = Quaternion.identity
};
if (s.HeldId != 0)
{
s.HeldLocalPos = ReadVector(r);
s.HeldLocalRot = ReadQuaternion(r);
}
// Ältere Versionen senden keinen Sitz
if (r.BaseStream.Length - r.BaseStream.Position >= 4)
{
s.SeatId = r.ReadInt32();
if (s.SeatId != 0)
{
s.SeatHead = ReadVector(r);
s.SeatView = ReadQuaternion(r);
}
}
return s;
}
+477 -28
View File
@@ -16,17 +16,43 @@ namespace MSCO
public int Id;
public string Path;
public Rigidbody Rb;
public string Name;
public GameObject Go; // zum Unterscheiden: nur Rigidbody weg (angebaut) oder ganzes Objekt weg (verbraucht)
public Vector3 LastPos;
public bool IsVehicle;
public string RootName;
public int OwnerId = NoOwner;
public bool OwnerActive; // Besitzer hält/fährt es gerade aktiv
public bool WasKinematic; // Zustand bevor ein anderer Spieler es übernommen hat
public RigidbodyInterpolation WasInterpolation;
public bool InterpolationSet; // Fahrzeug eines anderen: bei uns interpoliert (siehe FreezeForRemote)
// Gelenke der angebauten Teile, solange ein anderer das Fahrzeug fährt: unzerbrechlich (vorherige Bruchkraft/-moment)
public Dictionary<Joint, Vector2> HeldJoints;
public float NextPartScan;
// Nach der Rückgabe erst so spät wieder zerbrechlich (bis dahin schwingen die Teile ein)
public float JointRestoreAt = float.MaxValue;
// Item liegt in diesem Fahrzeug (Kofferraum, Sitz ...): gehört dann dem, der das Fahrzeug fährt
public SyncedBody Carrier;
public float NextCarrierCheck;
// Fahrzeug: Umriss in lokalen Koordinaten (für "liegt im Auto")
public Bounds LocalBounds;
public bool HasLocalBounds;
public float StillTime;
public float NoMotionClaimUntil; // kurz nach einer Übergabe nicht wegen Nachfedern/Nachrollen übernehmen
// Am Auto angebautes Teil (Gelenk zum Fahrzeug oder unter einem anderen Rigidbody): bewegt sich mit dem Auto,
// wird dann NICHT als loses Objekt synchronisiert - sonst reißt das Gelenk und das Teil fliegt weg
public bool Attached;
public float NextAttachCheck;
// Wie oft kurz hintereinander weggeschleudert (steckt es in etwas fest?)
public int RunawayCount;
public float RunawayWindowStart;
public float LastRunawayLog = -100f;
public Vector3 TargetPos;
public Quaternion TargetRot;
public Vector3 TargetVel; // Geschwindigkeit beim Besitzer: damit wird die Position vorausberechnet
public float TargetTime; // wann TargetPos ankam (Physik-Zeit, Time.fixedTime)
public float LastRecvTime;
public float AvatarHoldTime = -100f; // zuletzt von der Hand eines Mitspielers platziert
@@ -53,7 +79,8 @@ namespace MSCO
static readonly string[] IgnoredRoots = { "PLAYER", "TRAFFIC", "NPC_CARS" };
const float MaxSmallItemMass = 60f; // schwerere Dinge ohne ITEM/PART-Tag sind Teil der Welt
const float SendInterval = 1f / 15f;
const float SendInterval = 1f / 30f; // unbewegte Objekte werden ohnehin nicht gesendet
const float MaxExtrapolation = 0.25f; // höchstens so weit (Sekunden) in die Zukunft vorausrechnen
const float KeepAliveInterval = 1f;
const float ScanInterval = 3f;
const float FirstScanDelay = 3f;
@@ -63,6 +90,13 @@ namespace MSCO
const float ReleaseAfterStill = 1f;
const float SnapDistance = 10f;
const float RemoteTimeout = 3f; // Besitzer meldet sich nicht mehr (z.B. Teil wurde dort montiert)
// Ein gefahrenes Auto NICHT nach 3 s freigeben: hängt das Spiel des Fahrers kurz (z.B. beim Speichern), blieb es
// bei den anderen schlagartig stehen und die Teile rissen ab. Trennt der Fahrer, gibt ReleaseAllOf es frei.
const float DrivenVehicleTimeout = 30f;
const float JointSettleTime = 3f; // so lange nach der Rückgabe bleiben die Gelenke der Teile unzerbrechlich
const float CarrierCheckInterval = 0.4f;
const float CarrierMinSpeed = 1.5f; // ab dieser Geschwindigkeit nimmt ein Fahrzeug seine Ladung mit
const float RunawaySpeed = 90f; // m/s - schneller fährt in MSC nichts; darüber hat die Physik es weggeschleudert
const float HandoverCooldown = 3f; // nach Freigabe/Übergabe keine Übernahme durch Bewegung
const float MaxHoldDistance = 1.5f; // gehaltene Items sind so nah an der Kamera
const float VehicleClaimSpeed = 0.7f; // Fahrzeuge erst ab echter Bewegung übernehmen (nicht Federn)
@@ -74,6 +108,51 @@ namespace MSCO
public int LocalId;
/// <summary>Senden an alle anderen Spieler (setzt der MultiplayerManager).</summary>
public Action<MsgType, byte[]> Send;
/// <summary>Neues Item bei uns aufgetaucht (Einkauf ...) bzw. bei uns verbraucht - der Manager meldet es allen.</summary>
public Action<SyncedBody> ItemAppeared, ItemDestroyed;
// Übers Netz erzeugte Items (nicht nochmal als "neu" melden)
readonly HashSet<GameObject> networkCreated = new HashSet<GameObject>();
Vector3 lastPlayerPos;
const float LocalEventRange = 8f; // Aufgetaucht/verbraucht in dieser Nähe = wir haben es ausgelöst
public void MarkNetworkCreated(GameObject go) { if (go != null) networkCreated.Add(go); }
/// <summary>
/// Verschwundene Items melden (gegessen, getrunken, verbraucht) - nur wenn das ganze Objekt weg ist (nicht nur der
/// Rigidbody wie beim Anbauen), es ein Item ist und es bei uns passiert ist. Verschwinden viele auf einmal, wird
/// gerade die Szene entladen (Neu laden, Hauptmenü) - dann NICHTS melden, sonst löscht man bei allen die Welt.
/// </summary>
void ReportConsumed(Transform player)
{
if (ItemDestroyed == null || removeList.Count == 0) return;
if (player == null || removeList.Count > 2 || Time.time - loadTime < 30f) return;
foreach (SyncedBody b in removeList)
{
if (b.Go != null || b.IsVehicle || b.Name == null || !b.Name.EndsWith("(itemx)")) continue;
if (IsLocal(b) || (b.LastPos - lastPlayerPos).sqrMagnitude < LocalEventRange * LocalEventRange)
ItemDestroyed(b);
}
}
/// <summary>Gibt es dieses Objekt bei uns schon an dieser Stelle? (dann nicht nochmal erzeugen)</summary>
public bool HasNear(int id, Vector3 pos, float range)
{
SyncedBody b;
return byId.TryGetValue(id, out b) && b.Rb != null && (b.Rb.position - pos).sqrMagnitude < range * range;
}
/// <summary>Ein anderer Spieler hat dieses Item verbraucht: bei uns auch entfernen.</summary>
public bool DestroyRemote(int id)
{
SyncedBody b;
if (!byId.TryGetValue(id, out b)) return false;
// Nur echte Items (Essen, Getränke ...) - nie Fahrzeuge oder Teile, egal was ein anderer meldet
if (b.IsVehicle || b.Name == null || !b.Name.EndsWith("(itemx)")) return false;
if (b.Go != null) UnityEngine.Object.Destroy(b.Go);
Unregister(b);
return true;
}
readonly Dictionary<int, SyncedBody> byId = new Dictionary<int, SyncedBody>();
readonly Dictionary<Rigidbody, SyncedBody> byRb = new Dictionary<Rigidbody, SyncedBody>();
@@ -86,6 +165,8 @@ namespace MSCO
float loadTime;
float nextScan;
int frame;
readonly List<SyncedBody> drivenList = new List<SyncedBody>();
float sendTimer;
FsmGameObject pickedObject;
@@ -102,6 +183,9 @@ namespace MSCO
{
SyncedBody b;
if (!byId.TryGetValue(id, out b) || b.Rb == null || b.OwnerId != SyncedBody.NoOwner) return false;
if (!IsValidPose(pos, rot)) return false;
if (IsAttached(b)) return false; // inzwischen angebaut: Position kommt vom Auto
if (b.IsVehicle) pos = AboveGround(pos, b.Rb.transform); // gespeicherte Position evtl. knapp im Boden
b.Rb.transform.position = pos;
b.Rb.transform.rotation = rot;
if (!b.Rb.isKinematic)
@@ -121,6 +205,9 @@ namespace MSCO
public bool IsRemoteOwnedBody(SyncedBody b) { return IsRemoteOwned(b); }
/// <summary>Alle erfassten Fahrzeuge (Liste wird alle 2 s erneuert, nicht verändern).</summary>
public List<SyncedBody> AllVehicles() { return Vehicles(); }
/// <summary>Für "mp vehicles": Zustand aller erfassten Fahrzeuge (zur Fehlersuche).</summary>
public List<string> DescribeVehicles()
{
@@ -132,7 +219,8 @@ namespace MSCO
if (!b.IsVehicle) continue;
string owner = b.OwnerId == SyncedBody.NoOwner ? "free" : IsLocal(b) ? "ME" : "player " + b.OwnerId;
lines.Add(b.RootName + " id=" + b.Id + " owner=" + owner + (b.OwnerActive ? " (active)" : "") +
(b.Rb == null ? " [rigidbody gone]" : " kinematic=" + b.Rb.isKinematic + " speed=" + b.Rb.velocity.magnitude.ToString("0.0")));
(b.Rb == null ? " [rigidbody gone]" : " kinematic=" + b.Rb.isKinematic + " speed=" + b.Rb.velocity.magnitude.ToString("0.0") +
" pos=" + b.Rb.position.ToString("F0") + (b.Rb.gameObject.activeInHierarchy ? "" : " [inactive]")));
}
if (lines.Count == 1) lines.Add("No vehicles registered! (SATSUMA, HAYOSIKO, ... not found as root objects with a Rigidbody)");
return lines;
@@ -140,16 +228,30 @@ namespace MSCO
/// <summary>Fährt der lokale Spieler gerade selbst?</summary>
public bool IsDriving { get; private set; }
/// <summary>Das Fahrzeug, das der lokale Spieler gerade fährt (sonst null).</summary>
public SyncedBody Driven { get; private set; }
/// <summary>Fahrzeuge, die gerade ein anderer Spieler aktiv fährt.</summary>
public List<SyncedBody> RemoteDrivenVehicles()
{
var list = new List<SyncedBody>();
var list = drivenList; // wird jedes Frame gebraucht: Liste wiederverwenden
list.Clear();
foreach (SyncedBody b in byId.Values)
if (b.IsVehicle && IsRemoteOwned(b) && b.OwnerActive && b.Rb != null) list.Add(b);
return list;
}
/// <summary>Fahrzeuge, die gerade niemand besitzt (geparkt).</summary>
public List<SyncedBody> FreeVehicles()
{
var list = new List<SyncedBody>();
foreach (SyncedBody b in byId.Values)
if (b.IsVehicle && b.OwnerId == SyncedBody.NoOwner && b.Rb != null) list.Add(b);
return list;
}
public bool IsLocalBody(SyncedBody b) { return IsLocal(b); }
/// <summary>Fahrzeuge, die der lokale Spieler gerade besitzt (fährt/schiebt).</summary>
public List<SyncedBody> LocalVehicles()
{
@@ -173,10 +275,14 @@ namespace MSCO
public void Reset()
{
foreach (SyncedBody b in byId.Values)
if (IsRemoteOwned(b) && b.Rb != null) b.Rb.isKinematic = b.WasKinematic;
{
if (IsRemoteOwned(b) && b.Rb != null) RestoreKinematic(b);
RestoreJoints(b);
}
byId.Clear();
byRb.Clear();
rejected.Clear();
networkCreated.Clear();
drivenName = null;
drivenCache = null;
ScanComplete = false;
@@ -190,7 +296,7 @@ namespace MSCO
foreach (SyncedBody b in byId.Values)
{
if (b.OwnerId != playerId || b.Rb == null) continue;
b.Rb.isKinematic = b.WasKinematic;
RestoreKinematic(b);
b.OwnerId = SyncedBody.NoOwner;
b.NoMotionClaimUntil = Time.time + HandoverCooldown;
}
@@ -199,6 +305,7 @@ namespace MSCO
/// <summary>Besitzer eines Fahrzeugs (nach Wurzelname), NoOwner wenn frei oder unbekannt.</summary>
public int VehicleOwner(string rootName)
{
if (rootName == null) return SyncedBody.NoOwner;
foreach (SyncedBody b in byId.Values)
if (b.IsVehicle && b.RootName == rootName) return b.OwnerId;
return SyncedBody.NoOwner;
@@ -222,6 +329,7 @@ namespace MSCO
public void Update(Transform player, float dt)
{
if (player != null) lastPlayerPos = player.position;
if (Time.time >= nextScan)
{
nextScan = Time.time + ScanInterval;
@@ -232,20 +340,44 @@ namespace MSCO
SyncedBody held = HeldBody();
SyncedBody driven = DrivenBody();
Driven = driven;
IsDriving = driven != null;
bool motionClaimAllowed = Time.time - loadTime > MotionClaimGrace;
removeList.Clear();
frame++;
foreach (SyncedBody b in byId.Values)
{
if (b.Rb == null) { removeList.Add(b); continue; } // z.B. Teil wurde montiert
// Leistung: freie Objekte (der Großteil, meist liegen sie nur herum) nur jedes 4. Frame prüfen -
// eigene, fremde, gehaltene und gefahrene jedes Frame
if (b.OwnerId == SyncedBody.NoOwner && ((b.Id ^ frame) & 3) != 0 && b != held && b != driven) continue;
if (b.Rb == null)
{
removeList.Add(b);
continue;
}
b.LastPos = b.Rb.position;
if (!b.Rb.gameObject.activeInHierarchy) continue;
if (UpdateAttached(b)) continue; // angebaut: bewegt sich mit dem Auto
if (b.HeldJoints != null && !IsRemoteOwned(b) && Time.time >= b.JointRestoreAt) RestoreJoints(b);
bool active = b == held || b == driven;
if (!IsRemoteOwned(b)) StopRunaway(b);
// Ladung eines fahrenden Autos gehört dessen Fahrer: nur bei ihm wird sie mit dem Auto simuliert.
// Übernahm sie ein Mitfahrer, folgte sie beim Fahrer verspätet und kinematisch (unendlich schwer) und
// schlug Sitze und Teile ab.
SyncedBody carrier = active || b.IsVehicle ? null : CarrierOf(b);
bool carrierRemote = carrier != null && IsRemoteOwned(carrier);
bool carrierMine = carrier != null && IsLocal(carrier) && IsCarrying(carrier);
if (b.OwnerId == SyncedBody.NoOwner)
{
if (active) Claim(b, true);
else if (motionClaimAllowed && Time.time >= b.NoMotionClaimUntil && IsMoving(b) && IsNear(b, player)) Claim(b, false);
else if (carrierMine && IsMoving(b)) Claim(b, false);
else if (motionClaimAllowed && Time.time >= b.NoMotionClaimUntil && IsMoving(b) && IsNear(b, player) && !carrierRemote) Claim(b, false);
}
else if (IsLocal(b) && !active && carrierRemote && carrier.OwnerActive)
{
Release(b); // fährt ein anderer: der übernimmt die Ladung
}
else if (IsLocal(b))
{
@@ -264,10 +396,15 @@ namespace MSCO
}
else // gehört einem anderen Spieler
{
if (active && Wins(LocalId, true, b.OwnerId, b.OwnerActive)) Claim(b, true); // Spieler greift sich das Objekt
else if (Time.time - b.LastRecvTime > RemoteTimeout) SetFree(b);
// Trägt es gerade ein anderer, nur übernehmen, wenn wir es wirklich eben gegriffen haben - PickedObject
// zeigt oft noch auf ein altes Item; trägt es jemand an unserem Kopf vorbei, würden wir es ihm sonst entreißen
bool grab = b == driven || !b.OwnerActive || JustPicked;
if (active && grab && Wins(LocalId, true, b.OwnerId, b.OwnerActive)) Claim(b, true); // Spieler greift sich das Objekt
else if (carrierMine && !b.OwnerActive) Claim(b, false); // liegt in unserem fahrenden Auto
else if (Time.time - b.LastRecvTime > (b.IsVehicle && b.OwnerActive ? DrivenVehicleTimeout : RemoteTimeout)) SetFree(b);
}
}
ReportConsumed(player);
foreach (SyncedBody b in removeList) Unregister(b);
Held = held != null && held.Rb != null && IsLocal(held) ? held : null;
@@ -284,17 +421,25 @@ namespace MSCO
{
foreach (SyncedBody b in byId.Values)
{
if (!IsRemoteOwned(b) || b.Rb == null) continue;
if (!IsRemoteOwned(b) || b.Rb == null || b.Attached) continue;
if (b.IsVehicle && Time.time >= b.NextPartScan) HoldParts(b);
if (Time.time - b.AvatarHoldTime < 0.2f) continue; // sitzt gerade in der Hand eines Mitspielers
if ((b.Rb.position - b.TargetPos).sqrMagnitude > SnapDistance * SnapDistance)
// Dort, wo das Objekt beim Besitzer JETZT ungefähr ist: letzte Position + Geschwindigkeit * Alter.
// Ohne das hinken schnelle Objekte (fahrende Autos) immer einen Paketabstand hinterher und ruckeln.
// Beides in Physik-Zeit: mit der Bildzeit (Time.time beim Empfang) wurde das Alter in den ersten
// Physik-Schritten danach negativ - das Auto sprang bei jedem Paket ein Stück zurück (Ruckeln)
float age = Mathf.Clamp(Time.fixedTime - b.TargetTime, 0f, MaxExtrapolation);
Vector3 predicted = b.TargetPos + b.TargetVel * age;
if ((b.Rb.position - predicted).sqrMagnitude > SnapDistance * SnapDistance)
{
b.Rb.transform.position = b.TargetPos;
b.Rb.transform.position = predicted;
b.Rb.transform.rotation = b.TargetRot;
}
else
{
b.Rb.MovePosition(Vector3.Lerp(b.Rb.position, b.TargetPos, 0.35f));
b.Rb.MoveRotation(Quaternion.Slerp(b.Rb.rotation, b.TargetRot, 0.35f));
float follow = b.IsVehicle ? 0.5f : 0.35f;
b.Rb.MovePosition(Vector3.Lerp(b.Rb.position, predicted, follow));
b.Rb.MoveRotation(Quaternion.Slerp(b.Rb.rotation, b.TargetRot, follow));
}
}
}
@@ -306,7 +451,7 @@ namespace MSCO
public void ApplyAvatarHold(int playerId, int bodyId, Vector3 pos, Quaternion rot)
{
SyncedBody b;
if (!byId.TryGetValue(bodyId, out b) || b.Rb == null) return;
if (!byId.TryGetValue(bodyId, out b) || b.Rb == null || b.IsVehicle || b.Attached) return; // ältere Mods melden evtl. ein Auto als "gehalten"
if (b.OwnerId != playerId)
{
if (!Wins(playerId, true, b.OwnerId, b.OwnerActive)) return;
@@ -321,6 +466,8 @@ namespace MSCO
b.Rb.transform.rotation = rot;
b.TargetPos = pos;
b.TargetRot = rot;
b.TargetVel = Vector3.zero;
b.TargetTime = Time.time;
b.AvatarHoldTime = Time.time;
}
@@ -328,7 +475,7 @@ namespace MSCO
void Claim(SyncedBody b, bool active)
{
if (IsRemoteOwned(b)) b.Rb.isKinematic = b.WasKinematic;
if (IsRemoteOwned(b)) RestoreKinematic(b);
b.OwnerId = LocalId;
b.OwnerActive = active;
b.StillTime = 0f;
@@ -357,28 +504,162 @@ namespace MSCO
if (b.OwnerId == ownerId) return;
if (!IsRemoteOwned(b))
{
b.WasKinematic = b.Rb.isKinematic;
b.Rb.isKinematic = true;
FreezeForRemote(b);
b.TargetPos = b.Rb.position;
b.TargetRot = b.Rb.rotation;
b.TargetVel = Vector3.zero;
b.TargetTime = Time.time;
}
b.OwnerId = ownerId;
}
void SetFree(SyncedBody b)
{
if (IsRemoteOwned(b)) b.Rb.isKinematic = b.WasKinematic;
if (IsRemoteOwned(b)) RestoreKinematic(b);
b.OwnerId = SyncedBody.NoOwner;
b.NoMotionClaimUntil = Time.time + HandoverCooldown;
}
/// <summary>
/// Ein anderer Spieler bewegt das Objekt: bei uns nur noch folgen (kinematisch). Bei Fahrzeugen hängen die
/// angebauten Teile (Türen, Kotflügel, Sitze ...) mit Gelenken mit Bruchkraft am Auto. Die Teile dürfen NICHT
/// selbst kinematisch geschaltet werden (das bricht die Gelenke sofort, MSC wertet es als "Teil abgefallen"),
/// und das ruckartige Folgen würde sie ebenfalls abreißen. Deshalb werden ihre Gelenke solange unzerbrechlich.
/// </summary>
void FreezeForRemote(SyncedBody b)
{
b.WasKinematic = b.Rb.isKinematic;
b.Rb.isKinematic = true;
if (b.IsVehicle)
{
b.JointRestoreAt = float.MaxValue;
HoldParts(b);
// Physik läuft mit 50 Hz, das Bild öfter: ohne Interpolation springt das Auto nur in Physik-Schritten
// weiter - wer darin mitfährt, sieht die Welt ruckeln. Nur bei Fahrzeugen (Items setzt die Hand direkt).
b.WasInterpolation = b.Rb.interpolation;
b.InterpolationSet = true;
b.Rb.interpolation = RigidbodyInterpolation.Interpolate;
}
}
void HoldParts(SyncedBody b)
{
b.NextPartScan = Time.time + 2f; // später angebaute Teile ebenfalls
if (b.HeldJoints == null) b.HeldJoints = new Dictionary<Joint, Vector2>();
foreach (Joint j in b.Rb.GetComponentsInChildren<Joint>(true))
{
if (j == null || b.HeldJoints.ContainsKey(j)) continue;
b.HeldJoints[j] = new Vector2(j.breakForce, j.breakTorque);
j.breakForce = Mathf.Infinity;
j.breakTorque = Mathf.Infinity;
}
}
/// <summary>Wieder selbst simulieren: Auto zurück auf seinen Zustand, Gelenke wieder mit ihrer Bruchkraft.</summary>
void RestoreKinematic(SyncedBody b)
{
if (b.Rb != null)
{
b.Rb.isKinematic = b.WasKinematic;
if (b.InterpolationSet) b.Rb.interpolation = b.WasInterpolation;
// Mitten in der Fahrt zurückgegeben (Fahrer übergibt, steigt aus ...): mit seiner Geschwindigkeit
// weiterrollen statt schlagartig stehenzubleiben - die Teile wollen sonst mit Tempo weiter und reißen ab
if (b.IsVehicle && !b.Rb.isKinematic)
{
b.Rb.velocity = Time.fixedTime - b.TargetTime < 0.5f ? b.TargetVel : Vector3.zero;
b.Rb.angularVelocity = Vector3.zero;
}
}
b.InterpolationSet = false;
if (b.HeldJoints == null) return;
if (b.IsVehicle) { b.JointRestoreAt = Time.time + JointSettleTime; return; } // erst nach dem Einschwingen
RestoreJoints(b);
}
void RestoreJoints(SyncedBody b)
{
b.JointRestoreAt = float.MaxValue;
if (b.HeldJoints == null) return;
foreach (KeyValuePair<Joint, Vector2> kv in b.HeldJoints)
{
if (kv.Key == null) continue; // Teil wurde inzwischen abgebaut
kv.Key.breakForce = kv.Value.x;
kv.Key.breakTorque = kv.Value.y;
}
b.HeldJoints = null;
}
// ---------------------------------------------------------------- Ladung in Fahrzeugen
/// <summary>Fährt das Fahrzeug gerade (nimmt also seine Ladung mit)?</summary>
bool IsCarrying(SyncedBody v)
{
if (v.Rb == null) return false;
if (IsRemoteOwned(v)) return v.OwnerActive || v.TargetVel.sqrMagnitude > CarrierMinSpeed * CarrierMinSpeed;
return v == Driven || (!v.Rb.isKinematic && v.Rb.velocity.sqrMagnitude > CarrierMinSpeed * CarrierMinSpeed);
}
/// <summary>In welchem Fahrzeug liegt das Item gerade (null = in keinem)? Zwischengespeichert.</summary>
SyncedBody CarrierOf(SyncedBody item)
{
if (Time.time < item.NextCarrierCheck) return item.Carrier != null && item.Carrier.Rb != null ? item.Carrier : null;
item.NextCarrierCheck = Time.time + CarrierCheckInterval;
item.Carrier = null;
Vector3 p = item.Rb.worldCenterOfMass;
foreach (SyncedBody v in Vehicles())
{
if (v.Rb == null || (v.Rb.position - p).sqrMagnitude > 15f * 15f) continue;
if (!v.HasLocalBounds) ComputeLocalBounds(v);
if (v.LocalBounds.Contains(v.Rb.transform.InverseTransformPoint(p))) { item.Carrier = v; break; }
}
return item.Carrier;
}
readonly List<SyncedBody> vehicleList = new List<SyncedBody>();
float nextVehicleList;
List<SyncedBody> Vehicles()
{
if (Time.time < nextVehicleList) return vehicleList;
nextVehicleList = Time.time + 2f;
vehicleList.Clear();
foreach (SyncedBody b in byId.Values) if (b.IsVehicle && b.Rb != null) vehicleList.Add(b);
return vehicleList;
}
/// <summary>Umriss des Fahrzeugs (alle festen Collider) in seinen lokalen Koordinaten, etwas nach oben erweitert.</summary>
static void ComputeLocalBounds(SyncedBody v)
{
Transform t = v.Rb.transform;
bool any = false;
var local = new Bounds();
foreach (Collider c in v.Rb.GetComponentsInChildren<Collider>())
{
if (c == null || c.isTrigger || !c.enabled) continue;
Bounds wb = c.bounds;
for (int i = 0; i < 8; i++)
{
Vector3 corner = wb.center + Vector3.Scale(wb.extents, new Vector3((i & 1) == 0 ? -1 : 1, (i & 2) == 0 ? -1 : 1, (i & 4) == 0 ? -1 : 1));
Vector3 lp = t.InverseTransformPoint(corner);
if (!any) { local = new Bounds(lp, Vector3.zero); any = true; }
else local.Encapsulate(lp);
}
}
if (!any) local = new Bounds(Vector3.zero, new Vector3(2f, 1.5f, 4f));
// Weltachsen-Umrisse sind bei schrägem Auto zu groß - etwas verkleinern, oben Platz für Ladung lassen
local.Expand(new Vector3(-0.2f, 0f, -0.2f));
local.max = new Vector3(local.max.x, local.max.y + 0.5f, local.max.z);
v.LocalBounds = local;
v.HasLocalBounds = true;
}
void SendOwnedStates()
{
sendList.Clear();
float now = Time.time;
foreach (SyncedBody b in byId.Values)
{
if (!IsLocal(b) || b.Rb == null) continue;
if (!IsLocal(b) || b.Rb == null || b.Attached) continue;
bool changed = (b.Rb.position - b.LastSentPos).sqrMagnitude > 0.0001f
|| Quaternion.Angle(b.Rb.rotation, b.LastSentRot) > 0.5f;
if (!changed && now - b.LastSentTime < KeepAliveInterval) continue;
@@ -407,7 +688,7 @@ namespace MSCO
int id = r.ReadInt32();
bool active = r.ReadBoolean();
SyncedBody b;
if (!byId.TryGetValue(id, out b) || b.Rb == null) return;
if (!byId.TryGetValue(id, out b) || b.Rb == null || IsAttached(b)) return;
if (!Wins(from, active, b.OwnerId, b.OwnerActive))
{
// Wir besitzen es und gewinnen: nochmal melden, damit alle es wissen
@@ -426,12 +707,14 @@ namespace MSCO
int id; Vector3 pos; Quaternion rot; Vector3 vel;
ReadState(r, out id, out pos, out rot, out vel);
SyncedBody b;
if (!byId.TryGetValue(id, out b) || b.Rb == null) continue;
if (!byId.TryGetValue(id, out b) || b.Rb == null || !IsValidPose(pos, rot) || IsAttached(b)) continue;
// Nur dem aktuellen Besitzer folgen (oder freie Objekte übernehmen)
if (b.OwnerId != from && b.OwnerId != SyncedBody.NoOwner) continue;
SetRemote(b, from, b.OwnerId == from && b.OwnerActive);
b.TargetPos = pos;
b.TargetRot = rot;
b.TargetVel = vel.sqrMagnitude < 90f * 90f ? vel : Vector3.zero;
b.TargetTime = Time.fixedTime;
}
break;
}
@@ -442,9 +725,10 @@ namespace MSCO
ReadState(r, out id, out pos, out rot, out vel);
SyncedBody b;
if (!byId.TryGetValue(id, out b) || b.Rb == null || b.OwnerId != from) return;
if (!IsValidPose(pos, rot) || IsAttached(b)) { SetFree(b); return; }
b.Rb.transform.position = pos;
b.Rb.transform.rotation = rot;
b.Rb.isKinematic = b.WasKinematic;
RestoreKinematic(b);
if (!b.Rb.isKinematic)
{
b.Rb.velocity = vel;
@@ -457,6 +741,140 @@ namespace MSCO
}
}
/// <summary>
/// Wird ein Objekt von der Physik weggeschleudert (z.B. nach dem Setzen einer Position, die im Boden lag),
/// sofort anhalten und auf die letzte gute Position zurücksetzen - statt es quer über die Karte fliegen zu lassen.
/// </summary>
void StopRunaway(SyncedBody b)
{
if (b.Attached) return;
Rigidbody rb = b.Rb;
if (rb.isKinematic || rb.velocity.sqrMagnitude < RunawaySpeed * RunawaySpeed) return;
rb.velocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
b.NoMotionClaimUntil = Time.time + HandoverCooldown;
// Wird es kurz hintereinander wieder weggeschleudert, steckt es in etwas fest (z.B. loses Teil im Auto).
// NICHT an die alte Stelle zurücksetzen (dort steckt es ja) - sondern von oben auf die nächste freie Fläche.
if (Time.time - b.RunawayWindowStart > 5f) { b.RunawayWindowStart = Time.time; b.RunawayCount = 0; }
b.RunawayCount++;
bool stuck = b.RunawayCount >= 2;
if (stuck)
{
PlaceOnTop(rb);
b.RunawayCount = 0;
}
rb.Sleep();
// Nicht das Log fluten: pro Objekt höchstens alle 30 s melden
if (Time.time - b.LastRunawayLog > 30f)
{
b.LastRunawayLog = Time.time;
MscoLog.Warning("Stopped " + b.RootName + " - it was flung away by the physics" + (stuck ? " (was stuck, placed on top)" : ""));
}
}
/// <summary>Objekt von oben auf die höchste Fläche an seiner Stelle legen (eigene Collider und Spieler ignoriert).</summary>
static void PlaceOnTop(Rigidbody rb)
{
Bounds bounds = new Bounds(rb.worldCenterOfMass, Vector3.zero);
foreach (Collider c in rb.GetComponentsInChildren<Collider>()) bounds.Encapsulate(c.bounds);
Vector3 from = new Vector3(bounds.center.x, bounds.max.y + 50f, bounds.center.z);
float top = float.MinValue;
foreach (RaycastHit hit in Physics.RaycastAll(from, Vector3.down, 120f))
{
if (hit.collider.isTrigger || hit.transform.IsChildOf(rb.transform) || hit.transform.root.name == "PLAYER") continue;
if (hit.point.y > top) top = hit.point.y;
}
if (top == float.MinValue) return;
float lift = top + bounds.extents.y + 0.3f - bounds.center.y;
rb.transform.position += Vector3.up * lift;
}
/// <summary>
/// Ist das Objekt gerade an ein Fahrzeug angebaut? Dann Besitz aufgeben und nicht mehr als loses Objekt behandeln.
/// Wird es wieder abgebaut, läuft der Sync ganz normal weiter. Gibt true zurück, solange es angebaut ist.
/// </summary>
bool UpdateAttached(SyncedBody b)
{
if (Time.time < b.NextAttachCheck) return b.Attached;
b.NextAttachCheck = Time.time + 0.5f;
bool attached = IsAttached(b);
if (attached == b.Attached) return attached;
b.Attached = attached;
if (attached)
{
if (IsRemoteOwned(b)) RestoreKinematic(b);
b.OwnerId = SyncedBody.NoOwner; // jeder Spieler erkennt das Anbauen selbst (Montage kommt über FsmSync)
b.OwnerActive = false;
}
else b.NoMotionClaimUntil = Time.time + HandoverCooldown; // gerade abgebaut: kurz zur Ruhe kommen lassen
return attached;
}
static bool IsAttached(SyncedBody b)
{
if (b.IsVehicle || b.Rb == null) return false;
// Unter einem anderen Rigidbody (z.B. am Auto eingehängt)
for (Transform p = b.Rb.transform.parent; p != null; p = p.parent)
if (p.GetComponent<Rigidbody>() != null) return true;
// Per Gelenk mit etwas verbunden, das nicht der Spieler ist (die Hand hält Items nicht über Gelenke fest)
foreach (Joint j in b.Rb.GetComponents<Joint>())
if (j != null && j.connectedBody != null && j.connectedBody.transform.root.name != "PLAYER") return true;
return false;
}
/// <summary>Position innerhalb der Karte, Drehung gültig (gleiche Regel wie auf dem Server).</summary>
static bool IsValidPose(Vector3 p, Quaternion q)
{
if (float.IsNaN(p.x) || float.IsNaN(p.y) || float.IsNaN(p.z) || float.IsInfinity(p.x) || float.IsInfinity(p.y) || float.IsInfinity(p.z))
return false;
if (Mathf.Abs(p.x) > 6000f || Mathf.Abs(p.z) > 6000f || p.y < -200f || p.y > 3000f) return false;
float len = Mathf.Sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
return len > 0.9f && len < 1.1f;
}
/// <summary>
/// Liegt die Position im Boden, auf die Bodenoberfläche heben. Kurzer Strahl ab 2 m über dem Punkt
/// (nicht aufs Garagendach), die eigenen Teile des Fahrzeugs werden ignoriert.
/// </summary>
static Vector3 AboveGround(Vector3 pos, Transform ignore)
{
float ground = float.MinValue;
foreach (RaycastHit hit in Physics.RaycastAll(pos + Vector3.up * 2f, Vector3.down, 4f))
{
if (hit.collider.isTrigger || (ignore != null && hit.transform.IsChildOf(ignore))) continue;
if (hit.transform.root.name == "PLAYER") continue;
ground = Mathf.Max(ground, hit.point.y);
}
return ground > pos.y - 0.2f ? new Vector3(pos.x, ground + 0.6f, pos.z) : pos;
}
/// <summary>
/// Admin: Fahrzeug (Wurzelname beginnt mit rootPrefix) an eine Position holen. Fährt es gerade ein
/// anderer Spieler, geht das nicht. Gibt einen Statustext zurück.
/// </summary>
public string BringVehicle(string rootPrefix, Vector3 pos, Quaternion rot)
{
foreach (SyncedBody b in byId.Values)
{
if (!b.IsVehicle || b.Rb == null || !b.RootName.StartsWith(rootPrefix)) continue;
if (IsRemoteOwned(b) && b.OwnerActive) return rootPrefix + " is being driven by another player";
Claim(b, false); // wir besitzen es jetzt -> Position geht an alle und an den Server
b.Rb.transform.position = AboveGround(pos, b.Rb.transform);
b.Rb.transform.rotation = rot;
if (!b.Rb.isKinematic)
{
b.Rb.velocity = Vector3.zero;
b.Rb.angularVelocity = Vector3.zero;
}
b.StillTime = 0f;
b.LastSentTime = -100f;
return rootPrefix + " is next to you";
}
return rootPrefix + " not found (mp vehicles shows all vehicles)";
}
static void WriteState(BinaryWriter w, SyncedBody b)
{
Vector3 p = b.Rb.position;
@@ -521,20 +939,34 @@ namespace MSCO
if (best != null)
{
pickedObject = best.FsmVariables.FindFsmGameObject("PickedObject");
ModConsole.Print("[MSCO] Found pickup FSM on " + best.gameObject.name +
MscoLog.Info("[MSCO] Found pickup FSM on " + best.gameObject.name +
(best.gameObject.activeInHierarchy ? "" : " (inactive)"));
}
}
}
GameObject lastPicked; // letzter Wert von PickedObject - ändert er sich, hat der Spieler wirklich neu gegriffen
float pickTime = -100f;
/// <summary>Hat der Spieler gerade eben (nicht vor Minuten) etwas gegriffen?</summary>
bool JustPicked { get { return Time.time - pickTime < 0.6f; } }
SyncedBody HeldBody()
{
GameObject picked = pickedObject != null ? pickedObject.Value : null;
if (picked != lastPicked)
{
lastPicked = picked;
if (picked != null) pickTime = Time.time;
}
if (pickedObject == null || pickedObject.Value == null) return null;
for (Transform t = pickedObject.Value.transform; t != null; t = t.parent)
{
Rigidbody rb = t.GetComponent<Rigidbody>();
SyncedBody b;
if (rb != null && byRb.TryGetValue(rb, out b)) return IsReallyInHand(b) ? b : null;
// Fahrzeuge hält man nie in der Hand: im Auto zeigt PickedObject z.B. aufs Lenkrad, dessen
// Physik-Körper ist das Auto selbst - das Auto würde sonst mit dem Kopf mitkippen
if (rb != null && byRb.TryGetValue(rb, out b)) return !b.IsVehicle && IsReallyInHand(b) ? b : null;
}
return null;
}
@@ -567,6 +999,13 @@ namespace MSCO
// ---------------------------------------------------------------- Registrierung
/// <summary>Sofort nach neuen Objekten suchen (z.B. nach dem Nachspielen von Montage-Aktionen).</summary>
public void ScanNow()
{
Scan();
nextScan = Time.time + ScanInterval;
}
/// <summary>Neue Rigidbodies suchen (gekaufte Items, abmontierte Teile ...).</summary>
void Scan()
{
@@ -575,9 +1014,9 @@ namespace MSCO
var candidates = new List<KeyValuePair<Rigidbody, bool>>();
var rootsByName = new Dictionary<string, List<Transform>>();
foreach (UnityEngine.Object o in UnityEngine.Object.FindObjectsOfType(typeof(Rigidbody)))
foreach (Rigidbody rb in SceneCache.Rigidbodies(1f))
{
var rb = (Rigidbody)o;
if (rb == null) continue;
Transform root = rb.transform.root;
List<Transform> roots;
if (!rootsByName.TryGetValue(root.name, out roots)) rootsByName[root.name] = roots = new List<Transform>();
@@ -605,14 +1044,24 @@ namespace MSCO
Id = id,
Path = path,
Rb = c.Key,
Go = c.Key.gameObject,
Name = c.Key.gameObject.name,
LastPos = c.Key.position,
IsVehicle = c.Value,
RootName = c.Key.transform.root.name
};
byId[id] = b;
byRb[c.Key] = b;
added++;
// Neues Item nach dem ersten Suchlauf, bei uns in der Nähe und nicht übers Netz entstanden:
// wahrscheinlich gekauft / ausgepackt / geliefert - den anderen melden
bool isItem = !b.IsVehicle && b.Go.name.EndsWith("(itemx)");
if (ScanComplete && isItem && ItemAppeared != null && !networkCreated.Contains(b.Go) && Time.time - loadTime > 30f
&& (b.LastPos - lastPlayerPos).sqrMagnitude < LocalEventRange * LocalEventRange)
ItemAppeared(b);
}
if (added > 0) ModConsole.Print("[MSCO] Syncing " + byId.Count + " objects (+" + added + ")");
if (added > 0) MscoLog.Info("[MSCO] Syncing " + byId.Count + " objects (+" + added + ")");
}
static bool ShouldSync(Rigidbody rb, out bool isVehicle)
+187 -11
View File
@@ -14,7 +14,7 @@ namespace MSCO
}
/// <summary>
/// Fenster über dem Spiel: F8-Konsole (alle MSCO-Befehle), Chat (T) und Admin-Menü (/admin).
/// Fenster über dem Spiel: F7-Konsole (alle MSCO-Befehle), Chat (T) und Admin-Menü (/admin).
/// Solange eins davon offen ist, sind Spielerbewegung und Kamera gesperrt (InputLock).
/// </summary>
public class Overlays
@@ -51,6 +51,8 @@ namespace MSCO
// Admin
public bool AdminOpen { get; private set; }
List<AdminPlayer> adminPlayers;
List<string> bannedIds;
int adminTab;
string adminReason = "";
int confirmBanId = -1;
Vector2 adminScroll;
@@ -92,6 +94,10 @@ namespace MSCO
}
public void SetAdminPlayers(List<AdminPlayer> players) { adminPlayers = players; }
public void SetBanned(List<string> ids) { bannedIds = ids; }
public void SetBackups(List<string> names) { backupNames = names; }
List<string> backupNames;
string confirmWorld; // "reset" oder Name des Backups, das wiederhergestellt werden soll
public void CloseAll()
{
@@ -130,10 +136,11 @@ namespace MSCO
{
float width = Mathf.Min(Screen.width - 40, 960);
var rect = new Rect((Screen.width - width) / 2f, 20, width, Mathf.Min(340, Screen.height * 0.5f));
UiTheme.DrawShadow(rect);
GUI.Box(rect, GUIContent.none, panel);
GUI.Label(new Rect(rect.x + 16, rect.y + 10, 300, 20), "MSCO CONSOLE", caption);
GUI.Label(new Rect(rect.xMax - 216, rect.y + 10, 200, 20), "type 'help' - F8 to close", small);
GUI.Label(new Rect(rect.xMax - 216, rect.y + 10, 200, 20), "type 'help' - F7 to close", small);
// Tasten: Enter ausführen, Esc schließen, Hoch/Runter Verlauf
Event e = Event.current;
@@ -263,8 +270,9 @@ namespace MSCO
void DrawAdmin(MultiplayerManager mp)
{
float width = Mathf.Min(600, Screen.width - 40), height = Mathf.Min(460, Screen.height - 40);
float width = Mathf.Min(720, Screen.width - 40), height = Mathf.Min(580, Screen.height - 40);
var rect = new Rect((Screen.width - width) / 2f, (Screen.height - height) / 2f, width, height);
UiTheme.DrawShadow(rect);
GUI.Box(rect, GUIContent.none, panel);
if (Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Escape)
@@ -281,12 +289,47 @@ namespace MSCO
GUILayout.Space(8);
if (GUILayout.Button("CLOSE", secondary, GUILayout.Height(30), GUILayout.Width(90))) AdminOpen = false;
GUILayout.EndHorizontal();
GUILayout.Label(mp.IsDedicated ? "Players on this server. Banned players cannot join again." : "Players in your game.", small);
GUILayout.Space(6);
// Reiter
GUILayout.BeginHorizontal();
string[] tabs = { "PLAYERS", "ITEMS", "BANS" + (bannedIds != null && bannedIds.Count > 0 ? " (" + bannedIds.Count + ")" : ""), "WORLD" };
for (int i = 0; i < tabs.Length; i++)
{
if (GUILayout.Button(tabs[i], adminTab == i ? primary : secondary, GUILayout.Height(30), GUILayout.Width(130))) adminTab = i;
GUILayout.Space(6);
}
GUILayout.EndHorizontal();
GUILayout.Space(10);
GUILayout.Label("REASON (optional)", caption);
if (adminTab == 1) DrawAdminItems(mp);
else if (adminTab == 2) DrawAdminBans(mp);
else if (adminTab == 3) DrawAdminWorld(mp);
else DrawAdminPlayers(mp);
GUILayout.EndArea();
}
void DrawAdminPlayers(MultiplayerManager mp)
{
// Cheats für den eigenen Spieler (nur im Spiel)
if (mp.CanCheat)
{
GUILayout.BeginHorizontal();
if (GUILayout.Button("+1.000 MK", primary, GUILayout.Height(30))) mp.CheatMoney(1000f);
GUILayout.Space(6);
if (GUILayout.Button("+10.000 MK", primary, GUILayout.Height(30))) mp.CheatMoney(10000f);
GUILayout.Space(6);
if (GUILayout.Button("REFILL NEEDS", primary, GUILayout.Height(30))) mp.CheatNeeds();
GUILayout.Space(6);
if (GUILayout.Button("SATSUMA TO ME", primary, GUILayout.Height(30))) mp.BringCar();
GUILayout.EndHorizontal();
GUILayout.Label("Cheats are announced in the chat. GOTO teleports you to a player, BRING teleports a player to you.", small);
GUILayout.Space(8);
}
GUILayout.Label("REASON FOR KICK / BAN (optional)", caption);
adminReason = GUILayout.TextField(adminReason, 100, field, GUILayout.Height(32));
GUILayout.Space(12);
GUILayout.Space(10);
adminScroll = GUILayout.BeginScrollView(adminScroll);
if (adminPlayers == null) GUILayout.Label("Loading players...", small);
@@ -304,12 +347,28 @@ namespace MSCO
GUILayout.FlexibleSpace();
bool self = p.Id == mp.LocalId;
if (mp.CanCheat)
{
GUI.enabled = !self;
if (GUILayout.Button("GOTO", secondary, GUILayout.Height(30), GUILayout.Width(70))) mp.GotoPlayer(p.Id);
GUILayout.Space(6);
if (GUILayout.Button("BRING", secondary, GUILayout.Height(30), GUILayout.Width(70))) mp.BringPlayer(p.Id);
GUILayout.Space(12);
}
if (mp.IsDedicated)
{
// Admin geben/nehmen (braucht Steam; der Server meldet, wenn es nicht geht)
GUI.enabled = !self;
if (GUILayout.Button(p.IsAdmin ? "REMOVE ADMIN" : "MAKE ADMIN", secondary, GUILayout.Height(30), GUILayout.Width(120)))
mp.SetAdmin(p.Id, !p.IsAdmin);
GUILayout.Space(12);
}
GUI.enabled = !self && !p.IsAdmin;
if (GUILayout.Button("KICK", secondary, GUILayout.Height(30), GUILayout.Width(80)))
if (GUILayout.Button("KICK", secondary, GUILayout.Height(30), GUILayout.Width(70)))
mp.AdminAction(false, p.Id, adminReason);
GUILayout.Space(6);
bool confirming = confirmBanId == p.Id;
if (GUILayout.Button(confirming ? "SURE?" : "BAN", danger, GUILayout.Height(30), GUILayout.Width(80)))
if (GUILayout.Button(confirming ? "SURE?" : "BAN", danger, GUILayout.Height(30), GUILayout.Width(70)))
{
if (confirming) { mp.AdminAction(true, p.Id, adminReason); confirmBanId = -1; }
else confirmBanId = p.Id;
@@ -320,7 +379,123 @@ namespace MSCO
}
}
GUILayout.EndScrollView();
GUILayout.EndArea();
}
void DrawAdminItems(MultiplayerManager mp)
{
if (!mp.CanCheat)
{
GUILayout.Label("Items can only be spawned in the game.", small);
return;
}
GUILayout.Label("Click an item to spawn it in front of you. Everyone sees it, and it is saved on the server.", small);
GUILayout.Space(8);
adminScroll = GUILayout.BeginScrollView(adminScroll);
const int perRow = 4;
for (int i = 0; i < ItemSpawner.Items.Length; i += perRow)
{
GUILayout.BeginHorizontal();
for (int k = i; k < Mathf.Min(i + perRow, ItemSpawner.Items.Length); k++)
{
string item = ItemSpawner.Items[k];
if (GUILayout.Button(item.ToUpper(), secondary, GUILayout.Height(34), GUILayout.Width(155))) mp.SpawnItem(item);
GUILayout.Space(6);
}
GUILayout.EndHorizontal();
GUILayout.Space(6);
}
GUILayout.EndScrollView();
}
/// <summary>Welt zurücksetzen, Backups anlegen und wiederherstellen (dedizierter Server).</summary>
void DrawAdminWorld(MultiplayerManager mp)
{
if (!mp.IsDedicated)
{
GUILayout.Label("World reset and save backups are only available on a dedicated server.", small);
return;
}
GUILayout.Label("Everyone reconnects after a reset or restore. The current state is backed up first.", small);
GUILayout.Space(8);
GUILayout.BeginHorizontal();
if (confirmWorld == "reset")
{
if (GUILayout.Button("REALLY RESET?", danger, GUILayout.Height(32), GUILayout.Width(170))) { mp.WorldAction(5, null); confirmWorld = null; }
GUILayout.Space(6);
if (GUILayout.Button("CANCEL", secondary, GUILayout.Height(32), GUILayout.Width(100))) confirmWorld = null;
}
else if (GUILayout.Button("RESET WORLD", danger, GUILayout.Height(32), GUILayout.Width(170))) confirmWorld = "reset";
GUILayout.Space(10);
if (GUILayout.Button("BACKUP NOW", primary, GUILayout.Height(32), GUILayout.Width(150))) mp.WorldAction(6, null);
GUILayout.Space(6);
if (confirmWorld == "point")
{
if (GUILayout.Button("REALLY?", danger, GUILayout.Height(32), GUILayout.Width(100))) { mp.WorldAction(8, null); confirmWorld = null; }
GUILayout.Space(4);
if (GUILayout.Button("CANCEL", secondary, GUILayout.Height(32), GUILayout.Width(90))) confirmWorld = null;
}
else if (GUILayout.Button("SET RESET POINT", secondary, GUILayout.Height(32), GUILayout.Width(170))) confirmWorld = "point";
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
GUILayout.Label("Reset: the whole world goes back to the save game as it was uploaded to the server (the reset point). " +
"SET RESET POINT makes the current save the new reset point.", small);
GUILayout.Space(10);
GUILayout.Label("SAVE BACKUPS (NEWEST FIRST)", small);
adminScroll = GUILayout.BeginScrollView(adminScroll);
if (backupNames == null) GUILayout.Label("Loading...", small);
else if (backupNames.Count == 0) GUILayout.Label("No backups yet. They are created automatically when the server takes over a save game, or with BACKUP NOW.", small);
else
{
foreach (string name in backupNames)
{
GUILayout.BeginHorizontal(rowStyle, GUILayout.Height(40));
GUILayout.Label(PrettyBackup(name), chatName);
GUILayout.FlexibleSpace();
if (confirmWorld == name)
{
if (GUILayout.Button("REALLY?", danger, GUILayout.Height(28), GUILayout.Width(100))) { mp.WorldAction(7, name); confirmWorld = null; }
GUILayout.Space(4);
if (GUILayout.Button("CANCEL", secondary, GUILayout.Height(28), GUILayout.Width(90))) confirmWorld = null;
}
else if (GUILayout.Button("RESTORE", primary, GUILayout.Height(28), GUILayout.Width(100))) confirmWorld = name;
GUILayout.EndHorizontal();
GUILayout.Space(4);
}
}
GUILayout.EndScrollView();
}
/// <summary>"20260927-104626" -> "27.09.2026 10:46:26".</summary>
static string PrettyBackup(string name)
{
if (name == null || name.Length != 15 || name[8] != '-') return name;
return name.Substring(6, 2) + "." + name.Substring(4, 2) + "." + name.Substring(0, 4) + " " +
name.Substring(9, 2) + ":" + name.Substring(11, 2) + ":" + name.Substring(13, 2);
}
void DrawAdminBans(MultiplayerManager mp)
{
GUILayout.Label(mp.IsDedicated ? "Banned players cannot join this server. UNBAN lets them in again."
: "Players banned from your game in this session.", small);
GUILayout.Space(8);
adminScroll = GUILayout.BeginScrollView(adminScroll);
if (bannedIds == null) GUILayout.Label("Loading...", small);
else if (bannedIds.Count == 0) GUILayout.Label("Nobody is banned.", small);
else
{
foreach (string id in bannedIds)
{
GUILayout.BeginHorizontal(rowStyle, GUILayout.Height(40));
GUILayout.Label(id.StartsWith("name:") ? id.Substring(5) + " (no Steam)" : "SteamID " + id, chatName);
GUILayout.FlexibleSpace();
if (GUILayout.Button("UNBAN", primary, GUILayout.Height(28), GUILayout.Width(90))) mp.Unban(id);
GUILayout.EndHorizontal();
GUILayout.Space(4);
}
}
GUILayout.EndScrollView();
}
// ---------------------------------------------------------------- Stile
@@ -328,18 +503,19 @@ namespace MSCO
void EnsureStyles()
{
if (stylesReady && panel.normal.background != null) return;
panel = UiTheme.Panel9(UiTheme.Background);
panel = UiTheme.Card(UiTheme.Background, 10);
rowStyle = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.72f));
rowStyle.padding = new RectOffset(12, 12, 6, 6);
field = UiTheme.Field(14);
logText = UiTheme.Label(12, UiTheme.Text);
logText.wordWrap = true;
logText.richText = true; // Warnungen/Fehler farbig markiert (MscoLog)
logText.margin = new RectOffset(0, 0, 0, 0);
caption = UiTheme.Label(11, UiTheme.Muted, FontStyle.Bold);
title = UiTheme.Label(22, UiTheme.Text, FontStyle.Bold);
title.wordWrap = false;
small = UiTheme.Label(11, UiTheme.Muted);
primary = UiTheme.Button(UiTheme.Accent, UiTheme.AccentHover, Color.white);
primary = UiTheme.PrimaryButton();
secondary = UiTheme.Button(UiTheme.Secondary, UiTheme.SecondaryHover, UiTheme.Text, 12);
danger = UiTheme.Button(UiTheme.Danger, UiTheme.DangerHover, Color.white, 12);
chatName = UiTheme.Label(13, UiTheme.AccentHover, FontStyle.Bold, TextAnchor.MiddleLeft);
+227 -26
View File
@@ -4,14 +4,20 @@ using UnityEngine;
namespace MSCO
{
/// <summary>
/// Beifahrer: Neben einem Fahrzeug, das ein anderer Spieler fährt, per Taste einsteigen.
/// Der Sitz ist die Fahrerposition, auf die andere Seite gespiegelt (bei Fahrzeugen mit
/// Sitz in der Mitte, z.B. Traktor oder Moped: dahinter). Während der Fahrt wird der
/// Spieler jedes Frame auf den Sitz gesetzt und dreht mit dem Fahrzeug mit; umschauen geht weiter.
/// Mitfahren: Wer mit dem Kopf im Fahrzeug ist (Kabine, offene Tür, Ladefläche ...), wird genau dort festgehalten und
/// fährt mit - egal ob schon jemand fährt oder das Auto noch steht. Aussteigen mit Leertaste oder der Beifahrer-Taste.
/// Steht man per Taste neben dem Auto, kommt man auf den Beifahrersitz (Fahrerposition des Spiels, gespiegelt).
/// Während der Fahrt wird der Spieler jedes Frame an seine Stelle im Fahrzeug gesetzt und dreht mit; umschauen geht.
/// </summary>
public class Passenger
{
const float EnterRange = 4f; // so nah muss man am Fahrzeug stehen
const float EnterRange = 3f; // per Taste: so nah muss man an der Karosserie stehen
const float AutoEnterRange = 1.0f; // automatisch: Kopf so nah am Beifahrersitz ...
const float AutoEnterSide = 0.6f; // ... und seitlich höchstens so weit außerhalb des Sitzes - an der
// geschlossenen Tür steht man weiter draußen, nur in der offenen Tür so nah
const float AutoEnterDwell = 0.35f; // ... so lange (nicht beim bloßen Vorbeilaufen)
const float AutoRearmRange = 2f; // nach dem Aussteigen erst wieder, wenn man so weit weg war
const float AutoCheckInterval = 0.15f;
const float CenterSeatThreshold = 0.2f;
const float BehindCenterSeat = 0.7f;
const float ExitSideOffset = 1.4f; // beim Aussteigen so weit neben den Sitz
@@ -24,8 +30,23 @@ namespace MSCO
float lastYaw;
readonly List<Behaviour> disabled = new List<Behaviour>();
CharacterController controller;
// Feste Collider des Spielers, die während der Fahrt aus sind (sonst stecken sie in Sitzen und Teilen des Autos)
readonly List<Collider> disabledColliders = new List<Collider>();
readonly List<Rigidbody> frozenBodies = new List<Rigidbody>();
// Fahrerkopf je Fahrzeug (lokal), aus DriverHeadPivot - null = nicht gefunden
readonly Dictionary<Rigidbody, Vector3?> driverHeads = new Dictionary<Rigidbody, Vector3?>();
float nextAutoCheck, dwellStart = -1f, lastDriving = -100f;
const float AfterDrivingPause = 4f; // nach dem Fahren (Ein-/Aussteigen am Fahrersitz) nicht automatisch
SyncedBody dwellVehicle;
bool autoArmed = true;
Vector3 lastExitSeat;
public bool IsRiding { get { return vehicle != null; } }
/// <summary>Fahrzeug, in dem wir mitfahren (sonst null).</summary>
public SyncedBody Vehicle { get { return vehicle; } }
/// <summary>Kopf auf dem Sitz, relativ zum Fahrzeug (genau, nicht aus der Weltposition des letzten Frames).</summary>
public Vector3 SeatHeadLocal { get { return seatHeadLocal; } }
/// <summary>Taste gedrückt: einsteigen oder aussteigen. Gibt einen Statustext zurück.</summary>
public string Toggle(Transform player, Camera cam, ObjectSync objects, IDictionary<int, PlayerInfo> players)
@@ -35,42 +56,191 @@ namespace MSCO
Exit(player, cam);
return "Got out";
}
if (cam == null) return null;
if (cam == null || objects.IsDriving) return null;
SyncedBody inside = VehicleAround(objects, cam.transform.position);
if (inside != null)
{
Enter(inside, inside.Rb.transform.InverseTransformPoint(cam.transform.position), player);
return "Riding along (press again or Space to get out)";
}
SyncedBody best = null;
RemotePlayer driver = null;
float bestDist = EnterRange * EnterRange;
foreach (SyncedBody b in objects.RemoteDrivenVehicles())
Vector3 bestSeat = Vector3.zero;
float bestDist = EnterRange * EnterRange, nearest = float.MaxValue;
foreach (SyncedBody b in objects.AllVehicles())
{
if (b.Rb == null || (b.Rb.position - player.position).sqrMagnitude > 30f * 30f) continue;
float d = DistanceToVehicleSq(b, player.position);
Vector3? seat = SeatFor(b, objects, players);
if (seat == null) continue;
nearest = Mathf.Min(nearest, d);
if (d < bestDist) { bestDist = d; best = b; bestSeat = seat.Value; }
}
if (best == null)
{
if (nearest == float.MaxValue) return "Passenger: no vehicle nearby";
return "Passenger: get closer to the car (" + Mathf.Sqrt(nearest).ToString("0") + " m away, max " + EnterRange.ToString("0") + " m)";
}
Enter(best, bestSeat, player);
return "Riding as passenger (press again or Space to get out)";
}
/// <summary>
/// Jedes Frame (zu Fuß): ist man mit dem Kopf IM Fahrzeug (Kabine, Ladefläche ...), wird die aktuelle Position im
/// Fahrzeug festgehalten - man fährt genau dort mit, wo man ist (kein Umsetzen auf einen berechneten Sitz).
/// Gibt einen Statustext zurück, wenn man "eingestiegen" ist.
/// </summary>
public string AutoUpdate(Transform player, Camera cam, ObjectSync objects, IDictionary<int, PlayerInfo> players)
{
if (objects.IsDriving) lastDriving = Time.time;
if (IsRiding || cam == null || player == null || objects.IsDriving) { dwellStart = -1f; return null; }
// Beim Ein-/Aussteigen am Fahrersitz übernimmt das Spiel die Steuerung (CharacterController aus, Spieler an ein
// Fahrzeug gehängt) - dann und kurz nach dem Fahren nicht festhalten
if (Time.time - lastDriving < AfterDrivingPause) { dwellStart = -1f; return null; }
CharacterController cc = player.GetComponent<CharacterController>();
if ((cc != null && !cc.enabled) || player.parent != null) { lastDriving = Time.time; dwellStart = -1f; return null; }
if (Time.time < nextAutoCheck) return null;
nextAutoCheck = Time.time + AutoCheckInterval;
Vector3 head = cam.transform.position;
// Nach dem Aussteigen erst einmal aus dem Fahrzeug heraus, sonst säße man sofort wieder drin
if (!autoArmed)
{
if (VehicleAround(objects, head) == null) autoArmed = true;
else return null;
}
SyncedBody found = VehicleAround(objects, head);
if (found == null || found != dwellVehicle)
{
dwellVehicle = found;
dwellStart = found != null ? Time.time : -1f;
return null;
}
if (Time.time - dwellStart < AutoEnterDwell) return null;
dwellStart = -1f;
dwellVehicle = null;
Enter(found, found.Rb.transform.InverseTransformPoint(head), player); // genau hier festhalten
return "Riding along (Space or the passenger key to get out)";
}
/// <summary>Fahrzeug, in dessen Umriss der Kopf gerade ist (null = in keinem).</summary>
SyncedBody VehicleAround(ObjectSync objects, Vector3 head)
{
foreach (SyncedBody b in objects.AllVehicles())
{
if (b.Rb == null || (b.Rb.position - head).sqrMagnitude > 10f * 10f) continue;
Bounds inner = InnerBounds(b);
if (inner.Contains(b.Rb.transform.InverseTransformPoint(head))) return b;
}
return null;
}
readonly Dictionary<Rigidbody, Bounds> innerBounds = new Dictionary<Rigidbody, Bounds>();
/// <summary>
/// Innenraum des Fahrzeugs (lokal): Umriss aller festen Collider, seitlich und vorne/hinten etwas kleiner - neben
/// der geschlossenen Tür steht man noch draußen, erst in der offenen Tür bzw. drin ist man "im Auto".
/// </summary>
Bounds InnerBounds(SyncedBody b)
{
Bounds result;
if (innerBounds.TryGetValue(b.Rb, out result)) return result;
Transform t = b.Rb.transform;
bool any = false;
foreach (Collider c in b.Rb.GetComponentsInChildren<Collider>())
{
if (c == null || c.isTrigger || !c.enabled) continue;
Bounds wb = c.bounds;
for (int i = 0; i < 8; i++)
{
Vector3 corner = wb.center + Vector3.Scale(wb.extents, new Vector3((i & 1) == 0 ? -1 : 1, (i & 2) == 0 ? -1 : 1, (i & 4) == 0 ? -1 : 1));
Vector3 lp = t.InverseTransformPoint(corner);
if (!any) { result = new Bounds(lp, Vector3.zero); any = true; }
else result.Encapsulate(lp);
}
}
if (!any) result = new Bounds(Vector3.zero, Vector3.zero);
else
{
Vector3 size = result.size;
// Weltachsen-Umrisse schräger Teile sind zu groß: seitlich 0.25 m, vorne/hinten 0.3 m kleiner,
// oben 0.6 m Luft (Kopf über dem Dach der Ladefläche / im offenen Fahrzeug)
result.size = new Vector3(Mathf.Max(0.1f, size.x - 0.5f), size.y + 0.6f, Mathf.Max(0.1f, size.z - 0.6f));
result.center += new Vector3(0f, 0.3f, 0f);
}
innerBounds[b.Rb] = result;
return result;
}
void Enter(SyncedBody b, Vector3 seat, Transform player)
{
seatHeadLocal = seat;
vehicle = b;
lastYaw = b.Rb.transform.eulerAngles.y;
SetMovementEnabled(player, false);
}
/// <summary>Beifahrersitz (Kopfposition, lokal im Fahrzeug) - aus dem Fahrersitz des Spiels oder dem Fahrer.</summary>
Vector3? SeatFor(SyncedBody b, ObjectSync objects, IDictionary<int, PlayerInfo> players)
{
Vector3? driverHead;
if (!driverHeads.TryGetValue(b.Rb, out driverHead))
{
driverHead = null;
foreach (Transform t in b.Rb.GetComponentsInChildren<Transform>(true))
{
if (t.name != "DriverHeadPivot") continue;
driverHead = b.Rb.transform.InverseTransformPoint(t.position);
break;
}
driverHeads[b.Rb] = driverHead;
}
// Ohne DriverHeadPivot: nur, wenn gerade jemand fährt (dessen Kopf ist die Fahrerposition)
if (driverHead == null && objects.IsRemoteOwnedBody(b) && b.OwnerActive)
{
PlayerInfo owner;
if (!players.TryGetValue(b.OwnerId, out owner) || owner.Avatar == null) continue;
float d = (b.Rb.worldCenterOfMass - player.position).sqrMagnitude;
if (d < bestDist) { bestDist = d; best = b; driver = owner.Avatar; }
if (players.TryGetValue(b.OwnerId, out owner) && owner.Avatar != null)
driverHead = b.Rb.transform.InverseTransformPoint(owner.Avatar.HeadPosition);
}
if (best == null) return "No vehicle driven by another player nearby";
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
}
Transform vt = best.Rb.transform;
Vector3 driverHead = vt.InverseTransformPoint(driver.HeadPosition);
seatHeadLocal = Mathf.Abs(driverHead.x) < CenterSeatThreshold
? driverHead - new Vector3(0f, 0f, BehindCenterSeat) // Sitz in der Mitte: dahinter
: new Vector3(-driverHead.x, driverHead.y, driverHead.z); // gegenüber vom Fahrer
vehicle = best;
lastYaw = vt.eulerAngles.y;
SetMovementEnabled(player, false);
return "Riding as passenger (press again to get out)";
/// <summary>
/// Abstand zur nächsten Stelle der Karosserie (Collider), nicht zum Schwerpunkt - den legt MSC teils weit weg,
/// dann hieß es "26 m entfernt", obwohl man direkt an der Tür stand.
/// </summary>
static float DistanceToVehicleSq(SyncedBody b, Vector3 point)
{
float best = (b.Rb.position - point).sqrMagnitude;
foreach (Collider c in b.Rb.GetComponentsInChildren<Collider>())
{
if (c == null || !c.enabled || c.isTrigger) continue;
float d = (c.bounds.ClosestPoint(point) - point).sqrMagnitude;
if (d < best) best = d;
}
return best;
}
/// <summary>Nach der Physik (LateUpdate): Spieler auf den Sitz setzen und mitdrehen.</summary>
public void LateUpdate(Transform player, Camera cam, ObjectSync objects)
{
if (!IsRiding) return;
// Fahrer ausgestiegen oder Fahrzeug weg: automatisch aussteigen
if (vehicle.Rb == null || cam == null || !objects.IsRemoteOwnedBody(vehicle))
if (vehicle.Rb == null || cam == null)
{
Exit(player, cam);
return;
}
// Selbst ans Steuer gesetzt (Fahrersitz des Spiels): Beifahrer-Modus ohne Umsetzen beenden
if (objects.IsDriving)
{
Reset();
return;
}
Transform vt = vehicle.Rb.transform;
float yaw = vt.eulerAngles.y;
@@ -86,9 +256,15 @@ namespace MSCO
if (!IsRiding) return;
if (player != null && vehicle.Rb != null)
{
lastExitSeat = vehicle.Rb.transform.TransformPoint(seatHeadLocal);
autoArmed = false;
// Auf der Seite des Sitzes neben das Fahrzeug stellen
// Zur näheren Seite neben das Fahrzeug (außerhalb seines Umrisses), auf Kopfhöhe von vorher
float side = seatHeadLocal.x >= 0f ? 1f : -1f;
Vector3 outside = vehicle.Rb.transform.TransformPoint(seatHeadLocal + new Vector3(side * ExitSideOffset, 0f, 0f));
Bounds inner = InnerBounds(vehicle);
float x = inner.size.sqrMagnitude > 0f ? inner.center.x + side * (inner.extents.x + 0.85f) : seatHeadLocal.x + side * ExitSideOffset;
var local = new Vector3(x, seatHeadLocal.y, seatHeadLocal.z);
Vector3 outside = vehicle.Rb.transform.TransformPoint(local);
Vector3 cameraOffset = cam != null ? cam.transform.position - player.position : Vector3.up * 1.6f;
player.position = outside - cameraOffset + Vector3.up * 0.1f;
}
@@ -100,6 +276,10 @@ namespace MSCO
public void Reset()
{
vehicle = null;
dwellVehicle = null;
dwellStart = -1f;
driverHeads.Clear();
innerBounds.Clear();
SetMovementEnabled(null, true);
}
@@ -118,6 +298,23 @@ namespace MSCO
controller = player.GetComponent<CharacterController>();
if (controller != null && controller.enabled) controller.enabled = false;
else controller = null;
// Der Spieler sitzt mitten im Auto: seine festen Collider würden Sitze und Teile wegdrücken
// (Teile reißen ab, Sitze fliegen, bei Tempo stürzt die Physik ab). Wie beim echten Einsteigen: aus.
disabledColliders.Clear();
foreach (Collider c in player.GetComponentsInChildren<Collider>())
{
if (c == null || !c.enabled || c.isTrigger || c is CharacterController) continue;
c.enabled = false;
disabledColliders.Add(c);
}
frozenBodies.Clear();
foreach (Rigidbody rb in player.GetComponentsInChildren<Rigidbody>())
{
if (rb == null || rb.isKinematic) continue;
rb.isKinematic = true;
frozenBodies.Add(rb);
}
}
else
{
@@ -125,6 +322,10 @@ namespace MSCO
disabled.Clear();
if (controller != null) controller.enabled = true;
controller = null;
foreach (Collider c in disabledColliders) if (c != null) c.enabled = true;
disabledColliders.Clear();
foreach (Rigidbody rb in frozenBodies) if (rb != null) rb.isKinematic = false;
frozenBodies.Clear();
}
}
}
+55
View File
@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.IO;
using HutongGames.PlayMaker;
namespace MSCO
{
/// <summary>
/// Bedürfnisse pro Spieler (Hunger, Durst, Stress, Harndrang, Müdigkeit, Schmutz): Im gemeinsamen Spielstand gibt es
/// sie nur einmal - ohne das hätte jeder beim Beitreten die Werte desjenigen, der den Spielstand zuletzt hochgeladen
/// hat. Deshalb schickt jeder seine eigenen Werte regelmäßig an den Server, der sie pro Spieler speichert und beim
/// nächsten Beitreten zurückgibt.
/// </summary>
public static class PlayerNeeds
{
public static readonly string[] Names =
{
"PlayerHunger", "PlayerThirst", "PlayerStress", "PlayerUrine", "PlayerFatigue", "PlayerDirtiness"
};
/// <summary>Eigene Werte: int n, je [string Name, float Wert]. null, wenn das Spiel sie (noch) nicht hat.</summary>
public static byte[] Build()
{
var values = new List<KeyValuePair<string, float>>();
foreach (string name in Names)
{
FsmFloat f = FsmVariables.GlobalVariables.FindFsmFloat(name);
if (f != null && !float.IsNaN(f.Value) && !float.IsInfinity(f.Value)) values.Add(new KeyValuePair<string, float>(name, f.Value));
}
if (values.Count == 0) return null;
return NetSession.Build(w =>
{
w.Write(values.Count);
foreach (KeyValuePair<string, float> v in values) { w.Write(v.Key); w.Write(v.Value); }
});
}
/// <summary>Gespeicherte Werte vom Server übernehmen (Format wie Build).</summary>
public static int Apply(BinaryReader r)
{
int count = r.ReadInt32();
int applied = 0;
for (int i = 0; i < count; i++)
{
string name = r.ReadString();
float value = r.ReadSingle();
if (System.Array.IndexOf(Names, name) < 0 || float.IsNaN(value) || float.IsInfinity(value)) continue;
FsmFloat f = FsmVariables.GlobalVariables.FindFsmFloat(name);
if (f == null) continue;
f.Value = value;
applied++;
}
return applied;
}
}
}
+3 -3
View File
@@ -8,7 +8,7 @@ using System.Runtime.InteropServices;
[assembly: AssemblyTitle("MSCO")]
[assembly: AssemblyDescription("MSCLoader Mod file")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyCompany("MBM")]
[assembly: AssemblyProduct("MSCO")]
[assembly: AssemblyCopyright("Copyright © 2026 by MBM")]
[assembly: AssemblyTrademark("")]
@@ -32,5 +32,5 @@ using System.Runtime.InteropServices;
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: AssemblyVersion("2.8.0.0")]
[assembly: AssemblyFileVersion("2.8.0.0")]
+36 -10
View File
@@ -10,7 +10,7 @@ Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern
## Features
**Verbinden**
- Launcher im Hauptmenü im Stil von FiveM: P2P-Seite und Server-Browser
- Launcher im Hauptmenü: P2P-Seite und Server-Browser
- **P2P über Steam:** HOST GAME, Freunde einladen, Beitritt über die Steam-Freundesliste (max. 3 Spieler)
- **Server-Browser:** Suche, Filter (Steam / Non-Steam / volle ausblenden), Ping, Server-Profil
(Besitzer, Version, Laufzeit, Spieler), lokale Server auf diesem PC werden automatisch gefunden
@@ -20,7 +20,8 @@ Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern
und nach dem Spielen wiederhergestellt
**Synchronisiert**
- Spieler als Spielfigur (Modell aus den NPCs des Spiels) mit Blickrichtung, Ducken/Lehnen, Namensschild
- Spieler als eigene Figur im MSC-Stil (Karohemd, Jeans, Schnurrbart, Kappe – je Spieler etwas anders) mit
Blickrichtung, Laufen, Ducken/Lehnen, Sitzen, gehaltenem Item und Namensschild
- Items, lose Teile, Schrauben und Fahrzeuge (auch mit G geschoben) mit Besitzer-System – inkl. was jemand in der Hand hält
- Fahrzeuge: Position, Lichter, Motorgeräusch; als **Beifahrer** mitfahren (Taste P)
- **Fahrzeugzustand:** Tank, Öl, Kühlwasser, Bremsflüssigkeit, Batterie, Schrauben-Festigkeit, Schäden, Verschleiß,
@@ -30,11 +31,12 @@ Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern
**Im Spiel**
- **T** – Chat
- **F8** – MSCO-Konsole mit allen Befehlen
- **/admin** – Admin-Menü (Spieler kicken/bannen) für Server-Admins bzw. den P2P-Host
- **F7** – MSCO-Konsole mit allen Befehlen
- **/admin** – Admin-Menü für Server-Admins bzw. den P2P-Host: Spieler kicken/bannen, GOTO/BRING (teleportieren),
Cheats (+1.000 / +10.000 MK, Bedürfnisse auffüllen, Satsuma zu sich holen) – Cheats werden im Chat angezeigt
- **P** – als Beifahrer ein-/aussteigen
- Alle Tasten sind in den MSCLoader-Einstellungen der Mod änderbar
- Discord Rich Presence („In the Menus“, Server, Spielerzahl)
- Rich Presence: zeigt Freunden deinen Status („In the Menus“, Server, Spielerzahl)
## Installation
@@ -43,9 +45,24 @@ Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern
Mods-Ordner kopieren (Standard: `Documents\MySummerCar\Mods`).
3. Spiel **über Steam** starten – ohne Steam gehen nur Non-Steam-Server.
### Einen eigenen Server betreiben
Siehe [MSCO-Dedicated-Server](https://git.mbm.wtf/MrMaho/MSCO-Dedicated-Server) – Lizenzschlüssel gibt es kostenlos
unter https://msco.mbm.wtf/keys.
## Probleme?
| Problem | Lösung |
|---|---|
| Server fehlt im Browser | REFRESH drücken; Filter (STEAM / NON-STEAM) prüfen; der Server muss von außen erreichbar sein |
| „This server requires Steam“ | Spiel über Steam starten |
| Warnung „Version mismatch“ | Mod und Server auf dieselbe Version bringen (Releases) |
| Eigener Spielstand weg | Wird nach dem Verlassen automatisch wiederhergestellt; Sicherung liegt in `AppData\LocalLow\Amistech\My Summer Car\MSCO_Backup` |
| Absturz / Hänger beim Beitreten | `output_log.txt` im Spielordner an den Server-Betreiber bzw. als Issue schicken |
## Befehle
In der F8-Konsole direkt, in der MSCLoader-Konsole mit `mp` davor (z.B. `mp connect ...`):
In der F7-Konsole direkt, in der MSCLoader-Konsole mit `mp` davor (z.B. `mp connect ...`):
| Befehl | Beschreibung |
|---|---|
@@ -53,15 +70,15 @@ In der F8-Konsole direkt, in der MSCLoader-Konsole mit `mp` davor (z.B. `mp conn
| `disconnect` | Server / Spiel verlassen |
| `host [port]` / `join <ip> [port]` | LAN-Testsitzung ohne Steam |
| `save` | Spiel speichern (Host / Einzelspieler) |
| `ping [on\|off]` | Ping zum Server oben rechts anzeigen (grün < 80 ms, gelb < 150 ms, rot darüber) |
| `admin` bzw. `/admin` im Chat | Admin-Menü |
| `vehicles`, `rig` | Diagnose: Fahrzeuge / Spielermodelle |
| `debug` | Sync-Debugausgaben an/aus |
| `master [url]` | Adresse der Serverliste anzeigen/ändern |
| `clear` | Konsole leeren |
## Selbst bauen
- Visual Studio 2022 oder neuer, .NET Framework 3.5 (Unity 5.0 / Mono)
- Eine C#-Entwicklungsumgebung, .NET Framework 3.5 (Unity 5.0 / Mono)
- Die Verweise in `MSCO.csproj` zeigen auf `F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\`
(UnityEngine, PlayMaker, Assembly-CSharp, Assembly-CSharp-firstpass, MSCLoader, 0Harmony, Newtonsoft.Json, cInput) –
bei anderem Installationsort anpassen.
@@ -71,6 +88,15 @@ In der F8-Konsole direkt, in der MSCLoader-Konsole mit `mp` davor (z.B. `mp conn
msbuild MSCO.csproj -p:Configuration=Release
```
### Updates veröffentlichen (MSC Launcher / MSCLoader)
- **Kleines Update** (Fehlerbehebungen, neue Funktionen, Netzwerk unverändert): in `MSCO.cs` nur die dritte Stelle von
`ModVersion` erhöhen (`2.8.1` → `2.8.2`), Release bauen und `bin/Release/MSCO.dll` hochladen. Spieler mit 2.8.x
spielen weiter zusammen, Server 2.8 müssen nicht aktualisiert werden.
- **Großes Update** (Protokoll inkompatibel geändert): `ProtocolVersion` in `MSCO.cs` **und** `GameServer.ModVersion` im
Server erhöhen (z.B. `2.9`), `ModVersion` auf `2.9.0`. Dann brauchen alle die neue Mod und den neuen Server.
- Verglichen werden immer nur die ersten zwei Stellen; übers Netz geht nur die Netzwerk-Version.
## Aufbau
| Datei | Aufgabe |
@@ -83,9 +109,9 @@ msbuild MSCO.csproj -p:Configuration=Release
| `ObjectSync.cs` | Items/Teile/Fahrzeuge mit Besitzer-System |
| `VehicleStateSync.cs`, `VehicleFx.cs`, `Passenger.cs` | Fahrzeugzustand, Lichter/Sound, Beifahrer |
| `FsmSync.cs`, `DoorSync.cs`, `WorldSync.cs`, `MoneySync.cs` | Montage-Events, Türen, Zeit/Wetter, Geld |
| `RemotePlayer.cs`, `GameCharacter.cs` | Spielfiguren der Mitspieler |
| `RemotePlayer.cs` | Spielfiguren der Mitspieler |
| `SaveSync.cs`, `MenuHelper.cs` | Spielstand übertragen/sichern, Laden aus dem Menü |
| `SteamLobby.cs`, `JoinLinks.cs`, `DiscordPresence.cs` | Steam-Lobbys, `msco://`-Links, Discord |
| `SteamLobby.cs`, `JoinLinks.cs`, `DiscordPresence.cs` | Steam-Lobbys, `msco://`-Links, Rich Presence |
| `SafetyPatches.cs`, `InputLock.cs`, `SceneCache.cs` | Absturzschutz, Eingabesperre, geteilte FSM-Suche |
## Bekannte Einschränkungen
+244 -68
View File
@@ -3,10 +3,11 @@ using UnityEngine;
namespace MSCO
{
/// <summary>
/// Figur eines Mitspielers mit echter Körperhaltung. Aus Kopfposition (Kamera) und Bodenhöhe wird
/// jedes Frame die Pose berechnet: Ducken beugt die Knie, nach vorne Lehnen neigt den Oberkörper,
/// Laufen bewegt Beine und Arme, beim Halten greift der rechte Arm (mit Ellbogen) zum Item, im Auto
/// sitzt die Figur. Die Körperteile sind abgerundete Kapseln zwischen den Gelenken.
/// Figur eines Mitspielers im Stil der My-Summer-Car-Leute (selbst gebaut, keine NPC-Kopie):
/// Karohemd/Arbeitsjacke, Jeans, Arbeitsschuhe, Schnurrbart, Kappe oder Haare, leichter Bauch.
/// Aus Kopfposition (Kamera) und Bodenhöhe wird jedes Frame die Pose berechnet: Ducken beugt die Knie,
/// nach vorne Lehnen neigt den Oberkörper, Laufen bewegt Beine und Arme, beim Halten greift der rechte Arm
/// zum Item, im Auto sitzt die Figur.
/// </summary>
public class RemotePlayer : MonoBehaviour
{
@@ -15,22 +16,31 @@ namespace MSCO
const float MaxHeadPitch = 55f;
// Körpermaße (Meter)
const float ThighLength = 0.45f, ShinLength = 0.45f;
const float ThighLength = 0.41f, ShinLength = 0.41f; // im Stehen fast gestreckt
const float UpperArmLength = 0.3f, ForearmLength = 0.29f;
const float TorsoLength = 0.55f; // Hüfte bis Hals
const float HipWidth = 0.1f, ShoulderWidth = 0.21f;
const float AnkleHeight = 0.07f;
const float LegReach = ThighLength + ShinLength + AnkleHeight - 0.02f; // Hüfthöhe bei gestrecktem Bein
static readonly Color Skin = new Color(0.93f, 0.76f, 0.62f);
static readonly Color Jeans = new Color(0.18f, 0.25f, 0.42f);
static readonly Color Boots = new Color(0.24f, 0.16f, 0.1f);
static readonly Color Skin = new Color(0.86f, 0.66f, 0.52f);
static readonly Color Jeans = new Color(0.2f, 0.26f, 0.38f);
static readonly Color Boots = new Color(0.22f, 0.15f, 0.09f);
static readonly Color Sole = new Color(0.07f, 0.06f, 0.05f);
static readonly Color Dark = new Color(0.08f, 0.08f, 0.08f);
static readonly Color Metal = new Color(0.62f, 0.62f, 0.6f);
static readonly Color[] HairColors =
{
new Color(0.23f, 0.15f, 0.09f), new Color(0.45f, 0.33f, 0.2f), new Color(0.12f, 0.1f, 0.08f), new Color(0.55f, 0.5f, 0.45f)
};
Color jacket;
int variant;
Transform headPivot, view;
Transform pelvis, torso, belt, neck;
Transform pelvis, torso, belt, neck, belly, collar, placket, buckle;
readonly Transform[] thigh = new Transform[2], shin = new Transform[2], boot = new Transform[2];
readonly Transform[] upperArm = new Transform[2], forearm = new Transform[2], hand = new Transform[2];
readonly Transform[] sole = new Transform[2], cuff = new Transform[2], shoulderBall = new Transform[2];
Vector3 targetFeet;
Vector3 targetHead;
@@ -39,16 +49,31 @@ namespace MSCO
Quaternion targetViewRot = Quaternion.identity;
PlayerState last;
// Fahrzeug, in dem die Figur sitzt (setzt der Manager jedes Frame; null = läuft frei)
public Transform Seat;
Transform lastSeat;
Vector3 seatHead;
Quaternion seatView = Quaternion.identity;
Vector3 lastPosition;
float walkPhase, walkAmount, speed;
// Echtes Spielmodell (NPC aus der Szene); null = eigene Figur als Rückfall
GameCharacter model;
float airAmount; // 0 = steht am Boden, 1 = in der Luft (Sprung, Fallen)
public int HeldId { get { return last.HeldId; } }
public PlayerState LastState { get { return last; } }
public Vector3 HeadPosition { get { return model != null ? model.HeadPosition : headPivot.position; } }
public string ModelDescription { get { return model != null ? "game model: " + model.Description : "fallback figure (no NPC template found)"; } }
public Vector3 HeadPosition { get { return headPivot.position; } }
public string ModelDescription { get { return model != null ? model.Description : "MSCO figure, variant " + variant; } }
/// <summary>Mitspieler als echte MSC-Menschen zeigen (Konsole "model game|classic", gespeichert).</summary>
public static bool UseGameModels
{
get { return PlayerPrefs.GetInt("MSCO_GameModels", 1) == 1; }
set { PlayerPrefs.SetInt("MSCO_GameModels", value ? 1 : 0); PlayerPrefs.Save(); }
}
// Echte Spielfigur (null = eigene MSCO-Figur sichtbar); die MSCO-Figur liefert dann nur noch die Gelenkpunkte
GameCharacter model;
readonly GameCharacter.Rig rig = new GameCharacter.Rig();
// ---------------------------------------------------------------- Aufbau
@@ -57,43 +82,50 @@ namespace MSCO
var root = new GameObject(name);
var rp = root.AddComponent<RemotePlayer>();
rp.jacket = jacketColor;
rp.variant = Mathf.Abs(variant);
rp.Build();
if (UseGameModels) rp.UseGameModel();
rp.SetTarget(state);
root.transform.position = rp.targetFeet;
root.transform.rotation = Quaternion.Euler(0f, rp.targetYaw, 0f);
// Wenn möglich einen echten NPC aus dem Spiel als Figur nehmen, sonst die eigene Figur
try { rp.model = GameCharacter.TryCreate(root.transform, variant); }
catch (System.Exception e) { MSCLoader.ModConsole.Warning("[MSCO] Game model failed: " + e.Message); }
if (rp.model != null)
foreach (Renderer r in root.GetComponentsInChildren<Renderer>(true))
if (!r.transform.IsChildOf(rp.model.Root)) r.enabled = false;
rp.headWorld = rp.targetHead;
rp.pitch = rp.targetPitch;
rp.view.rotation = rp.targetViewRot;
rp.lastPosition = root.transform.position;
if (rp.model == null) rp.Pose(0f);
rp.Pose(0f);
return rp;
}
void Build()
{
Transform r = transform;
Color jacketDark = jacket * 0.75f;
Color jacketDark = jacket * 0.7f;
jacketDark.a = 1f;
Color hairColor = HairColors[variant % HairColors.Length];
bool wearsCap = variant % 3 != 2;
bool sunglasses = variant % 4 == 1;
Texture2D plaid = Plaid(jacket);
// Körper: Karohemd/Jacke, Jeans, Gürtel, leichter Bauch (wie die Leute in MSC)
pelvis = Capsule("Pelvis", r, Jeans);
torso = Capsule("Torso", r, jacket);
belt = Capsule("Belt", r, Dark);
torso = Textured(Capsule("Chest", r, Color.white), plaid, new Vector2(4f, 4f));
belly = Textured(Capsule("Belly", r, Color.white), plaid, new Vector2(4f, 2f));
belt = Primitive(PrimitiveType.Cylinder, "Belt", r, new Color(0.2f, 0.13f, 0.08f)); // rund um den Bauch
buckle = Box("Buckle", r, Metal);
placket = Box("Buttons", r, jacketDark);
collar = Capsule("Collar", r, jacketDark);
neck = Capsule("Neck", r, Skin);
for (int i = 0; i < 2; i++)
{
thigh[i] = Capsule("Thigh", r, Jeans);
shin[i] = Capsule("Shin", r, Jeans);
boot[i] = Box("Boot", r, Boots);
upperArm[i] = Capsule("UpperArm", r, jacket);
forearm[i] = Capsule("Forearm", r, jacketDark);
shin[i] = Capsule("Shin", r, Jeans * 0.92f);
boot[i] = Capsule("Boot", r, Boots); // abgerundete Arbeitsstiefel statt Klötze
sole[i] = Capsule("Sole", r, Sole);
upperArm[i] = Textured(Capsule("UpperArm", r, Color.white), plaid, new Vector2(2f, 3f));
forearm[i] = Textured(Capsule("Forearm", r, Color.white), plaid, new Vector2(2f, 3f));
cuff[i] = Capsule("Cuff", r, jacketDark);
shoulderBall[i] = Textured(Sphere("Shoulder", r, Color.white, 0.13f), plaid, new Vector2(2f, 2f)); // runde Schulter
hand[i] = Sphere("Hand", r, Skin, 0.1f);
}
@@ -101,31 +133,108 @@ namespace MSCO
headPivot = new GameObject("Head").transform;
headPivot.SetParent(r, false);
Transform skull = Sphere("Skull", headPivot, Skin, 0.24f);
skull.localScale = new Vector3(0.22f, 0.26f, 0.24f);
Transform hair = Sphere("Hair", headPivot, new Color(0.25f, 0.17f, 0.1f), 0.24f);
hair.localPosition = new Vector3(0f, 0.02f, -0.025f);
hair.localScale = new Vector3(0.23f, 0.25f, 0.23f);
Transform cap = Sphere("Cap", headPivot, jacketDark, 0.25f);
cap.localPosition = new Vector3(0f, 0.07f, -0.005f);
cap.localScale = new Vector3(0.245f, 0.16f, 0.255f);
Transform brim = Box("CapBrim", headPivot, jacketDark);
brim.localPosition = new Vector3(0f, 0.075f, 0.13f);
brim.localScale = new Vector3(0.2f, 0.02f, 0.11f);
brim.localRotation = Quaternion.Euler(-8f, 0f, 0f);
skull.localScale = new Vector3(0.225f, 0.27f, 0.25f);
Transform jaw = Sphere("Jaw", headPivot, Skin, 0.2f);
jaw.localPosition = new Vector3(0f, -0.07f, 0.022f);
jaw.localScale = new Vector3(0.19f, 0.14f, 0.2f);
Transform hair = Sphere("Hair", headPivot, hairColor, 0.24f);
hair.localPosition = new Vector3(0f, 0.028f, -0.032f);
hair.localScale = new Vector3(0.235f, 0.255f, 0.235f);
for (int i = 0; i < 2; i++)
{
Transform eye = Sphere("Eye", headPivot, Dark, 0.035f);
eye.localPosition = new Vector3(i == 0 ? -0.045f : 0.045f, 0.015f, 0.105f);
float side = i == 0 ? -1f : 1f;
Transform ear = Sphere("Ear", headPivot, Skin * 0.96f, 0.05f);
ear.localPosition = new Vector3(side * 0.112f, -0.005f, 0f);
ear.localScale = new Vector3(0.035f, 0.065f, 0.05f);
Transform eye = Sphere("Eye", headPivot, Dark, 0.026f);
eye.localPosition = new Vector3(side * 0.045f, 0.018f, 0.112f);
Transform brow = Box("Brow", headPivot, hairColor);
brow.localPosition = new Vector3(side * 0.046f, 0.046f, 0.117f);
brow.localScale = new Vector3(0.045f, 0.012f, 0.012f);
brow.localRotation = Quaternion.Euler(0f, 0f, side * -8f);
}
Transform nose = Box("Nose", headPivot, Skin * 0.93f);
nose.localPosition = new Vector3(0f, -0.012f, 0.128f);
nose.localScale = new Vector3(0.035f, 0.055f, 0.045f);
// Der typische Schnurrbart
Transform mustache = Box("Mustache", headPivot, hairColor);
mustache.localPosition = new Vector3(0f, -0.054f, 0.12f);
mustache.localScale = new Vector3(0.085f, 0.024f, 0.03f);
Transform mouth = Box("Mouth", headPivot, new Color(0.45f, 0.25f, 0.22f));
mouth.localPosition = new Vector3(0f, -0.082f, 0.114f);
mouth.localScale = new Vector3(0.05f, 0.01f, 0.01f);
if (sunglasses)
{
Transform glasses = Box("Sunglasses", headPivot, Dark);
glasses.localPosition = new Vector3(0f, 0.018f, 0.121f);
glasses.localScale = new Vector3(0.15f, 0.035f, 0.012f);
}
if (wearsCap)
{
// Kappe ("lippis") in der Jackenfarbe
Transform cap = Sphere("Cap", headPivot, jacketDark, 0.25f);
cap.localPosition = new Vector3(0f, 0.08f, -0.005f);
cap.localScale = new Vector3(0.25f, 0.16f, 0.26f);
Transform brim = Box("CapBrim", headPivot, jacketDark);
brim.localPosition = new Vector3(0f, 0.07f, 0.15f);
brim.localScale = new Vector3(0.2f, 0.014f, 0.1f);
brim.localRotation = Quaternion.Euler(8f, 0f, 0f); // Schirm leicht nach unten
Color patchColor = Color.Lerp(jacketDark, Color.white, 0.35f);
Transform patch = Box("CapPatch", headPivot, patchColor);
patch.localPosition = new Vector3(0f, 0.115f, 0.118f);
patch.localScale = new Vector3(0.06f, 0.035f, 0.01f);
patch.localRotation = Quaternion.Euler(-25f, 0f, 0f);
}
Transform nose = Box("Nose", headPivot, Skin * 0.95f);
nose.localPosition = new Vector3(0f, -0.02f, 0.12f);
nose.localScale = new Vector3(0.035f, 0.05f, 0.04f);
// Unsichtbarer "Blick" mit der exakten Kameradrehung - daran hängt das gehaltene Item
view = new GameObject("View").transform;
view.SetParent(r, false);
}
void UseGameModel()
{
model = GameCharacter.Create(transform, variant);
if (model == null) return;
// Eigene Figur unsichtbar (sie bewegt sich weiter und gibt die Gelenke vor)
Transform modelRoot = transform.Find("MSCO_Model");
foreach (Renderer r in GetComponentsInChildren<Renderer>(true))
if (modelRoot == null || !r.transform.IsChildOf(modelRoot)) r.enabled = false;
}
/// <summary>Kleines Karomuster (Flanellhemd) in der Grundfarbe; wird auf die Kapseln gekachelt.</summary>
static Texture2D Plaid(Color baseColor)
{
const int size = 16;
var tex = new Texture2D(size, size, TextureFormat.RGB24, false);
// Weiches Flanell-Karo: wenig Kontrast, dünne Linien - wirkt aus der Entfernung wie Stoff statt Schachbrett
Color light = Color.Lerp(baseColor, Color.white, 0.1f);
Color dark = baseColor * 0.78f;
for (int y = 0; y < size; y++)
{
for (int x = 0; x < size; x++)
{
bool bandX = (x / 4) % 2 == 0, bandY = (y / 4) % 2 == 0;
Color c = bandX && bandY ? dark : bandX || bandY ? baseColor : light;
if (x % 8 == 1 || y % 8 == 1) c = Color.Lerp(c, Color.black, 0.25f); // dünne dunkle Linien
c.a = 1f;
tex.SetPixel(x, y, c);
}
}
tex.filterMode = FilterMode.Bilinear;
tex.wrapMode = TextureWrapMode.Repeat;
tex.Apply();
return tex;
}
static Transform Textured(Transform part, Texture2D tex, Vector2 tiling)
{
Material m = part.GetComponent<Renderer>().material;
m.mainTexture = tex;
m.mainTextureScale = tiling;
return part;
}
static Transform Capsule(string name, Transform parent, Color color) { return Primitive(PrimitiveType.Capsule, name, parent, color); }
static Transform Box(string name, Transform parent, Color color) { return Primitive(PrimitiveType.Cube, name, parent, color); }
@@ -144,6 +253,9 @@ namespace MSCO
Destroy(go.GetComponent<Collider>()); // stört sonst Physik/Trigger
var renderer = go.GetComponent<Renderer>();
renderer.material.color = color;
// Matt wie Stoff/Haut statt glänzendem Plastik (Standard-Shader)
if (renderer.material.HasProperty("_Glossiness")) renderer.material.SetFloat("_Glossiness", 0.08f);
if (renderer.material.HasProperty("_Metallic")) renderer.material.SetFloat("_Metallic", 0f);
renderer.receiveShadows = true;
go.transform.SetParent(parent, false);
return go.transform;
@@ -174,6 +286,15 @@ namespace MSCO
public void Tick(float dt)
{
if (Seat != null && last.SeatId != 0)
{
TickSeated(dt);
UpdateWalk(dt);
Pose(dt);
return;
}
lastSeat = null;
// Bei großen Sprüngen (Teleport, Laden) direkt setzen statt gleiten
bool snap = (transform.position - targetFeet).sqrMagnitude > 100f;
float t = snap ? 1f : Mathf.Clamp01(dt * Smoothing);
@@ -186,18 +307,33 @@ namespace MSCO
view.rotation = Quaternion.Slerp(view.rotation, targetViewRot, t);
UpdateWalk(dt);
if (model != null) model.Animate(speed); // Spiel-Animation (Idle/Walk) läuft vor LateUpdate
else Pose(dt);
Pose(dt);
}
/// <summary>Spielmodell: nach der Animation Ducken/Lehnen/Kopf/Arme anpassen.</summary>
void LateUpdate()
/// <summary>
/// Im Fahrzeug: relativ zum Fahrzeug glätten und jedes Frame an dessen aktuelle Lage setzen. In Weltkoordinaten
/// hinkt die Figur einem fahrenden Auto um Tempo / Glättung hinterher (bei 90 km/h fast 2 m) und ruckelt.
/// </summary>
void TickSeated(float dt)
{
if (model == null) return;
Vector3 heldPos;
Quaternion heldRot;
bool holding = TryGetHeldPose(out heldPos, out heldRot);
model.Pose(transform, headWorld, pitch, last.Seated, holding, heldPos);
bool fresh = lastSeat != Seat; // gerade eingestiegen: direkt auf den Sitz
lastSeat = Seat;
float t = fresh ? 1f : Mathf.Clamp01(dt * Smoothing);
seatHead = Vector3.Lerp(seatHead, last.SeatHead, t);
seatView = Quaternion.Slerp(seatView, last.SeatView, t);
Vector3 up = Seat.up;
Quaternion viewRot = Seat.rotation * seatView;
Vector3 forward = viewRot * Vector3.forward;
Vector3 flat = forward - up * Vector3.Dot(forward, up);
if (flat.sqrMagnitude < 0.0001f) flat = Seat.forward;
headWorld = Seat.TransformPoint(seatHead);
transform.rotation = Quaternion.LookRotation(flat, up); // neigt sich mit dem Auto (Berg, Kurve)
transform.position = headWorld - up * 1.2f;
pitch = Mathf.Clamp(-Mathf.Asin(Mathf.Clamp(Vector3.Dot(forward.normalized, up), -1f, 1f)) * Mathf.Rad2Deg, -MaxHeadPitch, MaxHeadPitch);
view.position = headWorld;
view.rotation = viewRot;
}
void UpdateWalk(float dt)
@@ -222,6 +358,7 @@ namespace MSCO
// Hüfte: beim Lehnen nach hinten ausweichen, beim Ducken absinken (Füße bleiben am Boden)
Vector3 hip;
float air = 0f;
if (last.Seated)
{
hip = neckBase + new Vector3(0f, -TorsoLength, -0.08f);
@@ -232,24 +369,45 @@ namespace MSCO
hip = -lean * 0.25f;
float horizontal = (new Vector3(neckBase.x - hip.x, 0f, neckBase.z - hip.z)).magnitude;
float vertical = Mathf.Sqrt(Mathf.Max(0.04f, TorsoLength * TorsoLength - horizontal * horizontal));
hip.y = Mathf.Clamp(neckBase.y - vertical, 0.3f, ThighLength + ShinLength + AnkleHeight - 0.02f);
// Ist der Kopf höher, als die Beine reichen (Sprung, Fallen), hebt die ganze Figur ab - vorher blieben die
// Füße am Boden und nur der Oberkörper ging nach oben
hip.y = Mathf.Max(0.3f, neckBase.y - vertical);
air = Mathf.Max(0f, hip.y - LegReach);
}
airAmount = Mathf.Lerp(airAmount, last.Seated ? 0f : Mathf.Clamp01(air / 0.12f), dt > 0f ? Mathf.Clamp01(dt * 10f) : 1f);
Vector3 up = (neckBase - hip).normalized;
Vector3 right = Vector3.right;
Vector3 forward = Vector3.Cross(right, up).normalized;
// Rumpf
Segment(torso, hip + up * 0.08f, neckBase, 0.38f, 0.24f);
Segment(belt, hip + up * 0.06f, hip + up * 0.1f, 0.37f, 0.23f);
Segment(pelvis, hip - right * HipWidth, hip + right * HipWidth, 0.2f, 0.22f);
Segment(neck, neckBase, neckTop, 0.09f, 0.09f);
Quaternion torsoRot = Quaternion.LookRotation(forward, up);
// Brust (breit oben) und Bauch (etwas tiefer = leichter Bauch) - fließend statt Klotz mit Kugel
Segment(torso, hip + up * 0.26f, neckBase - up * 0.04f, 0.37f, 0.22f);
Segment(belly, hip + up * 0.07f + forward * 0.015f, hip + up * 0.3f + forward * 0.015f, 0.33f, 0.25f);
belt.localPosition = hip + up * 0.06f;
belt.localRotation = torsoRot;
belt.localScale = new Vector3(0.34f, 0.024f, 0.27f); // Zylinder: 0.048 m hoch, elliptisch
buckle.localPosition = hip + up * 0.06f + forward * 0.136f;
buckle.localRotation = torsoRot;
buckle.localScale = new Vector3(0.055f, 0.04f, 0.012f);
placket.localPosition = Vector3.Lerp(hip + up * 0.12f, neckBase, 0.6f) + forward * 0.124f;
placket.localRotation = torsoRot;
placket.localScale = new Vector3(0.022f, 0.3f, 0.01f);
Segment(pelvis, hip - right * (HipWidth * 0.8f) - up * 0.02f, hip + right * (HipWidth * 0.8f) - up * 0.02f, 0.2f, 0.22f);
Segment(collar, neckBase - up * 0.02f, neckBase + up * 0.035f, 0.17f, 0.15f);
Segment(neck, neckBase, neckTop, 0.1f, 0.1f);
// Kopf
headPivot.localPosition = head;
headPivot.localRotation = Quaternion.Euler(pitch, 0f, 0f);
rig.Hip = hip;
rig.NeckBase = neckBase;
rig.Head = head;
rig.Up = up;
rig.Pitch = pitch;
float swing = Mathf.Sin(walkPhase) * walkAmount;
float swing = Mathf.Sin(walkPhase) * walkAmount * (1f - airAmount);
for (int i = 0; i < 2; i++)
{
float side = i == 0 ? -1f : 1f;
@@ -261,16 +419,27 @@ namespace MSCO
if (last.Seated)
ankle = hipJoint + new Vector3(0f, -0.4f, 0.48f);
else
{
ankle = new Vector3(HipWidth * side, AnkleHeight + Mathf.Max(0f, Mathf.Cos(walkPhase + i * Mathf.PI)) * 0.07f * walkAmount, phase * 0.3f);
// In der Luft: Füße unter der Hüfte mitnehmen und die Knie etwas anziehen
ankle.y += air + 0.1f * airAmount;
ankle.z -= 0.06f * airAmount;
}
Vector3 knee = Joint(hipJoint, ankle, ThighLength, ShinLength, Vector3.forward);
Segment(thigh[i], hipJoint, knee, 0.17f, 0.17f);
Segment(shin[i], knee, ankle, 0.14f, 0.14f);
boot[i].localPosition = ankle + new Vector3(0f, -0.02f, 0.05f);
boot[i].localRotation = Quaternion.identity;
boot[i].localScale = new Vector3(0.12f, 0.1f, 0.27f);
Segment(thigh[i], hipJoint, knee, 0.15f, 0.16f);
rig.HipJoint[i] = hipJoint;
rig.Knee[i] = knee;
rig.Ankle[i] = ankle;
Segment(shin[i], knee, ankle, 0.12f, 0.13f);
// Stiefel: Kapsel von der Ferse zur Spitze, darunter eine flache Sohle
Segment(boot[i], ankle + new Vector3(0f, -0.025f, -0.06f), ankle + new Vector3(0f, -0.03f, 0.14f), 0.12f, 0.1f);
Segment(sole[i], ankle + new Vector3(0f, -0.07f, -0.075f), ankle + new Vector3(0f, -0.07f, 0.155f), 0.125f, 0.03f);
// Arme
Vector3 shoulder = neckBase - up * 0.06f + right * (ShoulderWidth * side);
shoulderBall[i].localPosition = shoulder + up * 0.005f;
shoulderBall[i].localRotation = torsoRot;
shoulderBall[i].localScale = new Vector3(0.125f, 0.12f, 0.13f);
Vector3 handTarget;
Vector3 heldPos;
Quaternion heldRot;
@@ -279,7 +448,7 @@ namespace MSCO
else if (last.Seated)
handTarget = shoulder + new Vector3(-side * 0.05f, -0.3f, 0.38f); // Hände nach vorne (Lenkrad)
else
handTarget = shoulder + new Vector3(side * 0.03f, -(UpperArmLength + ForearmLength) + 0.04f, -phase * 0.22f);
handTarget = shoulder + new Vector3(side * (0.06f + 0.12f * airAmount), -(UpperArmLength + ForearmLength) + 0.05f + 0.14f * airAmount, 0.02f - phase * 0.22f);
// Nicht weiter als die Armlänge
Vector3 reach = handTarget - shoulder;
@@ -287,10 +456,17 @@ namespace MSCO
if (reach.magnitude > maxReach) handTarget = shoulder + reach.normalized * maxReach;
Vector3 elbow = Joint(shoulder, handTarget, UpperArmLength, ForearmLength, new Vector3(side * 0.4f, -0.2f, -1f));
Segment(upperArm[i], shoulder, elbow, 0.12f, 0.12f);
Segment(forearm[i], elbow, handTarget, 0.11f, 0.11f);
Segment(upperArm[i], shoulder, elbow, 0.11f, 0.11f);
rig.Shoulder[i] = shoulder;
rig.Elbow[i] = elbow;
rig.Hand[i] = handTarget;
Segment(forearm[i], elbow, handTarget, 0.095f, 0.095f);
Segment(cuff[i], Vector3.Lerp(elbow, handTarget, 0.82f), Vector3.Lerp(elbow, handTarget, 0.9f), 0.105f, 0.105f);
hand[i].localPosition = handTarget;
hand[i].localRotation = Quaternion.LookRotation(Vector3.forward, (handTarget - elbow).sqrMagnitude > 0f ? elbow - handTarget : Vector3.up);
hand[i].localScale = new Vector3(0.085f, 0.125f, 0.11f);
}
if (model != null) model.Apply(rig);
}
/// <summary>Zwei-Knochen-Gelenk (Knie/Ellbogen): Punkt zwischen a und b, zur Seite "bend" geknickt.</summary>
+3 -3
View File
@@ -32,7 +32,7 @@ namespace MSCO
if (action != null) break;
}
MethodInfo onEnter = action != null ? action.GetMethod("OnEnter", BindingFlags.Instance | BindingFlags.Public) : null;
if (onEnter == null) { ModConsole.Warning("[MSCO] SetParent action not found - hierarchy guard disabled"); return; }
if (onEnter == null) { MscoLog.Warning("[MSCO] SetParent action not found - hierarchy guard disabled"); return; }
gameObjectField = action.GetField("gameObject");
parentField = action.GetField("parent");
@@ -41,7 +41,7 @@ namespace MSCO
}
catch (Exception e)
{
ModConsole.Warning("[MSCO] Could not install hierarchy guard: " + e.Message);
MscoLog.Warning("[MSCO] Could not install hierarchy guard: " + e.Message);
}
}
@@ -60,7 +60,7 @@ namespace MSCO
blocked++;
if (blocked <= 20)
ModConsole.Warning("[MSCO] Blocked SetParent that would crash the game: " + go.name + " -> " + parent.Value.name +
MscoLog.Warning("[MSCO] Blocked SetParent that would crash the game: " + go.name + " -> " + parent.Value.name +
" (" + __instance.Fsm.Name + ")");
__instance.Finish();
return false;
+51 -3
View File
@@ -24,6 +24,54 @@ namespace MSCO
public static bool HasBackup { get { return File.Exists(Marker); } }
static string ArchiveDir { get { return Path.Combine(SaveDir, "MSCO_Saves"); } }
const int KeepArchives = 10;
/// <summary>Gibt es einen eigenen Spielstand zum Weiterspielen?</summary>
public static bool HasLocalSave { get { return File.Exists(Path.Combine(SaveDir, "defaultES2File.txt")); } }
/// <summary>Wann der eigene Spielstand zuletzt gespeichert wurde (null = keiner).</summary>
public static System.DateTime? LocalSaveTime
{
get
{
string file = Path.Combine(SaveDir, "defaultES2File.txt");
return File.Exists(file) ? File.GetLastWriteTime(file) : (System.DateTime?)null;
}
}
/// <summary>Ordner mit den Spielständen, die vor einem neuen Spiel gesichert wurden.</summary>
public static string ArchiveFolder { get { return ArchiveDir; } }
/// <summary>
/// Neues Spiel: den bisherigen Spielstand nach MSCO_Saves/Datum kopieren (nichts geht verloren) und die
/// Spielstand-Dateien leeren, damit das Spiel eine frische Welt anlegt. Gibt den Sicherungsordner zurück (oder null).
/// </summary>
public static string ArchiveForNewGame()
{
// War noch der Spielstand eines anderen Hosts aktiv, erst den eigenen zurückholen
if (HasBackup) RestoreBackup();
string target = null;
if (HasLocalSave)
{
target = Path.Combine(ArchiveDir, System.DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"));
Directory.CreateDirectory(target);
foreach (string name in Files)
{
string path = Path.Combine(SaveDir, name);
if (File.Exists(path)) File.Copy(path, Path.Combine(target, name), true);
}
MscoLog.Info("[MSCO] Your previous save game was backed up to " + target);
// Nur die letzten Sicherungen behalten
string[] all = Directory.GetDirectories(ArchiveDir);
System.Array.Sort(all);
for (int i = 0; i < all.Length - KeepArchives; i++)
try { Directory.Delete(all[i], true); } catch { }
}
DeleteSaveFiles();
return target;
}
public static List<KeyValuePair<string, byte[]>> ReadLocalSave()
{
var result = new List<KeyValuePair<string, byte[]>>();
@@ -48,7 +96,7 @@ namespace MSCO
if (File.Exists(path)) File.Copy(path, Path.Combine(BackupDir, name), true);
}
File.WriteAllText(Marker, "MSCO backup of the client's own save. Restored automatically.");
ModConsole.Print("[MSCO] Own save backed up to " + BackupDir);
MscoLog.Info("[MSCO] Own save backed up to " + BackupDir);
}
DeleteSaveFiles();
}
@@ -58,7 +106,7 @@ namespace MSCO
// Nur bekannte Dateinamen erlauben, damit der Host nichts anderes überschreiben kann
if (System.Array.IndexOf(Files, name) < 0)
{
ModConsole.Warning("[MSCO] Ignored unexpected save file: " + name);
MscoLog.Warning("[MSCO] Ignored unexpected save file: " + name);
return;
}
File.WriteAllBytes(Path.Combine(SaveDir, name), data);
@@ -74,7 +122,7 @@ namespace MSCO
if (File.Exists(backup)) File.Copy(backup, Path.Combine(SaveDir, name), true);
}
Directory.Delete(BackupDir, true);
ModConsole.Print("[MSCO] Own save restored.");
MscoLog.Info("[MSCO] Own save restored.");
}
static void DeleteSaveFiles()
+21
View File
@@ -14,6 +14,27 @@ namespace MSCO
static float builtAt = -1000f;
static int builtForLevel = -1;
static Rigidbody[] bodies = new Rigidbody[0];
static float bodiesAt = -1000f;
static int bodiesForLevel = -1;
/// <summary>
/// Alle Rigidbodies; höchstens maxAge Sekunden alt (Objekt-Sync und Verkehr teilen sich die teure Suche).
/// Einträge können inzwischen zerstört sein (== null prüfen).
/// </summary>
public static Rigidbody[] Rigidbodies(float maxAge)
{
if (Time.realtimeSinceStartup - bodiesAt > maxAge || bodiesForLevel != Application.loadedLevel)
{
Object[] found = Object.FindObjectsOfType(typeof(Rigidbody));
bodies = new Rigidbody[found.Length];
for (int i = 0; i < found.Length; i++) bodies[i] = (Rigidbody)found[i];
bodiesAt = Time.realtimeSinceStartup;
bodiesForLevel = Application.loadedLevel;
}
return bodies;
}
/// <summary>Alle FSMs; höchstens maxAge Sekunden alt. Einträge können inzwischen zerstört sein (== null prüfen).</summary>
public static PlayMakerFSM[] Fsms(float maxAge)
{
+5 -10
View File
@@ -23,6 +23,8 @@ namespace MSCO
public string Version;
public bool RequireSteam = true; // false = Non-Steam-Server
public string Owner; // Besitzer des Lizenzschlüssels
public bool Featured; // vom Keymaster-Admin hervorgehoben (steht in der Liste oben)
public List<string> Tags = new List<string>(); // Admin-Tags (z.B. "official") und eigene Tags des Servers (Web-Panel)
[JsonIgnore] public int UptimeSeconds = -1;
[JsonIgnore] public List<string> PlayerNames = new List<string>();
@@ -37,11 +39,11 @@ namespace MSCO
/// </summary>
public class ServerBrowser
{
public const string DefaultMasterUrl = "https://msco.mbm.wtf";
const string PrefMasterUrl = "MSCO_MasterUrl";
/// <summary>Offizieller Master (Serverliste) - fest eingebaut.</summary>
public const string MasterUrl = "https://msco.mbm.wtf";
const int QueryTimeoutMs = 3000;
// Wie bei FiveM: ein Server auf diesem PC erscheint automatisch oben in der Liste
// Ein Server auf diesem PC erscheint automatisch oben in der Liste
static readonly int[] LocalPorts = { 7777, 7778, 7779, 7780 };
public const float AutoRefreshSeconds = 15f;
@@ -54,13 +56,6 @@ namespace MSCO
/// <summary>Zeitpunkt der letzten Aktualisierung (Time.realtimeSinceStartup), für das Auto-Refresh.</summary>
public float LastRefresh { get; private set; }
/// <summary>Adresse des Master-Servers (änderbar mit "mp master &lt;url&gt;", z.B. zum lokalen Testen).</summary>
public static string MasterUrl
{
get { return PlayerPrefs.GetString(PrefMasterUrl, DefaultMasterUrl).TrimEnd('/'); }
set { PlayerPrefs.SetString(PrefMasterUrl, string.IsNullOrEmpty(value) ? DefaultMasterUrl : value.TrimEnd('/')); }
}
/// <summary>Lokale Server zuerst, dann die öffentliche Liste.</summary>
public List<ServerListEntry> Servers
{
+3 -3
View File
@@ -107,7 +107,7 @@ namespace MSCO
}
catch (Exception e)
{
ModConsole.Error("[MSCO] Steam init failed: " + e.Message);
MscoLog.Error("[MSCO] Steam init failed: " + e.Message);
}
return initialized;
}
@@ -164,7 +164,7 @@ namespace MSCO
return;
}
LobbyId = new CSteamID(result.m_ulSteamIDLobby);
SteamMatchmaking.SetLobbyData(LobbyId, VersionKey, MSCO.ModVersion);
SteamMatchmaking.SetLobbyData(LobbyId, VersionKey, MSCO.ProtocolVersion); // ältere Mods vergleichen genau
if (Created != null) Created(LobbyId);
}
@@ -178,7 +178,7 @@ namespace MSCO
LobbyId = new CSteamID(result.m_ulSteamIDLobby);
string hostVersion = SteamMatchmaking.GetLobbyData(LobbyId, VersionKey);
if (!string.IsNullOrEmpty(hostVersion) && hostVersion != MSCO.ModVersion)
if (!string.IsNullOrEmpty(hostVersion) && !MSCO.Compatible(hostVersion))
{
Leave();
Fail("Version mismatch (host " + hostVersion + ", you " + MSCO.ModVersion + ")");
+101
View File
@@ -0,0 +1,101 @@
using System.Collections.Generic;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Kleine Hinweise oben rechts (unter der Ping-Anzeige), z.B. "Bob joined" / "Bob left".
/// Gleiten von rechts herein, bleiben ein paar Sekunden und blenden dann aus. Höchstens 4 gleichzeitig.
/// </summary>
public class Toasts
{
const float Lifetime = 4.5f;
const float SlideIn = 0.25f;
const float FadeOut = 0.6f;
const float Width = 250f;
const float Height = 36f;
const int MaxShown = 4;
class Toast
{
public string Text;
public string Detail;
public Color Dot;
public float Start;
}
readonly List<Toast> toasts = new List<Toast>();
GUIStyle box, title, detail;
Texture2D dotTex;
public void Add(string text, string detail, Color dot)
{
toasts.Add(new Toast { Text = text, Detail = detail, Dot = dot, Start = Time.realtimeSinceStartup });
if (toasts.Count > MaxShown) toasts.RemoveAt(0);
}
public void Draw()
{
if (toasts.Count == 0) return;
EnsureStyles();
float now = Time.realtimeSinceStartup;
toasts.RemoveAll(t => now - t.Start > Lifetime);
Color before = GUI.color;
float y = 40f; // unter der Ping-Anzeige
for (int i = toasts.Count - 1; i >= 0; i--) // neueste oben
{
Toast t = toasts[i];
float age = now - t.Start;
float slide = Mathf.Clamp01(age / SlideIn);
slide = 1f - (1f - slide) * (1f - slide); // weich abbremsen
float alpha = Mathf.Clamp01((Lifetime - age) / FadeOut);
var r = new Rect(Screen.width - 12 - Width + (1f - slide) * (Width + 20f), y, Width, Height);
GUI.color = new Color(1f, 1f, 1f, alpha);
UiTheme.DrawShadow(r);
GUI.Box(r, GUIContent.none, box);
GUI.color = new Color(t.Dot.r, t.Dot.g, t.Dot.b, alpha);
GUI.DrawTexture(new Rect(r.x + 14, r.y + Height / 2f - 4, 8, 8), dotTex);
GUI.color = new Color(1f, 1f, 1f, alpha);
GUI.Label(new Rect(r.x + 32, r.y, r.width - 40, Height), t.Text, title);
if (!string.IsNullOrEmpty(t.Detail))
{
float w = title.CalcSize(new GUIContent(t.Text)).x;
GUI.Label(new Rect(r.x + 32 + w + 6, r.y, r.width - 46 - w, Height), t.Detail, detail);
}
y += Height + 6f;
}
GUI.color = before;
}
void EnsureStyles()
{
if (box != null && box.normal.background != null) return;
box = UiTheme.Card(new Color(0.06f, 0.06f, 0.07f, 0.92f), 6);
title = new GUIStyle(GUI.skin.label) { fontSize = 13, fontStyle = FontStyle.Bold, alignment = TextAnchor.MiddleLeft };
title.normal.textColor = Color.white;
title.wordWrap = false;
detail = new GUIStyle(title) { fontStyle = FontStyle.Normal, fontSize = 12 };
detail.normal.textColor = UiTheme.Muted;
dotTex = RoundDot(16);
}
static Texture2D RoundDot(int size)
{
var t = new Texture2D(size, size, TextureFormat.ARGB32, false);
float c = (size - 1) / 2f;
for (int x = 0; x < size; x++)
for (int y = 0; y < size; y++)
{
float d = Mathf.Sqrt((x - c) * (x - c) + (y - c) * (y - c));
t.SetPixel(x, y, new Color(1f, 1f, 1f, Mathf.Clamp01(c + 0.5f - d)));
}
t.Apply();
t.hideFlags = HideFlags.HideAndDontSave;
return t;
}
}
}
+164
View File
@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Verkehr (NPC-Autos): Der Spieler mit der kleinsten ID (Welt-Autorität, gibt auch Uhrzeit/Wetter vor)
/// simuliert den Verkehr und schickt die Positionen. Bei allen anderen fahren die Autos dann nur noch nach
/// (kinematisch) - so stehen sie überall an derselben Stelle und man kann sich gemeinsam darüber ärgern.
/// </summary>
public class TrafficSync
{
static readonly string[] Roots = { "TRAFFIC", "NPC_CARS" };
const float ScanInterval = 10f;
const float SendInterval = 0.1f;
const float Stale = 1.5f; // so lange ohne Daten: wieder selbst simulieren
const float Smoothing = 12f;
const float MaxExtrapolation = 0.3f;
class Car
{
public int Key;
public Rigidbody Rb;
public bool WasKinematic;
public bool Following;
public Vector3 TargetPos, TargetVel;
public Quaternion TargetRot;
public float TargetTime = -100f;
}
readonly Dictionary<int, Car> cars = new Dictionary<int, Car>();
readonly HashSet<Rigidbody> seen = new HashSet<Rigidbody>();
float nextScan, sendTimer;
public int Count { get { return cars.Count; } }
public void Reset()
{
foreach (Car c in cars.Values) StopFollowing(c);
cars.Clear();
seen.Clear();
nextScan = -1f; // erster Suchlauf verteilt, siehe Update
}
public void Update(bool authority, float dt, Action<MsgType, byte[]> send)
{
if (nextScan < 0f) nextScan = Time.time + 4.5f; // nicht alle Sync-Teile im selben Frame suchen lassen
if (Time.time >= nextScan)
{
nextScan = Time.time + ScanInterval;
Scan();
}
if (!authority)
{
foreach (Car c in cars.Values)
if (c.Following && Time.time - c.TargetTime > Stale) StopFollowing(c);
return;
}
// Wir simulieren: eventuell noch nachfahrende Autos freigeben, Positionen senden
foreach (Car c in cars.Values) if (c.Following) StopFollowing(c);
sendTimer += dt;
if (sendTimer < SendInterval) return;
sendTimer = 0f;
var list = new List<Car>();
foreach (Car c in cars.Values)
if (c.Rb != null && c.Rb.gameObject.activeInHierarchy) list.Add(c);
if (list.Count == 0) return;
send(MsgType.TrafficStates, NetSession.Build(w =>
{
w.Write(list.Count);
foreach (Car c in list)
{
Vector3 p = c.Rb.position, v = c.Rb.velocity;
Quaternion q = c.Rb.rotation;
w.Write(c.Key);
w.Write(p.x); w.Write(p.y); w.Write(p.z);
w.Write(q.x); w.Write(q.y); w.Write(q.z); w.Write(q.w);
w.Write(v.x); w.Write(v.y); w.Write(v.z);
}
}));
}
public void Handle(BinaryReader r, bool authority)
{
int count = r.ReadInt32();
for (int i = 0; i < count; i++)
{
int key = r.ReadInt32();
var p = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
var q = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
var v = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
Car c;
if (authority || !cars.TryGetValue(key, out c) || c.Rb == null) continue;
if (float.IsNaN(p.x) || float.IsNaN(q.w) || p.sqrMagnitude > 1e8f || v.sqrMagnitude > 1e4f) continue;
c.TargetPos = p;
c.TargetRot = q;
c.TargetVel = v;
c.TargetTime = Time.fixedTime; // Physik-Zeit wie in FixedUpdate (sonst negatives Alter = Rucken)
if (!c.Following)
{
c.WasKinematic = c.Rb.isKinematic;
c.Rb.isKinematic = true;
c.Rb.position = p;
c.Rb.rotation = q;
c.Following = true;
}
}
}
public void FixedUpdate()
{
foreach (Car c in cars.Values)
{
if (!c.Following || c.Rb == null) continue;
float ahead = Mathf.Clamp(Time.fixedTime - c.TargetTime, 0f, MaxExtrapolation);
Vector3 goal = c.TargetPos + c.TargetVel * ahead;
float t = Mathf.Clamp01(Time.fixedDeltaTime * Smoothing);
if ((c.Rb.position - goal).sqrMagnitude > 25f) { c.Rb.position = goal; c.Rb.rotation = c.TargetRot; continue; }
c.Rb.MovePosition(Vector3.Lerp(c.Rb.position, goal, t));
c.Rb.MoveRotation(Quaternion.Slerp(c.Rb.rotation, c.TargetRot, t));
}
}
static void StopFollowing(Car c)
{
if (!c.Following) return;
c.Following = false;
if (c.Rb != null) c.Rb.isKinematic = c.WasKinematic;
}
void Scan()
{
int before = cars.Count;
foreach (Rigidbody rb in SceneCache.Rigidbodies(5f)) // teilt sich die Suche mit dem Objekt-Sync
{
if (rb == null || !seen.Add(rb)) continue;
if (Array.IndexOf(Roots, rb.transform.root.name) < 0) continue;
// nur das oberste Rigidbody je Auto (keine Räder/Türen)
Transform parent = rb.transform.parent;
if (parent != null && parent.GetComponentInParent<Rigidbody>() != null) continue;
int key = Hash(FsmSync.PathOf(rb.transform));
if (cars.ContainsKey(key)) continue;
cars[key] = new Car { Key = key, Rb = rb };
}
if (cars.Count != before) MscoLog.Info("[MSCO] Traffic: " + cars.Count + " cars synced");
}
static int Hash(string s)
{
unchecked
{
uint h = 2166136261;
foreach (char c in s) { h ^= c; h *= 16777619; }
return (int)h;
}
}
}
}
+92 -1
View File
@@ -3,7 +3,7 @@ using UnityEngine;
namespace MSCO
{
/// <summary>
/// Gemeinsamer Stil aller MSCO-Fenster (Launcher, F8-Konsole, Admin-Menü, Statusleiste):
/// Gemeinsamer Stil aller MSCO-Fenster (Launcher, F7-Konsole, Admin-Menü, Statusleiste):
/// dunkle Flächen mit abgerundeten Ecken, orange Akzente, einheitliche Schriften und Abstände.
/// Unitys OnGUI kennt keine runden Ecken - dafür werden kleine Texturen erzeugt und per
/// 9-Slice (GUIStyle.border) gestreckt, sodass die Ecken rund bleiben.
@@ -26,8 +26,13 @@ namespace MSCO
public static readonly Color Muted = Hex(0x8e, 0x8e, 0x99);
public static readonly Color Online = Hex(0x3d, 0xd6, 0x8c);
public static readonly Color Pending = Hex(0xf2, 0xc2, 0x1d);
public static readonly Color Gold = Hex(0xff, 0xb4, 0x5a);
/// <summary>Feiner heller Rand um Fenster und Karten (wie auf der Website).</summary>
public static readonly Color Border = new Color(1f, 1f, 1f, 0.08f);
public const int Radius = 6;
const int ShadowBlur = 16;
static GUIStyle shadowStyle;
public static Color Hex(int r, int g, int b, float a = 1f)
{
@@ -58,6 +63,92 @@ namespace MSCO
return tex;
}
/// <summary>
/// Wie Rounded, aber mit senkrechtem Verlauf (oben -> unten) und optional 1 px Rand. Die Textur ist höher als
/// breit, damit der Verlauf beim 9-Slice-Strecken im Mittelteil erhalten bleibt.
/// </summary>
public static Texture2D RoundedEx(Color top, Color bottom, Color border, bool withBorder, int radius = Radius, int height = 48)
{
int width = radius * 2 + 4;
height = Mathf.Max(height, width);
var tex = new Texture2D(width, height, TextureFormat.ARGB32, false);
tex.wrapMode = TextureWrapMode.Clamp;
tex.filterMode = FilterMode.Bilinear;
for (int y = 0; y < height; y++)
{
// Texturzeile 0 ist unten
Color fill = Color.Lerp(bottom, top, (y + 0.5f) / height);
// Rand deckend auf die Fläche mischen (ein durchsichtiger Rand ließe das Spiel durchscheinen)
Color edge = Color.Lerp(fill, new Color(border.r, border.g, border.b, fill.a), border.a);
for (int x = 0; x < width; x++)
{
float cx = Mathf.Clamp(x + 0.5f, radius, width - radius);
float cy = Mathf.Clamp(y + 0.5f, radius, height - radius);
float d = Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy));
Color c = withBorder ? Color.Lerp(edge, fill, Mathf.Clamp01(radius - 1f - d + 0.5f)) : fill;
c.a *= Mathf.Clamp01(radius - d + 0.5f);
tex.SetPixel(x, y, c);
}
}
tex.Apply();
tex.hideFlags = HideFlags.HideAndDontSave;
return tex;
}
/// <summary>Fenster/Karte mit feinem Rand; mit GUI.Box zeichnen (vorher DrawShadow für Tiefe).</summary>
public static GUIStyle Card(Color color, int radius = Radius)
{
var s = new GUIStyle();
s.normal.background = RoundedEx(color, color, Border, true, radius, radius * 2 + 4);
s.border = new RectOffset(radius + 2, radius + 2, radius + 2, radius + 2);
return s;
}
/// <summary>Orange Hauptknopf mit leichtem Verlauf (wie .btn auf der Website).</summary>
public static GUIStyle PrimaryButton(int fontSize = 13)
{
GUIStyle s = Button(Accent, AccentHover, Color.white, fontSize);
ApplyPrimaryGradient(s);
return s;
}
/// <summary>Verlauf auf einen vorhandenen Knopf-Stil legen (Rundung und Rand bleiben).</summary>
public static void ApplyPrimaryGradient(GUIStyle s)
{
s.normal.background = s.focused.background = RoundedEx(AccentHover, Accent, Color.clear, false);
s.hover.background = s.active.background = s.onNormal.background =
RoundedEx(Color.Lerp(AccentHover, Color.white, 0.12f), AccentHover, Color.clear, false);
}
/// <summary>Weicher Schatten unter einem Fenster (etwas nach unten versetzt). Vor dem Fenster zeichnen.</summary>
public static void DrawShadow(Rect r)
{
if (shadowStyle == null || shadowStyle.normal.background == null)
{
const int radius = 10;
int reach = radius + ShadowBlur;
int size = reach * 2 + 4;
var tex = new Texture2D(size, size, TextureFormat.ARGB32, false);
tex.wrapMode = TextureWrapMode.Clamp;
tex.filterMode = FilterMode.Bilinear;
for (int y = 0; y < size; y++)
for (int x = 0; x < size; x++)
{
float cx = Mathf.Clamp(x + 0.5f, reach, size - reach);
float cy = Mathf.Clamp(y + 0.5f, reach, size - reach);
float outside = Mathf.Max(0f, Vector2.Distance(new Vector2(x + 0.5f, y + 0.5f), new Vector2(cx, cy)) - radius);
float fade = 1f - Mathf.Clamp01(outside / ShadowBlur);
tex.SetPixel(x, y, new Color(0f, 0f, 0f, 0.45f * fade * fade));
}
tex.Apply();
tex.hideFlags = HideFlags.HideAndDontSave;
shadowStyle = new GUIStyle();
shadowStyle.normal.background = tex;
shadowStyle.border = new RectOffset(reach + 2, reach + 2, reach + 2, reach + 2);
}
GUI.Box(new Rect(r.x - ShadowBlur, r.y - ShadowBlur + 6, r.width + ShadowBlur * 2, r.height + ShadowBlur * 2), GUIContent.none, shadowStyle);
}
public static Texture2D Solid(Color color)
{
var tex = new Texture2D(1, 1);
+119 -12
View File
@@ -7,14 +7,23 @@ using UnityEngine;
namespace MSCO
{
/// <summary>
/// Lichter und Dauergeräusche (Motor, Blinker, Hupe, Radio ...) von Fahrzeugen.
/// Wer ein Fahrzeug besitzt (fährt/schiebt), schickt regelmäßig deren Zustand;
/// bei allen anderen wird dieser Zustand jedes Frame übernommen.
/// Lichter, Dauergeräusche (Motor, Blinker, Hupe, Radio ...) und Anzeigen (Tacho, Drehzahlmesser, Tank-/Temperatur-
/// zeiger, Lenkrad) von Fahrzeugen. Wer ein Fahrzeug besitzt (fährt/schiebt), schickt regelmäßig deren Zustand;
/// bei allen anderen wird dieser Zustand jedes Frame übernommen. Die Zeiger bewegen sich beim Beifahrer sonst nicht,
/// weil das Auto dort nicht selbst fährt (die Spiel-Logik sieht Geschwindigkeit/Drehzahl 0).
/// </summary>
public class VehicleFx
{
const float SendInterval = 0.2f;
const float CacheRefresh = 5f; // Teile können an-/abgebaut werden
const float SendInterval = 0.1f;
const float DialSmoothing = 14f; // Zeiger gleiten zwischen den Paketen weich zur neuen Stellung
/// <summary>Objekte, deren Drehung eine Anzeige ist: Tacho-/Drehzahl-/Tank-/Temperaturzeiger, Lenkrad.</summary>
static bool IsDial(string name)
{
string n = name.ToLower();
return n.Contains("needle") || n.Contains("steeringwheel");
}
const float CacheRefresh = 15f; // Teile können an-/abgebaut werden (Nachschlagen ist teuer: ganzes Fahrzeug)
const float ApplyTimeout = 2f; // ohne neue Daten nichts mehr erzwingen
struct AudioState
@@ -30,13 +39,18 @@ namespace MSCO
public Transform Root;
public readonly Dictionary<int, Light> Lights = new Dictionary<int, Light>();
public readonly Dictionary<int, AudioSource> Audio = new Dictionary<int, AudioSource>();
public readonly Dictionary<int, Transform> Dials = new Dictionary<int, Transform>();
public readonly Dictionary<int, ParticleSystem> Particles = new Dictionary<int, ParticleSystem>();
}
class Snapshot
{
public float ReceivedAt;
public bool Released; // nach dem Timeout einmal freigegeben
public readonly Dictionary<int, bool> Lights = new Dictionary<int, bool>();
public readonly Dictionary<int, AudioState> Audio = new Dictionary<int, AudioState>();
public readonly Dictionary<int, Quaternion> Dials = new Dictionary<int, Quaternion>();
public readonly Dictionary<int, bool> Particles = new Dictionary<int, bool>();
}
readonly Dictionary<int, Components> components = new Dictionary<int, Components>();
@@ -51,13 +65,17 @@ namespace MSCO
// ---------------------------------------------------------------- Senden
public void Update(ObjectSync objects, Action<MsgType, byte[]> send, float dt)
public void Update(ObjectSync objects, Action<MsgType, byte[]> send, float dt, bool worldAuthority)
{
sendTimer += dt;
if (sendTimer < SendInterval) return;
sendTimer = 0f;
foreach (SyncedBody b in objects.LocalVehicles())
// Eigene Fahrzeuge (fahren/schieben) und - wenn wir die Welt vorgeben - auch freie, geparkte Fahrzeuge:
// sonst würde z.B. ein brennendes, abgestelltes Auto nur bei einem Spieler brennen
List<SyncedBody> vehicles = objects.LocalVehicles();
if (worldAuthority) vehicles.AddRange(objects.FreeVehicles());
foreach (SyncedBody b in vehicles)
{
Components c = GetComponents(b);
if (c == null) continue;
@@ -82,6 +100,27 @@ namespace MSCO
w.Write(a.Value.pitch);
w.Write(a.Value.volume);
}
// Anzeigen (am Ende angehängt - ältere Versionen lesen hier nicht weiter)
int dialCount = 0;
foreach (Transform t in c.Dials.Values) if (t != null) dialCount++;
w.Write(dialCount);
foreach (KeyValuePair<int, Transform> d in c.Dials)
{
if (d.Value == null) continue;
Quaternion q = d.Value.localRotation;
w.Write(d.Key);
w.Write(q.x); w.Write(q.y); w.Write(q.z); w.Write(q.w);
}
// Partikel-Effekte (Feuer, Rauch, Dampf, Funken): läuft / läuft nicht
int particleCount = 0;
foreach (ParticleSystem ps in c.Particles.Values) if (ps != null) particleCount++;
w.Write(particleCount);
foreach (KeyValuePair<int, ParticleSystem> ps in c.Particles)
{
if (ps.Value == null) continue;
w.Write(ps.Key);
w.Write(ps.Value.gameObject.activeInHierarchy && ps.Value.isPlaying);
}
}));
}
}
@@ -100,6 +139,21 @@ namespace MSCO
int key = r.ReadInt32();
s.Audio[key] = new AudioState { Playing = r.ReadBoolean(), Pitch = r.ReadSingle(), Volume = r.ReadSingle() };
}
if (r.BaseStream.Length - r.BaseStream.Position >= 4)
{
int dials = r.ReadInt32();
for (int i = 0; i < dials; i++)
{
int key = r.ReadInt32();
var q = new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
if (!float.IsNaN(q.x) && !float.IsNaN(q.w)) s.Dials[key] = q;
}
}
if (r.BaseStream.Length - r.BaseStream.Position >= 4)
{
int particles = r.ReadInt32();
for (int i = 0; i < particles; i++) s.Particles[r.ReadInt32()] = r.ReadBoolean();
}
incoming[id] = s;
}
@@ -108,11 +162,17 @@ namespace MSCO
{
foreach (KeyValuePair<int, Snapshot> kv in incoming)
{
if (Time.time - kv.Value.ReceivedAt > ApplyTimeout) continue;
SyncedBody b = objects.Get(kv.Key);
if (b == null || !objects.IsRemoteOwnedBody(b)) continue;
if (b == null || objects.IsLocalBody(b)) continue; // eigene Fahrzeuge simulieren wir selbst
Components c = GetComponents(b);
if (c == null) continue;
if (Time.time - kv.Value.ReceivedAt > ApplyTimeout)
{
// Keine Daten mehr (Fahrer ausgestiegen o.ä.): einmal alles loslassen, was wir erzwungen haben -
// sonst läuft z.B. das Anlasser-Geräusch endlos weiter ("orgelt")
if (!kv.Value.Released) Release(kv.Value, c);
continue;
}
foreach (KeyValuePair<int, bool> l in kv.Value.Lights)
{
@@ -124,6 +184,38 @@ namespace MSCO
AudioSource src;
if (c.Audio.TryGetValue(a.Key, out src) && src != null) SetAudio(src, a.Value);
}
foreach (KeyValuePair<int, bool> p in kv.Value.Particles)
{
ParticleSystem ps;
if (c.Particles.TryGetValue(p.Key, out ps) && ps != null) SetParticles(ps, p.Value, c.Root);
}
float t = Mathf.Clamp01(Time.deltaTime * DialSmoothing);
foreach (KeyValuePair<int, Quaternion> d in kv.Value.Dials)
{
Transform dial;
if (c.Dials.TryGetValue(d.Key, out dial) && dial != null)
dial.localRotation = Quaternion.Slerp(dial.localRotation, d.Value, t);
}
}
}
static void Release(Snapshot s, Components c)
{
s.Released = true;
foreach (KeyValuePair<int, AudioState> a in s.Audio)
{
AudioSource src;
if (a.Value.Playing && c.Audio.TryGetValue(a.Key, out src) && src != null && src.isPlaying) src.Stop();
}
foreach (KeyValuePair<int, bool> p in s.Particles)
{
ParticleSystem ps;
if (p.Value && c.Particles.TryGetValue(p.Key, out ps) && ps != null && ps.isPlaying) ps.Stop();
}
foreach (KeyValuePair<int, bool> l in s.Lights)
{
Light light;
if (l.Value && c.Lights.TryGetValue(l.Key, out light) && light != null) light.enabled = false;
}
}
@@ -134,10 +226,19 @@ namespace MSCO
if (light.enabled) light.enabled = false;
return;
}
// Nur die Lampe selbst einschalten - übergeordnete Objekte NICHT aktivieren: das weckt Spiel-Logik auf
// (FSMs im OnEnable), die dort nicht laufen soll, und kann das Spiel abstürzen lassen
if (!light.enabled) light.enabled = true;
// Lichter werden im Spiel oft über ein übergeordnetes Objekt ein-/ausgeschaltet
for (Transform t = light.transform; t != null && t != root; t = t.parent)
if (!t.gameObject.activeSelf) t.gameObject.SetActive(true);
}
static void SetParticles(ParticleSystem ps, bool playing, Transform root)
{
if (playing)
{
// Nur Effekte auf bereits aktiven Objekten starten (Objekte aktivieren weckt Spiel-Logik - Absturzgefahr)
if (ps.gameObject.activeInHierarchy && !ps.isPlaying) ps.Play();
}
else if (ps.isPlaying) ps.Stop();
}
static void SetAudio(AudioSource src, AudioState state)
@@ -164,6 +265,12 @@ namespace MSCO
seen.Clear();
foreach (AudioSource a in b.Rb.GetComponentsInChildren<AudioSource>(true))
if (a.loop) c.Audio[Key(a.transform, c.Root, seen)] = a; // nur Dauergeräusche
seen.Clear();
foreach (Transform t in b.Rb.GetComponentsInChildren<Transform>(true))
if (IsDial(t.name)) c.Dials[Key(t, c.Root, seen)] = t;
seen.Clear();
foreach (ParticleSystem ps in b.Rb.GetComponentsInChildren<ParticleSystem>(true))
c.Particles[Key(ps.transform, c.Root, seen)] = ps;
components[b.Id] = c;
return c;
}
+194 -27
View File
@@ -21,13 +21,29 @@ namespace MSCO
static readonly string[] Keywords =
{
"fuel", "oil", "coolant", "water", "fluid", "brake", "clutchfluid", "battery", "charge",
"bolt", "tight", "damage", "wear", "condition", "odo", "km", "dirt", "paint", "color", "colour",
"damage", "wear", "condition", "odo", "km", "dirt", "paint", "color", "colour",
"tune", "tuning", "ratio", "broken", "fixed", "flat", "pressure", "level", "mixture", "timing",
"idle", "air", "jet", "spark", "gap", "rust", "health", "burn", "cool", "temp"
};
// Montiert-Flags nicht direkt setzen: das echte Anbauen läuft über FsmSync (Events)
static readonly string[] Excluded = { "install", "rpm", "speed", "steer", "throttle", "gear", "raycast", "distance" };
// Schrauben/Festigkeit laufen über FsmSync (Events) - ein falscher Wert hier würde Teile lockern und abfallen lassen
// Vom Spiel selbst berechnete Werte ebenfalls nicht: z.B. LeakOilpan/LeakOilfilter (leckt Öl?) ergibt sich aus
// angebauten/festgezogenen Teilen - ein übertragener alter Wert ließ beim Beitreten Öl auslaufen. Raten (…Rate)
// rechnet das Spiel laufend neu aus.
static readonly string[] Excluded =
{
"install", "bolt", "tight", "detach", "attach", "rpm", "speed", "steer", "throttle", "gear", "raycast", "distance",
"leak", "rate",
// Spieler-Werte (Database/PlayerDatabase: Schmutz, Verbrennungen ...) sind pro Spieler; Fluch-Geräusche,
// Ton-Verzerrung und Anzeige-Hilfswerte (GUI) rechnet jeder selbst
"player", "swear", "distortion", "gui"
};
// Wurzeln außer den Fahrzeugen: Satsuma-Teile (lose und angebaut) und ihre Datenbank (Verschleiß, beschädigt,
// Lack, Füllstände ...). Maßgeblich ist dort wie beim Satsuma selbst, wer den Satsuma gerade fährt.
static readonly string[] PartRoots = { "CARPARTS", "Database" };
const string PartOwnerPrefix = "SATSUMA";
const float ScanInterval = 10f;
const float CheckInterval = 0.5f;
@@ -36,9 +52,12 @@ namespace MSCO
class Var
{
public int Key;
public string Name; // Pfad|FSM|Variable (für die Diagnose "vars")
public string Root;
public NamedVariable Variable;
public byte Type; // 1 float, 2 int, 3 bool
public byte Type; // 1 float, 2 int, 3 bool (übertragen); Farben als 4 einzelne Kanäle vom Typ 1
public int Channel = -1; // FsmColor: 0-3 = r/g/b/a
public PlayMakerFSM Fsm;
public float LastF;
public int LastI;
public bool LastB;
@@ -47,21 +66,47 @@ namespace MSCO
readonly Dictionary<int, Var> vars = new Dictionary<int, Var>();
readonly Dictionary<int, byte[]> pending = new Dictionary<int, byte[]>(); // noch nicht gefundene Werte
readonly List<Var> outgoing = new List<Var>();
readonly HashSet<PlayMakerFSM> scanned = new HashSet<PlayMakerFSM>();
// Erfasste FSMs mit dem Elternobjekt beim Erfassen: wird ein Teil an-/abgebaut, ändert sich sein Pfad (und damit
// die Schlüssel) - dann neu erfassen. FSMs außerhalb der Fahrzeuge/Teile werden bei jedem Suchlauf neu geprüft
// (vorher einmal als "nicht Fahrzeug" gemerkt und nie wieder angesehen: später angebaute Teile fehlten)
readonly Dictionary<PlayMakerFSM, Transform> scanned = new Dictionary<PlayMakerFSM, Transform>();
readonly Dictionary<PlayMakerFSM, List<int>> keysByFsm = new Dictionary<PlayMakerFSM, List<int>>();
string satsumaRoot;
float nextScan, nextCheck;
public int Count { get { return vars.Count; } }
/// <summary>Diagnose (Konsole "vars <filter>"): synchronisierte Fahrzeugwerte mit aktuellem Wert.</summary>
public List<string> Describe(string filter, int max)
{
var lines = new List<string>();
string f = (filter ?? "").ToLower();
foreach (Var v in vars.Values)
{
if (f.Length > 0 && (v.Name == null || !v.Name.ToLower().Contains(f))) continue;
string value = v.Type == 1 ? GetFloat(v).ToString("0.###")
: v.Type == 2 ? ((FsmInt)v.Variable).Value.ToString()
: ((FsmBool)v.Variable).Value.ToString();
lines.Add(" " + v.Name + " = " + value);
if (lines.Count >= max) { lines.Add(" ... (more - use a narrower filter)"); break; }
}
if (lines.Count == 0) lines.Add(" no synced vehicle value matches '" + filter + "'");
return lines;
}
public void Reset()
{
vars.Clear();
pending.Clear();
scanned.Clear();
nextScan = 0f;
keysByFsm.Clear();
satsumaRoot = null;
nextScan = -1f; // erster Suchlauf verteilt, siehe Update
}
public void Update(ObjectSync objects, int localId, Action<MsgType, byte[]> send)
{
if (nextScan < 0f) nextScan = Time.time + 3.1f; // nicht alle Sync-Teile im selben Frame suchen lassen
if (Time.time >= nextScan)
{
nextScan = Time.time + ScanInterval;
@@ -71,13 +116,19 @@ namespace MSCO
nextCheck = Time.time + CheckInterval;
outgoing.Clear();
ResolveSatsuma(objects);
foreach (Var v in vars.Values)
{
if (!Changed(v)) continue;
// Fährt/schiebt ein anderer das Fahrzeug, ist seine Version maßgeblich - nichts senden
int owner = objects.VehicleOwner(OwnerRoot(v.Root));
if (owner != SyncedBody.NoOwner && owner != localId) { if (Changed(v, false)) Remember(v); continue; }
// Eigenes Fahrzeug: jede Änderung. Freies Fahrzeug: nur deutliche Sprünge (Öl nachfüllen, tanken ...) -
// langsames Abdriften (z.B. Abkühlen) rechnet jeder selbst, sonst überschreiben sich alle gegenseitig
bool big = owner == SyncedBody.NoOwner;
if (!Changed(v, big)) continue;
Remember(v);
// Fährt/schiebt ein anderer das Fahrzeug, ist seine Version maßgeblich
int owner = objects.VehicleOwner(v.Root);
if (owner == SyncedBody.NoOwner || owner == localId) outgoing.Add(v);
outgoing.Add(v);
}
for (int start = 0; start < outgoing.Count; start += MaxPerMessage)
@@ -101,7 +152,7 @@ namespace MSCO
byte[] value = r.ReadBytes(type == 3 ? 1 : 4);
Var v;
if (!vars.TryGetValue(key, out v)) { pending[key] = Combine(type, value); continue; }
if (from >= 0 && objects.VehicleOwner(v.Root) == localId) continue; // ich fahre gerade: meine Werte gelten
if (from >= 0 && objects.VehicleOwner(OwnerRoot(v.Root)) == localId) continue; // ich fahre gerade: meine Werte gelten
Apply(v, type, value);
}
}
@@ -126,30 +177,72 @@ namespace MSCO
void Scan()
{
int before = vars.Count;
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
foreach (PlayMakerFSM fsm in SceneCache.Fsms(20f))
{
// Schon erfasste FSMs nicht jedes Mal neu durchgehen (Pfad + Hash kosten)
if (fsm == null || !scanned.Add(fsm)) continue;
if (fsm == null) continue;
// Schon erfasst und nicht umgehängt (an-/abgebaut): nichts zu tun (Pfad + Hash kosten)
Transform parent;
if (scanned.TryGetValue(fsm, out parent) && parent == fsm.transform.parent) continue;
string root = fsm.transform.root.name;
if (!IsVehicle(root)) continue;
if (!IsTracked(root)) { Forget(fsm); continue; }
scanned[fsm] = fsm.transform.parent;
Forget(fsm);
// FSMs, die Größen/Positionen setzen (z.B. Füllstands-Anzeigen), NICHT anfassen: ein übertragener Wert
// kann dort eine Größe 0 ergeben - Unity stürzt dann in der Physik ab ("cleaning the mesh failed")
if (TouchesTransform(fsm)) continue;
if (fsm.transform.root.name == "Database" && FsmSync.PathOf(fsm.transform).Contains("PlayerDatabase")) continue;
string prefix = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName + "|";
FsmVariables fv = fsm.FsmVariables;
foreach (FsmFloat f in fv.FloatVariables) Register(prefix, root, f, 1);
foreach (FsmInt n in fv.IntVariables) Register(prefix, root, n, 2);
foreach (FsmBool b in fv.BoolVariables) Register(prefix, root, b, 3);
foreach (FsmFloat f in fv.FloatVariables) Register(fsm, prefix, root, f, 1, -1);
foreach (FsmInt n in fv.IntVariables) Register(fsm, prefix, root, n, 2, -1);
foreach (FsmBool b in fv.BoolVariables) Register(fsm, prefix, root, b, 3, -1);
// Lack-/Farbwerte (FsmColor) als vier Zahlen: alte Server/Mods kennen nur float/int/bool
foreach (FsmColor c in fv.ColorVariables)
{
if (c == null || !IsColorName(c.Name)) continue;
for (int ch = 0; ch < 4; ch++) Register(fsm, prefix, root, c, 1, ch);
}
}
if (vars.Count != before) ModConsole.Print("[MSCO] Vehicle state: " + vars.Count + " values synced");
if (vars.Count != before) MscoLog.Info("[MSCO] Vehicle state: " + vars.Count + " values synced");
}
void Register(string prefix, string root, NamedVariable variable, byte type)
static readonly string[] TransformActionWords = { "Scale", "Position", "Rotate", "Rotation", "Translate", "Parent" };
static bool TouchesTransform(PlayMakerFSM fsm)
{
if (variable == null || string.IsNullOrEmpty(variable.Name) || !Wanted(variable.Name)) return;
int key = Hash(prefix + variable.Name);
try
{
foreach (FsmState state in fsm.FsmStates)
{
if (!state.ActionsLoaded) state.LoadActions();
if (state.Actions == null) continue;
foreach (FsmStateAction action in state.Actions)
{
if (action == null) continue;
string type = action.GetType().Name;
foreach (string word in TransformActionWords)
if (type.Contains(word)) return true;
}
}
}
catch { return true; } // im Zweifel nicht anfassen
return false;
}
void Register(PlayMakerFSM fsm, string prefix, string root, NamedVariable variable, byte type, int channel)
{
if (variable == null || string.IsNullOrEmpty(variable.Name)) return;
if (channel < 0 && !Wanted(variable.Name)) return;
string name = prefix + variable.Name + (channel >= 0 ? "#" + "rgba"[channel] : "");
int key = Hash(name);
Var existing;
if (vars.TryGetValue(key, out existing) && existing.Variable == variable) return;
var v = new Var { Key = key, Root = root, Variable = variable, Type = type };
var v = new Var { Key = key, Name = name, Root = root, Variable = variable, Type = type, Channel = channel, Fsm = fsm };
Remember(v);
vars[key] = v;
List<int> keys;
if (!keysByFsm.TryGetValue(fsm, out keys)) keysByFsm[fsm] = keys = new List<int>();
keys.Add(key);
byte[] stored;
if (pending.TryGetValue(key, out stored))
@@ -175,13 +268,83 @@ namespace MSCO
return false;
}
static bool Changed(Var v)
static bool IsTracked(string root)
{
return IsVehicle(root) || Array.IndexOf(PartRoots, root) >= 0;
}
static bool IsColorName(string name)
{
string n = name.ToLower();
return n.Contains("paint") || n.Contains("color") || n.Contains("colour");
}
/// <summary>Werte einer umgehängten/verschwundenen FSM vergessen (Pfad und damit Schlüssel ändern sich).</summary>
void Forget(PlayMakerFSM fsm)
{
List<int> keys;
if (!keysByFsm.TryGetValue(fsm, out keys)) return;
foreach (int k in keys)
{
Var v;
if (vars.TryGetValue(k, out v) && v.Fsm == fsm) vars.Remove(k);
}
keysByFsm.Remove(fsm);
}
/// <summary>Satsuma-Teile gehören dem, der den Satsuma fährt.</summary>
string OwnerRoot(string root)
{
return Array.IndexOf(PartRoots, root) >= 0 ? satsumaRoot : root;
}
void ResolveSatsuma(ObjectSync objects)
{
if (satsumaRoot != null) return;
foreach (SyncedBody b in objects.AllVehicles())
if (b.RootName != null && b.RootName.StartsWith(PartOwnerPrefix)) { satsumaRoot = b.RootName; return; }
}
static float GetFloat(Var v)
{
if (v.Channel < 0) return ((FsmFloat)v.Variable).Value;
Color c = ((FsmColor)v.Variable).Value;
return c[v.Channel];
}
static void SetFloat(Var v, float f)
{
if (v.Channel < 0) { ((FsmFloat)v.Variable).Value = f; return; }
var cv = (FsmColor)v.Variable;
Color c = cv.Value;
c[v.Channel] = Mathf.Clamp(f, 0f, 4f);
cv.Value = c;
ShowPaint(v, c);
}
/// <summary>
/// Lack sichtbar machen: Die Lackier-FSM eines Teils setzt beim Lackieren die Materialfarbe ihres eigenen Meshes.
/// Beim Mitspieler kommt nur der Farbwert an - also dieselbe Farbe aufs Mesh des Teils legen.
/// </summary>
static void ShowPaint(Var v, Color c)
{
if (v.Fsm == null || v.Fsm.FsmName.IndexOf("paint", StringComparison.OrdinalIgnoreCase) < 0) return;
Renderer r = v.Fsm.GetComponent<Renderer>();
if (r == null || r.material == null) return;
r.material.color = c;
}
static bool Changed(Var v, bool bigOnly)
{
switch (v.Type)
{
case 1:
float f = ((FsmFloat)v.Variable).Value;
return Mathf.Abs(f - v.LastF) > Mathf.Max(0.01f, Mathf.Abs(v.LastF) * 0.002f);
float f = GetFloat(v);
float delta = Mathf.Abs(f - v.LastF);
if (v.Channel >= 0) return delta > 0.002f; // Farben: jede Änderung (Lackieren)
return bigOnly
? delta > Mathf.Max(0.5f, Mathf.Abs(v.LastF) * 0.05f)
: delta > Mathf.Max(0.01f, Mathf.Abs(v.LastF) * 0.002f);
case 2: return ((FsmInt)v.Variable).Value != v.LastI;
default: return ((FsmBool)v.Variable).Value != v.LastB;
}
@@ -191,7 +354,7 @@ namespace MSCO
{
switch (v.Type)
{
case 1: v.LastF = ((FsmFloat)v.Variable).Value; break;
case 1: v.LastF = GetFloat(v); break;
case 2: v.LastI = ((FsmInt)v.Variable).Value; break;
default: v.LastB = ((FsmBool)v.Variable).Value; break;
}
@@ -202,7 +365,11 @@ namespace MSCO
if (type != v.Type) return;
switch (type)
{
case 1: ((FsmFloat)v.Variable).Value = BitConverter.ToSingle(value, 0); break;
case 1:
float f = BitConverter.ToSingle(value, 0);
if (float.IsNaN(f) || float.IsInfinity(f)) return; // nie ungültige Werte übernehmen
SetFloat(v, f);
break;
case 2: ((FsmInt)v.Variable).Value = BitConverter.ToInt32(value, 0); break;
default: ((FsmBool)v.Variable).Value = value[0] != 0; break;
}
+103
View File
@@ -0,0 +1,103 @@
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Räder an Fahrzeugen, die ein anderer Spieler fährt, drehen sich mit. Bei uns wird so ein Auto nur verschoben
/// (kinematisch), die Rad-Physik des Spiels (CarDynamics "Wheel") dreht die Räder dann nicht. Wir drehen das
/// Rad-Modell passend zur Geschwindigkeit um seine Achse - nach dem Spiel (LateUpdate), ohne dessen Werte zu ändern.
/// </summary>
public class WheelSpin
{
const float CacheRefresh = 5f; // Räder können ab-/angebaut werden
class WheelInfo
{
public Transform Model;
public Transform Hub; // Transform der Wheel-Komponente (Fahrtrichtung)
public float Radius;
public float Angle;
public Quaternion Base; // Drehung ohne unsere Rollbewegung
public Quaternion Written; // zuletzt von uns gesetzt
public bool HasWritten;
}
class Vehicle
{
public float BuiltAt;
public readonly List<WheelInfo> Wheels = new List<WheelInfo>();
}
readonly Dictionary<int, Vehicle> vehicles = new Dictionary<int, Vehicle>();
static System.Type wheelType;
static FieldInfo modelField, radiusField;
static bool lookedUp;
public void Reset() { vehicles.Clear(); }
public void Apply(ObjectSync objects, float dt)
{
if (!Lookup()) return;
List<SyncedBody> driven = objects.RemoteDrivenVehicles();
if (driven.Count == 0) { if (vehicles.Count > 0) vehicles.Clear(); return; }
foreach (SyncedBody b in driven)
{
Vehicle v;
if (!vehicles.TryGetValue(b.Id, out v) || Time.time - v.BuiltAt > CacheRefresh)
{
v = Build(b.Rb.transform);
vehicles[b.Id] = v;
}
Vector3 vel = b.TargetVel;
foreach (WheelInfo w in v.Wheels)
{
if (w.Model == null || w.Hub == null) continue;
float speed = Vector3.Dot(vel, w.Hub.forward);
w.Angle = Mathf.Repeat(w.Angle + speed / w.Radius * Mathf.Rad2Deg * dt, 360f);
if (float.IsNaN(w.Angle) || float.IsInfinity(w.Angle)) w.Angle = 0f;
// Hat das Spiel das Modell in diesem Frame selbst gedreht (z.B. Lenkung), ist das die neue Basis
Quaternion current = w.Model.localRotation;
if (!w.HasWritten || current != w.Written) w.Base = current;
w.Written = w.Base * Quaternion.Euler(w.Angle, 0f, 0f);
w.Model.localRotation = w.Written;
w.HasWritten = true;
}
}
}
static Vehicle Build(Transform root)
{
var v = new Vehicle { BuiltAt = Time.time };
foreach (Component c in root.GetComponentsInChildren(wheelType, true))
{
var model = modelField.GetValue(c) as GameObject;
float radius = radiusField != null ? (float)radiusField.GetValue(c) : 0.3f;
if (model == null || radius < 0.05f || radius > 2f) continue;
v.Wheels.Add(new WheelInfo { Model = model.transform, Hub = c.transform, Radius = radius });
}
return v;
}
static bool Lookup()
{
if (lookedUp) return wheelType != null;
lookedUp = true;
foreach (Assembly a in System.AppDomain.CurrentDomain.GetAssemblies())
{
if (a.GetName().Name != "Assembly-CSharp") continue;
wheelType = a.GetType("Wheel");
break;
}
if (wheelType == null) { MscoLog.Warning("[MSCO] Wheel type not found - remote wheels will not spin"); return false; }
modelField = wheelType.GetField("model", BindingFlags.Public | BindingFlags.Instance);
radiusField = wheelType.GetField("radius", BindingFlags.Public | BindingFlags.Instance);
if (modelField == null) { wheelType = null; return false; }
return true;
}
}
}
+3 -3
View File
@@ -163,7 +163,7 @@ namespace MSCO
{
nextDiscover = Time.time + DiscoverRetry;
tracked.Clear();
foreach (PlayMakerFSM fsm in SceneCache.Fsms(5f))
foreach (PlayMakerFSM fsm in SceneCache.Fsms(20f))
{
if (fsm == null) continue;
if (Array.IndexOf(TrackedFsmNames, fsm.FsmName) < 0) continue;
@@ -175,9 +175,9 @@ namespace MSCO
}
if (tracked.Count == 0) return; // Welt noch nicht fertig geladen - später nochmal
discovered = true;
ModConsole.Print("[MSCO] Time/weather sync: " + tracked.Count + " FSMs");
MscoLog.Info("[MSCO] Time/weather sync: " + tracked.Count + " FSMs");
if (FsmSync.Debug)
foreach (string key in tracked.Keys) ModConsole.Print("[MSCO] " + key);
foreach (string key in tracked.Keys) MscoLog.Info("[MSCO] " + key);
}
}
}
+180
View File
@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Jobs, Aufträge und Laden: deren Fortschritt steht in PlayMaker-FSMs (z.B. "Auftrag angenommen", Anzahl
/// geleerter Klärgruben, bestellte Teile). Übertragen werden nur Ganzzahlen und Schalter - keine Kommazahlen
/// (Timer, Positionen), die jeder selbst weiterzählt. Wer etwas ändert, schickt es; Werte, die ständig hin und her
/// springen (interne Zähler), werden nach kurzer Zeit nicht mehr übertragen.
/// </summary>
public class WorldVarSync
{
/// <summary>Wurzelobjekte mit Jobs/Aufträgen/Laden.</summary>
public static readonly string[] Roots =
{
"JOBS", "STORE", "REPAIRSHOP", "INSPECTION", "LANDFILL", "DANCEHALL", "COTTAGE", "RALLY",
// Weitere Events/Orte: Dragrace (Zeiten, Anmeldung), Kilju-Käufer, Theater, Fußball, Dorf, Hütte
"DRAGRACE", "KILJUGUY", "THEATRE", "SOCCER", "PERAJARVI", "CABIN"
};
const float ScanInterval = 15f;
const float CheckInterval = 0.5f;
const float MinResend = 2f; // höchstens so oft pro Wert
const int FlipLimit = 6; // so viele Änderungen ...
const float FlipWindow = 30f; // ... in dieser Zeit = interner Zähler, nicht mehr übertragen
const int MaxPerMessage = 200;
class Var
{
public int Key;
public NamedVariable Variable;
public byte Type; // 2 int, 3 bool
public int LastI;
public bool LastB;
public float LastSent = -100f;
public float WindowStart;
public int Flips;
public bool Muted;
}
readonly Dictionary<int, Var> vars = new Dictionary<int, Var>();
readonly HashSet<PlayMakerFSM> scanned = new HashSet<PlayMakerFSM>();
readonly List<Var> outgoing = new List<Var>();
// Werte für noch nicht gefundene FSMs (Server-Snapshot vor dem ersten Suchlauf): [typ, wert]
readonly Dictionary<int, KeyValuePair<byte, int>> pending = new Dictionary<int, KeyValuePair<byte, int>>();
float nextScan, nextCheck;
public int Count { get { return vars.Count; } }
public void Reset()
{
vars.Clear();
scanned.Clear();
pending.Clear();
nextScan = -1f; // erster Suchlauf verteilt, siehe Update
}
public void Update(Action<MsgType, byte[]> send)
{
if (nextScan < 0f) nextScan = Time.time + 3.8f; // nicht alle Sync-Teile im selben Frame suchen lassen
if (Time.time >= nextScan)
{
nextScan = Time.time + ScanInterval;
Scan();
}
if (Time.time < nextCheck) return;
nextCheck = Time.time + CheckInterval;
outgoing.Clear();
foreach (Var v in vars.Values)
{
if (v.Muted || !Changed(v)) continue;
if (Time.time - v.LastSent < MinResend) continue; // später nochmal prüfen
Remember(v);
if (Time.time - v.WindowStart > FlipWindow) { v.WindowStart = Time.time; v.Flips = 0; }
if (++v.Flips > FlipLimit) { v.Muted = true; continue; }
v.LastSent = Time.time;
outgoing.Add(v);
}
for (int start = 0; start < outgoing.Count; start += MaxPerMessage)
{
int from = start, count = Math.Min(MaxPerMessage, outgoing.Count - start);
send(MsgType.WorldVars, NetSession.Build(w =>
{
w.Write(count);
for (int i = from; i < from + count; i++)
{
Var v = outgoing[i];
w.Write(v.Key);
w.Write(v.Type);
if (v.Type == 2) w.Write(v.LastI); else w.Write(v.LastB);
}
}));
}
}
public void Handle(BinaryReader r)
{
int count = r.ReadInt32();
for (int i = 0; i < count; i++)
{
int key = r.ReadInt32();
byte type = r.ReadByte();
int iv = 0; bool bv = false;
if (type == 2) iv = r.ReadInt32(); else if (type == 3) bv = r.ReadBoolean(); else return;
Var v;
if (!vars.TryGetValue(key, out v))
{
if (pending.Count < 20000) pending[key] = new KeyValuePair<byte, int>(type, type == 2 ? iv : (bv ? 1 : 0));
continue;
}
if (v.Type != type) continue;
Set(v, type == 2 ? iv : (bv ? 1 : 0));
}
}
void Scan()
{
int before = vars.Count;
foreach (PlayMakerFSM fsm in SceneCache.Fsms(20f))
{
if (fsm == null || !scanned.Add(fsm)) continue;
if (Array.IndexOf(Roots, fsm.transform.root.name) < 0) continue;
string prefix = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName + "|";
FsmVariables fv = fsm.FsmVariables;
foreach (FsmInt n in fv.IntVariables) Register(prefix, n, 2);
foreach (FsmBool b in fv.BoolVariables) Register(prefix, b, 3);
}
if (vars.Count != before) MscoLog.Info("[MSCO] Jobs/shop: " + vars.Count + " values synced");
}
void Register(string prefix, NamedVariable variable, byte type)
{
if (variable == null || string.IsNullOrEmpty(variable.Name)) return;
int key = Hash(prefix + variable.Name);
if (vars.ContainsKey(key)) return;
var v = new Var { Key = key, Variable = variable, Type = type };
Remember(v);
vars[key] = v;
KeyValuePair<byte, int> waiting;
if (pending.TryGetValue(key, out waiting))
{
pending.Remove(key);
if (waiting.Key == type) Set(v, waiting.Value);
}
}
static void Set(Var v, int value)
{
if (v.Type == 2) ((FsmInt)v.Variable).Value = value; else ((FsmBool)v.Variable).Value = value != 0;
Remember(v); // nicht zurückschicken
}
static bool Changed(Var v)
{
return v.Type == 2 ? ((FsmInt)v.Variable).Value != v.LastI : ((FsmBool)v.Variable).Value != v.LastB;
}
static void Remember(Var v)
{
if (v.Type == 2) v.LastI = ((FsmInt)v.Variable).Value; else v.LastB = ((FsmBool)v.Variable).Value;
}
static int Hash(string s)
{
unchecked
{
uint h = 2166136261;
foreach (byte c in Encoding.UTF8.GetBytes(s)) { h ^= c; h *= 16777619; }
return (int)h;
}
}
}
}