98 Commits
Author SHA1 Message Date
MrMahoandClaude Opus 5.5 06d3834576 2.10.15: PERF report 2 min after the world loaded, then every 5 min, and a final one when quitting; reset per scene
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:57:10 +02:00
MrMahoandClaude Opus 5.5 47c172859c 2.10.14: warn when Steam launch options run the game with the Mono debugger (costs FPS)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:53:36 +02:00
MrMahoandClaude Opus 5.5 e46b26c1d7 Show Bolt Size 1.0.4: find the wrench 'Check' FSM among inactive objects too, log wrench size, bolt scale, mesh size and all FSM numbers for diagnosis
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:16:37 +02:00
MrMahoandClaude Opus 5.5 1da5e3bb59 2.10.13: mods draw with the dynamic font (no font warning flood), mod time in PerfStats, PERF report every 5 min in game, redesigned announcement cards in the MSCO menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:10:07 +02:00
MrMahoandClaude Opus 5.5 b2ff221526 2.10.12: fix MSCO menu not drawn (announcement title style was copied from rowNameStyle before it existed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:55:15 +02:00
MrMahoandClaude Opus 5.5 8bac7d194a 2.10.11: security levels (no joining servers/hosts below the required level, OUTDATED in the browser), announcements and maintenance notice from the keymaster in the MSCO menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:26:52 +02:00
MrMahoandClaude Opus 5.5 ceaa2e29aa Show Bolt Size 1.0.3: size from the game's own wrench check (FPSCamera/2Spanner/Raycast 'Check' BoltSize) - the bolts' own BoltSize is 0; one-time dump of the tool FSMs for diagnosis
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:42:12 +02:00
MrMahoandClaude Opus 5.5 733a0d9635 Mods in singleplayer (-msco-singleplayer), 2.10.10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:40:09 +02:00
MrMahoandClaude Opus 5.5 7bef8fe265 Show Bolt Size 1.0.2: aim by distance to the view line (5 cm), visible bolt head as position, '? mm' for unknown sizes, diagnostics in the log
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:21:51 +02:00
Nico MeisterandClaude Opus 5.5 05e68a683d MODDING: uploads are checked automatically (virus scan, readable code, what the mod may do); moderator review for risky mods
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:05:25 +02:00
Nico MeisterandClaude Opus 5.5 d567d5992a Less garbage, fewer GC hitches: no Joint array per part every 0.5 s, cheap aim check after clicks, cached FPS/ping/chat labels; perf report measures object sync, vehicle fx and part/bolt sync correctly
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:31:56 +02:00
MrMahoandClaude Opus 5.5 fe08fa5213 Release from Windows or Mac
- MSCO.csproj is SDK-style now (net35 via Microsoft.NETFramework.ReferenceAssemblies):
  builds with "dotnet build -c Release" on Windows and macOS; game DLL folder via
  MSC_MANAGED / -p:MscManaged (default: the game folder). Output verified identical
  in structure (references, types, methods, resources) to the old project.
- release.ps1 works with Windows PowerShell 5.1 and PowerShell 7: secrets in DPAPI files
  on Windows, in the macOS keychain on a Mac (file with chmod 600 on Linux); cross-platform
  RSA/password input; release target calls pwsh on non-Windows
- RELEASING.md: how to release and how to set up the Mac

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:22:04 +02:00
MrMahoandClaude Opus 5.5 578bd30b3d release.ps1: signing key backup creates the folder, asks the password twice, verifies; -ImportSigningKey to restore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:07:22 +02:00
MrMahoandClaude Opus 5.5 dd4e004d51 2.10.9: beta notice in the MSCO menu and the in-game status bar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:45:59 +02:00
MrMahoandClaude Opus 5.5 d195a484d8 2.10.8: fewer hitches
- Sending on a background thread (the game thread blocked whenever the TCP
  buffer was full, e.g. while uploading the save); small frames are batched
- Server save requests: routine saves wait for a calm moment (not while driving
  fast) and are announced; urgent flag from server 2.10.5
- Dynamic built-in font for all MSCO GUI (Unity logged hundreds of
  "Font size and style overrides" warnings with stutters)
- Admin VEHICLE tab rescans every 30 s / after actions instead of every 5 s;
  time/weather FSM search less often
- Reused buffers for Build/Frame/Poll (less garbage, fewer GC pauses)
- Hitch log: GC and save detection, count of hitches between reports
- Show Bolt Size 1.0.1: finds the FPSCamera (Camera.main may be missing), uses
  MSCO's shared FSM list instead of FindObjectsOfType every 2 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:08:36 +02:00
MrMahoandClaude Opus 5.5 e32ddadbc8 release.ps1: secrets in %USERPROFILE%\.msco (AppData is virtualized for packaged apps)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:33:54 +02:00
MrMahoandClaude Opus 5.5 77c51b52c5 2.10.7: changelog; release.ps1 signs releases
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:55:04 +02:00
MrMahoandClaude Opus 5.5 a499577b7c Harden the mod against hostile servers and players
- PacketReader for all network data: lengths are checked against the real data
  (Mono allocated the claimed size first - a fake 2 GB length crashed the game)
- FsmSync: remote players can no longer switch arbitrary FSMs - never PLAYER /
  Systems / MSCO objects, CLICK only on FSMs that wait for a click, bolt/assembly
  events only along a transition the FSM really has for that event
- Time/weather (msco.world) and '* ' system-style chat only from the host in P2P
  (the dedicated server filters them itself); other msco.* ids are reserved

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:44:03 +02:00
MrMahoandClaude Opus 5.5 9c933ed486 release.ps1: release token encrypted with DPAPI (-SaveToken), MSCO_NO_RELEASE=1 skips uploads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:24:15 +02:00
MrMahoandClaude Opus 5.5 92a6125f22 MODDING: obfuscated mods are rejected
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:02:18 +02:00
MrMahoandClaude Opus 5.5 0a9da7bc9c oilcheck console command: show every oil leak source (2.10.6)
Engine leak values, oilpan bolts incl. drain plug, oil filter item (items.txt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:41:08 +02:00
MrMahoandClaude Opus 5.5 a067e238da Example mod Show Bolt Size; docs for publishing mods
Shows the size of the bolt closest to the crosshair (Screw FSM "BoltSize").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:28:56 +02:00
MrMahoandClaude Opus 5.5 2502a0800e Fix teleport to places landing in the wrong spot (2.10.5)
Use the median of a place's parts and the dense group around it instead of
the bounds of everything (scattered/disabled children pulled the center into
the forest); only take top-level objects as the place root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:04:55 +02:00
MrMahoandClaude Opus 5.5 13e5193616 Release 2.10.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:16:37 +02:00
MrMahoandClaude Opus 5.5 4be6b68f25 Admin: install parts, set time and weather; join without prompt
- VEHICLE: INSTALL for parts that are not installed - runs the game's own
  assembly (trigger "Assembly" like on load), so the part sits exactly on its
  pivot; synced to other players via FsmSync (MSCO_INSTALL)
- WORLD: time buttons (2-hour steps via the sun FSM's load path) and
  CLEAR / RAIN / THUNDERSTORM (weather front moved above the admin);
  sent to everyone as mod message "msco.world" (no server update needed)
- JOIN from the server browser/website connects right away in the main menu;
  server mods still ask before downloading

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:04:27 +02:00
MrMahoandClaude Opus 5.5 06df8fecc5 Admin menu rework: filters, search, cleaner rows
- Fixed-size admin window with equal-width tabs
- VEHICLE: part search, PROBLEMS/category chips, collapsible fluids grid,
  one-line rows with state pill and wear bar
- LOOSE state derived from actual tightness (DB "Bolted" lags behind)
- ITEMS: search field and grid
- Console and admin menu are mutually exclusive (console stole clicks)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:43:38 +02:00
MrMahoandClaude Opus 5.5 29a3697cc8 MSCO 2.10.3: bolt stages and part tightness synced, snap to pivot, admin tighten/loosen
- Every bolt (Screw FSM, stage 0-8) and every part's tightness (BoltCheck/Use "Tightness", counted
  by the tool) is synced as state - before only the bolt turned on other clients, the part stayed
  loose or tight there
- Tightening a part (tool, admin, other player) snaps it exactly onto its pivot (installed parts
  sit at local 0), except screw-in parts and hinged doors/hoods
- Admin VEHICLE tab: TIGHTEN/LOOSEN per part, TIGHTEN ALL/LOOSEN ALL, bolted/loose state
- fsmdump "pivots": installed part offsets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:16:29 +02:00
MrMahoandClaude Opus 5.5 7b4a89d50f MSCO 2.10.2: passenger only while moving, drain plug sync, admin VEHICLE tab, profile layout
- Passenger: auto ride-along only when the vehicle moves (speed from its motion, also for
  kinematic remote cars) - no more getting stuck while working in the engine bay or re-captured
- Bolts with a function (drain plug: stage 0-5 = oil leak on) are synced as stage and applied
  through the bolt's own stage logic; world authority sends its state once after joining.
  Fixes endless oil leaking when players had the drain plug in different states
- Admin menu VEHICLE tab: fluids with FILL/EMPTY, all Database parts with wear/damage,
  REPAIR/BREAK per part, REPAIR ALL, filter
- Server profile: info and players as two equal cards, no wrapping
- fsmdump also lists FSM variables

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:02:21 +02:00
MrMahoandClaude Opus 5.5 adc6eff9e8 MSCO 2.10.1: car doors and garage door synced, door lock fix, perf tools
- Car doors (FSM with bool "Open" and "Open door"/"Close door") are synced as open/closed:
  the receiver removes the hold joint and frees the hinge, closing runs the game's own logic
- Fix: every synced close of an already closed Satsuma door added another FixedJoint, so the
  door could not be opened anymore; extra joints are removed automatically
- Hinge sync also covers world doors/gates with hinges (garage door), interaction by bounds
- Scene name read once per frame (Application.loadedLevelName allocates a string per call)
- Console "perf" (FPS, ms per MSCO part incl. OnGUI, GC, hook calls), -msco-perf start option
- Console "fsmdump <text>" / -msco-dump: PlayMaker FSMs and hinges to MSCO\fsmdump.txt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:01:27 +02:00
MrMahoandClaude Opus 5.5 4c1366b5d9 MSCO 2.10.0: mod support, ESC no longer quits from the main menu
- MscoMod API (ModApi.cs) and ModLoader: own mods from MSCO\Mods, hooks for menu/game/update/GUI,
  session and player events, Send/OnMessage between players (MsgType.ModMessage)
- Server mods: ModList before Welcome, consent prompt (INSTALL & JOIN), download with SHA-256 check
  into MSCO\Mods\Servers, active only on that server; game start waits for the download
- Mods page in the MSCO menu, console command "mods"
- Server browser: MODS pill, VANILLA/MODDED filters, mod list in the server profile
- PlayMaker ApplicationQuit is ignored in the main menu (ESC used to close the game)
- MODDING.md and examples/HelloMod

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 01:00:16 +02:00
MrMahoandClaude Opus 5.5 027296d27c MSCO 2.9.1: fixes without MSCLoader, admin teleport, MSCO branding
- Own JSON reader (MiniJson) instead of Newtonsoft.Json - the server browser failed with
  "Could not load System.Runtime.Serialization" once MSCLoader was gone
- Mouse cursor visible in admin menu/chat/console (PlayerInMenu)
- Intro "Press any key" screen replaced by an MSCO loading screen; hook PlayMakerFSM.Awake too
- Join links while the game runs: picked up from MSCO\join.txt (written by the launcher)
- Admin menu: TELEPORT tab (home, shop, repair shop, inspection, villages, events, Satsuma)
- Window title/icon "MSCO - My Summer Car Online", MSCO logo in the main menu
- In-game UI restyled like the keymaster website (rounded cards, pills, glow, slim scrollbars)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:34:03 +02:00
MrMahoandClaude Opus 5.5 166f873669 release.ps1: build the multipart upload by hand
Windows PowerShell (.NET Framework) writes form-data names without quotes and a quoted
boundary, which the keymaster's parser rejected ("Send multipart/form-data"). Saved with
BOM so PowerShell 5.1 reads the umlauts correctly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:51:08 +02:00
MrMahoandClaude Opus 5.5 461d94c5fc MSCO 2.9.0: standalone without MSCLoader (Unity Doorstop)
- Bootstrap: Doorstop entry point, loads Harmony/Newtonsoft from the MSCO folder, hooks
  PlayMakerArrayListProxy.Awake and starts MSCO in the main menu; skips the news screen
- No MSCLoader reference anymore; key bindings in PlayerPrefs, bind console command
- Harmony 1.2 and Newtonsoft.Json in lib/ (MIT notices); build copies to <game>\MSCO
- README/CHANGELOG updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:36:28 +02:00
MrMahoandClaude Opus 5.5 ee162bd64f release.ps1: accept product 'installer'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:44:17 +02:00
MrMahoandClaude Opus 5.5 6bd4e67382 Update docs and README: launcher, downloads, accounts, MySQL, new game, updates, tags, riding along, models, version scheme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:28:47 +02:00
MrMahoandClaude Opus 5.5 15c9ee548d Release build uploads new mod versions to the keymaster automatically
release.ps1 (needs MSCO_RELEASE_TOKEN), changelog from CHANGELOG.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:12:46 +02:00
MrMahoandClaude Opus 5.5 3ea0e8ebca DLL carries the mod version (product version) for the MSCO Launcher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:21:48 +02:00
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
65 changed files with 10855 additions and 1183 deletions
+280
View File
@@ -0,0 +1,280 @@
using System;
using System.IO;
using System.Reflection;
using Harmony;
using UnityEngine;
namespace Doorstop
{
/// <summary>
/// Einstieg ohne MSCLoader: Unity Doorstop (winhttp.dll im Spielordner) ruft beim Spielstart diese Methode auf - der
/// MSCO Launcher startet das Spiel mit "--doorstop-target-assembly ...\MSCO\MSCO.dll", MSCLoader wird dann gar nicht
/// geladen. Hier läuft noch nichts von Unity: nur vorbereiten, gestartet wird im Hauptmenü (siehe MSCO.Bootstrap).
/// </summary>
public static class Entrypoint
{
public static void Start()
{
MSCO.Bootstrap.Start();
}
}
}
namespace MSCO
{
/// <summary>
/// Startet MSCO eigenständig: Abhängigkeiten (Harmony) aus dem MSCO-Ordner laden, warten bis das
/// Spiel seinen Code geladen hat, dann beim ersten PlayMakerArrayListProxy.Awake (Hauptmenü, Hauptthread) den
/// MultiplayerManager anlegen. Protokoll: &lt;Spiel&gt;\MSCO\MSCO_Loader.txt.
/// </summary>
public static class Bootstrap
{
static string folder;
/// <summary>Ordner von MSCO.dll (&lt;Spiel&gt;\MSCO).</summary>
public static string Folder { get { return folder; } }
static bool patchedGame, patchedPlayMaker, started;
/// <summary>
/// Singleplayer mit Mods (Launcher: SINGLEPLAYER, "-msco-singleplayer"): nur die eigenen Mods laden - kein
/// MSCO-Menü, kein Multiplayer, das Spiel bleibt sonst unverändert.
/// </summary>
public static readonly bool Singleplayer = Array.IndexOf(Environment.GetCommandLineArgs(), "-msco-singleplayer") >= 0;
static StreamWriter log;
public static void Start()
{
try
{
folder = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
log = new StreamWriter(Path.Combine(folder, "MSCO_Loader.txt"), false) { AutoFlush = true };
Log("MSCO " + MSCO.ModVersion + " loader started (standalone, without MSCLoader)");
Log("Command line: " + string.Join(" ", Environment.GetCommandLineArgs()));
if (Singleplayer) Log("Singleplayer mode: only mods, no multiplayer");
AppDomain.CurrentDomain.AssemblyResolve += Resolve;
AppDomain.CurrentDomain.AssemblyLoad += (_, e) => OnAssemblyLoaded(e.LoadedAssembly);
foreach (Assembly a in AppDomain.CurrentDomain.GetAssemblies()) OnAssemblyLoaded(a);
}
catch (Exception e)
{
Log("Loader failed: " + e);
}
}
public static void Log(string line)
{
try { if (log != null) log.WriteLine(DateTime.Now.ToString("HH:mm:ss") + " " + line); } catch { }
}
/// <summary>Unsere Abhängigkeiten liegen neben MSCO.dll (das Spiel sucht nur in Managed).</summary>
static Assembly Resolve(object sender, ResolveEventArgs args)
{
try
{
string name = new AssemblyName(args.Name).Name;
if (name == typeof(Bootstrap).Assembly.GetName().Name) return typeof(Bootstrap).Assembly; // Mods verweisen auf MSCO
string file = Path.Combine(folder, name + ".dll");
if (File.Exists(file)) return Assembly.LoadFrom(file);
}
catch (Exception e) { Log("Resolve " + args.Name + " failed: " + e.Message); }
return null;
}
static void OnAssemblyLoaded(Assembly assembly)
{
if (patchedGame && patchedPlayMaker) return;
string name;
try { name = assembly.GetName().Name; } catch { return; }
try
{
if (name == "Assembly-CSharp" && !patchedGame) { patchedGame = true; Hook(assembly, "PlayMakerArrayListProxy", "Awake"); }
// PlayMaker läuft schon im Startbildschirm (Nachrichten, "Press any key") - so startet MSCO sofort
if (name == "PlayMaker" && !patchedPlayMaker) { patchedPlayMaker = true; Hook(assembly, "PlayMakerFSM", "Awake"); }
}
catch (Exception e) { Log("Could not hook into " + name + ": " + e); }
}
/// <summary>
/// Haken in eine Awake-Methode, die früh auf dem Hauptthread läuft (PlayMakerFSM schon im Startbildschirm,
/// PlayMakerArrayListProxy im Hauptmenü als Rückfallebene). Der erste, der läuft, startet MSCO.
/// </summary>
static void Hook(Assembly assembly, string typeName, string methodName)
{
Type type = assembly.GetType(typeName);
MethodInfo method = type != null ? type.GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) : null;
if (method == null)
{
Log(typeName + "." + methodName + " not found");
return;
}
HarmonyInstance.Create("mbm.msco.bootstrap." + typeName).Patch(method,
new HarmonyMethod(typeof(Bootstrap).GetMethod("BeforeAwake", BindingFlags.Static | BindingFlags.NonPublic)), null);
Log("Hooked into " + typeName + "." + methodName);
}
static void BeforeAwake()
{
if (started) return;
started = true;
try
{
var go = new GameObject("MSCO_Host");
UnityEngine.Object.DontDestroyOnLoad(go);
go.AddComponent<MscoHost>();
Log("MSCO started");
}
catch (Exception e) { Log("Start failed: " + e); }
}
}
/// <summary>Lebt über alle Szenen: legt im Hauptmenü den MultiplayerManager an (früher MSCLoaders OnMenuLoad).</summary>
public class MscoHost : MonoBehaviour
{
void Start()
{
host = this;
MscoLog.Info("Welcome to MSCO by mbm.wtf");
if (!Bootstrap.Singleplayer) MenuBranding.ApplyWindow();
// Den Nachrichten-Bildschirm des Spiels ("Latest news ... press any key") überspringen - direkt ins Hauptmenü
string scene = Application.loadedLevelName;
Bootstrap.Log("First scene: " + scene);
if (scene != "MainMenu" && scene != "GAME")
{
// Erst einen Frame lang den MSCO-Ladebildschirm zeigen (Kameras und Ton des Startbildschirms aus), dann
// laden - sonst steht während des Ladens (ein paar Sekunden) "Press any key" auf dem Bildschirm
Bootstrap.Log("Skipping the intro/news screen");
skipping = true;
skipFrame = Time.frameCount;
foreach (Camera c in Camera.allCameras) c.enabled = false;
previousVolume = AudioListener.volume;
AudioListener.volume = 0f;
return;
}
if (!Bootstrap.Singleplayer) MultiplayerManager.Ensure();
SceneChanged(scene);
}
/// <summary>Mods: beim ersten Hauptmenü laden, dann Menü/Spiel melden.</summary>
static void SceneChanged(string scene)
{
if (scene == "MainMenu")
{
ModLoader.LoadLocal();
ModLoader.MenuLoad();
}
else if (scene == "GAME")
{
ModLoader.GameLoad();
string[] cmd = Environment.GetCommandLineArgs();
int i = Array.IndexOf(cmd, "-msco-dump");
if (i >= 0 && i + 1 < cmd.Length && host != null) host.StartCoroutine(DumpLater(cmd[i + 1]));
}
}
static MscoHost host;
static System.Collections.IEnumerator DumpLater(string filter)
{
yield return new WaitForSeconds(25f);
try { Bootstrap.Log("FSM dump: " + FsmDump.Write(filter)); }
catch (Exception e) { Bootstrap.Log("FSM dump failed: " + e); }
}
readonly System.Diagnostics.Stopwatch modWatch = new System.Diagnostics.Stopwatch();
/// <summary>Zeit der Mods messen (PERF-Bericht, Konsole "perf").</summary>
void MeasureMods(string part, Action call)
{
modWatch.Reset();
modWatch.Start();
try { call(); }
finally
{
modWatch.Stop();
PerfStats.Add(part, modWatch.Elapsed.TotalMilliseconds);
}
}
static readonly Action modFixedUpdate = ModLoader.FixedUpdate, modUpdate = ModLoader.Update, modOnGui = ModLoader.OnGUI;
void FixedUpdate()
{
MeasureMods("mods (physics)", modFixedUpdate);
}
bool skipping, loadingScreen;
float previousVolume = -1f;
int skipFrame;
Texture2D black;
void Update()
{
MeasureMods("mods", modUpdate);
if (!skipping || Time.frameCount < skipFrame + 2) return;
skipping = false;
loadingScreen = true;
Application.LoadLevel("MainMenu");
}
void OnGUI()
{
if (!skipping && !loadingScreen)
{
// Mods zeichnen mit der dynamischen Schrift: Schriftgröße/Fett gehen sonst nicht, und Unity schreibt bei jedem
// Zeichnen eine Warnung samt Stacktrace ins Log (z.B. Show Bolt Size: hunderte pro Minute, kostet FPS)
GUISkin skin = GUI.skin;
Font previous = skin.font, font = UiTheme.DynamicFont;
if (font != null) skin.font = font;
try { MeasureMods("mods (interface)", modOnGui); }
finally { skin.font = previous; }
return;
}
GUI.depth = -10000;
if (black == null)
{
black = new Texture2D(1, 1);
black.SetPixel(0, 0, new Color(0.05f, 0.05f, 0.063f));
black.Apply();
}
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), black);
Texture2D logo = Branding.LogoTexture;
float size = Mathf.Round(Screen.height * 0.14f);
if (logo != null) GUI.DrawTexture(new Rect((Screen.width - size) / 2f, Screen.height * 0.40f - size / 2f, size, size), logo);
var style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = Mathf.RoundToInt(Screen.height * 0.022f) };
style.normal.textColor = new Color(0.56f, 0.56f, 0.6f);
GUI.Label(new Rect(0, Screen.height * 0.40f + size / 2f + 16f, Screen.width, 40f), "Loading MSCO …", style);
}
void OnLevelWasLoaded(int level)
{
MenuHelper.SceneChanged();
loadingScreen = false;
if (previousVolume >= 0f) { AudioListener.volume = previousVolume; previousVolume = -1f; }
Bootstrap.Log("Level loaded: " + Application.loadedLevelName);
if (!Bootstrap.Singleplayer)
{
MenuBranding.ApplyWindow();
if (Application.loadedLevelName == "MainMenu") StartCoroutine(BrandMenu());
MultiplayerManager.Ensure();
}
SceneChanged(Application.loadedLevelName);
}
/// <summary>Logo im Hauptmenü ersetzen - ein paar Mal, falls das Menü es erst nach dem Laden zeigt.</summary>
System.Collections.IEnumerator BrandMenu()
{
for (int i = 0; i < 4 && Application.loadedLevelName == "MainMenu"; i++)
{
yield return new WaitForSeconds(i == 0 ? 0.1f : 1f);
try { MenuBranding.ReplaceMenuLogo(); }
catch (System.Exception e) { Bootstrap.Log("Menu logo: " + e.Message); yield break; }
}
}
void OnApplicationQuit()
{
try { PerfStats.FinalReport(); } catch { }
// Wer beendet das Spiel? (Diagnose)
Bootstrap.Log("Game is quitting (scene " + Application.loadedLevelName + "): " + System.Environment.StackTrace);
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ namespace MSCO
{
/// <summary>
/// MSCO-Logo (branding/logo-128.png, als Ressource in die DLL eingebettet):
/// Symbol in der MSCLoader-Modliste und im Launcher.
/// Symbol im MSCO-Launcher (Hauptmenü) und in Overlays.
/// </summary>
public static class Branding
{
+401
View File
@@ -0,0 +1,401 @@
# Changelog
## 2.10.15
- Leistungsbericht: der erste kommt 2 Minuten nach dem Laden der Welt (das Laden zählt nicht mehr mit), danach alle
5 Minuten, und beim Beenden des Spiels einer für die letzten Minuten
## 2.10.14
- **Hinweis bei Debug-Startoptionen:** Stehen in den Steam-Startoptionen von My Summer Car noch
`--doorstop-mono-debug-enabled true` (vom MSCLoader-Entwicklermodus), läuft das ganze Spiel mit Mono-Debugger und
spürbar langsamer. MSCO meldet das jetzt beim Start (Hinweis und Konsole F7)
## 2.10.13
- **Mehr FPS mit Mods:** Mods (z.B. Show Bolt Size) zeichnen jetzt mit der richtigen Schrift. Vorher schrieb Unity bei
jedem Zeichnen eine Warnung samt Stacktrace ins Log – in einer Sitzung über 400-mal, spürbar als Ruckeln
- **Leistungsbericht:** Im Spiel schreibt MSCO alle 5 Minuten eine Zeile nach `MSCO\MSCO_Loader.txt` (FPS, schlimmstes
Bild, Zeit von MSCO und den Mods). Die Mods stehen jetzt auch in `perf` (Konsole F7)
- **Ankündigungen im MSCO-Menü** neu gestaltet: Abstand innen, farbiger Balken je nach Art, Abzeichen und Titel auf
einer Linie, gut lesbarer Text
## 2.10.12
- **Fix:** Das MSCO-Menü wurde in 2.10.11 nicht angezeigt (Fehler beim Anlegen der Schrift für Ankündigungen)
## 2.10.11
- **Sicherheitsstufe:** Server und P2P-Hosts, denen MSCO-Sicherheitsupdates fehlen (Server älter als 2.10.6), kann man
nicht mehr betreten – im Server-Browser stehen sie als **OUTDATED**, statt JOIN steht dort „UPDATE REQUIRED“, und
wer trotzdem verbindet, bekommt den Grund angezeigt. Server-Betreiber: im Web-Panel auf *Update* klicken
- **Ankündigungen & Wartung:** Oben im MSCO-Menü stehen Ankündigungen des MSCO-Teams und ein Hinweis, wenn MSCO gerade
gewartet wird (mit × ausblendbar). Neue Ankündigungen während des Spielens erscheinen kurz am Bildschirmrand und
in der Konsole (F7)
## 2.10.10
- **Mods im Singleplayer:** Startet der Launcher mit SINGLEPLAYER, lädt MSCO nur noch die eigenen Mods
(`Mods\*.dll`) – kein MSCO-Menü, kein Multiplayer, das Spiel und der eigene Spielstand bleiben sonst unverändert
- Für Mod-Entwickler: `IsSingleplayer` in `MscoMod`
## 2.10.9
- **Beta-Hinweis:** im MSCO-Menü steht „BETA“ neben dem Schriftzug und ein kurzer Hinweis, dass Fehler auftreten
können; die Statusleiste im Spiel zeigt „MSCO BETA“
- Die Statusleiste baut ihren Text nur noch bei Änderungen neu (nicht jedes Frame)
## 2.10.8
**Stabilität – weniger Hänger**
- Senden läuft jetzt in einem eigenen Thread: vorher schrieb das Spiel selbst ins Netzwerk und stand still, sobald die
Leitung kurz voll war (Spielstand hochladen, schwaches WLAN). Kleine Pakete werden gebündelt
- Speichern für den Server (friert das Spiel einige Sekunden ein): bei Routine-Speicherungen wartet das Spiel auf einen
ruhigen Moment (nicht in schneller Fahrt) und zeigt vorher „Saving the world for the server …“. Zusammen mit Server
2.10.5 wird viel seltener gespeichert (beim Beitreten nur, wenn der letzte Stand älter als 15 Minuten ist, sonst
alle 30 statt 10 Minuten)
- Eigene dynamische Schrift für alle MSCO-Anzeigen (Namensschilder, HUD, Menüs): vorher schrieb Unity hunderte
Warnungen ins Log – mit Rucklern
- Admin-Menü: der Reiter VEHICLE durchsucht das Spiel nur noch alle 30 s bzw. nach einer Aktion (vorher alle 5 s –
spürbarer Ruckler bei offenem Menü); Uhrzeit/Wetter suchen ihre FSMs seltener
- Weniger Speichermüll pro Nachricht (wiederverwendete Puffer) – der Garbage Collector läuft seltener
- Hänger-Meldungen im Log sagen jetzt auch, wenn der Garbage Collector oder das Speichern die Ursache war, und wie
viele Hänger es seit der letzten Meldung gab
## 2.10.7
**Sicherheit**
- Daten von Servern und Mitspielern werden auf gültige Längen geprüft – eine gefälschte Längenangabe ließ das Spiel
vorher mit „Out of Memory“ abstürzen
- Mitspieler können nicht mehr beliebige Spiel-Zustände bei anderen auslösen (z.B. am eigenen Spieler oder an
Systemen wie Tod/Speichern) – nur noch Klicks auf klickbare Dinge und echte Schraub-/Montage-Schritte
- Uhrzeit/Wetter und Meldungen im Stil von Server-/Admin-Nachrichten („* …“) nur noch vom Admin bzw. Host
- Release ist mit dem MSCO-Release-Schlüssel signiert
## 2.10.6
- Neuer Konsolenbefehl **oilcheck**: zeigt, woher Öl ausläuft – die Leck-Werte des Motors (Ölwanne, Ölfilter,
Zylinderkopfdichtung, Ventildeckel), die Schrauben der Ölwanne samt Ablassschraube und den Ölfilter (eingebaut,
Festigkeit). Wichtig: der Ölfilter ist ein gekauftes Teil und steht in **items.txt** – fehlt diese Datei im
Spielstand eines Servers, fehlt der Filter am Auto und Öl läuft aus
- Neue Beispiel-Mod **Show Bolt Size** (examples/ShowBoltSize); Mods lassen sich jetzt über den Launcher
veröffentlichen (siehe MODDING.md)
## 2.10.5
- **Teleport zu Orten** (Admin, Reiter TELEPORT) landet wieder am Ort: vorher zählten alle weit verstreuten Teile
eines Ortes mit (Briefkasten, Wege, abgeschaltete Objekte) und man landete irgendwo im Wald; außerdem wurde
manchmal ein gleichnamiges Unterobjekt statt des Ortes genommen
## 2.10.4
**Admin-Menü neu aufgebaut**
- Festes Fenster mit gleich breiten Reitern, Zeilen mit Status-Abzeichen statt langer Listen
- VEHICLE: Suche, Filter **PROBLEMS** (kaputt, lose, verschlissen) und je Kategorie, Füllstände ein-/ausklappbar
- **INSTALL** für nicht eingebaute Teile: das Teil wird wie beim Laden eines Spielstands eingebaut und sitzt exakt
auf seinem Halter (auch bei den Mitspielern); fehlt das Trägerteil, steht der Grund im Log
- Status **LOOSE** kommt jetzt aus der echten Festigkeit (vorher blieb nach LOOSEN „BOLTED“ stehen)
- WORLD: **Uhrzeit** (in 2-Stunden-Schritten) und **Wetter** (klar, Regen, Gewitter – direkt über dir) für alle
- ITEMS: Suchfeld; Konsole und Admin-Menü überdecken sich nicht mehr
**JOIN ohne Rückfrage**
- JOIN im Server-Browser oder auf der Website verbindet im Hauptmenü sofort. Server-Mods fragen weiterhin nach
## 2.10.3
**Schrauben synchron**
- Jede Schraube wird jetzt als Stufe (0–8) übertragen und die Festigkeit jedes Teils dazu: vorher drehte sich beim
Mitspieler nur die Schraube, sein Teil blieb „lose“ bzw. „fest“ (die Festigkeit zählt im Spiel das Werkzeug hoch) –
Teile ließen sich dann nicht abbauen oder fielen ab
- Wird ein Teil festgezogen (Werkzeug, Admin oder vom Mitspieler), springt es exakt auf seinen Halter – verrutschte
Teile sitzen wieder richtig (nicht bei eingedrehten Teilen wie Zündkerzen/Ölfilter und nicht bei Türen/Hauben)
**Admin-Menü, Reiter VEHICLE**
- Je Teil **TIGHTEN** / **LOOSEN** (alle Schrauben ganz fest bzw. ganz lose, wie mit dem Werkzeug) und oben
**TIGHTEN ALL** / **LOOSEN ALL**; Anzeige „bolted“/„loose“; das Fenster ist auf diesem Reiter breiter
## 2.10.2
- **Beifahrer**: automatisch festgehalten wird man nur noch, wenn das Fahrzeug fährt (egal wer fährt) – am stehenden
Auto kann man sich frei bewegen und schrauben (vorher hing man fest, sobald man sich in den Motorraum beugte, und
wurde nach dem Aussteigen gleich wieder eingefangen)
- **Öl lief endlos aus**: die Ablassschraube war bei Mitspielern unterschiedlich weit eingedreht (offen = Leck). Schrauben
mit Funktion (Ablassschraube) werden jetzt als Stufe übertragen und über die Logik des Spiels angewendet; beim Beitreten
gibt die Welt-Autorität ihren Stand vor
- **Admin-Menü, Reiter VEHICLE**: Füllstände (Benzin, Öl, Brems-/Kupplungsflüssigkeit, Kühlwasser) mit FILL/EMPTY, alle
Teile mit Verschleiß und Zustand – einzeln REPAIR/BREAK, REPAIR ALL, Filter (zum Testen; im Chat angezeigt)
- **Server-Profil** im Server-Browser aufgeräumt: Infos und Spieler als zwei gleich hohe Karten, nichts bricht mehr um
- Diagnose `fsmdump` zeigt jetzt auch die Variablen der FSMs
## 2.10.1
**Türen**
- Autotüren (Satsuma, Hayosiko, Kekmet, Gifu, Ferndale, Ruscko ...) werden jetzt als offen/zu übertragen: beim
Mitspieler geht die Tür wirklich auf (Halte-Joint weg, Scharnier frei) und schwingt mit, Zumachen läuft über die
Schließ-Logik des Spiels (mit Geräusch)
- Behoben: Satsuma-Türen ließen sich manchmal gar nicht mehr öffnen – jedes übertragene Zumachen hängte einen weiteren
Halte-Joint an. Solche „verklemmten“ Türen werden automatisch repariert
- Garagentor (und andere Tore/Türen mit Scharnier) synchron
**Leistung**
- Szenenname wird nur noch einmal pro Bild abgefragt (vorher hundertfach – jedes Mal ein neuer String)
- Konsole `perf`: FPS und Zeit pro MSCO-Teil (inkl. Oberfläche); Messung auf einem Server: MSCO braucht ~0,2 ms pro
Bild – die FPS begrenzt das Spiel selbst
- Konsole `fsmdump <text>`: Diagnose für Spiel-Logik (Zustände, Aktionen, Scharniere) in `MSCO\fsmdump.txt`
## 2.10.0
**Mod-Support**
- Eigene MSCO-Mods: DLLs in `<Spielordner>\MSCO\Mods` mit einer Klasse, die von `MscoMod` erbt (OnLoad, OnMenuLoad,
OnGameLoad, Update, OnGUI, Spieler kommen/gehen, Sitzung, eigene Netzwerk-Nachrichten zwischen den Spielern).
Anleitung und Beispiel: `MODDING.md`, `examples/HelloMod`
- **Server-Mods**: Server mit Mods schicken beim Beitreten die Liste; fehlende Mods lädt das Spiel nach Zustimmung
(„INSTALL & JOIN“) direkt vom Server (SHA-256 geprüft). Sie sind nur auf diesem Server aktiv
- Neue Seite **Mods** im MSCO-Menü, Konsole `mods`
- Server-Browser: Pille **MODS n**, Filter **VANILLA** / **MODDED**, Mod-Liste im Server-Profil
- **ESC im Hauptmenü** beendet das Spiel nicht mehr (zum Beenden: QUIT)
- Netzwerk-Version bleibt 2.8 – Server und Mitspieler mit 2.8.x/2.9.x funktionieren weiter
## 2.9.1
**Fehler ohne MSCLoader behoben**
- Server-Browser: „Could not load System.Runtime.Serialization“ – MSCO braucht Newtonsoft.Json nicht mehr (eigener
kleiner JSON-Leser); die Bibliothek kam vorher von MSCLoader
- Mauszeiger im Admin-Menü, Chat und in der Konsole wieder sichtbar (wie im Pausemenü des Spiels)
- Startbildschirm „Press any key“: statt ihm erscheint sofort ein MSCO-Ladebildschirm, dann das Hauptmenü
- JOIN im Browser, während das Spiel schon läuft: der Link kommt im Spiel an (Hinweis bzw. Beitreten im Hauptmenü)
**Admin-Menü: Teleport**
- Neuer Reiter **TELEPORT**: Haus, Laden (Teimo), Werkstatt, Prüfstelle, Peräjärvi, Müllkippe, Tanzhalle, Ferienhaus,
Hütte, Rallye, Dragrace, Fußballplatz, Kilju-Käufer, Theater, Satsuma – man landet neben dem Ort auf dem Boden
**MSCO-Look**
- Fenstertitel „MSCO - My Summer Car Online“ und MSCO-Symbol; im Hauptmenü ist das MSCO-Logo statt des Spiel-Logos
- Oberfläche im Stil der Website: rundere Karten mit feinem Rand, Filter als Pillen, oranger Schimmer, schlanke
Scrollleisten, Navigation mit abgerundeten Einträgen
## 2.9.0
**Eigenständig – ohne MSCLoader**
- MSCO braucht MSCLoader nicht mehr: es startet selbst über Unity Doorstop (`winhttp.dll`) aus `<Spielordner>\MSCO`.
Der MSCO Launcher richtet alles ein und stellt `doorstop_config.ini` auf MSCO um – auch der Start über Steam lädt dann
MSCO. MSCLoader ist dabei blockiert (seine Konfiguration wird als `doorstop_config.mscloader.ini` gesichert)
- Der Nachrichten-Bildschirm beim Spielstart („Press any key“) wird übersprungen, MSCO öffnet direkt im Hauptmenü
- Tasten ändern jetzt mit `bind` in der F7-Konsole (`bind passenger O`), gespeichert in den Spiel-Einstellungen
- Harmony und Newtonsoft.Json liegen neben `MSCO.dll`; Lade-Protokoll in `MSCO\MSCO_Loader.txt`
- `msco://`-Links werden vom Launcher übernommen, wenn er installiert ist
- Netzwerk-Version bleibt 2.8: spielt mit 2.8.x-Servern und -Mitspielern
## 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)
+148
View File
@@ -0,0 +1,148 @@
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>Teleport-Ziele im Admin-Menü: Anzeigename und Objekt im Spiel.</summary>
public static readonly string[][] Places =
{
new[] { "Home", "YARD" }, new[] { "Shop (Teimo)", "STORE" }, new[] { "Repair shop", "REPAIRSHOP" },
new[] { "Inspection", "INSPECTION" }, new[] { "Peräjärvi", "PERAJARVI" }, new[] { "Landfill", "LANDFILL" },
new[] { "Dance hall", "DANCEHALL" }, new[] { "Cottage", "COTTAGE" }, new[] { "Cabin", "CABIN" },
new[] { "Rally", "RALLY" }, new[] { "Drag race", "DRAGRACE" }, new[] { "Soccer field", "SOCCER" },
new[] { "Kilju buyer", "KILJUGUY" }, new[] { "Theatre", "THEATRE" }, new[] { "Satsuma", "SATSUMA" },
};
/// <summary>
/// Wohin bei einem Teleport-Ziel? Zu einem Ort gehören oft weit verstreute Teile (Briefkasten, Wege, Strecken,
/// abgeschaltete Objekte) - deren gemeinsame Mitte liegt irgendwo im Wald. Deshalb: Median aller Teile (der Ort
/// selbst, Ausreißer zählen nicht), darum die dichte Gruppe (60 m), daneben ein Stück außerhalb auf den Boden
/// (nicht ins Dach oder in eine Wand).
/// </summary>
public static bool FindPlace(string objectName, out Vector3 position)
{
position = Vector3.zero;
GameObject root = FindRoot(objectName);
if (root == null) return false;
var centers = new List<Vector3>();
foreach (Renderer r in root.GetComponentsInChildren<Renderer>(true))
{
if (r == null || r is ParticleRenderer || r is ParticleSystemRenderer) continue;
Vector3 size = r.bounds.size;
if (size == Vector3.zero || size.x > 150f || size.z > 150f) continue; // Gelände-/Wasserflächen
centers.Add(r.bounds.center);
}
if (centers.Count == 0)
{
Vector3 ground0;
position = Ground(root.transform.position, root.transform.position.y + 50f, out ground0) ? ground0 : root.transform.position;
return true;
}
Vector3 median = new Vector3(Median(centers, 0), Median(centers, 1), Median(centers, 2));
Bounds cluster = new Bounds(median, Vector3.zero);
foreach (Vector3 c in centers)
if (new Vector2(c.x - median.x, c.z - median.z).sqrMagnitude < 60f * 60f) cluster.Encapsulate(c);
Vector3 center = cluster.center;
float ox = Mathf.Min(cluster.extents.x, 20f) + 4f, oz = Mathf.Min(cluster.extents.z, 20f) + 4f;
Vector3[] candidates =
{
center + new Vector3(ox, 0f, 0f), center + new Vector3(-ox, 0f, 0f),
center + new Vector3(0f, 0f, oz), center + new Vector3(0f, 0f, -oz), center,
};
foreach (Vector3 c in candidates)
{
Vector3 ground;
if (Ground(c, cluster.max.y + 30f, out ground)) { position = ground; return true; }
}
position = center + Vector3.up * 2f;
return true;
}
static float Median(List<Vector3> points, int axis)
{
var values = new List<float>(points.Count);
foreach (Vector3 p in points) values.Add(p[axis]);
values.Sort();
return values[values.Count / 2];
}
/// <summary>Wurzelobjekt eines Ortes (nur oberste Ebene - "YARD" o.ä. gibt es auch als Unterobjekt).</summary>
static GameObject FindRoot(string name)
{
GameObject found = null;
// auch ausgeblendete Objekte (das Spiel schaltet entfernte Orte ab); aktive bevorzugt
foreach (GameObject candidate in Resources.FindObjectsOfTypeAll<GameObject>())
{
if (candidate.name != name || candidate.transform.parent != null || candidate.hideFlags != HideFlags.None) continue;
if (candidate.activeInHierarchy) return candidate;
if (found == null) found = candidate;
}
return found;
}
/// <summary>Höchster fester Boden unter einem Punkt (ohne Trigger).</summary>
static bool Ground(Vector3 at, float top, out Vector3 ground)
{
ground = at;
bool found = false;
foreach (RaycastHit hit in Physics.RaycastAll(new Vector3(at.x, top, at.z), Vector3.down, 1000f))
{
if (hit.collider == null || hit.collider.isTrigger || hit.normal.y < 0.6f) continue;
if (!found || hit.point.y > ground.y) { ground = hit.point; found = true; }
}
return found;
}
/// <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;
}
}
}
+115 -8
View File
@@ -1,24 +1,67 @@
using UnityEngine;
using System;
namespace MSCO
{
/// <summary>
/// Alle MSCO-Befehle an einer Stelle - genutzt von der eigenen F8-Konsole (ohne "mp" davor)
/// und von der MSCLoader-Konsole ("mp ...").
/// Alle MSCO-Befehle an einer Stelle - genutzt von der eigenen F7-Konsole (ohne "mp" davor)
/// (früher auch aus der MSCLoader-Konsole - MSCO läuft jetzt ohne MSCLoader).
/// </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" +
"bind [action key] - show / change keys (passenger, console, chat)\n" +
"mods - loaded MSCO mods (folder MSCO\\Mods)\n" +
"perf [reset] - FPS and how long each MSCO part takes per frame\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" +
"oilcheck - where does oil leak out? (all leak sources of the engine)\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 +102,63 @@ 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 "fsmdump":
print(FsmDump.Write(args.Length > 1 ? string.Join(" ", args, 1, args.Length - 1) : ""));
break;
case "perf":
if (args.Length > 1 && args[1].ToLower() == "reset") { PerfStats.Reset(); print("Performance statistics reset"); break; }
print(PerfStats.Report());
break;
case "mods":
if (ModLoader.All.Count == 0) { print("No mods. Put MSCO mods (.dll) into " + ModLoader.Folder + " and restart the game."); break; }
foreach (ModLoader.LoadedMod m in ModLoader.All)
{
string name = "?", version = "";
try { name = m.Mod.Name; version = m.Mod.Version; } catch { }
print(" " + name + " " + version + (m.FromServer ? " [server]" : "") +
(m.Error != null ? " turned off: " + m.Error : m.Active ? " active" : " inactive"));
}
break;
case "bind":
{
// Tasten ändern: "bind" zeigt alle, "bind passenger O" setzt eine (früher MSCLoader-Einstellungen)
if (args.Length < 3)
{
foreach (KeyBinding k in MSCO.Keys) print(" " + k.Id.PadRight(10) + " " + k.Key + " (" + k.Description + ", default " + k.Default + ")");
print("Change: bind <" + string.Join("|", System.Array.ConvertAll(MSCO.Keys, k => k.Id)) + "> <key> e.g. bind passenger O");
break;
}
KeyBinding target = System.Array.Find(MSCO.Keys, k => k.Id.Equals(args[1], System.StringComparison.OrdinalIgnoreCase));
KeyCode key;
if (target == null) print("Unknown action " + args[1] + " - see 'bind'");
else if (!KeyBinding.TryParse(args[2], out key)) print("Unknown key " + args[2] + " (Unity key names, e.g. O, F6, Keypad5, Mouse3)");
else { target.Key = key; print(target.Description + ": " + key); }
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 +166,23 @@ 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 "oilcheck":
foreach (string line in OilCheck.Report()) 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 -2
View File
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using MSCLoader;
using UnityEngine;
namespace MSCO
@@ -45,7 +44,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;
+150 -7
View File
@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
@@ -16,6 +15,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
@@ -26,8 +32,19 @@ namespace MSCO
public PlayMakerFSM Fsm;
public string LastState;
public float QuietUntil;
// Autotür (FSM mit Bool "Open" und Zuständen "Open door"/"Close door"): übertragen wird nur offen/zu
public bool IsCarDoor;
public FsmBool OpenVar;
public bool LastOpen;
public GameObject Door;
public HingeJoint Hinge;
public bool HasOpenLimits;
public float OpenMin, OpenMax;
}
// Statt Zustandsnamen: offen/zu einer Autotür (ältere Mods kennen diese "Zustände" nicht und ignorieren sie)
const string OpenToken = "__open", ClosedToken = "__closed";
readonly Dictionary<string, Tracked> byKey = new Dictionary<string, Tracked>();
float nextScan;
@@ -51,6 +68,18 @@ namespace MSCO
foreach (Tracked t in byKey.Values)
{
if (t.Fsm == null) continue;
if (t.IsCarDoor)
{
bool open = t.OpenVar.Value;
if (open == t.LastOpen) continue;
t.LastOpen = open;
if (now < t.QuietUntil) continue; // Folge einer übernommenen Änderung
t.QuietUntil = now + Cooldown;
string doorKey = t.Key, token = open ? OpenToken : ClosedToken;
send(MsgType.DoorState, NetSession.Build(w => { w.Write(doorKey); w.Write(token); }));
if (FsmSync.Debug) MscoLog.Info("[MSCO] >> CAR DOOR " + doorKey + " -> " + token);
continue;
}
string state = t.Fsm.ActiveStateName;
if (state == t.LastState) continue;
t.LastState = state;
@@ -62,7 +91,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,36 +109,150 @@ 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;
}
}
if (t.IsCarDoor)
{
// Ältere Mods schicken Zustandsnamen: die des Zumachens als "zu" verstehen, den Rest ignorieren
bool closing = state == ClosedToken || state == "Sound" || state == "Close door" || state == "State 1";
if (state == OpenToken || closing) ApplyCarDoor(t, state == OpenToken);
return;
}
if (t.Fsm.ActiveStateName == state) return;
FsmState target = t.Fsm.Fsm.GetState(state);
if (target == null) return;
t.QuietUntil = Time.realtimeSinceStartup + Cooldown; // Folge-Zustände nicht zurückschicken
t.LastState = state;
FsmSync.SwitchStateSilently(t.Fsm, target);
if (FsmSync.Debug) ModConsole.Print("[MSCO] << DOOR " + key + " -> " + state);
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;
if (byKey.TryGetValue(key, out t) && t.Fsm == fsm) continue;
byKey[key] = new Tracked { Key = key, Fsm = fsm, LastState = fsm.ActiveStateName };
if (byKey.TryGetValue(key, out t) && t.Fsm == fsm)
{
if (t.IsCarDoor && !t.OpenVar.Value) RemoveExtraFixedJoints(t);
continue;
}
t = new Tracked { Key = key, Fsm = fsm, LastState = fsm.ActiveStateName };
DetectCarDoor(t);
byKey[key] = t;
}
}
// ---------------------------------------------------------------- Autotüren
// Alle Autotüren im Spiel: Bool "Open", "Open door" (Halte-Joint weg, Scharnier-Grenzen auf, Drehmoment solange
// die Maus gedrückt ist) und "Close door" (zu, Grenzen 0..0 bzw. beim Satsuma ein neuer FixedJoint). "Open door"
// prüft die Maustaste und wurde deshalb nie übertragen - beim Mitspieler blieb die Tür fest. Und jedes übertragene
// Zumachen einer schon geschlossenen Satsuma-Tür hängte einen weiteren FixedJoint an - beim nächsten Öffnen wurde
// nur einer entfernt und die Tür ging gar nicht mehr auf.
static void DetectCarDoor(Tracked t)
{
try
{
Fsm f = t.Fsm.Fsm;
FsmState openState = f.GetState("Open door");
if (openState == null || f.GetState("Close door") == null) return;
FsmBool open = t.Fsm.FsmVariables.FindFsmBool("Open");
if (open == null) return;
FsmGameObject door = t.Fsm.FsmVariables.FindFsmGameObject("Door");
t.Door = door != null && door.Value != null ? door.Value : t.Fsm.gameObject;
t.Hinge = t.Door.GetComponent<HingeJoint>();
t.OpenVar = open;
t.LastOpen = open.Value;
t.IsCarDoor = true;
// Grenzen beim Öffnen: aus der Aktion "SetHingeJointLimits" des Zustands "Open door"
if (!openState.ActionsLoaded) openState.LoadActions();
if (openState.Actions == null) return;
foreach (FsmStateAction a in openState.Actions)
{
if (a == null || a.GetType().Name != "SetHingeJointLimits") continue;
var min = a.GetType().GetField("min") != null ? a.GetType().GetField("min").GetValue(a) as FsmFloat : null;
var max = a.GetType().GetField("max") != null ? a.GetType().GetField("max").GetValue(a) as FsmFloat : null;
if (min == null || max == null) continue;
t.OpenMin = min.Value;
t.OpenMax = max.Value;
t.HasOpenLimits = true;
}
}
catch (Exception e)
{
t.IsCarDoor = false;
MscoLog.Warning("[MSCO] Car door " + t.Key + ": " + e.Message);
}
}
/// <summary>Tür auf- bzw. zumachen wie beim Spieler, der sie bedient hat.</summary>
void ApplyCarDoor(Tracked t, bool open)
{
if (t.Door == null) return;
t.QuietUntil = Time.realtimeSinceStartup + Cooldown;
t.LastOpen = open;
if (open)
{
if (t.OpenVar.Value && t.Door.GetComponent<FixedJoint>() == null) return; // schon offen
foreach (FixedJoint j in t.Door.GetComponents<FixedJoint>()) UnityEngine.Object.Destroy(j);
if (t.Hinge != null && t.HasOpenLimits)
{
JointLimits l = t.Hinge.limits;
l.min = t.OpenMin;
l.max = t.OpenMax;
t.Hinge.limits = l;
t.Hinge.useLimits = true;
}
t.OpenVar.Value = true;
Rigidbody rb = t.Door.GetComponent<Rigidbody>();
if (rb != null) rb.WakeUp();
// Ruht die FSM (nicht gerade selbst an der Tür): neu prüfen lassen, damit sie "offen" kennt (Innenlicht ...)
string active = t.Fsm.ActiveStateName ?? "";
FsmState idle = t.Fsm.Fsm.GetState("Mouse off");
if (idle != null && (active.StartsWith("Mouse") || active == "Check position")) FsmSync.SwitchStateSilently(t.Fsm, idle);
}
else
{
if (!t.OpenVar.Value) { RemoveExtraFixedJoints(t); return; } // schon zu
// Halte-Joints weg, dann die Schließ-Logik des Spiels (Geräusch, zu, beim Satsuma genau EIN neuer FixedJoint)
foreach (FixedJoint j in t.Door.GetComponents<FixedJoint>()) UnityEngine.Object.Destroy(j);
FsmState close = t.Fsm.Fsm.GetState("Sound") ?? t.Fsm.Fsm.GetState("Close door");
if (close != null) FsmSync.SwitchStateSilently(t.Fsm, close);
}
if (FsmSync.Debug) MscoLog.Info("[MSCO] << CAR DOOR " + t.Key + " -> " + (open ? "open" : "closed"));
}
/// <summary>Mehrere FixedJoints an einer geschlossenen Tür (alter Fehler): bis auf einen entfernen.</summary>
static void RemoveExtraFixedJoints(Tracked t)
{
if (t.Door == null) return;
FixedJoint[] joints = t.Door.GetComponents<FixedJoint>();
if (joints.Length <= 1) return;
for (int i = 1; i < joints.Length; i++) UnityEngine.Object.Destroy(joints[i]);
MscoLog.Info("[MSCO] Fixed a stuck door (" + (joints.Length - 1) + " extra lock(s) removed): " + t.Door.name);
}
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";
}
}
}
+142
View File
@@ -0,0 +1,142 @@
using System;
using System.IO;
using System.Reflection;
using System.Text;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Diagnose: PlayMaker-FSMs (Zustände, Aktionen mit Werten, Übergänge) und Scharniere von Objekten, deren Pfad einen
/// Suchtext enthält, in MSCO\fsmdump.txt schreiben. Konsole (F7): "fsmdump &lt;text&gt;". Mit der Startoption
/// "-msco-dump text1,text2" automatisch 25 s nach dem Laden des Spiels.
/// </summary>
public static class FsmDump
{
public static string Write(string filter)
{
string[] filters = (filter ?? "").ToLowerInvariant().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
if (filter != null && filter.Trim().ToLowerInvariant() == "pivots") return WritePivots();
if (filters.Length == 0) return "fsmdump <text>[,text2] - e.g. fsmdump garage";
var sb = new StringBuilder();
int count = 0;
foreach (PlayMakerFSM fsm in Resources.FindObjectsOfTypeAll<PlayMakerFSM>())
{
if (fsm == null || fsm.gameObject == null || fsm.gameObject.hideFlags != HideFlags.None) continue;
string path, lower;
try { path = FsmSync.PathOf(fsm.transform); lower = (path + "|" + fsm.FsmName).ToLowerInvariant(); }
catch { continue; }
if (Array.FindIndex(filters, f => lower.Contains(f.Trim())) < 0) continue;
if (++count > 400) break;
try { Dump(sb, fsm, path); }
catch (Exception e) { sb.Append(" (error: ").Append(e.Message).Append(")").AppendLine(); }
}
foreach (HingeJoint j in Resources.FindObjectsOfTypeAll<HingeJoint>())
{
if (j == null || j.gameObject.hideFlags != HideFlags.None) continue;
string path = FsmSync.PathOf(j.transform);
if (Array.FindIndex(filters, f => path.ToLowerInvariant().Contains(f.Trim())) < 0) continue;
Rigidbody rb = j.GetComponent<Rigidbody>();
try {
sb.Append("HINGE ").Append(path).Append(" -> ").Append(j.connectedBody != null ? FsmSync.PathOf(j.connectedBody.transform) : "-")
.Append(" | active ").Append(j.gameObject.activeInHierarchy).Append(" angle ").Append(j.angle.ToString("0.0"))
.Append(" limits ").Append(j.useLimits).Append(' ').Append(j.limits.min.ToString("0")).Append("..").Append(j.limits.max.ToString("0"))
.Append(" spring ").Append(j.useSpring).Append(' ').Append(j.spring.spring.ToString("0")).Append('/').Append(j.spring.targetPosition.ToString("0"))
.Append(" motor ").Append(j.useMotor)
.Append(" kinematic ").Append(rb != null && rb.isKinematic).Append(" break ").Append(j.breakForce).AppendLine();
} catch (Exception e) { sb.Append(" (error: ").Append(e.Message).Append(")").AppendLine(); }
}
string file = Path.Combine(Bootstrap.Folder ?? ".", "fsmdump.txt");
File.WriteAllText(file, sb.ToString());
return count + " FSM(s) written to " + file;
}
/// <summary>Eingebaute Teile: Lage relativ zu ihrem Halter (pivot_...) - ist sie immer 0?</summary>
static string WritePivots()
{
var sb = new StringBuilder();
int count = 0;
foreach (Transform t in Resources.FindObjectsOfTypeAll<Transform>())
{
if (t == null || t.gameObject.hideFlags != HideFlags.None || !t.name.StartsWith("pivot_")) continue;
foreach (Transform child in t)
{
count++;
sb.Append(FsmSync.PathOf(child)).Append(" | pos ").Append(child.localPosition.ToString("F4"))
.Append(" rot ").Append(child.localEulerAngles.ToString("F1")).Append(" active ").Append(child.gameObject.activeInHierarchy).AppendLine();
}
}
string file = Path.Combine(Bootstrap.Folder ?? ".", "fsmdump.txt");
File.WriteAllText(file, sb.ToString());
return count + " installed part(s) written to " + file;
}
static void Dump(StringBuilder sb, PlayMakerFSM fsm, string path)
{
sb.Append("==== ").Append(path).Append(" | ").Append(fsm.FsmName).Append(" | active ").Append(fsm.gameObject.activeInHierarchy)
.Append(" | state ").Append(fsm.ActiveStateName).AppendLine();
try
{
FsmVariables fv = fsm.FsmVariables;
var vb = new StringBuilder();
foreach (FsmFloat f in fv.FloatVariables) vb.Append(" ").Append(f.Name).Append("=").Append(f.Value.ToString("0.###"));
foreach (FsmInt n in fv.IntVariables) vb.Append(" ").Append(n.Name).Append("=").Append(n.Value);
foreach (FsmBool b in fv.BoolVariables) vb.Append(" ").Append(b.Name).Append("=").Append(b.Value);
foreach (FsmString t in fv.StringVariables) vb.Append(" ").Append(t.Name).Append("=\"").Append(t.Value).Append("\"");
if (vb.Length > 0) sb.Append(" VARS").Append(vb).AppendLine();
}
catch { }
if (fsm.FsmGlobalTransitions != null)
foreach (FsmTransition t in fsm.FsmGlobalTransitions)
sb.Append(" GLOBAL ").Append(t.EventName).Append(" -> ").Append(t.ToState).AppendLine();
if (fsm.FsmStates == null) return;
foreach (FsmState s in fsm.FsmStates)
{
if (s == null) continue;
if (!s.ActionsLoaded) s.LoadActions();
sb.Append(" [").Append(s.Name).Append(']');
if (s.Transitions != null) foreach (FsmTransition t in s.Transitions) if (t != null) sb.Append(" ").Append(t.EventName).Append("->").Append(t.ToState);
sb.AppendLine();
if (s.Actions == null) continue;
foreach (FsmStateAction a in s.Actions)
{
if (a == null) continue;
sb.Append(" ").Append(a.GetType().Name).Append(Fields(a)).AppendLine();
}
}
}
static string Fields(FsmStateAction a)
{
var sb = new StringBuilder();
foreach (FieldInfo f in a.GetType().GetFields(BindingFlags.Public | BindingFlags.Instance))
{
object v;
try { v = f.GetValue(a); } catch { continue; }
string text = Describe(v);
if (text == null) continue;
sb.Append(' ').Append(f.Name).Append('=').Append(text);
}
return sb.ToString();
}
static string Describe(object v)
{
if (v == null) return null;
var named = v as NamedVariable;
if (named != null)
{
string value;
try { value = Convert.ToString(v.GetType().GetProperty("Value").GetValue(v, null)); } catch { value = "?"; }
return (string.IsNullOrEmpty(named.Name) ? "" : "{" + named.Name + "}") + value;
}
var ev = v as FsmEvent;
if (ev != null) return "event:" + ev.Name;
var owner = v as FsmOwnerDefault;
if (owner != null) return owner.OwnerOption == OwnerDefaultOption.UseOwner ? "owner" : Describe(owner.GameObject);
if (v is string || v is bool || v is int || v is float || v.GetType().IsEnum) return Convert.ToString(v);
return null;
}
}
}
+59 -16
View File
@@ -4,7 +4,6 @@ using System.IO;
using System.Reflection;
using Harmony;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
@@ -84,11 +83,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);
}
}
@@ -133,6 +132,7 @@ namespace MSCO
static void SwitchStatePrefix(Fsm __instance, FsmState toState)
{
PerfStats.SwitchStateCalls++;
if (!Enabled || applying > 0 || toState == null) return;
try
{
@@ -163,22 +163,29 @@ namespace MSCO
}
catch (Exception e)
{
ModConsole.Error("[MSCO] Click capture error: " + e.Message);
MscoLog.Error("[MSCO] Click capture error: " + e.Message);
}
}
static Camera aimCamera;
static int aimCameraFrame = -1;
static bool IsAimedAt(Fsm fsm, out float aimDistance)
{
aimDistance = float.MaxValue;
GameObject go = fsm.GameObject;
Camera cam = Camera.main;
// Läuft im Klick-Fenster für jede FSM, die den Zustand wechselt: Kamera nur einmal pro Bild suchen
if (aimCameraFrame != Time.frameCount) { aimCameraFrame = Time.frameCount; aimCamera = Camera.main; }
Camera cam = aimCamera;
if (go == null || cam == null) return false;
// Erst der billige Abstand, dann die Namen (jeder .name-Zugriff erzeugt einen neuen String)
Transform camT = cam.transform;
Vector3 toObject = go.transform.position - camT.position;
if (toObject.sqrMagnitude > ClickRange * ClickRange || Vector3.Dot(toObject, camT.forward) < 0f) return false;
string root = go.transform.root.name;
if (root == "PLAYER" || root.StartsWith("MSCO_")) return false;
Vector3 toObject = go.transform.position - cam.transform.position;
if (toObject.magnitude > ClickRange || Vector3.Dot(toObject, cam.transform.forward) < 0f) return false;
aimDistance = Vector3.Cross(cam.transform.forward, toObject).magnitude; // Abstand zur Blickachse
aimDistance = Vector3.Cross(camT.forward, toObject).magnitude; // Abstand zur Blickachse
return true;
}
@@ -212,7 +219,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>
@@ -236,6 +243,7 @@ namespace MSCO
static void EventPrefix(Fsm __instance, FsmEvent fsmEvent)
{
PerfStats.EventCalls++;
if (!Enabled || applying > 0 || fsmEvent == null) return;
if (Array.IndexOf(SyncedEvents, fsmEvent.Name) < 0) return;
@@ -262,11 +270,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,20 +296,55 @@ 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;
}
if (!Allowed(fsm, msg))
{
if (Debug) MscoLog.Warning("[MSCO] << refused: " + msg.EventName + " " + msg.Path + " [" + msg.FsmName + " -> " + msg.State + "]");
return;
}
if (msg.EventName == VehicleAdmin.InstallEvent) { VehicleAdmin.InstallRemote(fsm); return; } // Admin hat ein Teil eingebaut
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--; }
}
/// <summary>
/// Darf ein Mitspieler diese FSM in diesen Zustand schalten? Sonst könnte ein manipulierter Client beliebige
/// Zustände bei allen anderen auslösen (z.B. Tod oder Speichern). Nie: der eigene Spieler (PLAYER), Systeme
/// (Tod, Speichern ...), MSCO selbst. Klicks nur auf FSMs, die auf einen Klick warten; Schrauben/Montage nur
/// über einen Übergang, den die FSM für genau dieses Event auch hat.
/// </summary>
static bool Allowed(PlayMakerFSM fsm, FsmEventMsg msg)
{
try
{
string root = fsm.transform.root.name;
if (root == "PLAYER" || root == "Systems" || root.StartsWith("MSCO_")) return false;
if (msg.EventName == VehicleAdmin.InstallEvent) return fsm.FsmName == "Assembly";
if (msg.EventName == "CLICK")
{
foreach (FsmState s in fsm.FsmStates) if (ChecksInput(s)) return fsm.Fsm.GetState(msg.State) != null;
return false;
}
if (Array.IndexOf(SyncedEvents, msg.EventName) < 0) return false;
foreach (FsmState s in fsm.FsmStates)
foreach (FsmTransition t in s.Transitions)
if (t.EventName == msg.EventName && t.ToState == msg.State) return true;
foreach (FsmTransition t in fsm.Fsm.GlobalTransitions)
if (t.EventName == msg.EventName && t.ToState == msg.State) return true;
return false;
}
catch { return false; }
}
/// <summary>FSM in einen Zustand schalten, ohne dass das als eigene Aktion erkannt und gesendet wird.</summary>
public static void SwitchStateSilently(PlayMakerFSM fsm, FsmState state)
{
@@ -309,7 +352,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;
}
}
}
+236
View File
@@ -0,0 +1,236 @@
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 Collider Collider; // für den Abstand zum Spieler (große Türen wie das Garagentor)
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)
{
// Links öffnet, rechts schließt (Garagentor), F = Benutzen
if (!Input.GetMouseButtonDown(0) && !Input.GetMouseButtonDown(1) && !Input.GetKeyDown(KeyCode.F)) return;
foreach (Hinge h in hinges.Values)
{
if (h.Rb == null) continue;
Vector3 near = h.Collider != null ? h.Collider.ClosestPointOnBounds(playerPos) : h.Rb.worldCenterOfMass;
if ((near - 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) continue;
// Fahrzeugtüren/-hauben - und Türen/Tore der Welt mit Scharnier (z.B. das Garagentor)
bool vehicle = body != null && IsVehicle(body.transform.root.name);
if (!vehicle && !IsWorldDoor(j)) continue;
int key = Hash(FsmSync.PathOf(j.transform) + "|" + (body != null ? FsmSync.PathOf(body.transform) : "world"));
if (hinges.ContainsKey(key)) continue;
hinges[key] = new Hinge
{
Key = key, Root = (body != null ? body.transform : j.transform).root.name, Joint = j, Rb = rb,
Collider = rb.GetComponentInChildren<Collider>()
};
}
// 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");
}
/// <summary>Tür/Tor der Welt (nicht Verkehr/NPC-Autos, nicht der Spieler): Name enthält door/gate.</summary>
static bool IsWorldDoor(HingeJoint j)
{
string root = j.transform.root.name;
if (root == "PLAYER" || root == "NPC_CARS" || root == "TRAFFIC" || root.StartsWith("MSCO_")) return false;
string path = FsmSync.PathOf(j.transform).ToLowerInvariant();
return path.Contains("door") || path.Contains("gate");
}
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;
}
}
}
}
+64 -5
View File
@@ -2,13 +2,13 @@ using System;
using System.Collections.Generic;
using System.Reflection;
using Harmony;
using MSCLoader;
using HutongGames.PlayMaker;
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>
@@ -21,7 +21,7 @@ namespace MSCO
static readonly List<Behaviour> disabled = new List<Behaviour>();
static bool locked;
static bool previousVisible;
static bool previousVisible, previousInMenu;
static CursorLockMode previousLock;
static bool patched;
@@ -38,6 +38,8 @@ namespace MSCO
PatchCursorActions();
previousVisible = Cursor.visible;
previousLock = Cursor.lockState;
FsmBool inMenu = PlayerInMenu();
previousInMenu = inMenu != null && inMenu.Value;
GameObject player = GameObject.Find("PLAYER");
if (player != null)
{
@@ -58,6 +60,8 @@ namespace MSCO
PlayMakerGUI.HideCursor = !previousVisible;
Cursor.visible = previousVisible;
Cursor.lockState = previousLock;
FsmBool inMenu = PlayerInMenu();
if (inMenu != null) inMenu.Value = previousInMenu;
}
}
@@ -72,13 +76,27 @@ namespace MSCO
PlayMakerGUI.HideCursor = false;
Cursor.visible = true;
Cursor.lockState = CursorLockMode.None;
// So zeigt MSC selbst den Mauszeiger (Pausemenü, Kasse ...) - ohne MSCLoader brauchen wir das auch
FsmBool inMenu = PlayerInMenu();
if (inMenu != null) inMenu.Value = true;
}
/// <summary>PlayMaker-Aktionen, die den Mauszeiger setzen, überspringen, solange ein Fenster offen ist.</summary>
/// <summary>Globale Spielvariable "PlayerInMenu": true = Zeiger frei, Mausblick aus.</summary>
static FsmBool PlayerInMenu()
{
try { return FsmVariables.GlobalVariables.FindFsmBool("PlayerInMenu"); }
catch { return null; }
}
/// <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 +117,51 @@ 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)
{
PerfStats.InputCalls++;
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(); }
}
}
+33 -4
View File
@@ -1,6 +1,5 @@
using System;
using Microsoft.Win32;
using MSCLoader;
namespace MSCO
{
@@ -9,7 +8,7 @@ namespace MSCO
/// Die Mod registriert den Link-Typ in Windows so, dass ein Klick Steam direkt mit
/// "-applaunch 516750 +msco_connect &lt;link&gt;" startet - ohne cmd/Skript dazwischen, damit
/// präparierte Links keine Befehle ausführen können. Die Mod liest die Adresse beim Start
/// aus der Kommandozeile und fragt im Hauptmenü, ob verbunden werden soll.
/// aus der Kommandozeile und verbindet im Hauptmenü sofort (Server-Mods fragen weiterhin nach).
/// (Läuft das Spiel schon, gibt Steam den Link nicht weiter - dann den Server-Browser nutzen.)
/// </summary>
public static class JoinLinks
@@ -26,11 +25,18 @@ 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\"";
// Ist der MSCO Launcher installiert, gehören die Links ihm: er startet das Spiel mit MSCO (ohne MSCLoader)
using (RegistryKey existing = Registry.CurrentUser.OpenSubKey(@"Software\Classes\" + Scheme + @"\shell\open\command"))
{
string current = existing != null ? existing.GetValue("") as string : null;
if (current != null && current.IndexOf("MSCO-Launcher", StringComparison.OrdinalIgnoreCase) >= 0) return;
}
using (RegistryKey key = Registry.CurrentUser.CreateSubKey(@"Software\Classes\" + Scheme))
{
key.SetValue("", "URL:MSCO Join Link");
@@ -41,7 +47,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);
}
}
@@ -54,6 +60,29 @@ namespace MSCO
}
}
static float nextPoll;
/// <summary>
/// JOIN-Link, während das Spiel schon läuft: der MSCO Launcher legt ihn als &lt;Spiel&gt;\MSCO\join.txt ab (Steam
/// gibt Links an ein laufendes Spiel nicht weiter). Einmal pro Sekunde nachsehen.
/// </summary>
public static bool TryTakeRequest(out string host, out int port)
{
host = null;
port = 0;
if (UnityEngine.Time.realtimeSinceStartup < nextPoll) return false;
nextPoll = UnityEngine.Time.realtimeSinceStartup + 1f;
try
{
string file = System.IO.Path.Combine(Bootstrap.Folder ?? "", "join.txt");
if (Bootstrap.Folder == null || !System.IO.File.Exists(file)) return false;
string text = System.IO.File.ReadAllText(file);
System.IO.File.Delete(file);
return TryParse(text, out host, out port);
}
catch { return false; }
}
/// <summary>Wurde das Spiel über einen JOIN-Link gestartet?</summary>
public static bool TryGetFromCommandLine(out string host, out int port)
{
+940 -217
View File
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
# MSCO-Mods schreiben
MSCO läuft ohne MSCLoader und hat eine eigene, kleine Mod-Schnittstelle. Eine MSCO-Mod ist eine .NET-3.5-DLL mit einer
Klasse, die von `MSCO.MscoMod` erbt. MSCLoader-Mods laufen damit **nicht** (sie brauchen MSCLoader).
## Wo liegen Mods?
| Ordner | |
|---|---|
| `<Spielordner>\MSCO\Mods\*.dll` | eigene Mods – immer aktiv, werden beim Spielstart geladen (auch im Singleplayer, siehe unten) |
| `<Spielordner>\MSCO\Mods\*.dll.off` | ausgeschaltet (Launcher → *Mods*) |
| `<Spielordner>\MSCO\Mods\Servers\*.dll` | Mods von Servern – nur aktiv, solange man auf einem Server ist, der sie verlangt |
| Server: `data/mods/*.dll` | Mods, die der dedizierte Server an seine Spieler verteilt (Web-Panel → *Mods*) |
Im Spiel: Seite **Mods** im MSCO-Menü, Konsole (F7) `mods`. Im Launcher: Seite **Mods** (hinzufügen, aus-/einschalten,
entfernen).
## Singleplayer
**SINGLEPLAYER** im Launcher lädt die eigenen Mods ebenfalls: MSCO startet dann im Singleplayer-Modus
(`-msco-singleplayer`) – nur die Mods, kein MSCO-Menü, kein Multiplayer, der eigene Spielstand. `OnMenuLoad`,
`OnGameLoad`, `Update`, `OnGUI` usw. laufen wie gewohnt; `IsSingleplayer` ist `true`, `IsInSession` `false` und
`Send` tut nichts. Ohne eingeschaltete Mods (oder mit *Mods im Singleplayer* aus) startet das normale Spiel ohne MSCO.
Die F7-Konsole gibt es dort nicht – Meldungen stehen in `output_log.txt`.
## Server-Mods
Liegen Mods in `data/mods` des Servers, bekommt jeder Spieler beim Beitreten die Liste (Name, Version, Größe, SHA-256).
Fehlt ihm eine Mod, fragt das Spiel: **INSTALL & JOIN** lädt sie direkt vom Server (Prüfsumme wird kontrolliert),
**CANCEL** verlässt den Server. Danach geht es ins Spiel. Hat der Spieler dieselbe Datei schon (gleiche Prüfsumme), wird
nichts geladen.
Server mit Mods bekommen in der Serverliste automatisch den Tag **MODDED**; im Server-Browser (Spiel, Launcher, Website)
steht, welche Mods sie nutzen, und es gibt Filter *VANILLA* / *MODDED*.
> Mods sind Programme und laufen mit vollen Rechten auf dem PC der Spieler. Serverbetreiber: nur Mods anbieten, denen ihr
> vertraut. Spieler: nur auf Servern zustimmen, denen ihr vertraut.
## Mod veröffentlichen (Modliste auf msco.mbm.wtf/mods)
Im MSCO Launcher unter **Mods → Hochladen**: DLL wählen (Name, Version und Beschreibung werden aus den
Assembly-Attributen `AssemblyProduct`, `AssemblyInformationalVersion` und `AssemblyDescription` vorgeschlagen), eine
**Mod-ID** vergeben (3–40 Zeichen, `a-z 0-9 _ . -`, z.B. `showboltsize`) und hochladen. Hochladen geht mit dem
Launcher-Konto; eine Mod-ID gehört danach dir.
Jede Version wird nach dem Hochladen **automatisch geprüft** und ist erst danach öffentlich: auf
<https://msco.mbm.wtf/mods> und im Launcher unter **Mods → Durchsuchen** (ein Klick installiert, SHA-256 wird geprüft,
Updates werden angezeigt). Eine neue Version braucht dieselbe Mod-ID und eine höhere Versionsnummer; bis sie freigegeben
ist, bleibt die alte öffentlich. Den Status (wartet, öffentlich, abgelehnt mit Grund) siehst du unter **Meine Uploads**.
Höchstens 10 MB pro Datei.
Was die automatische Prüfung macht:
- **Virenscan** (ClamAV, zusätzlich VirusTotal).
- **Lesbarkeit:** Die DLL muss eine normale .NET-DLL sein, die `MSCO.dll` verwendet. Verschlüsselte/obfuskierte Mods
werden abgelehnt (siehe unten).
- **Was die Mod darf:** Verändert sie nur das Spiel, ist sie meist nach Sekunden bis Minuten öffentlich. Startet sie
Programme, nutzt das Internet, löscht Dateien, lädt oder erzeugt Code zur Laufzeit, ruft native Windows-Funktionen auf
oder entschlüsselt Daten, schaut sich zusätzlich ein **MSCO-Moderator** den Code an – das dauert dann etwas länger.
Das ist erlaubt, wenn es einen guten Grund gibt – schreib ihn in die Beschreibung.
> **Nicht verschlüsseln (obfuskieren)!** Verschlüsselte Mods (ConfuserEx, Dotfuscator, SmartAssembly …) lassen sich nicht
> prüfen und werden automatisch abgelehnt – der Grund steht unter **Meine Uploads**.
Beispiel mit Quellcode: [`examples/ShowBoltSize`](examples/ShowBoltSize) zeigt unter dem Fadenkreuz die Größe der
Schraube, auf die man schaut.
## Eine Mod bauen
Vorlage: [`examples/HelloMod`](examples/HelloMod) – Projekt (.NET Framework 3.5) mit Verweisen auf `UnityEngine.dll`
(aus `mysummercar_Data\Managed`) und `MSCO.dll` (aus `<Spielordner>\MSCO`, *Copy Local* aus).
```
msbuild examples/HelloMod/HelloMod.csproj -p:Configuration=Release -p:GamePath="C:\...\My Summer Car"
```
```csharp
using MSCO;
using UnityEngine;
public class HelloMod : MscoMod
{
public override string ID { get { return "hellomod"; } } // eindeutig: Buchstaben, Ziffern, . - _
public override string Name { get { return "Hello Mod"; } }
public override string Version { get { return "1.0.0"; } }
public override string Author { get { return "Ich"; } }
public override void OnGameLoad() { Log("Im Spiel!"); }
public override void Update()
{
if (Input.GetKeyDown(KeyCode.H)) Send(System.Text.Encoding.UTF8.GetBytes("wave"));
}
public override void OnMessage(int fromPlayerId, byte[] data)
{
string name;
Log((Players.TryGetValue(fromPlayerId, out name) ? name : "?") + " winkt");
}
}
```
Name und Version, die Launcher und Server-Panel anzeigen, kommen aus den Assembly-Attributen
(`AssemblyProduct`, `AssemblyInformationalVersion`).
## Schnittstelle
| Mitglied | |
|---|---|
| `ID`, `Name`, `Version`, `Author` | Angaben zur Mod (`ID` und `Name` müssen überschrieben werden) |
| `OnLoad()` | einmal nach dem Laden |
| `OnMenuLoad()`, `OnGameLoad()` | Hauptmenü bzw. Spiel (Szene `GAME`) geladen |
| `Update()`, `FixedUpdate()`, `OnGUI()` | jeden Frame (wie bei Unity) |
| `OnSessionStart()`, `OnSessionEnd()` | mit Server/Host verbunden bzw. getrennt |
| `OnPlayerJoined(id, name)`, `OnPlayerLeft(id, name)` | Mitspieler kommt/geht |
| `OnMessage(fromPlayerId, data)` | Daten, die dieselbe Mod (gleiche `ID`) bei einem anderen Spieler mit `Send` geschickt hat |
| `Send(byte[] data)` | an dieselbe Mod bei allen anderen Spielern (bis 60 KB; P2P und dedizierter Server) |
| `IsInSession`, `IsHost`, `LocalPlayerId`, `Players` | Sitzung, eigene ID, Spieler (ID → Name) |
| `IsSingleplayer` | im Singleplayer gestartet (nur Mods, kein Multiplayer) |
| `Log(text)`, `LogWarning(text)` | in die MSCO-Konsole (F7) und `output_log.txt` |
Fehler in einer Mod fängt MSCO ab und schreibt sie ins Log. Wirft eine Mod ständig Fehler (50×), wird sie abgeschaltet.
Harmony 1.2 (`0Harmony.dll`) liegt neben `MSCO.dll` und kann von Mods mitbenutzt werden.
+87 -59
View File
@@ -1,83 +1,111 @@
using MSCLoader;
using UnityEngine;
namespace MSCO
{
public class MSCO : Mod
/// <summary>
/// MSCO - My Summer Car Online. Läuft eigenständig (ohne MSCLoader): gestartet über Unity Doorstop, siehe Bootstrap.
/// </summary>
public static class MSCO
{
public const string ModVersion = "2.6";
/// <summary>
/// Version der Mod. Kleine Updates: nur die dritte Stelle erhöhen (2.9.1, 2.9.2 ...) - sie bleiben mit
/// Servern und Mitspielern derselben ersten zwei Stellen kompatibel.
/// </summary>
public const string ModVersion = "2.10.15";
/// <summary>
/// Netzwerk-Version (die ersten beiden Stellen). Nur erhöhen, wenn sich das Protokoll inkompatibel ändert - dann
/// auch GameServer.ModVersion im Server.
/// </summary>
public const string ProtocolVersion = "2.8";
public override string ID => "MSCO"; // Your (unique) mod ID
public override string Name => "MSCO"; // Your mod name
public override string Author => "MBM"; // Name of the Author (your name)
public override string Version => ModVersion; // Version
public override string Description => "Online mod by mbm.wtf"; // Short description of your mod
public override Game SupportedGames => Game.MySummerCar;
// Logo in der MSCLoader-Modliste (branding/logo-128.png, in die DLL eingebettet)
public override byte[] Icon { get { return Branding.LogoPng; } set { } }
/// <summary>
/// Sicherheitsstufe dieser Mod-Version - steigt mit jedem Sicherheitsupdate (gleiche Zählung wie
/// GameServer.SecurityLevel im Server). Als P2P-Host meldet die Mod sie den Mitspielern.
/// 1 = Oktober 2026.
/// </summary>
public const int SecurityLevel = 1;
bool commandAdded;
/// <summary>
/// Server und P2P-Hosts unter dieser Stufe fehlen Sicherheitsupdates: die Mod tritt ihnen nicht bei. Die
/// MSCO-Admins können das Minimum im Keymaster noch anheben (SiteStatus.RequiredServerSecurity).
/// </summary>
public const int MinServerSecurity = 1;
public override void ModSetup()
/// <summary>Passt eine andere Version (Server, Host, Mitspieler) zu uns? Verglichen wird die Netzwerk-Version.</summary>
public static bool Compatible(string other)
{
SetupFunction(Setup.OnMenuLoad, Mod_OnMenuLoad);
SetupFunction(Setup.OnLoad, Mod_OnLoad);
SetupFunction(Setup.PostLoad, Mod_PostLoad);
SetupFunction(Setup.OnSave, Mod_OnSave);
SetupFunction(Setup.OnGUI, Mod_OnGUI);
SetupFunction(Setup.Update, Mod_Update);
SetupFunction(Setup.FixedUpdate, Mod_FixedUpdate);
SetupFunction(Setup.ModSettings, Mod_Settings);
return Protocol(other) == ProtocolVersion;
}
/// <summary>Taste zum Ein-/Aussteigen als Beifahrer (in den MSCLoader-Einstellungen änderbar).</summary>
internal static SettingsKeybind PassengerKey;
/// <summary>MSCO-Konsole (Standard F8) und Chat (Standard T), ebenfalls änderbar.</summary>
internal static SettingsKeybind ConsoleKey, ChatKey;
private void Mod_Settings()
/// <summary>"2.8.3" -> "2.8"</summary>
public static string Protocol(string version)
{
// All settings should be created here.
// DO NOT put anything that isn't settings or keybinds in here!
PassengerKey = Keybind.Add("passenger", "Ride as passenger (toggle)", KeyCode.P);
ChatKey = Keybind.Add("chat", "Open chat", KeyCode.T);
ConsoleKey = Keybind.Add("console", "Open MSCO console", KeyCode.F8);
if (string.IsNullOrEmpty(version)) return "";
string[] parts = version.Trim().Split('.');
return parts.Length >= 2 ? parts[0] + "." + parts[1] : version.Trim();
}
private void Mod_OnMenuLoad()
/// <summary>Beifahrer ein-/aussteigen (Standard P), Konsole (F7), Chat (T) - änderbar mit "bind" in der Konsole.</summary>
internal static readonly KeyBinding PassengerKey = new KeyBinding("passenger", "Ride along / get out", KeyCode.P);
internal static readonly KeyBinding ConsoleKey = new KeyBinding("console", "Open the MSCO console", KeyCode.F7);
internal static readonly KeyBinding ChatKey = new KeyBinding("chat", "Open the chat", KeyCode.T);
internal static readonly KeyBinding[] Keys = { PassengerKey, ConsoleKey, ChatKey };
}
/// <summary>Tastenbelegung, gespeichert in den PlayerPrefs (früher in den MSCLoader-Einstellungen).</summary>
public class KeyBinding
{
public readonly string Id, Description;
public readonly KeyCode Default;
bool loaded;
KeyCode key;
public KeyBinding(string id, string description, KeyCode defaultKey)
{
ModConsole.Print("Welcome to MSCO by mbm.wtf");
// Netzwerk + Lobby leben in einem eigenen Objekt, das Szenenwechsel überlebt
MultiplayerManager.Ensure();
if (!commandAdded)
Id = id;
Description = description;
Default = defaultKey;
}
string Pref { get { return "MSCO_Key_" + Id; } }
public KeyCode Key
{
get
{
ConsoleCommand.Add(new MpCommand());
commandAdded = true;
if (!loaded)
{
// Erst im Spiel lesen (PlayerPrefs gehen nicht in statischen Initialisierern)
KeyCode parsed;
key = TryParse(PlayerPrefs.GetString(Pref, ""), out parsed) ? parsed : Default;
loaded = true;
}
return key;
}
set
{
key = value;
loaded = true;
PlayerPrefs.SetString(Pref, value.ToString());
PlayerPrefs.Save();
}
}
private void Mod_OnLoad()
public bool GetKeybindDown()
{
// Called once, when mod is loading after game is fully loaded
return Key != KeyCode.None && Input.GetKeyDown(Key);
}
private void Mod_PostLoad()
public static bool TryParse(string text, out KeyCode key)
{
// Called once, after all mods finished OnLoad
}
private void Mod_OnSave()
{
// Called once, when save and quit
}
private void Mod_OnGUI()
{
// Lobby/Status zeichnet der MultiplayerManager
}
private void Mod_Update()
{
// Netzwerk läuft im MultiplayerManager
}
private void Mod_FixedUpdate()
{
// FixedUpdate is called once per fixed frame
key = KeyCode.None;
if (string.IsNullOrEmpty(text)) return false;
try
{
key = (KeyCode)System.Enum.Parse(typeof(KeyCode), text.Trim(), true);
return true;
}
catch { return false; }
}
}
}
+62 -103
View File
@@ -1,140 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<Project Sdk="Microsoft.NET.Sdk">
<!--
MSCO (Unity 5 / Mono, .NET 3.5). Baut mit dem normalen .NET SDK - unter Windows und auch auf dem Mac (nur zum
Bauen/Veröffentlichen, das Spiel gibt es nur für Windows): dotnet build -c Release
Die Spiel-DLLs (UnityEngine, Assembly-CSharp, PlayMaker, cInput) kommen aus dem Spielordner. Woanders (z.B. Mac):
den Ordner "mysummercar_Data/Managed" dorthin kopieren und MSC_MANAGED (Umgebungsvariable) oder
-p:MscManaged=<ordner> setzen. Release-Builds laden neue Versionen automatisch hoch (release.ps1).
-->
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Release</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{CE3FFBAB-946E-9569-CCAA-9CFA2D5C11F3}</ProjectGuid>
<TargetFramework>net35</TargetFramework>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>MSCO</RootNamespace>
<AssemblyName>MSCO</AssemblyName>
<TargetFrameworkVersion>v3.5</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<TargetFrameworkProfile>Unity Full v3.5</TargetFrameworkProfile>
<RootNamespace>MSCO</RootNamespace>
<LangVersion>13</LangVersion>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<!-- Versionsangaben stehen in Properties/AssemblyInfo.cs (aus MSCO.ModVersion) -->
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Configuration Condition=" '$(Configuration)' == '' ">Release</Configuration>
<!-- Ausgabe wie bisher: bin\Release\MSCO.dll (ohne Unterordner net35) -->
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<DebugType Condition=" '$(Configuration)' == 'Release' ">none</DebugType>
<Optimize Condition=" '$(Configuration)' == 'Release' ">true</Optimize>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591;NU1702</NoWarn>
<!-- Spielordner und dessen Managed-Ordner (überschreibbar) -->
<GamePath Condition=" '$(GamePath)' == '' ">F:\SteamLibrary\steamapps\common\My Summer Car</GamePath>
<MscManaged Condition=" '$(MscManaged)' == '' and '$(MSC_MANAGED)' != '' ">$(MSC_MANAGED)</MscManaged>
<MscManaged Condition=" '$(MscManaged)' == '' ">$(GamePath)\mysummercar_Data\Managed</MscManaged>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>none</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<!-- .NET-3.5-Referenzen ohne installiertes Targeting Pack (auch auf dem Mac) -->
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<!-- Beispiel-Mods sind eigene Projekte -->
<Compile Remove="examples/**" />
<None Remove="examples/**" />
<EmbeddedResource Remove="examples/**" />
</ItemGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="System.Data" />
<Reference Include="System.Xml" />
<Reference Include="UnityEngine">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\UnityEngine.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="MSCLoader">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\MSCLoader.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Newtonsoft.Json.dll</HintPath>
<HintPath>$(MscManaged)\UnityEngine.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Assembly-CSharp-firstpass">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Assembly-CSharp-firstpass.dll</HintPath>
<HintPath>$(MscManaged)\Assembly-CSharp-firstpass.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Assembly-CSharp">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\Assembly-CSharp.dll</HintPath>
<HintPath>$(MscManaged)\Assembly-CSharp.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="cInput">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\cInput.dll</HintPath>
<HintPath>$(MscManaged)\cInput.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="PlayMaker">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\PlayMaker.dll</HintPath>
<HintPath>$(MscManaged)\PlayMaker.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>F:\SteamLibrary\steamapps\common\My Summer Car\mysummercar_Data\Managed\0Harmony.dll</HintPath>
<HintPath>lib\0Harmony.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="MoneySync.cs" />
<Compile Include="MpCommand.cs" />
<Compile Include="Commands.cs" />
<Compile Include="InputLock.cs" />
<Compile Include="Overlays.cs" />
<Compile Include="SafetyPatches.cs" />
<Compile Include="SceneCache.cs" />
<Compile Include="UiTheme.cs" />
<Compile Include="VehicleStateSync.cs" />
<Compile Include="Branding.cs" />
<Compile Include="DiscordPresence.cs" />
<Compile Include="DoorSync.cs" />
<Compile Include="FsmSync.cs" />
<Compile Include="GameCharacter.cs" />
<Compile Include="JoinLinks.cs" />
<Compile Include="LobbyUI.cs" />
<Compile Include="MenuHelper.cs" />
<Compile Include="MenuInputBlocker.cs" />
<Compile Include="MSCO.cs" />
<Compile Include="MultiplayerManager.cs" />
<Compile Include="NetSession.cs" />
<Compile Include="ObjectSync.cs" />
<Compile Include="Passenger.cs" />
<Compile Include="SaveSync.cs" />
<Compile Include="ServerBrowser.cs" />
<Compile Include="SteamLobby.cs" />
<Compile Include="VehicleFx.cs" />
<Compile Include="WinHttp.cs" />
<Compile Include="WorldSync.cs" />
<EmbeddedResource Include="menu-logo.png">
<LogicalName>MSCO.menu-logo.png</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="logo.png">
<LogicalName>MSCO.logo.png</LogicalName>
</EmbeddedResource>
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="RemotePlayer.cs" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<PropertyGroup>
<MSCMODSFOLDER>C:\Users\nico\Documents\MySummerCar\Mods</MSCMODSFOLDER>
<MWCMODSFOLDER>NONE</MWCMODSFOLDER>
<ABFOLDER>NONE</ABFOLDER>
<PostBuildEvent>
echo .
echo "== Running post build actions for configuration $(ConfigurationName)! ==="
IF "$(ConfigurationName)" == "Release" (
IF NOT "$(MSCMODSFOLDER)" == "NONE" (
copy "$(TargetPath)" "$(MSCMODSFOLDER)" /y
echo "Copied dll into MSC mods folder!"
)
IF NOT "$(MWCMODSFOLDER)" == "NONE" (
copy "$(TargetPath)" "$(MWCMODSFOLDER)" /y
echo "Copied dll into MWC mods folder!"
)
)
<!-- Zum Testen: MSCO.dll direkt in den Spielordner (My Summer Car\MSCO) - läuft das Spiel, ist sie gesperrt -->
<Target Name="CopyToGame" AfterTargets="Build" Condition="Exists('$(GamePath)\MSCO')">
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(GamePath)\MSCO" ContinueOnError="true" />
</Target>
if "$(ConfigurationName)" == "Debug" (
copy "$(TargetPath)" "C:\Users\nico\Documents\MySummerCar\Mods" /y
copy "$(TargetDir)$(TargetName).pdb" "C:\Users\nico\Documents\MySummerCar\Mods" /y
cd "C:\Users\nico\Documents\MySummerCar\Mods"
call "C:\Users\nico\Documents\MySummerCar\Mods\debug.bat"
) ELSE (
copy "$(TargetPath)" "C:\Users\nico\Documents\MySummerCar\Mods\Mods" /y
)
</PostBuildEvent>
</PropertyGroup>
</Project>
<!-- Release-Build: neue Version automatisch auf den Keymaster laden (Token + Signier-Schlüssel, siehe release.ps1) -->
<Target Name="MscoRelease" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'">
<Exec Condition="'$(OS)' == 'Windows_NT'" Command="powershell -NoProfile -ExecutionPolicy Bypass -File &quot;$(MSBuildProjectDirectory)\release.ps1&quot; -Product mod -File &quot;$(TargetPath)&quot;" ContinueOnError="true" />
<Exec Condition="'$(OS)' != 'Windows_NT'" Command="pwsh -NoProfile -File &quot;$(MSBuildProjectDirectory)/release.ps1&quot; -Product mod -File &quot;$(TargetPath)&quot;" ContinueOnError="true" />
</Target>
</Project>
+182
View File
@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// MSCO statt "My Summer Car": Fenstertitel und -symbol, und im Hauptmenü das große Spiel-Logo durch das MSCO-Logo
/// (branding/menu-logo.png, in die DLL eingebettet) ersetzen.
/// </summary>
public static class MenuBranding
{
const string WindowTitle = "MSCO - My Summer Car Online";
const string MenuLogoResource = "MSCO.menu-logo.png";
// ---------------------------------------------------------------- Fenster
delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam);
[DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam);
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint processId);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassNameW(IntPtr hwnd, StringBuilder name, int max);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern bool SetWindowTextW(IntPtr hwnd, string text);
[DllImport("user32.dll")] static extern IntPtr SendMessageW(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")] static extern IntPtr CreateIconFromResourceEx(byte[] data, uint size, bool icon, uint version, int cx, int cy, uint flags);
static readonly EnumWindowsProc enumProc = OnWindow; // Delegate festhalten (sonst räumt der GC ihn weg)
static uint processId;
static IntPtr bigIcon, smallIcon;
/// <summary>Titel und Symbol des Spielfensters auf MSCO setzen (beim Start und nach jedem Szenenwechsel).</summary>
public static void ApplyWindow()
{
try
{
processId = (uint)Process.GetCurrentProcess().Id;
if (bigIcon == IntPtr.Zero && Branding.LogoPng.Length > 0)
{
// PNG-Daten direkt als Symbol (geht seit Windows Vista)
bigIcon = CreateIconFromResourceEx(Branding.LogoPng, (uint)Branding.LogoPng.Length, true, 0x00030000, 0, 0, 0);
smallIcon = CreateIconFromResourceEx(Branding.LogoPng, (uint)Branding.LogoPng.Length, true, 0x00030000, 16, 16, 0);
}
EnumWindows(enumProc, IntPtr.Zero);
}
catch (Exception e) { Bootstrap.Log("Window title/icon: " + e.Message); }
}
static bool OnWindow(IntPtr hwnd, IntPtr lParam)
{
uint pid;
GetWindowThreadProcessId(hwnd, out pid);
if (pid != processId) return true;
var name = new StringBuilder(64);
GetClassNameW(hwnd, name, name.Capacity);
if (name.ToString() != "UnityWndClass") return true;
SetWindowTextW(hwnd, WindowTitle);
const uint WM_SETICON = 0x0080;
if (bigIcon != IntPtr.Zero) SendMessageW(hwnd, WM_SETICON, (IntPtr)1, bigIcon);
if (smallIcon != IntPtr.Zero) SendMessageW(hwnd, WM_SETICON, IntPtr.Zero, smallIcon);
return true;
}
// ---------------------------------------------------------------- Logo im Hauptmenü
static Texture2D source;
static readonly Dictionary<string, Texture2D> fitted = new Dictionary<string, Texture2D>();
static bool dumped;
static Texture2D Source
{
get
{
if (source != null) return source;
using (Stream s = Assembly.GetExecutingAssembly().GetManifestResourceStream(MenuLogoResource))
{
if (s == null) return null;
var data = new byte[s.Length];
int read = 0;
while (read < data.Length) read += s.Read(data, read, data.Length - read);
source = new Texture2D(2, 2, TextureFormat.ARGB32, false);
source.LoadImage(data);
source.hideFlags = HideFlags.HideAndDontSave;
}
return source;
}
}
/// <summary>Ist das eine Textur des Spiel-Logos? (Name enthält "logo", aber nicht vom Auto/Laden ...)</summary>
static bool IsGameLogo(Texture t)
{
if (t == null || t == source) return false;
string n = t.name.ToLowerInvariant();
return n.Contains("logo") && !n.Contains("msco") && !n.Contains("satsuma") && !n.Contains("teimo") && !n.Contains("amistech");
}
/// <summary>Im Hauptmenü das Spiel-Logo ersetzen. Gibt die Anzahl ersetzter Texturen zurück.</summary>
public static int ReplaceMenuLogo()
{
if (Source == null) return 0;
int replaced = 0;
var seen = new List<string>();
// Auch ausgeblendete Objekte (das Menü wechselt zwischen mehreren Logo-Objekten)
foreach (Renderer r in Resources.FindObjectsOfTypeAll<Renderer>())
{
if (r == null || r.gameObject.hideFlags != HideFlags.None) continue;
Material[] materials = r.sharedMaterials;
for (int i = 0; i < materials.Length; i++)
{
Material m = materials[i];
if (m == null || m.mainTexture == null) continue;
if (!dumped && seen.Count < 200) seen.Add(Path(r.transform) + " : " + m.mainTexture.name);
if (!IsGameLogo(m.mainTexture)) continue;
Texture original = m.mainTexture;
m.mainTexture = Fit(original.width, original.height);
Bootstrap.Log("Menu logo replaced: " + Path(r.transform) + " (" + original.name + ", " + original.width + "x" + original.height + ")");
replaced++;
}
}
foreach (SpriteRenderer sr in UnityEngine.Object.FindObjectsOfType<SpriteRenderer>())
{
if (sr == null || sr.sprite == null || !IsGameLogo(sr.sprite.texture)) continue;
Texture2D tex = Fit(sr.sprite.texture.width, sr.sprite.texture.height);
sr.sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f), sr.sprite.pixelsPerUnit);
Bootstrap.Log("Menu logo (sprite) replaced: " + Path(sr.transform));
replaced++;
}
foreach (GUITexture g in UnityEngine.Object.FindObjectsOfType<GUITexture>())
{
if (g == null || !IsGameLogo(g.texture)) continue;
g.texture = Fit(g.texture.width, g.texture.height);
Bootstrap.Log("Menu logo (GUITexture) replaced: " + Path(g.transform));
replaced++;
}
if (!dumped && replaced == 0)
{
// Zur Diagnose: welche Texturen gibt es im Menü? (nur einmal)
Bootstrap.Log("Menu logo not found. Textures in the menu:\n " + string.Join("\n ", seen.ToArray()));
}
dumped = true;
return replaced;
}
/// <summary>MSCO-Logo mittig in eine Textur der Größe des Originals (gleiches Seitenverhältnis wie dessen Fläche).</summary>
static Texture2D Fit(int width, int height)
{
width = Mathf.Clamp(width, 64, 2048);
height = Mathf.Clamp(height, 64, 2048);
string key = width + "x" + height;
Texture2D tex;
if (fitted.TryGetValue(key, out tex) && tex != null) return tex;
tex = new Texture2D(width, height, TextureFormat.ARGB32, true);
tex.hideFlags = HideFlags.HideAndDontSave;
tex.wrapMode = TextureWrapMode.Clamp;
float scale = Mathf.Min((float)width / Source.width, (float)height / Source.height);
float w = Source.width * scale, h = Source.height * scale;
float x0 = (width - w) / 2f, y0 = (height - h) / 2f;
var pixels = new Color[width * height];
for (int y = 0; y < height; y++)
for (int x = 0; x < width; x++)
{
float u = (x - x0) / w, v = (y - y0) / h;
pixels[y * width + x] = u < 0f || u > 1f || v < 0f || v > 1f ? new Color(0, 0, 0, 0) : Source.GetPixelBilinear(u, v);
}
tex.SetPixels(pixels);
tex.Apply(true);
fitted[key] = tex;
return tex;
}
static string Path(Transform t)
{
string path = t.name;
for (Transform p = t.parent; p != null; p = p.parent) path = p.name + "/" + path;
return path;
}
}
}
+123 -5
View File
@@ -1,7 +1,6 @@
using System.Reflection;
using HutongGames.PlayMaker;
using HutongGames.PlayMaker.Actions;
using MSCLoader;
using UnityEngine;
namespace MSCO
@@ -12,8 +11,32 @@ namespace MSCO
public const string MenuScene = "MainMenu";
public const string GameScene = "GAME";
public static bool InMenu { get { return Application.loadedLevelName == MenuScene; } }
public static bool InGame { get { return Application.loadedLevelName == GameScene; } }
public static bool InMenu { get { return Scene == MenuScene; } }
public static bool InGame { get { return Scene == GameScene; } }
static string scene;
static int sceneFrame = -1;
/// <summary>
/// Aktuelle Szene, einmal pro Bild gelesen: Application.loadedLevelName erzeugt bei jedem Aufruf einen neuen String
/// (Müll für den Garbage Collector), und InGame/InMenu werden pro Bild sehr oft abgefragt.
/// </summary>
public static string Scene
{
get
{
int frame = Time.frameCount;
if (frame != sceneFrame || scene == null)
{
sceneFrame = frame;
scene = Application.loadedLevelName;
}
return scene;
}
}
/// <summary>Nach einem Szenenwechsel (OnLevelWasLoaded) neu lesen.</summary>
public static void SceneChanged() { scene = null; }
static readonly MethodInfo switchState =
typeof(Fsm).GetMethod("SwitchState", BindingFlags.NonPublic | BindingFlags.Instance);
@@ -51,22 +74,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)
+166
View File
@@ -0,0 +1,166 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
namespace MSCO
{
/// <summary>
/// Kleiner eigener JSON-Leser (ohne Newtonsoft.Json - das braucht System.Runtime.Serialization, das ohne MSCLoader im
/// Spiel fehlt). Ergebnis: Dictionary&lt;string, object&gt; (Objekt, Schlüssel ohne Groß-/Kleinschreibung),
/// List&lt;object&gt;, string, double, bool oder null.
/// </summary>
public static class MiniJson
{
public static object Parse(string json)
{
if (json == null) throw new FormatException("No JSON");
int i = 0;
object value = ReadValue(json, ref i);
SkipWhitespace(json, ref i);
if (i < json.Length) throw new FormatException("Unexpected text at " + i);
return value;
}
// ---------------------------------------------------------------- Zugriff
public static string Str(Dictionary<string, object> o, string key, string fallback = null)
{
object v;
if (o == null || !o.TryGetValue(key, out v) || v == null) return fallback;
if (v is double) return ((double)v).ToString(CultureInfo.InvariantCulture);
return v.ToString();
}
public static int Int(Dictionary<string, object> o, string key, int fallback = 0)
{
object v;
if (o == null || !o.TryGetValue(key, out v)) return fallback;
if (v is double) return (int)(double)v;
int parsed;
return v is string && int.TryParse((string)v, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed) ? parsed : fallback;
}
public static bool Bool(Dictionary<string, object> o, string key, bool fallback = false)
{
object v;
if (o == null || !o.TryGetValue(key, out v)) return fallback;
return v is bool ? (bool)v : fallback;
}
public static List<string> Strings(Dictionary<string, object> o, string key)
{
var result = new List<string>();
object v;
if (o == null || !o.TryGetValue(key, out v)) return result;
var list = v as List<object>;
if (list != null) foreach (object item in list) if (item != null) result.Add(item.ToString());
return result;
}
// ---------------------------------------------------------------- Lesen
static object ReadValue(string s, ref int i)
{
SkipWhitespace(s, ref i);
if (i >= s.Length) throw new FormatException("Unexpected end of JSON");
char c = s[i];
if (c == '{') return ReadObject(s, ref i);
if (c == '[') return ReadArray(s, ref i);
if (c == '"') return ReadString(s, ref i);
if (c == '-' || (c >= '0' && c <= '9')) return ReadNumber(s, ref i);
if (Word(s, ref i, "true")) return true;
if (Word(s, ref i, "false")) return false;
if (Word(s, ref i, "null")) return null;
throw new FormatException("Unexpected '" + c + "' at " + i);
}
static Dictionary<string, object> ReadObject(string s, ref int i)
{
var o = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
i++; // {
SkipWhitespace(s, ref i);
if (i < s.Length && s[i] == '}') { i++; return o; }
while (true)
{
SkipWhitespace(s, ref i);
if (i >= s.Length || s[i] != '"') throw new FormatException("Expected a name at " + i);
string key = ReadString(s, ref i);
SkipWhitespace(s, ref i);
if (i >= s.Length || s[i] != ':') throw new FormatException("Expected ':' at " + i);
i++;
o[key] = ReadValue(s, ref i);
SkipWhitespace(s, ref i);
if (i >= s.Length) throw new FormatException("Unexpected end of JSON");
if (s[i] == ',') { i++; continue; }
if (s[i] == '}') { i++; return o; }
throw new FormatException("Expected ',' or '}' at " + i);
}
}
static List<object> ReadArray(string s, ref int i)
{
var list = new List<object>();
i++; // [
SkipWhitespace(s, ref i);
if (i < s.Length && s[i] == ']') { i++; return list; }
while (true)
{
list.Add(ReadValue(s, ref i));
SkipWhitespace(s, ref i);
if (i >= s.Length) throw new FormatException("Unexpected end of JSON");
if (s[i] == ',') { i++; continue; }
if (s[i] == ']') { i++; return list; }
throw new FormatException("Expected ',' or ']' at " + i);
}
}
static string ReadString(string s, ref int i)
{
var sb = new StringBuilder();
i++; // "
while (i < s.Length)
{
char c = s[i++];
if (c == '"') return sb.ToString();
if (c != '\\') { sb.Append(c); continue; }
if (i >= s.Length) break;
char e = s[i++];
switch (e)
{
case 'b': sb.Append('\b'); break;
case 'f': sb.Append('\f'); break;
case 'n': sb.Append('\n'); break;
case 'r': sb.Append('\r'); break;
case 't': sb.Append('\t'); break;
case 'u':
if (i + 4 > s.Length) throw new FormatException("Bad \\u escape");
sb.Append((char)int.Parse(s.Substring(i, 4), NumberStyles.HexNumber, CultureInfo.InvariantCulture));
i += 4;
break;
default: sb.Append(e); break; // \" \\ \/
}
}
throw new FormatException("Unterminated string");
}
static double ReadNumber(string s, ref int i)
{
int start = i;
while (i < s.Length && "+-0123456789.eE".IndexOf(s[i]) >= 0) i++;
return double.Parse(s.Substring(start, i - start), NumberStyles.Float, CultureInfo.InvariantCulture);
}
static bool Word(string s, ref int i, string word)
{
if (string.CompareOrdinal(s, i, word, 0, word.Length) != 0) return false;
i += word.Length;
return true;
}
static void SkipWhitespace(string s, ref int i)
{
while (i < s.Length && char.IsWhiteSpace(s[i])) i++;
}
}
}
+71
View File
@@ -0,0 +1,71 @@
using System.Collections.Generic;
namespace MSCO
{
/// <summary>
/// Basis für MSCO-Mods. Eine Mod ist eine .NET-3.5-DLL in &lt;Spiel&gt;\MSCO\Mods mit (mindestens) einer Klasse, die
/// von MscoMod erbt. MSCO lädt sie beim Spielstart; Mods eines Servers bekommen Spieler beim Beitreten (nach
/// Zustimmung) und sie sind nur auf diesem Server aktiv. Anleitung: MODDING.md im MSCO-Repository.
/// </summary>
public abstract class MscoMod
{
/// <summary>Eindeutige ID (Buchstaben, Ziffern, . - _). Mods mit derselben ID reden über Send/OnMessage miteinander.</summary>
public abstract string ID { get; }
public abstract string Name { get; }
public virtual string Version { get { return "1.0.0"; } }
public virtual string Author { get { return ""; } }
// ---------------------------------------------------------------- Lebenszyklus (überschreiben nach Bedarf)
/// <summary>Einmal nach dem Laden der Mod (Hauptmenü oder - bei Server-Mods - beim Beitreten).</summary>
public virtual void OnLoad() { }
/// <summary>Hauptmenü wurde geladen.</summary>
public virtual void OnMenuLoad() { }
/// <summary>Das Spiel (Szene GAME) wurde geladen.</summary>
public virtual void OnGameLoad() { }
public virtual void Update() { }
public virtual void FixedUpdate() { }
public virtual void OnGUI() { }
/// <summary>Mit einem Server/Host verbunden (bzw. selbst P2P-Host geworden).</summary>
public virtual void OnSessionStart() { }
/// <summary>Sitzung beendet (getrennt, gekickt, Host weg ...).</summary>
public virtual void OnSessionEnd() { }
public virtual void OnPlayerJoined(int playerId, string name) { }
public virtual void OnPlayerLeft(int playerId, string name) { }
/// <summary>Daten, die dieselbe Mod bei einem anderen Spieler mit Send() geschickt hat.</summary>
public virtual void OnMessage(int fromPlayerId, byte[] data) { }
// ---------------------------------------------------------------- Helfer
/// <summary>Daten an dieselbe Mod bei allen anderen Spielern schicken (max. 60 KB, nur in einer Sitzung).</summary>
protected bool Send(byte[] data)
{
MultiplayerManager mp = MultiplayerManager.Instance;
return mp != null && mp.SendModMessage(ID, data);
}
protected void Log(string text) { MscoLog.Info("[" + Name + "] " + text); }
protected void LogWarning(string text) { MscoLog.Warning("[" + Name + "] " + text); }
/// <summary>Singleplayer mit Mods (Launcher: SINGLEPLAYER) - kein MSCO-Menü, kein Multiplayer, Send() geht nicht.</summary>
protected bool IsSingleplayer { get { return Bootstrap.Singleplayer; } }
/// <summary>In einer Sitzung (Server oder P2P)?</summary>
protected bool IsInSession { get { return MultiplayerManager.Instance != null && MultiplayerManager.Instance.IsInSession; } }
/// <summary>Selbst P2P-Host?</summary>
protected bool IsHost { get { return MultiplayerManager.Instance != null && MultiplayerManager.Instance.IsHost; } }
/// <summary>Eigene Spieler-ID in der Sitzung.</summary>
protected int LocalPlayerId { get { return MultiplayerManager.Instance != null ? MultiplayerManager.Instance.LocalId : -1; } }
/// <summary>Spieler der Sitzung: ID -&gt; Name.</summary>
protected Dictionary<int, string> Players
{
get
{
var result = new Dictionary<int, string>();
MultiplayerManager mp = MultiplayerManager.Instance;
if (mp != null) foreach (PlayerInfo p in mp.Players) result[p.Id] = p.Name;
return result;
}
}
}
}
+167
View File
@@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Security.Cryptography;
namespace MSCO
{
/// <summary>
/// Lädt MSCO-Mods und ruft sie auf. Eigene Mods: &lt;Spiel&gt;\MSCO\Mods\*.dll (immer aktiv). Mods eines Servers:
/// &lt;Spiel&gt;\MSCO\Mods\Servers\&lt;id&gt;.dll - nur aktiv, solange man auf einem Server ist, der sie verlangt.
/// Fehler einer Mod werden abgefangen und protokolliert; wirft sie ständig Fehler, wird sie abgeschaltet.
/// </summary>
public static class ModLoader
{
public sealed class LoadedMod
{
public MscoMod Mod;
public string File, Sha256;
public bool FromServer, Active;
public int Errors;
public string Error;
}
const int MaxErrors = 50;
static readonly List<LoadedMod> mods = new List<LoadedMod>();
static bool localLoaded;
public static IList<LoadedMod> All { get { return mods.AsReadOnly(); } }
public static string Folder { get { return Path.Combine(Bootstrap.Folder ?? ".", "Mods"); } }
public static string ServerFolder { get { return Path.Combine(Folder, "Servers"); } }
public static string ServerFile(string id) { return Path.Combine(ServerFolder, id + ".dll"); }
/// <summary>Eigene Mods einmal beim ersten Hauptmenü laden.</summary>
public static void LoadLocal()
{
if (localLoaded) return;
localLoaded = true;
try
{
Directory.CreateDirectory(Folder);
foreach (string file in Directory.GetFiles(Folder, "*.dll"))
Load(file, false);
if (mods.Count > 0) MscoLog.Info("[MSCO] " + mods.Count + " mod(s) loaded from " + Folder);
}
catch (Exception e) { MscoLog.Warning("[MSCO] Could not load mods: " + e.Message); }
}
public static string Sha256Of(byte[] data)
{
using (var sha = SHA256.Create())
return BitConverter.ToString(sha.ComputeHash(data)).Replace("-", "").ToLowerInvariant();
}
public static LoadedMod FindBySha(string sha)
{
foreach (LoadedMod m in mods) if (string.Equals(m.Sha256, sha, StringComparison.OrdinalIgnoreCase)) return m;
return null;
}
/// <summary>
/// DLL laden (aus den Bytes - die Datei bleibt frei, und eine neue Version einer Server-Mod kann neben der alten
/// geladen werden) und alle MscoMod-Klassen darin anlegen. Gibt die erste Mod zurück (oder null).
/// </summary>
public static LoadedMod Load(string file, bool fromServer)
{
LoadedMod first = null;
try
{
byte[] data = System.IO.File.ReadAllBytes(file);
string sha = Sha256Of(data);
LoadedMod existing = FindBySha(sha);
if (existing != null) return existing;
Assembly asm = Assembly.Load(data);
Type[] types;
try { types = asm.GetTypes(); }
catch (ReflectionTypeLoadException e) { types = Array.FindAll(e.Types, t => t != null); }
foreach (Type t in types)
{
if (t.IsAbstract || !typeof(MscoMod).IsAssignableFrom(t)) continue;
MscoMod mod;
try { mod = (MscoMod)Activator.CreateInstance(t); }
catch (Exception e)
{
MscoLog.Warning("[MSCO] Mod " + t.FullName + " could not be created: " + (e.InnerException ?? e).Message);
continue;
}
var loaded = new LoadedMod { Mod = mod, File = file, Sha256 = sha, FromServer = fromServer, Active = !fromServer };
mods.Add(loaded);
if (first == null) first = loaded;
MscoLog.Info("[MSCO] Mod loaded: " + Safe(() => mod.Name, t.Name) + " " + Safe(() => mod.Version, "") +
(fromServer ? " (from the server)" : ""));
Call(loaded, m => m.OnLoad());
}
if (first == null) MscoLog.Warning("[MSCO] " + Path.GetFileName(file) + " contains no MSCO mod (class deriving from MscoMod)");
}
catch (Exception e)
{
MscoLog.Warning("[MSCO] Could not load mod " + Path.GetFileName(file) + ": " + e.Message);
}
return first;
}
static string Safe(Func<string> get, string fallback)
{
try { return get() ?? fallback; } catch { return fallback; }
}
/// <summary>Server-Mods für die aktuelle Sitzung einschalten (alle anderen Server-Mods aus).</summary>
public static void ActivateServerMods(ICollection<string> shas)
{
foreach (LoadedMod m in mods)
if (m.FromServer && m.Error == null) m.Active = shas != null && shas.Contains(m.Sha256);
}
// ---------------------------------------------------------------- Aufrufe
static void Call(LoadedMod m, Action<MscoMod> action)
{
try { action(m.Mod); }
catch (Exception e)
{
m.Errors++;
if (m.Errors <= 3) MscoLog.Warning("[MSCO] Mod " + Safe(() => m.Mod.Name, "?") + ": " + e);
if (m.Errors >= MaxErrors)
{
m.Active = false;
m.Error = e.Message;
MscoLog.Warning("[MSCO] Mod " + Safe(() => m.Mod.Name, "?") + " was turned off (too many errors)");
}
}
}
static void Each(Action<MscoMod> action)
{
for (int i = 0; i < mods.Count; i++)
if (mods[i].Active) Call(mods[i], action);
}
public static void MenuLoad() { Each(m => m.OnMenuLoad()); }
public static void GameLoad() { Each(m => m.OnGameLoad()); }
public static void Update() { if (mods.Count > 0) Each(m => m.Update()); }
public static void FixedUpdate() { if (mods.Count > 0) Each(m => m.FixedUpdate()); }
public static void OnGUI() { if (mods.Count > 0) Each(m => m.OnGUI()); }
public static void SessionStart() { Each(m => m.OnSessionStart()); }
public static void PlayerJoined(int id, string name) { Each(m => m.OnPlayerJoined(id, name)); }
public static void PlayerLeft(int id, string name) { Each(m => m.OnPlayerLeft(id, name)); }
/// <summary>Sitzung vorbei: Mods informieren, Server-Mods ausschalten.</summary>
public static void SessionEnd()
{
Each(m => m.OnSessionEnd());
ActivateServerMods(null);
}
public static void Message(string modId, int from, byte[] data)
{
for (int i = 0; i < mods.Count; i++)
{
LoadedMod m = mods[i];
if (!m.Active || Safe(() => m.Mod.ID, null) != modId) continue;
Call(m, mod => mod.OnMessage(from, data));
}
}
}
}
-16
View File
@@ -1,16 +0,0 @@
using MSCLoader;
namespace MSCO
{
/// <summary>MSCLoader-Konsole: "mp &lt;befehl&gt;" - gleiche Befehle wie in der F8-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 void Run(string[] args)
{
Commands.Execute(args, line => ModConsole.Print("[MSCO] " + line));
}
}
}
+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 einer Loader-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;
}
}
}
+1555 -73
View File
File diff suppressed because it is too large Load Diff
+167 -30
View File
@@ -34,10 +34,30 @@ 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]
ModMessage = 45, // Nachricht einer MSCO-Mod an die anderen Spieler: string Mod-ID, int Länge, bytes
ModList = 46, // Server -> Client (vor Welcome): int n, je [string ID, string Name, string Version, string SHA-256, int Größe]
ModFileRequest = 47, // Client -> Server: string Mod-ID
ModFilePart = 48, // Server -> Client: string Mod-ID, int Gesamtgröße, int Offset, int Länge, bytes
ModFileDone = 49 // Server -> Client: string Mod-ID
}
public struct Message
@@ -47,7 +67,7 @@ namespace MSCO
public BinaryReader Reader()
{
return new BinaryReader(new MemoryStream(Payload));
return new PacketReader(Payload);
}
}
@@ -62,6 +82,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 +101,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 +124,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;
}
@@ -157,7 +200,8 @@ namespace MSCO
{
if (payload == null) payload = new byte[0];
var frame = new byte[5 + payload.Length];
BitConverter.GetBytes(payload.Length + 1).CopyTo(frame, 0);
int length = payload.Length + 1; // wie BitConverter (Little Endian), ohne zusätzliches Array
frame[0] = (byte)length; frame[1] = (byte)(length >> 8); frame[2] = (byte)(length >> 16); frame[3] = (byte)(length >> 24);
frame[4] = (byte)type;
Buffer.BlockCopy(payload, 0, frame, 5, payload.Length);
return frame;
@@ -172,16 +216,30 @@ namespace MSCO
}
}
/// <summary>TCP-Verbindung (LAN / IP). Lesen ist nicht-blockierend und wird über Poll() abgefragt.</summary>
/// <summary>
/// TCP-Verbindung (LAN / IP). Lesen ist nicht-blockierend und wird über Poll() abgefragt. Senden läuft über eine
/// Warteschlange und einen eigenen Thread: vorher schrieb das Spiel selbst (blockierend) - war die Leitung kurz voll
/// (Spielstand hochladen, schwaches WLAN), stand das ganze Spiel. Kleine Pakete werden dabei zusammengefasst.
/// </summary>
public class Connection : IConnection
{
public bool IsClosed { get; private set; }
public string CloseReason { get; private set; }
volatile bool closed;
volatile string closeReason;
public bool IsClosed { get { return closed; } }
public string CloseReason { get { return closeReason; } }
// Mehr als das unterwegs = die Gegenseite nimmt nichts mehr an
const long MaxQueuedBytes = 96L * 1024 * 1024;
const int MaxBatchBytes = 256 * 1024;
readonly TcpClient client;
readonly NetworkStream stream;
readonly FrameParser parser = new FrameParser();
readonly byte[] readBuffer = new byte[64 * 1024];
readonly Queue<byte[]> outgoing = new Queue<byte[]>();
readonly object gate = new object();
long queuedBytes;
readonly System.Threading.Thread writer;
public Connection(TcpClient client)
{
@@ -189,11 +247,46 @@ namespace MSCO
client.NoDelay = true;
client.SendBufferSize = 4 * 1024 * 1024;
stream = client.GetStream();
writer = new System.Threading.Thread(WriteLoop) { IsBackground = true, Name = "MSCO send" };
writer.Start();
}
/// <summary>Sende-Thread: wartet auf Pakete und schreibt sie (gebündelt) in die Verbindung.</summary>
void WriteLoop()
{
var batch = new MemoryStream(MaxBatchBytes);
try
{
while (true)
{
batch.SetLength(0);
lock (gate)
{
while (outgoing.Count == 0 && !closed) System.Threading.Monitor.Wait(gate);
if (outgoing.Count == 0) return; // geschlossen und nichts mehr zu senden
// Mehrere kleine Pakete in einem Rutsch (große einzeln)
do
{
byte[] frame = outgoing.Dequeue();
queuedBytes -= frame.Length;
batch.Write(frame, 0, frame.Length);
} while (outgoing.Count > 0 && batch.Length + outgoing.Peek().Length <= MaxBatchBytes);
System.Threading.Monitor.PulseAll(gate); // Close() wartet evtl. aufs Leerwerden
}
stream.Write(batch.GetBuffer(), 0, (int)batch.Length);
}
}
catch (Exception e)
{
if (!closed) Close("Network error: " + e.Message);
}
}
readonly List<Message> polled = new List<Message>(); // wiederverwendet: der Aufrufer geht sie sofort durch
public List<Message> Poll()
{
var messages = new List<Message>();
var messages = polled;
messages.Clear();
if (IsClosed) return messages;
try
{
@@ -220,23 +313,37 @@ namespace MSCO
public void Send(MsgType type, byte[] payload)
{
if (IsClosed) return;
try
if (closed) return;
byte[] frame = FrameParser.Frame(type, payload);
bool tooMuch;
lock (gate)
{
byte[] frame = FrameParser.Frame(type, payload);
stream.Write(frame, 0, frame.Length); // blockierend, damit große Dateien komplett rausgehen
}
catch (Exception e)
{
Close("Network error: " + e.Message);
tooMuch = queuedBytes + frame.Length > MaxQueuedBytes;
if (!tooMuch)
{
outgoing.Enqueue(frame);
queuedBytes += frame.Length;
System.Threading.Monitor.Pulse(gate);
}
}
if (tooMuch) Close("The connection is too slow - too much data waiting to be sent");
}
public void Close(string reason)
{
if (IsClosed) return;
IsClosed = true;
CloseReason = reason;
lock (gate)
{
if (closed) return;
// Was schon in der Warteschlange liegt (z.B. eine Abschiedsnachricht), kurz noch rausgehen lassen
var until = DateTime.UtcNow.AddMilliseconds(300);
while (outgoing.Count > 0 && DateTime.UtcNow < until && System.Threading.Thread.CurrentThread != writer)
System.Threading.Monitor.Wait(gate, 50);
closed = true;
closeReason = reason;
outgoing.Clear();
queuedBytes = 0;
System.Threading.Monitor.PulseAll(gate);
}
try { client.Close(); } catch { }
}
}
@@ -270,9 +377,11 @@ namespace MSCO
lastReceive = Time.realtimeSinceStartup;
}
readonly List<Message> polled = new List<Message>(); // wiederverwendet: der Aufrufer geht sie sofort durch
public List<Message> Poll()
{
var messages = new List<Message>();
var messages = polled;
messages.Clear();
if (IsClosed) return messages;
try { parser.Parse(messages); }
catch (Exception e) { Close("Network error: " + e.Message); }
@@ -376,9 +485,11 @@ namespace MSCO
return new NetSession { IsSteam = true, server = new SteamConnection(host), pendingConnected = true };
}
readonly List<NetEvent> polledEvents = new List<NetEvent>(); // wiederverwendet (jedes Frame)
public List<NetEvent> Poll()
{
var events = new List<NetEvent>();
var events = polledEvents;
events.Clear();
if (IsClosed) return events;
try
{
@@ -525,15 +636,41 @@ namespace MSCO
try { if (listener != null) listener.Stop(); } catch { }
}
// Puffer zum Bauen von Nachrichten pro Thread wiederverwenden (vorher pro Nachricht neuer Speicherstrom + Schreiber:
// bei vielen Nachrichten pro Sekunde viel Speichermüll -> häufiger Garbage Collector -> Ruckler)
[ThreadStatic] static MemoryStream buildStream;
[ThreadStatic] static BinaryWriter buildWriter;
[ThreadStatic] static bool building;
public static byte[] Build(Action<BinaryWriter> write)
{
using (var ms = new MemoryStream())
using (var w = new BinaryWriter(ms))
if (building)
{
write(w);
w.Flush();
return ms.ToArray();
// Build innerhalb von Build: eigener Puffer
using (var ms = new MemoryStream())
using (var w = new BinaryWriter(ms))
{
write(w);
w.Flush();
return ms.ToArray();
}
}
if (buildStream == null)
{
buildStream = new MemoryStream(4096);
buildWriter = new BinaryWriter(buildStream);
}
building = true;
try
{
buildStream.SetLength(0);
write(buildWriter);
buildWriter.Flush();
byte[] result = buildStream.ToArray();
if (buildStream.Capacity > 1024 * 1024) { buildStream = null; buildWriter = null; } // große Dateien nicht ewig im Speicher halten
return result;
}
finally { building = false; }
}
/// <summary>Stellt der Nachricht die Absender-ID voran (für weitergeleitete Nachrichten).</summary>
+485 -29
View File
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.IO;
using System.Text;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
@@ -16,17 +15,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 +78,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 +89,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 +107,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 +164,8 @@ namespace MSCO
float loadTime;
float nextScan;
int frame;
readonly List<SyncedBody> drivenList = new List<SyncedBody>();
float sendTimer;
FsmGameObject pickedObject;
@@ -102,6 +182,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 +204,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 +218,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 +227,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 +274,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 +295,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 +304,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 +328,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 +339,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 +395,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 +420,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 +450,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 +465,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 +474,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 +503,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 +687,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 +706,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 +724,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 +740,148 @@ 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;
}
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).
// Läuft alle 0,5 s für jedes Teil: erst billig prüfen, ob es überhaupt ein Gelenk hat (GetComponents legt
// jedes Mal ein neues Array an - die allermeisten Teile haben keins).
if (b.Rb.GetComponent<Joint>() == null) return false;
Transform playerRoot = Head != null ? Head.root : null;
foreach (Joint j in b.Rb.GetComponents<Joint>())
{
if (j == null || j.connectedBody == null) continue;
Transform root = j.connectedBody.transform.root;
if (playerRoot != null ? root != playerRoot : 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 +946,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 +1006,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 +1021,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 +1051,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)
+117
View File
@@ -0,0 +1,117 @@
using System.Collections.Generic;
using System.Globalization;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Konsole "oilcheck": woher läuft Öl aus? Das Spiel kennt fünf Quellen - die Motor-FSM rechnet LeakOilpan,
/// LeakOilfilter, LeakHeadgasket und LeakRockerCover aus den Teilen aus, dazu die Ablassschraube der Ölwanne
/// (Stufe 0-5 = offen, Objekt "Oil" aktiv). Der Ölfilter ist ein gekauftes Teil und steht in items.txt
/// (Installed/Tightness) - fehlt die Datei im Spielstand eines Servers, fehlt auch der Filter.
/// </summary>
public static class OilCheck
{
static readonly string[] PartWords = { "oilpan", "oilfilter", "oil filter", "headgasket", "rocker", "valvecover", "drain" };
public static List<string> Report()
{
var lines = new List<string>();
if (!MenuHelper.InGame) { lines.Add("Only in the game."); return lines; }
var fsms = Object.FindObjectsOfType<PlayMakerFSM>();
// 1. Leck-Werte der Motor-FSM
bool leakFsm = false;
foreach (PlayMakerFSM f in fsms)
{
if (f == null || f.FsmVariables.FindFsmFloat("LeakOilfilter") == null) continue;
leakFsm = true;
var parts = new List<string>();
foreach (FsmFloat v in f.FsmVariables.FloatVariables)
if (v != null && v.Name.StartsWith("Leak")) parts.Add(v.Name + "=" + Num(v.Value));
lines.Add("Engine leaks (" + f.gameObject.name + "/" + f.FsmName + ", state " + f.ActiveStateName + "): " + string.Join(" ", parts.ToArray()));
}
if (!leakFsm) lines.Add("Engine leak values: not found (engine not assembled?)");
// 2. Teile in der Database (eingebaut, festgeschraubt, kaputt, Ölstand)
foreach (PlayMakerFSM f in Resources.FindObjectsOfTypeAll<PlayMakerFSM>())
{
if (f == null || f.FsmName != "Data" || f.gameObject.hideFlags != HideFlags.None) continue;
if (f.transform.root.name != "Database" || !Matches(f.gameObject.name)) continue;
lines.Add(" " + f.gameObject.name + ": " + Values(f.FsmVariables, "Installed", "Bolted", "Damaged", "Tightness", "Oil", "Wear"));
}
// 3. Schrauben der Ölwanne (die Ablassschraube schaltet das Leck "Oil")
foreach (PlayMakerFSM f in fsms)
{
if (f == null || f.FsmName != "Screw") continue;
string path = FsmSync.PathOf(f.transform);
if (path.ToLower().IndexOf("oilpan", System.StringComparison.Ordinal) < 0) continue;
FsmInt stage = f.FsmVariables.FindFsmInt("Stage");
lines.Add(" bolt " + Short(path) + ": stage " + (stage != null ? stage.Value.ToString() : "?") + (Switches(f) ? " (drain plug - 0-5 = open)" : ""));
}
foreach (GameObject go in Object.FindObjectsOfType<GameObject>())
if (go.name == "Oil" && go.transform.parent != null && FsmSync.PathOf(go.transform).ToLower().Contains("oilpan"))
lines.Add(" drain leak object " + Short(FsmSync.PathOf(go.transform)) + " is ACTIVE (oil runs out of the drain plug)");
// 4. Ölfilter als Gegenstand (items.txt)
int filters = 0;
foreach (PlayMakerFSM f in fsms)
{
if (f == null || !f.gameObject.name.StartsWith("oil filter")) continue;
if (f.FsmVariables.FindFsmBool("Installed") == null && f.FsmVariables.FindFsmFloat("Tightness") == null) continue;
filters++;
lines.Add(" item " + f.gameObject.name + " (" + f.FsmName + ", under " + f.transform.root.name + "): "
+ Values(f.FsmVariables, "Installed", "Tightness", "Consumed", "Dirt"));
}
if (filters == 0) lines.Add(" no oil filter item in the world - if the server's save has no items.txt, bought parts are missing");
return lines;
}
static bool Matches(string name)
{
string n = name.ToLower();
foreach (string w in PartWords) if (n.Contains(w)) return true;
return false;
}
static bool Switches(PlayMakerFSM f)
{
try
{
foreach (FsmState s in f.FsmStates)
{
if (!s.ActionsLoaded) s.LoadActions();
if (s.Actions == null) continue;
foreach (FsmStateAction a in s.Actions) if (a != null && a.GetType().Name == "ActivateGameObject") return true;
}
}
catch { }
return false;
}
static string Values(FsmVariables vars, params string[] names)
{
var parts = new List<string>();
foreach (string n in names)
{
FsmBool b = vars.FindFsmBool(n);
if (b != null) { parts.Add(n + "=" + b.Value); continue; }
FsmFloat f = vars.FindFsmFloat(n);
if (f != null) { parts.Add(n + "=" + Num(f.Value)); continue; }
FsmInt i = vars.FindFsmInt(n);
if (i != null) parts.Add(n + "=" + i.Value);
}
return parts.Count == 0 ? "-" : string.Join(" ", parts.ToArray());
}
static string Num(float v) { return v.ToString("0.###", CultureInfo.InvariantCulture); }
static string Short(string path)
{
string[] s = path.Split('|');
return s.Length <= 3 ? path : "…|" + string.Join("|", s, s.Length - 3, 3);
}
}
}
+480 -39
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
@@ -30,8 +30,13 @@ namespace MSCO
public string Name, Text;
public bool System;
public float Time;
// "Name:" und seine Breite - einmal berechnet statt bei jedem OnGUI-Durchlauf (2-3x pro Bild)
public string NameLabel;
public float NameWidth = -1f;
}
readonly List<ChatLine> visibleChat = new List<ChatLine>();
// Konsole
public bool ConsoleOpen { get; private set; }
readonly List<string> consoleLines = new List<string>();
@@ -51,6 +56,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;
@@ -69,6 +76,7 @@ namespace MSCO
if (ConsoleOpen)
{
ChatOpen = false;
AdminOpen = false; // sonst liegt die Konsole über dem Admin-Menü und schluckt dessen Klicks
consoleOpenedFrame = Time.frameCount;
consoleScrollToEnd = true;
}
@@ -92,6 +100,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 +142,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;
@@ -203,7 +216,8 @@ namespace MSCO
int maxLines = ChatOpen ? 12 : 6;
// Sichtbare Zeilen: offen = die letzten, geschlossen = nur neue (blenden aus)
var visible = new List<ChatLine>();
var visible = visibleChat;
visible.Clear();
for (int i = chat.Count - 1; i >= 0 && visible.Count < maxLines; i--)
if (ChatOpen || now - chat[i].Time < ChatFadeSeconds) visible.Insert(0, chat[i]);
@@ -225,9 +239,14 @@ namespace MSCO
}
else
{
Vector2 size = chatName.CalcSize(new GUIContent(line.Name + ":"));
GUI.Label(new Rect(rect.x + 12, y, size.x, lineHeight), line.Name + ":", chatName);
GUI.Label(new Rect(rect.x + 16 + size.x, y, width - 28 - size.x, lineHeight), line.Text, chatText);
if (line.NameWidth < 0f)
{
line.NameLabel = line.Name + ":";
line.NameWidth = chatName.CalcSize(new GUIContent(line.NameLabel)).x;
}
float nameWidth = line.NameWidth;
GUI.Label(new Rect(rect.x + 12, y, nameWidth, lineHeight), line.NameLabel, chatName);
GUI.Label(new Rect(rect.x + 16 + nameWidth, y, width - 28 - nameWidth, lineHeight), line.Text, chatText);
}
y += lineHeight;
}
@@ -261,10 +280,82 @@ namespace MSCO
GUI.FocusControl(ChatControl);
}
// ---------------------------------------------------------------- Admin-Menü
// Feste Fenstergröße für alle Reiter, Reiter gleich breit, Zeilen mit fest gesetzten Rechtecken (Texte mittig).
const float AdminRow = 40f, AdminGap = 6f;
GUIStyle nameMid, smallMid, captionMid, pillOk, pillWarn, pillBad, pillOff, chipOn, chipOff;
Texture2D texBarBack, texBarFill, texBarLow;
Vector2 teleportScroll, vehicleScroll;
string partFilter = "", itemFilter = "", partCategory = "";
bool partProblems, fluidsOpen = true;
void EnsureAdminStyles()
{
if (nameMid != null && texBarBack != null) return;
nameMid = new GUIStyle(chatName) { alignment = TextAnchor.MiddleLeft, wordWrap = false, clipping = TextClipping.Clip };
nameMid.margin = new RectOffset();
nameMid.padding = new RectOffset();
smallMid = new GUIStyle(small) { alignment = TextAnchor.MiddleLeft, wordWrap = false, clipping = TextClipping.Clip };
smallMid.margin = new RectOffset();
smallMid.padding = new RectOffset();
captionMid = new GUIStyle(caption) { alignment = TextAnchor.MiddleLeft };
captionMid.margin = new RectOffset();
pillOk = Pill(UiTheme.Online);
pillWarn = Pill(UiTheme.Pending);
pillBad = Pill(UiTheme.Danger);
pillOff = Pill(UiTheme.Muted);
chipOn = UiTheme.Button(new Color(UiTheme.Accent.r, UiTheme.Accent.g, UiTheme.Accent.b, 0.25f), new Color(UiTheme.Accent.r, UiTheme.Accent.g, UiTheme.Accent.b, 0.35f), UiTheme.AccentHover, 11);
chipOff = UiTheme.Button(UiTheme.Secondary, UiTheme.SecondaryHover, UiTheme.Muted, 11);
texBarBack = UiTheme.Solid(new Color(1f, 1f, 1f, 0.1f));
texBarFill = UiTheme.Solid(UiTheme.Online);
texBarLow = UiTheme.Solid(UiTheme.Danger);
}
static GUIStyle Pill(Color c)
{
GUIStyle st = UiTheme.Panel9(new Color(c.r, c.g, c.b, 0.18f), 4);
st.normal.textColor = c;
st.fontSize = 10;
st.fontStyle = FontStyle.Bold;
st.alignment = TextAnchor.MiddleCenter;
st.padding = new RectOffset(7, 7, 0, 0);
return st;
}
/// <summary>Zeile mit Hintergrund in einer Layout-Liste reservieren; gibt das Innere zurück (12 px Rand).</summary>
Rect AdminRowRect(float height)
{
Rect r = GUILayoutUtility.GetRect(10, height, GUILayout.ExpandWidth(true));
GUI.Box(new Rect(r.x, r.y + 2, r.width, r.height - 4), GUIContent.none, rowStyle);
return new Rect(r.x + 12, r.y + 2, r.width - 24, r.height - 4);
}
/// <summary>Knöpfe von rechts nach links in einer Zeile; gibt die linke Kante des belegten Bereichs zurück.</summary>
static bool RightButton(ref float right, Rect row, float width, string text, GUIStyle style, bool enabled = true)
{
right -= width;
bool old = GUI.enabled;
GUI.enabled = old && enabled;
bool clicked = GUI.Button(new Rect(right, row.y + (row.height - 28) / 2f, width, 28), text, style);
GUI.enabled = old;
right -= AdminGap;
return clicked;
}
void Bar(Rect r, float frac, float low)
{
var back = new Rect(r.x, r.y + (r.height - 6) / 2f, r.width, 6);
GUI.DrawTexture(back, texBarBack);
GUI.DrawTexture(new Rect(back.x, back.y, Mathf.Max(2f, back.width * Mathf.Clamp01(frac)), back.height), frac < low ? texBarLow : texBarFill);
}
void DrawAdmin(MultiplayerManager mp)
{
float width = Mathf.Min(600, Screen.width - 40), height = Mathf.Min(460, Screen.height - 40);
var rect = new Rect((Screen.width - width) / 2f, (Screen.height - height) / 2f, width, height);
EnsureAdminStyles();
float width = Mathf.Min(1000, Screen.width - 40), height = Mathf.Min(720, Screen.height - 40);
var rect = new Rect(Mathf.Round((Screen.width - width) / 2f), Mathf.Round((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)
@@ -274,19 +365,57 @@ namespace MSCO
return;
}
GUILayout.BeginArea(new Rect(rect.x + 20, rect.y + 16, rect.width - 40, rect.height - 32));
GUILayout.BeginHorizontal();
GUILayout.Label("Admin", title, GUILayout.ExpandWidth(true)); // nicht von den Buttons zusammendrücken lassen
if (GUILayout.Button("REFRESH", secondary, GUILayout.Height(30), GUILayout.Width(100))) mp.RequestAdminList();
GUILayout.Space(8);
if (GUILayout.Button("CLOSE", secondary, GUILayout.Height(30), GUILayout.Width(90))) AdminOpen = false;
GUILayout.EndHorizontal();
GUILayout.Label(mp.IsDedicated ? "Players on this server. Banned players cannot join again." : "Players in your game.", small);
GUILayout.Space(10);
// Kopf
float x = rect.x + 20, w = rect.width - 40;
GUI.Label(new Rect(x, rect.y + 16, 300, 34), "Admin", title);
if (GUI.Button(new Rect(rect.xMax - 20 - 90, rect.y + 18, 90, 30), "CLOSE", secondary)) AdminOpen = false;
if (GUI.Button(new Rect(rect.xMax - 20 - 90 - 8 - 100, rect.y + 18, 100, 30), "REFRESH", secondary))
{
mp.RequestAdminList();
VehicleAdmin.Refresh(true);
}
GUILayout.Label("REASON (optional)", caption);
adminReason = GUILayout.TextField(adminReason, 100, field, GUILayout.Height(32));
GUILayout.Space(12);
// Reiter (gleich breit über die ganze Zeile)
string[] tabs = { "PLAYERS", "TELEPORT", "VEHICLE", "ITEMS", "BANS" + (bannedIds != null && bannedIds.Count > 0 ? " (" + bannedIds.Count + ")" : ""), "WORLD" };
float tabW = (w - AdminGap * (tabs.Length - 1)) / tabs.Length;
for (int i = 0; i < tabs.Length; i++)
if (GUI.Button(new Rect(x + i * (tabW + AdminGap), rect.y + 62, tabW, 32), tabs[i], adminTab == i ? primary : secondary)) adminTab = i;
GUILayout.BeginArea(new Rect(x, rect.y + 108, w, rect.height - 124));
if (adminTab == 1) DrawAdminTeleport(mp);
else if (adminTab == 2) DrawAdminVehicle(mp);
else if (adminTab == 3) DrawAdminItems(mp);
else if (adminTab == 4) DrawAdminBans(mp);
else if (adminTab == 5) DrawAdminWorld(mp);
else DrawAdminPlayers(mp);
GUILayout.EndArea();
}
// ---------------------------------------------------------------- PLAYERS
void DrawAdminPlayers(MultiplayerManager mp)
{
if (mp.CanCheat)
{
Rect r = GUILayoutUtility.GetRect(10, 34, GUILayout.ExpandWidth(true));
string[] labels = { "+1.000 MK", "+10.000 MK", "REFILL NEEDS", "SATSUMA TO ME" };
float bw = (r.width - AdminGap * 3) / 4f;
for (int i = 0; i < labels.Length; i++)
{
if (!GUI.Button(new Rect(r.x + i * (bw + AdminGap), r.y, bw, 32), labels[i], primary)) continue;
if (i == 0) mp.CheatMoney(1000f);
else if (i == 1) mp.CheatMoney(10000f);
else if (i == 2) mp.CheatNeeds();
else mp.BringCar();
}
GUILayout.Label("Cheats are announced in the chat. GOTO teleports you to a player, BRING teleports a player to you.", small);
GUILayout.Space(8);
}
Rect reason = GUILayoutUtility.GetRect(10, 32, GUILayout.ExpandWidth(true));
GUI.Label(new Rect(reason.x, reason.y, 210, 32), "REASON FOR KICK / BAN (optional)", captionMid);
adminReason = GUI.TextField(new Rect(reason.x + 214, reason.y, reason.width - 214, 32), adminReason ?? "", 100, field);
GUILayout.Space(10);
adminScroll = GUILayout.BeginScrollView(adminScroll);
if (adminPlayers == null) GUILayout.Label("Loading players...", small);
@@ -295,32 +424,343 @@ namespace MSCO
{
foreach (AdminPlayer p in adminPlayers)
{
GUILayout.BeginHorizontal(rowStyle, GUILayout.Height(44));
GUILayout.BeginVertical();
bool you = p.Id == mp.LocalId;
GUILayout.Label(p.Name + (you ? " (you)" : "") + (p.IsAdmin ? " (admin)" : ""), chatName);
GUILayout.Label(string.IsNullOrEmpty(p.SteamId) ? "id " + p.Id : p.SteamId.StartsWith("name:") ? "no Steam" : "SteamID " + p.SteamId, small);
GUILayout.EndVertical();
GUILayout.FlexibleSpace();
Rect row = AdminRowRect(54);
bool self = p.Id == mp.LocalId;
GUI.enabled = !self && !p.IsAdmin;
if (GUILayout.Button("KICK", secondary, GUILayout.Height(30), GUILayout.Width(80)))
mp.AdminAction(false, p.Id, adminReason);
GUILayout.Space(6);
GUI.Label(new Rect(row.x, row.y + 6, row.width * 0.45f, 22), p.Name + (self ? " (you)" : "") + (p.IsAdmin ? " (admin)" : ""), nameMid);
GUI.Label(new Rect(row.x, row.y + 26, row.width * 0.45f, 18),
string.IsNullOrEmpty(p.SteamId) ? "id " + p.Id : p.SteamId.StartsWith("name:") ? "no Steam" : "SteamID " + p.SteamId, smallMid);
float right = row.xMax;
bool confirming = confirmBanId == p.Id;
if (GUILayout.Button(confirming ? "SURE?" : "BAN", danger, GUILayout.Height(30), GUILayout.Width(80)))
if (RightButton(ref right, row, 76, confirming ? "SURE?" : "BAN", danger, !self && !p.IsAdmin))
{
if (confirming) { mp.AdminAction(true, p.Id, adminReason); confirmBanId = -1; }
else confirmBanId = p.Id;
}
GUI.enabled = true;
GUILayout.EndHorizontal();
GUILayout.Space(4);
if (RightButton(ref right, row, 76, "KICK", secondary, !self && !p.IsAdmin)) mp.AdminAction(false, p.Id, adminReason);
if (mp.IsDedicated && RightButton(ref right, row, 124, p.IsAdmin ? "REMOVE ADMIN" : "MAKE ADMIN", secondary, !self)) mp.SetAdmin(p.Id, !p.IsAdmin);
if (mp.CanCheat)
{
if (RightButton(ref right, row, 76, "BRING", secondary, !self)) mp.BringPlayer(p.Id);
if (RightButton(ref right, row, 76, "GOTO", secondary, !self)) mp.GotoPlayer(p.Id);
}
}
}
GUILayout.EndScrollView();
}
// ---------------------------------------------------------------- TELEPORT
void DrawAdminTeleport(MultiplayerManager mp)
{
if (!mp.CanCheat)
{
GUILayout.Label("Teleporting only works in the game.", small);
return;
}
GUILayout.Label("Teleport yourself to a place. Players: use GOTO / BRING on the PLAYERS tab.", small);
GUILayout.Space(8);
teleportScroll = GUILayout.BeginScrollView(teleportScroll);
const int columns = 4;
for (int i = 0; i < Cheats.Places.Length; i += columns)
{
Rect r = GUILayoutUtility.GetRect(10, 44, GUILayout.ExpandWidth(true));
float bw = (r.width - AdminGap * (columns - 1)) / columns;
for (int c = 0; c < columns && i + c < Cheats.Places.Length; c++)
{
string[] place = Cheats.Places[i + c];
if (GUI.Button(new Rect(r.x + c * (bw + AdminGap), r.y, bw, 38), place[0].ToUpperInvariant(), secondary))
mp.TeleportToPlace(place[0], place[1]);
}
}
GUILayout.EndScrollView();
}
// ---------------------------------------------------------------- VEHICLE
void DrawAdminVehicle(MultiplayerManager mp)
{
if (!mp.CanCheat)
{
GUILayout.Label("Only works in the game.", small);
return;
}
List<VehicleAdmin.Part> parts = VehicleAdmin.Parts;
// Werkzeugleiste: Aktionen links, Suche rechts
Rect bar = GUILayoutUtility.GetRect(10, 34, GUILayout.ExpandWidth(true));
float bx = bar.x;
if (GUI.Button(new Rect(bx, bar.y, 120, 32), "REPAIR ALL", primary))
{
int n = VehicleAdmin.RepairAll();
Log(n + " part(s) repaired");
if (n > 0) mp.VehicleCheat("repaired the Satsuma (" + n + " parts)");
}
bx += 120 + AdminGap;
if (GUI.Button(new Rect(bx, bar.y, 120, 32), "TIGHTEN ALL", secondary))
{
int n = VehicleAdmin.SetAllBolts(true);
Log(n + " part(s) tightened");
if (n > 0) mp.VehicleCheat("tightened all bolts (" + n + " parts)");
}
bx += 120 + AdminGap;
if (GUI.Button(new Rect(bx, bar.y, 116, 32), "LOOSEN ALL", danger))
{
int n = VehicleAdmin.SetAllBolts(false);
Log(n + " part(s) loosened");
if (n > 0) mp.VehicleCheat("loosened all bolts (" + n + " parts, testing)");
}
bx += 116 + 16;
var search = new Rect(bx, bar.y, bar.xMax - bx, 32);
partFilter = GUI.TextField(search, partFilter ?? "", 30, field);
if (string.IsNullOrEmpty(partFilter)) GUI.Label(new Rect(search.x + 12, search.y, search.width, 32), "Search parts …", smallMid);
GUILayout.Space(8);
// Schnellfilter: Kategorien + nur Probleme + Füllstände ein/aus
var categories = new List<string>();
foreach (VehicleAdmin.Part p in parts) if (!categories.Contains(p.Category)) categories.Add(p.Category);
Rect chips = GUILayoutUtility.GetRect(10, 30, GUILayout.ExpandWidth(true));
float cx = chips.x;
if (GUI.Button(new Rect(cx, chips.y, 104, 26), "FLUIDS " + (fluidsOpen ? "▾" : "▸"), fluidsOpen ? chipOn : chipOff)) fluidsOpen = !fluidsOpen;
cx += 104 + 14;
if (GUI.Button(new Rect(cx, chips.y, 104, 26), "PROBLEMS", partProblems ? chipOn : chipOff)) partProblems = !partProblems;
cx += 104 + AdminGap;
if (GUI.Button(new Rect(cx, chips.y, 60, 26), "ALL", partCategory == "" ? chipOn : chipOff)) partCategory = "";
cx += 60 + AdminGap;
foreach (string cat in categories)
{
string label = (cat.Length == 0 ? "OTHER" : cat).ToUpperInvariant();
float cw = Mathf.Max(70, chipOff.CalcSize(new GUIContent(label)).x + 20);
if (cx + cw > chips.xMax) break;
if (GUI.Button(new Rect(cx, chips.y, cw, 26), label, partCategory == cat ? chipOn : chipOff)) partCategory = partCategory == cat ? "" : cat;
cx += cw + AdminGap;
}
GUILayout.Space(6);
vehicleScroll = GUILayout.BeginScrollView(vehicleScroll);
if (fluidsOpen)
{
List<VehicleAdmin.Fluid> fluids = VehicleAdmin.Fluids;
if (fluids.Count == 0) GUILayout.Label("No fluids found.", small);
// zwei Spalten
for (int i = 0; i < fluids.Count; i += 2)
{
Rect line = GUILayoutUtility.GetRect(10, AdminRow, GUILayout.ExpandWidth(true));
float colW = (line.width - 10) / 2f;
for (int c = 0; c < 2 && i + c < fluids.Count; c++)
{
VehicleAdmin.Fluid f = fluids[i + c];
var cell = new Rect(line.x + c * (colW + 10), line.y + 2, colW, AdminRow - 4);
GUI.Box(cell, GUIContent.none, rowStyle);
var inner = new Rect(cell.x + 12, cell.y, cell.width - 24, cell.height);
GUI.Label(new Rect(inner.x, inner.y, 128, inner.height), f.Label, nameMid);
float right = inner.xMax;
if (RightButton(ref right, inner, 74, "EMPTY", danger)) { VehicleAdmin.SetFluid(f, false); mp.VehicleCheat("emptied " + f.Label.ToLower()); }
if (RightButton(ref right, inner, 60, "FILL", secondary)) { VehicleAdmin.SetFluid(f, true); mp.VehicleCheat("filled up " + f.Label.ToLower()); }
GUI.Label(new Rect(right - 66, inner.y, 66, inner.height), f.Value.Value.ToString("0.0") + " / " + f.Max.ToString("0.#"), smallMid);
Bar(new Rect(inner.x + 132, inner.y, right - 72 - (inner.x + 132), inner.height), f.Max > 0f ? f.Value.Value / f.Max : 0f, 0.25f);
}
}
GUILayout.Space(10);
}
string filter = (partFilter ?? "").Trim().ToLowerInvariant();
int shown = 0;
foreach (VehicleAdmin.Part p in parts)
{
bool installed = p.Installed == null || p.Installed.Value;
bool damaged = p.Damaged != null && p.Damaged.Value;
// "Bolted" in der Database zieht das Spiel erst später nach - die Festigkeit selbst ist aktuell
bool loose = installed && p.Tightness != null && p.Tightness.Value < p.MaxTightness - 0.01f;
bool worn = p.Wear != null && p.Wear.Value < 50f;
if (partCategory.Length > 0 && p.Category != partCategory) continue;
if (partProblems && !(damaged || loose || worn)) continue;
if (filter.Length > 0 && !(p.Name + " " + p.Category).ToLowerInvariant().Contains(filter)) continue;
shown++;
Rect row = AdminRowRect(AdminRow);
GUI.Label(new Rect(row.x, row.y, 200, row.height), p.Name, nameMid);
GUI.Label(new Rect(row.x + 204, row.y, 90, row.height), p.Category, smallMid);
// Zustand als Abzeichen
string state = !installed ? "NOT INSTALLED" : damaged ? "DAMAGED" : loose ? "LOOSE" : p.Tightness != null ? "BOLTED" : "OK";
GUIStyle pill = !installed ? pillOff : damaged ? pillBad : loose ? pillWarn : pillOk;
GUI.Label(new Rect(row.x + 298, row.y + (row.height - 18) / 2f, 104, 18), state, pill);
float right = row.xMax;
if (RightButton(ref right, row, 66, "BREAK", danger)) { VehicleAdmin.Break(p); mp.VehicleCheat("broke " + p.Name + " (testing)"); }
if (RightButton(ref right, row, 70, "REPAIR", secondary)) { VehicleAdmin.Repair(p); mp.VehicleCheat("repaired " + p.Name); }
if (!installed)
{
// nicht eingebaut: statt der Schrauben-Knöpfe INSTALL (setzt das Teil auf seinen Halter)
if (RightButton(ref right, row, 154, "INSTALL", primary, p.CanInstall))
{
string error = VehicleAdmin.Install(p);
if (error == null) mp.VehicleCheat("installed " + p.Name);
else Log(p.Name + ": " + error);
}
}
bool bolts = p.HasBolts && installed;
if (installed)
{
if (RightButton(ref right, row, 72, "LOOSEN", secondary, bolts)) { VehicleAdmin.SetBolts(p, false); mp.VehicleCheat("loosened " + p.Name); }
if (RightButton(ref right, row, 76, "TIGHTEN", secondary, bolts)) { VehicleAdmin.SetBolts(p, true); mp.VehicleCheat("tightened " + p.Name); }
}
// Verschleiß
float wearX = row.x + 410;
if (p.Wear != null && right - wearX > 80)
{
GUI.Label(new Rect(right - 44, row.y, 44, row.height), p.Wear.Value.ToString("0") + " %", smallMid);
Bar(new Rect(wearX, row.y, right - 50 - wearX, row.height), p.Wear.Value / 100f, 0.3f);
}
}
if (shown == 0) GUILayout.Label(parts.Count == 0 ? "No parts found." : "No part matches the filter.", small);
GUILayout.EndScrollView();
}
// ---------------------------------------------------------------- ITEMS
void DrawAdminItems(MultiplayerManager mp)
{
if (!mp.CanCheat)
{
GUILayout.Label("Items can only be spawned in the game.", small);
return;
}
Rect top = GUILayoutUtility.GetRect(10, 32, GUILayout.ExpandWidth(true));
GUI.Label(new Rect(top.x, top.y, top.width - 330, 32), "Click an item to spawn it in front of you (everyone sees it, saved on the server).", smallMid);
var search = new Rect(top.xMax - 320, top.y, 320, 32);
itemFilter = GUI.TextField(search, itemFilter ?? "", 30, field);
if (string.IsNullOrEmpty(itemFilter)) GUI.Label(new Rect(search.x + 12, search.y, search.width, 32), "Search items …", smallMid);
GUILayout.Space(8);
var items = new List<string>();
string f = (itemFilter ?? "").Trim().ToLowerInvariant();
foreach (string item in ItemSpawner.Items) if (f.Length == 0 || item.ToLowerInvariant().Contains(f)) items.Add(item);
adminScroll = GUILayout.BeginScrollView(adminScroll);
const int perRow = 5;
for (int i = 0; i < items.Count; i += perRow)
{
Rect r = GUILayoutUtility.GetRect(10, 42, GUILayout.ExpandWidth(true));
float bw = (r.width - AdminGap * (perRow - 1)) / perRow;
for (int k = 0; k < perRow && i + k < items.Count; k++)
if (GUI.Button(new Rect(r.x + k * (bw + AdminGap), r.y, bw, 36), items[i + k].ToUpper(), secondary)) mp.SpawnItem(items[i + k]);
}
if (items.Count == 0) GUILayout.Label("No item matches the search.", small);
GUILayout.EndScrollView();
}
// ---------------------------------------------------------------- WORLD
/// <summary>Welt zurücksetzen, Backups anlegen und wiederherstellen (dedizierter Server).</summary>
void DrawAdminWorld(MultiplayerManager mp)
{
// Uhrzeit: je 2 Stunden ein Knopf (so schaltet das Spiel die Tageszeit)
int hour = WorldAdmin.CurrentHour;
GUILayout.Label("TIME" + (hour >= 0 ? " (now " + hour.ToString("00") + ":00 - " + ((hour + 2) % 24).ToString("00") + ":00)" : ""), caption);
Rect hours = GUILayoutUtility.GetRect(10, 30, GUILayout.ExpandWidth(true));
float hw = (hours.width - AdminGap * 11) / 12f;
for (int i = 0; i < 12; i++)
{
int h = i * 2;
if (GUI.Button(new Rect(hours.x + i * (hw + AdminGap), hours.y, hw, 28), h.ToString("00") + ":00", hour == h ? chipOn : chipOff))
mp.WorldCheat(WorldAdmin.KindHour, h);
}
GUILayout.Space(8);
int weatherNow = WorldAdmin.CurrentWeather;
GUILayout.Label("WEATHER" + (weatherNow >= 0 ? " (now " + WorldAdmin.WeatherNames[weatherNow] + ")" : ""), caption);
Rect wr = GUILayoutUtility.GetRect(10, 34, GUILayout.ExpandWidth(true));
float ww = (wr.width - AdminGap * 2) / 3f;
string[] labels = { "CLEAR", "RAIN", "THUNDERSTORM" };
for (int i = 0; i < 3; i++)
if (GUI.Button(new Rect(wr.x + i * (ww + AdminGap), wr.y, ww, 32), labels[i], weatherNow == i ? primary : secondary))
mp.WorldCheat(WorldAdmin.KindWeather, i);
GUILayout.Label("Rain and thunder start right above you. Everyone gets the same time and weather.", small);
GUILayout.Space(14);
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);
Rect r = GUILayoutUtility.GetRect(10, 34, GUILayout.ExpandWidth(true));
float bw = (r.width - AdminGap * 2) / 3f;
var b1 = new Rect(r.x, r.y, bw, 32);
var b2 = new Rect(r.x + bw + AdminGap, r.y, bw, 32);
var b3 = new Rect(r.x + 2 * (bw + AdminGap), r.y, bw, 32);
if (confirmWorld == "reset")
{
if (GUI.Button(new Rect(b1.x, b1.y, bw / 2f - 3, 32), "REALLY RESET?", danger)) { mp.WorldAction(5, null); confirmWorld = null; }
if (GUI.Button(new Rect(b1.x + bw / 2f + 3, b1.y, bw / 2f - 3, 32), "CANCEL", secondary)) confirmWorld = null;
}
else if (GUI.Button(b1, "RESET WORLD", danger)) confirmWorld = "reset";
if (GUI.Button(b2, "BACKUP NOW", primary)) mp.WorldAction(6, null);
if (confirmWorld == "point")
{
if (GUI.Button(new Rect(b3.x, b3.y, bw / 2f - 3, 32), "REALLY?", danger)) { mp.WorldAction(8, null); confirmWorld = null; }
if (GUI.Button(new Rect(b3.x + bw / 2f + 3, b3.y, bw / 2f - 3, 32), "CANCEL", secondary)) confirmWorld = null;
}
else if (GUI.Button(b3, "SET RESET POINT", secondary)) confirmWorld = "point";
GUILayout.Space(4);
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)", caption);
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)
{
Rect row = AdminRowRect(AdminRow);
GUI.Label(new Rect(row.x, row.y, 300, row.height), PrettyBackup(name), nameMid);
float right = row.xMax;
if (confirmWorld == name)
{
if (RightButton(ref right, row, 90, "CANCEL", secondary)) confirmWorld = null;
if (RightButton(ref right, row, 100, "REALLY?", danger)) { mp.WorldAction(7, name); confirmWorld = null; }
}
else if (RightButton(ref right, row, 100, "RESTORE", secondary)) confirmWorld = name;
}
}
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
{
EnsureAdminStyles();
foreach (string id in bannedIds)
{
Rect row = AdminRowRect(AdminRow);
GUI.Label(new Rect(row.x, row.y, row.width - 110, row.height), id.StartsWith("name:") ? id.Substring(5) + " (no Steam)" : "SteamID " + id, nameMid);
float right = row.xMax;
if (RightButton(ref right, row, 90, "UNBAN", secondary)) mp.Unban(id);
}
}
GUILayout.EndScrollView();
GUILayout.EndArea();
}
// ---------------------------------------------------------------- Stile
@@ -328,18 +768,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);
+37
View File
@@ -0,0 +1,37 @@
using System.IO;
namespace MSCO
{
/// <summary>
/// BinaryReader für Daten aus dem Netz (Server, Mitspieler). Mono legt bei ReadBytes(n) und ReadString() vorab ein
/// Array bzw. einen StringBuilder in der angegebenen Länge an - eine gefälschte Länge (z.B. 2 Milliarden) ließ das
/// Spiel mit "Out of Memory" abstürzen. Hier wird jede Länge gegen die tatsächlich vorhandenen Bytes geprüft.
/// </summary>
public sealed class PacketReader : BinaryReader
{
public PacketReader(byte[] data) : base(new MemoryStream(data ?? new byte[0], false)) { }
long Remaining { get { return BaseStream.Length - BaseStream.Position; } }
public override byte[] ReadBytes(int count)
{
if (count < 0 || count > Remaining) throw new EndOfStreamException("Invalid length " + count);
return base.ReadBytes(count);
}
public override char[] ReadChars(int count)
{
if (count < 0 || count > Remaining) throw new EndOfStreamException("Invalid length " + count);
return base.ReadChars(count);
}
public override string ReadString()
{
long start = BaseStream.Position;
int length = Read7BitEncodedInt();
if (length < 0 || length > Remaining) throw new EndOfStreamException("Invalid string length " + length);
BaseStream.Position = start;
return base.ReadString();
}
}
}
+258 -26
View File
@@ -4,14 +4,22 @@ 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,
/// sobald das Fahrzeug FÄHRT (egal wer fährt). Am stehenden Auto wird niemand automatisch festgehalten - sonst hing
/// man fest, sobald man sich zum Schrauben in den Motorraum beugte. 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.15f; // ... so lange
const float MovingSpeed = 1.2f; // m/s: ab hier "fährt" das Fahrzeug (dann wird man festgehalten)
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 +32,27 @@ 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;
// Geschwindigkeit je Fahrzeug aus der Bewegung (Fahrzeuge anderer sind kinematisch - deren velocity ist 0)
readonly Dictionary<Rigidbody, Vector3> lastVehiclePos = new Dictionary<Rigidbody, Vector3>();
readonly Dictionary<Rigidbody, float> vehicleSpeed = new Dictionary<Rigidbody, float>();
float lastSpeedSample = -1f;
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 +62,213 @@ 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;
SampleSpeeds(objects);
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 && SpeedOf(found) < MovingSpeed) found = null; // steht: frei bewegen (z.B. schrauben)
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>Geschwindigkeit aller Fahrzeuge in der Nähe aus ihrer Bewegung seit der letzten Messung.</summary>
void SampleSpeeds(ObjectSync objects)
{
float now = Time.time, dt = lastSpeedSample < 0f ? 0f : now - lastSpeedSample;
lastSpeedSample = now;
foreach (SyncedBody b in objects.AllVehicles())
{
if (b.Rb == null) continue;
Vector3 pos = b.Rb.position, last;
if (dt > 0.01f && lastVehiclePos.TryGetValue(b.Rb, out last)) vehicleSpeed[b.Rb] = (pos - last).magnitude / dt;
lastVehiclePos[b.Rb] = pos;
}
}
float SpeedOf(SyncedBody b)
{
float v;
return b.Rb != null && vehicleSpeed.TryGetValue(b.Rb, out v) ? v : 0f;
}
/// <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 +284,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 +304,13 @@ namespace MSCO
public void Reset()
{
vehicle = null;
dwellVehicle = null;
dwellStart = -1f;
driverHeads.Clear();
innerBounds.Clear();
lastVehiclePos.Clear();
vehicleSpeed.Clear();
lastSpeedSample = -1f;
SetMovementEnabled(null, true);
}
@@ -118,6 +329,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 +353,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();
}
}
}
+91
View File
@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Laufzeit-Statistik: wie viele ms pro Bild jeder MSCO-Teil braucht, FPS und Garbage-Collections. Konsole (F7):
/// "perf" bzw. "perf reset". Mit der Startoption -msco-perf wird alle 30 s eine Zeile in MSCO\MSCO_Loader.txt
/// geschrieben (zum Messen ohne Konsole).
/// </summary>
public static class PerfStats
{
static readonly Dictionary<string, double> totals = new Dictionary<string, double>();
/// <summary>Aufrufe der Harmony-Haken (Fsm.SwitchState, Fsm.Event, cInput) - zum Abschätzen ihrer Kosten.</summary>
public static int SwitchStateCalls, EventCalls, InputCalls;
static int frames, gcStart = -1;
static double frameSeconds, worstFrame;
static float started = -1f;
static int level = -1;
static int reports;
static readonly bool autoLog = Array.IndexOf(Environment.GetCommandLineArgs(), "-msco-perf") >= 0;
public static void Add(string part, double ms)
{
double t;
totals.TryGetValue(part, out t);
totals[part] = t + ms;
}
/// <summary>Einmal pro Bild (Update).</summary>
public static void Frame(float dt)
{
// Neue Szene (Menü -> Spiel): neu anfangen, sonst zählt das Laden mit
if (started < 0f || Application.loadedLevel != level) { level = Application.loadedLevel; reports = 0; Reset(); }
frames++;
frameSeconds += dt;
if (dt > worstFrame) worstFrame = dt;
// Mit -msco-perf alle 30 s, sonst im Spiel nach 2 Minuten und dann alle 5 Minuten ein Bericht in MSCO_Loader.txt -
// so lässt sich bei "wenig FPS" nachsehen, ob MSCO, eine Mod oder das Spiel selbst die Zeit braucht
float every = autoLog ? 30f : reports == 0 ? 120f : 300f;
if ((autoLog || MenuHelper.InGame) && Time.realtimeSinceStartup - started >= every)
{
Bootstrap.Log(Report());
reports++;
Reset();
}
}
/// <summary>Beim Beenden: was seit dem letzten Bericht gemessen wurde (mindestens 20 s im Spiel).</summary>
public static void FinalReport()
{
if (frames > 0 && MenuHelper.InGame && Time.realtimeSinceStartup - started >= 20f) Bootstrap.Log(Report());
}
public static void Reset()
{
totals.Clear();
frames = 0;
frameSeconds = 0;
worstFrame = 0;
started = Time.realtimeSinceStartup;
gcStart = GC.CollectionCount(0);
SwitchStateCalls = EventCalls = InputCalls = 0;
}
public static string Report()
{
if (frames == 0) return "PERF: no frames measured yet";
double seconds = Math.Max(0.001, Time.realtimeSinceStartup - started);
double msco = 0;
foreach (double v in totals.Values) msco += v;
var parts = new List<KeyValuePair<string, double>>(totals);
parts.Sort((a, b) => b.Value.CompareTo(a.Value));
var sb = new StringBuilder();
sb.Append("PERF ").Append(seconds.ToString("0")).Append(" s (").Append(MenuHelper.Scene).Append("): ")
.Append((frames / frameSeconds).ToString("0")).Append(" fps, ")
.Append((frameSeconds * 1000.0 / frames).ToString("0.0")).Append(" ms/frame (worst ")
.Append((worstFrame * 1000.0).ToString("0")).Append(" ms), MSCO ")
.Append((msco / frames).ToString("0.00")).Append(" ms/frame, GC ")
.Append(((GC.CollectionCount(0) - gcStart) * 60.0 / seconds).ToString("0.0")).Append("/min")
.Append(", hooks/frame: state ").Append((SwitchStateCalls / (double)frames).ToString("0"))
.Append(" event ").Append((EventCalls / (double)frames).ToString("0"))
.Append(" input ").Append((InputCalls / (double)frames).ToString("0"));
for (int i = 0; i < parts.Count && i < 8; i++)
sb.Append(i == 0 ? " | " : ", ").Append(parts[i].Key).Append(' ').Append((parts[i].Value / frames).ToString("0.000"));
return sb.ToString();
}
}
}
+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;
}
}
}
+6 -4
View File
@@ -6,9 +6,9 @@ using System.Runtime.InteropServices;
// Please disclose yourself when generating code for this project
// Put "Generated By: {AI model name}" in AssemblyTrademark("") field
[assembly: AssemblyTitle("MSCO")]
[assembly: AssemblyDescription("MSCLoader Mod file")]
[assembly: AssemblyDescription("MSCO - My Summer Car Online (standalone, Unity Doorstop)")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyCompany("MBM")]
[assembly: AssemblyProduct("MSCO")]
[assembly: AssemblyCopyright("Copyright © 2026 by MBM")]
[assembly: AssemblyTrademark("")]
@@ -32,5 +32,7 @@ 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(MSCO.MSCO.ModVersion + ".0")]
// Mod-Version (z.B. 2.8.1) - der MSCO Launcher liest sie aus der DLL (Produktversion)
[assembly: AssemblyInformationalVersion(MSCO.MSCO.ModVersion)]
+112 -28
View File
@@ -1,51 +1,108 @@
# MSCO – My Summer Car Online
Multiplayer-Mod für **My Summer Car** (MSCLoader). Spiel mit bis zu 3 Spielern zusammen – per Steam-Einladung
(P2P) oder auf einem dedizierten Server aus dem Server-Browser.
Multiplayer-Mod für **My Summer Car**. Spiel mit bis zu 3 Spielern zusammen – per Steam-Einladung (P2P) oder auf
einem dedizierten Server aus dem Server-Browser. MSCO läuft **eigenständig, ohne MSCLoader** (Start über
[Unity Doorstop](https://github.com/NeighTools/UnityDoorstop)); MSCLoader wird beim Spielen mit MSCO blockiert.
- Mod: dieses Repository
- Dedizierter Server: [MSCO-Dedicated-Server](https://git.mbm.wtf/MrMaho/MSCO-Dedicated-Server)
- Keymaster & Serverliste: [MSCO-Keymaster](https://git.mbm.wtf/MrMaho/MSCO-Keymaster) – live unter https://msco.mbm.wtf
- Launcher (installiert und aktualisiert die Mod): [MSCO-Launcher](https://git.mbm.wtf/MrMaho/MSCO-Launcher)
- Download: https://msco.mbm.wtf/download
## Features
**Verbinden**
- Launcher im Hauptmenü im Stil von FiveM: P2P-Seite und Server-Browser
- Launcher im Hauptmenü (Vollbild, automatisch skaliert): P2P-Seite, Server-Browser und Favoriten; mit **–** einklappen,
dann ist das Startmenü des Spiels wieder bedienbar
- **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
- **Direct Connect** per Adresse, JOIN-Links von der Website (`msco://`) starten das Spiel und verbinden direkt
- Server ohne Steam („Non-Steam“) werden unterstützt, andere Mod-Version = Warnung statt Sperre
- **Favoriten** mit Herz und Kurzname, schnell wechseln mit `/switch <kurzname>`
- **P2P: neues Spiel oder weiterspielen** – der Host startet ein frisches Spiel oder lädt seinen Spielstand
- Server ohne Steam („Non-Steam“) werden unterstützt; Versionen passen, solange die ersten zwei Stellen gleich sind
- Nach einem Kick/Bann ein Fenster mit dem Grund; nach Welt-Reset oder Server-Neustart verbindet man automatisch neu
- Der Host-Spielstand wird beim Start automatisch an alle übertragen; der eigene Spielstand wird vorher gesichert
und nach dem Spielen wiederhergestellt
**Synchronisiert**
- Spieler als Spielfigur (Modell aus den NPCs des Spiels) mit Blickrichtung, Ducken/Lehnen, Namensschild
- Spieler als **echte MSC-Menschen** (derselbe Körper wie die Leute im Spiel, je Spieler anderes Hemd, Hose, Gesicht)
mit Blickrichtung, Laufen, Springen, Ducken/Lehnen, Sitzen, gehaltenem Item und Namensschild (`model classic` = einfache
MSCO-Figur)
- 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ß,
Kilometerstand, Lack, Vergaser-/Zündungseinstellungen
- Fahrzeuge: Position, Lichter, Motorgeräusch, Bedienelemente (Zündung, Licht, Blinker, Wischer, Handbremse …), Tacho,
Lenkrad, drehende Räder, Türen/Haube/Kofferraum, Unfallschäden
- **Mitfahren**: wer im Fahrzeug ist (Kabine, offene Tür, Ladefläche), fährt genau dort mit – mit oder ohne Fahrer
- **Fahrzeugzustand:** Tank, Öl, Kühlwasser, Bremsflüssigkeit, Batterie, Schäden, Verschleiß, Kilometerstand,
Vergaser-/Zündungseinstellungen; beim Satsuma zusätzlich jedes Teil (Verschleiß, kaputte Teile, Reifen, Ölwanne,
Fahrwerk) und der **Lack**
- Anbauen/Abbauen von Teilen und Schrauben (PlayMaker-Events)
- Türen, Tore, Hauben (z.B. Garagentor), Geld, Uhrzeit und Wetter
- Türen, Tore, Hauben (z.B. Garagentor), Geld, Uhrzeit und Wetter, NPC-Verkehr
- Jobs und Events: Laden, Werkstatt, Prüfstelle, Müllkippe, Tanzhalle, Rallye, Dragrace, Kilju-Käufer, Theater, Fußball,
Peräjärvi, Ferienhaus, Hütte
- Pro Spieler auf dem Server: letzte Position und Bedürfnisse (Hunger, Durst, Stress …)
**Im Spiel**
- **T** – Chat
- **F8** – MSCO-Konsole mit allen Befehlen
- **/admin** – Admin-Menü (Spieler kicken/bannen) für Server-Admins bzw. den P2P-Host
- **P** – als Beifahrer ein-/aussteigen
- Alle Tasten sind in den MSCLoader-Einstellungen der Mod änderbar
- Discord Rich Presence („In the Menus“, Server, Spielerzahl)
- **F7** – MSCO-Konsole mit allen Befehlen
- **/admin** – Admin-Menü für Server-Admins bzw. den P2P-Host: Spieler kicken/bannen, **Admin geben/nehmen**,
GOTO/BRING (teleportieren), **TELEPORT** zu Orten (Haus, Laden, Werkstatt, Prüfstelle, Peräjärvi …), **VEHICLE**
(Füllstände auffüllen/leeren, Teile reparieren/zerstören – zum Testen), Cheats (+1.000 / +10.000 MK, Bedürfnisse auffüllen, Satsuma zu sich holen), Items spawnen,
Welt zurücksetzen/Backups – Cheats werden im Chat angezeigt
- **P** / **Leertaste** – aussteigen; **P** neben einem Auto – auf den Beifahrersitz
- Tasten ändern mit `bind` in der F7-Konsole (z.B. `bind passenger O`)
- Rich Presence: zeigt Freunden deinen Status („In the Menus“, Server, Spielerzahl)
- MSCO-Look: Fenstertitel, Symbol und Logo im Hauptmenü; der „Press any key“-Startbildschirm wird übersprungen;
ESC im Hauptmenü beendet das Spiel nicht
- **Mods**: eigene MSCO-Mods in `<Spielordner>\MSCO\Mods`, Server können eigene Mods mitbringen (Download nach
Zustimmung) – siehe [MODDING.md](MODDING.md)
## Installation
1. [MSCLoader](https://github.com/piotrulos/MSCModLoader) installieren.
2. `MSCO.dll` von den [Releases](https://git.mbm.wtf/MrMaho/MSCO/releases/latest) herunterladen und in den
Mods-Ordner kopieren (Standard: `Documents\MySummerCar\Mods`).
3. Spiel **über Steam** starten – ohne Steam gehen nur Non-Steam-Server.
**Mit dem Launcher (empfohlen):** den [MSCO Launcher](https://msco.mbm.wtf/download) installieren – er installiert
und aktualisiert MSCO und startet das Spiel. MSCLoader wird **nicht** gebraucht.
Was der Launcher im Spielordner einrichtet:
| Datei | |
|---|---|
| `MSCO\MSCO.dll` (+ `0Harmony.dll`) | die Mod |
| `winhttp.dll` | Unity Doorstop 4 – nur wenn noch keiner da ist (MSCLoader bringt denselben mit) |
| `doorstop_config.ini` | zeigt auf `MSCO\MSCO.dll` – eine MSCLoader-Konfiguration wird vorher als `doorstop_config.mscloader.ini` gesichert |
Alte MSCO-Versionen im MSCLoader-Mods-Ordner werden in `MSCO.dll.old` umbenannt. Damit startet My Summer Car auch über
Steam mit MSCO und ohne MSCLoader. **MSCLoader wieder benutzen:** `doorstop_config.mscloader.ini` zurück in
`doorstop_config.ini` umbenennen.
**Von Hand:** Doorstop 4 (`winhttp.dll`, x64) in den Spielordner, `MSCO.dll` von der
[Download-Seite](https://msco.mbm.wtf/download) mit `0Harmony.dll` (Harmony 1.2) nach
`<Spielordner>\MSCO\` und in `doorstop_config.ini` `target_assembly = MSCO\MSCO.dll` eintragen.
Spiel **mit Steam** starten – ohne Steam gehen nur Non-Steam-Server. Das Lade-Protokoll steht in
`<Spielordner>\MSCO\MSCO_Loader.txt`.
### 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“ | Die ersten zwei Stellen unterscheiden sich – Mod (Launcher) oder Server aktualisieren |
| Mod lässt sich nicht aktualisieren | Spiel schließen – solange es läuft, ist `MSCO.dll` gesperrt |
| Statt MSCO startet MSCLoader | Im Launcher **SPIELEN** drücken – er stellt `doorstop_config.ini` wieder auf MSCO (z.B. nach einem MSCLoader-Update) |
| MSCO startet gar nicht | `<Spielordner>\MSCO\MSCO_Loader.txt` ansehen |
| 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:
| Befehl | Beschreibung |
|---|---|
@@ -53,29 +110,54 @@ 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) |
| `fps [on\|off]` | Bilder pro Sekunde oben rechts |
| `switch <kurzname>`, `fav …` | Zu einem Favoriten wechseln, Favoriten verwalten |
| `model [game\|classic]` | Mitspieler als MSC-Menschen oder als einfache Figur |
| `vars <filter>` | Diagnose: synchronisierte Fahrzeugwerte |
| `mods` | geladene MSCO-Mods |
| `perf [reset]` | FPS und wie lange jeder MSCO-Teil pro Bild braucht |
| `fsmdump <text>` | Diagnose: Spiel-Logik (PlayMaker) und Scharniere von Objekten mit diesem Namen nach `MSCO\fsmdump.txt` |
| `admin` bzw. `/admin` im Chat | Admin-Menü |
| `vehicles`, `rig` | Diagnose: Fahrzeuge / Spielermodelle |
| `debug` | Sync-Debugausgaben an/aus |
| `master [url]` | Adresse der Serverliste anzeigen/ändern |
| `bind [aktion taste]` | Tasten anzeigen / ändern (`passenger`, `console`, `chat`) |
| `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.
- Nach dem Build wird die DLL automatisch nach `Documents\MySummerCar\Mods` kopiert (`MSCMODSFOLDER` in der csproj).
(UnityEngine, PlayMaker, Assembly-CSharp, Assembly-CSharp-firstpass, cInput) – bei anderem Installationsort anpassen.
Harmony 1.2 liegt in `lib/` (MIT, siehe `lib/THIRD-PARTY.txt`). JSON liest MSCO selbst (`MiniJson.cs`) – das Spiel
hat ohne MSCLoader kein `System.Runtime.Serialization`.
- Nach dem Build wird die DLL automatisch nach `<Spielordner>\MSCO` kopiert (Ziel `CopyToGame` in der csproj).
- Einstieg: `Bootstrap.cs` (`Doorstop.Entrypoint.Start` → Harmony-Haken in `PlayMakerArrayListProxy.Awake` → `MSCO_Host`
im Hauptmenü, überspringt den Nachrichten-Bildschirm).
```
msbuild MSCO.csproj -p:Configuration=Release
```
### Updates veröffentlichen
- **Kleines Update** (Fehlerbehebungen, neue Funktionen, Netzwerk unverändert): in `MSCO.cs` nur die dritte Stelle von
`ModVersion` erhöhen (`2.9.0` → `2.9.1`), Release bauen und `bin/Release/MSCO.dll` auf den Keymaster laden. Spieler
mit derselben Netzwerk-Version spielen weiter zusammen, Server 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.
- **Automatisch hochladen**: Ist lokal `MSCO_RELEASE_TOKEN` gesetzt (= `RELEASE_TOKEN` des Keymasters, oder in
`%AppData%\MSCO\release-token.txt`), lädt jeder Release-Build eine neue Version selbst auf den Keymaster
(`release.ps1`, Changelog aus dem passenden Abschnitt in `CHANGELOG.md`). Gibt es die Version schon, passiert nichts.
## Aufbau
| Datei | Aufgabe |
|---|---|
| `MSCO.cs` | Mod-Einstieg, Tastenbelegung |
| `Bootstrap.cs`, `MSCO.cs` | Start ohne MSCLoader (Doorstop), Ladebildschirm, Versionen, Tastenbelegung |
| `MenuBranding.cs`, `MiniJson.cs` | Fenstertitel/-symbol und Menü-Logo, eigener JSON-Leser |
| `ModApi.cs`, `ModLoader.cs` | Mod-Schnittstelle `MscoMod`, Laden und Aufrufen der Mods (siehe MODDING.md) |
| `MultiplayerManager.cs` | Sitzung, Nachrichten, Spielerliste, Spielstart, HUD |
| `NetSession.cs` | Netzwerk (TCP und Steam P2P), Protokoll |
| `LobbyUI.cs`, `Overlays.cs`, `UiTheme.cs` | Launcher, Konsole/Chat/Admin-Menü, gemeinsamer Stil |
@@ -83,16 +165,18 @@ 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`, `GameCharacter.cs` | Spielfiguren der Mitspieler (Animation, MSC-Körper mit eigenem Skelett) |
| `HingeSync.cs`, `ControlSync.cs`, `DamageSync.cs`, `WorldVarSync.cs` | Türwinkel, Schalter, Unfallschäden, Jobs/Events |
| `Favorites.cs`, `Commands.cs`, `PlayerNeeds.cs` | Favoriten, Konsolenbefehle, Bedürfnisse pro Spieler |
| `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
- Teile, die nach dem Server-Spielstand montiert wurden, werden für neue Spieler nachgespielt – das klappt nicht immer
perfekt. `reset-world` auf dem Server setzt den Stand zurück.
- Maximal 3 Spieler.
- Maximal 3 Spieler im P2P-Spiel (dedizierte Server: so viele wie eingestellt).
## Lizenz
+66
View File
@@ -0,0 +1,66 @@
# Updates veröffentlichen (Mod & Launcher)
Neue Versionen der Mod und des Launchers landen beim Bauen automatisch auf dem Keymaster (`release.ps1`). Jede Datei
wird dabei mit dem privaten Release-Schlüssel **signiert** – der Launcher und das Setup installieren nur signierte
Dateien. Dafür braucht der Rechner, auf dem gebaut wird, zwei Geheimnisse:
| | Windows | macOS |
|---|---|---|
| Release-Token (Keymaster `RELEASE_TOKEN`) | `%USERPROFILE%\.msco\release-token.dpapi` (DPAPI) | Schlüsselbund: *MSCO release-token* |
| Signier-Schlüssel (RSA-3072) | `%USERPROFILE%\.msco\release-signing-key.dpapi` (DPAPI) | Schlüsselbund: *MSCO release-signing-key* |
Sicherung des Schlüssels: `\\mbmnas\mrmaho\MSCOModBackupData\msco-signing-key-backup.txt` (AES-256, eigenes
Passwort – das Passwort **nicht** auf dem NAS speichern).
## Ablauf
1. Version erhöhen: Mod in `MSCO.cs` (`ModVersion`), Launcher in `MSCO.Launcher.csproj` (`<Version>`) – und den
Abschnitt `## <Version>` in der jeweiligen `CHANGELOG.md` (wird zum Changelog auf der Website/im Launcher).
2. Bauen:
- Mod: im Ordner `MSCO` → `dotnet build -c Release`
- Launcher: im Ordner `MSCO.Launcher` → `dotnet publish -c Release`
3. Die Ausgabe zeigt `MSCO release: mod 2.x.y uploaded to https://msco.mbm.wtf`. Gibt es die Version schon, passiert
nichts. `MSCO_NO_RELEASE=1` (Umgebungsvariable) baut, ohne hochzuladen.
4. Code committen und pushen.
## Einrichten auf Windows
Läuft schon (Token und Schlüssel liegen in `%USERPROFILE%\.msco`). Neuer PC:
```powershell
powershell -ExecutionPolicy Bypass -File MSCO\release.ps1 -SaveToken
powershell -ExecutionPolicy Bypass -File MSCO\release.ps1 -ImportSigningKey \\mbmnas\mrmaho\MSCOModBackupData\msco-signing-key-backup.txt
```
> In einem PowerShell-Fenster **ohne** „Als Administrator ausführen“ – sonst sind Netzlaufwerke wie `Z:` nicht da
> (der Pfad `\\mbmnas\mrmaho\…` geht in beiden).
## Einrichten auf dem Mac (nur bauen/veröffentlichen – das Spiel gibt es nur für Windows)
1. Werkzeuge: [.NET 10 SDK](https://dotnet.microsoft.com/download) und PowerShell 7:
```bash
brew install --cask dotnet-sdk powershell
```
2. Repositories klonen (`MSCO`, `MSCO-Launcher` von git.mbm.wtf).
3. NAS verbinden (Finder → *Gehe zu* → *Mit Server verbinden* → `smb://mbmnas/mrmaho`) – erscheint unter
`/Volumes/mrmaho`.
4. Spiel-DLLs zum Kompilieren der Mod (UnityEngine, Assembly-CSharp, PlayMaker, cInput) liegen unter
`MSCOModBackupData/Managed` auf dem NAS. Am besten einmal lokal kopieren und den Ordner merken:
```bash
mkdir -p ~/MSC-Managed && cp /Volumes/mrmaho/MSCOModBackupData/Managed/*.dll ~/MSC-Managed/
echo 'export MSC_MANAGED="$HOME/MSC-Managed"' >> ~/.zshrc && source ~/.zshrc
```
5. Token und Schlüssel in den Schlüsselbund:
```bash
pwsh MSCO/release.ps1 -SaveToken
pwsh MSCO/release.ps1 -ImportSigningKey /Volumes/mrmaho/MSCOModBackupData/msco-signing-key-backup.txt
```
6. Danach wie oben: `dotnet build -c Release` (Mod) bzw. `dotnet publish -c Release` (Launcher).
Nach einem Spiel-Update (neue `Assembly-CSharp.dll` usw.) die DLLs auf dem NAS bzw. in `~/MSC-Managed` erneuern.
## Neuer Schlüssel?
Nur im Notfall (Schlüssel verloren): `release.ps1 -NewSigningKey` erzeugt ein neues Paar – dann muss der neue
öffentliche Schlüssel in `MSCO.Launcher/Services/ReleaseSignature.cs` und `MSCO.Launcher/Setup/Install.cs`, und
Spieler mit einem älteren Launcher müssen ihn einmal neu installieren (ihr Launcher lehnt die neuen Signaturen ab).
+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>
+38 -4
View File
@@ -2,7 +2,6 @@ using System;
using System.Reflection;
using Harmony;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
@@ -32,7 +31,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,8 +40,43 @@ 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);
}
PatchMenuQuit();
}
/// <summary>
/// ESC im Hauptmenü beendet beim Spiel sofort das Programm (PlayMaker-Aktion "ApplicationQuit" im Menü). Im
/// MSCO-Hauptmenü gibt es dafür den QUIT-Knopf - dort also nicht über die Spiel-Logik beenden. Im Spiel selbst
/// (Pausemenü) bleibt alles wie gehabt.
/// </summary>
static void PatchMenuQuit()
{
try
{
Type action = null;
foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies())
{
action = asm.GetType("HutongGames.PlayMaker.Actions.ApplicationQuit");
if (action != null) break;
}
MethodInfo onEnter = action != null ? action.GetMethod("OnEnter", BindingFlags.Instance | BindingFlags.Public) : null;
if (onEnter == null) return;
HarmonyInstance.Create("mbm.msco.menuquit").Patch(onEnter,
new HarmonyMethod(typeof(SafetyPatches).GetMethod("QuitPrefix", BindingFlags.Static | BindingFlags.NonPublic)), null);
}
catch (Exception e)
{
MscoLog.Warning("[MSCO] Could not block ESC-quit in the main menu: " + e.Message);
}
}
static bool QuitPrefix(FsmStateAction __instance)
{
if (!MenuHelper.InMenu) return true;
MscoLog.Info("[MSCO] The game tried to quit from the main menu (ESC) - use QUIT in the MSCO menu to exit");
try { __instance.Finish(); } catch { }
return false;
}
static bool SetParentPrefix(FsmStateAction __instance)
@@ -60,7 +94,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 -4
View File
@@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.IO;
using MSCLoader;
using UnityEngine;
namespace MSCO
@@ -24,6 +23,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 +95,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 +105,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 +121,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)
{
+76 -18
View File
@@ -6,7 +6,6 @@ using System.IO;
using System.Net.Sockets;
using System.Threading;
// (Unitys WWW wird nicht mehr benutzt - siehe WinHttp)
using Newtonsoft.Json;
using UnityEngine;
namespace MSCO
@@ -21,14 +20,22 @@ namespace MSCO
public int Players;
public int MaxPlayers;
public string Version;
public string ServerVersion = ""; // Programmversion des Servers (z.B. 2.10.6), ältere melden sie nicht
public int Security; // Sicherheitsstufe des Servers (0 = alt, ohne Sicherheitsupdates)
/// <summary>Fehlen dem Server Sicherheitsupdates? Dann tritt die Mod nicht bei.</summary>
public bool Outdated { get { return Security < SiteStatus.RequiredServerSecurity; } }
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)
public List<string> Mods = new List<string>(); // Mods des Servers ("Name Version") - bekommt man beim Beitreten
public bool Modded { get { return Mods.Count > 0 || Tags.Contains("modded"); } }
[JsonIgnore] public int UptimeSeconds = -1;
[JsonIgnore] public List<string> PlayerNames = new List<string>();
[JsonIgnore] public int Ping = -1; // -1 = noch nicht abgefragt
[JsonIgnore] public bool Offline;
[JsonIgnore] public bool IsLocal; // Server auf diesem PC (localhost)
public int UptimeSeconds = -1;
public List<string> PlayerNames = new List<string>();
public int Ping = -1; // -1 = noch nicht abgefragt
public bool Offline;
public bool IsLocal; // Server auf diesem PC (localhost)
}
/// <summary>
@@ -37,11 +44,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 +61,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
{
@@ -109,7 +109,7 @@ namespace MSCO
}
List<ServerListEntry> list;
try { list = JsonConvert.DeserializeObject<List<ServerListEntry>>(text) ?? new List<ServerListEntry>(); }
try { list = ParseList(text); }
catch (Exception e)
{
Error = "Invalid server list: " + e.Message;
@@ -120,6 +120,53 @@ namespace MSCO
foreach (ServerListEntry entry in list) QueryAsync(entry);
}
/// <summary>Antwort von /api/servers lesen (eigener JSON-Leser, siehe MiniJson).</summary>
static List<ServerListEntry> ParseList(string text)
{
var list = new List<ServerListEntry>();
var items = MiniJson.Parse(text) as List<object>;
if (items == null) throw new FormatException("Expected a list");
foreach (object item in items)
{
var o = item as Dictionary<string, object>;
if (o == null) continue;
list.Add(new ServerListEntry
{
Name = MiniJson.Str(o, "name", ""),
Description = MiniJson.Str(o, "description", ""),
Address = MiniJson.Str(o, "address", ""),
Port = MiniJson.Int(o, "port", 7777),
Players = MiniJson.Int(o, "players"),
MaxPlayers = MiniJson.Int(o, "maxPlayers"),
Version = MiniJson.Str(o, "version", ""),
ServerVersion = MiniJson.Str(o, "serverVersion", ""),
Security = MiniJson.Int(o, "security"),
RequireSteam = MiniJson.Bool(o, "requireSteam", true),
Owner = MiniJson.Str(o, "owner"),
Featured = MiniJson.Bool(o, "featured"),
Tags = MiniJson.Strings(o, "tags"),
Mods = ModNames(o),
});
}
return list;
}
static List<string> ModNames(Dictionary<string, object> o)
{
var names = new List<string>();
object v;
var list = o.TryGetValue("mods", out v) ? v as List<object> : null;
if (list == null) return names;
foreach (object item in list)
{
var m = item as Dictionary<string, object>;
if (m == null) continue;
string name = MiniJson.Str(m, "name", MiniJson.Str(m, "id", "?")), version = MiniJson.Str(m, "version", "");
names.Add(string.IsNullOrEmpty(version) ? name : name + " " + version);
}
return names;
}
/// <summary>Server auf diesem PC suchen (127.0.0.1, übliche Ports); nur antwortende werden gezeigt.</summary>
void ProbeLocalServers()
{
@@ -151,6 +198,9 @@ namespace MSCO
entry.MaxPlayers = info.MaxPlayers;
entry.Version = info.Version;
entry.Ping = info.Ping;
// Direkt vom Server - gilt auch für lokale/direkt eingegebene Server ohne Keymaster
entry.Security = info.Security;
if (!string.IsNullOrEmpty(info.ServerVersion)) entry.ServerVersion = info.ServerVersion;
if (info.HasProfile)
{
entry.RequireSteam = info.RequireSteam;
@@ -177,6 +227,9 @@ namespace MSCO
public string Owner;
public int UptimeSeconds;
public List<string> PlayerNames;
// Ab Server 2.10.6: Programmversion und Sicherheitsstufe (ältere Server: "" und 0)
public string ServerVersion;
public int Security;
}
/// <summary>Server direkt nach Name, Spielerzahl und Version fragen (blockierend - im Hintergrund aufrufen).</summary>
@@ -203,7 +256,7 @@ namespace MSCO
byte[] payload = ReadExactly(stream, length - 1);
info.Ping = (int)watch.ElapsedMilliseconds;
using (var r = new BinaryReader(new MemoryStream(payload)))
using (var r = new PacketReader(payload))
{
info.Name = r.ReadString();
info.Description = r.ReadString();
@@ -220,6 +273,11 @@ namespace MSCO
int count = r.ReadInt32();
info.PlayerNames = new List<string>();
for (int i = 0; i < count && i < 256; i++) info.PlayerNames.Add(r.ReadString());
if (r.BaseStream.Position < r.BaseStream.Length)
{
info.ServerVersion = r.ReadString();
info.Security = r.ReadInt32();
}
}
}
return true;
+134
View File
@@ -0,0 +1,134 @@
using System;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;
namespace MSCO
{
/// <summary>Eine Ankündigung der MSCO-Admins (Keymaster → Wartung &amp; News, Ziel "Spiel").</summary>
public class Announcement
{
public string Id, Title, Text, Level; // Level: info, warning, critical
}
/// <summary>
/// Status vom Keymaster (/api/status?for=game): Wartungsmodus, Ankündigungen und die Mindest-Sicherheitsstufe der
/// Server. Wird im Hintergrund geladen (WinHTTP, blockiert das Spiel nicht) und alle paar Minuten aufgefrischt.
/// Ohne Verbindung gilt das Eingebaute (MSCO.MinServerSecurity), Ankündigungen fehlen dann einfach.
/// </summary>
public static class SiteStatus
{
const float RefreshSeconds = 300f;
static readonly object gate = new object();
static List<Announcement> announcements = new List<Announcement>();
static bool maintenance;
static string maintenanceMessage = "";
static int minServerLevel; // vom Keymaster (0 = unbekannt)
static float nextFetch;
static bool fetching;
/// <summary>Aktuelle Ankündigungen (wichtigste zuerst).</summary>
public static List<Announcement> Announcements { get { lock (gate) return new List<Announcement>(announcements); } }
public static bool Maintenance { get { lock (gate) return maintenance; } }
public static string MaintenanceMessage { get { lock (gate) return maintenanceMessage; } }
/// <summary>
/// Sicherheitsstufe, die Server und P2P-Hosts mindestens haben müssen: das Eingebaute oder - wenn höher - was die
/// MSCO-Admins im Keymaster eingestellt haben.
/// </summary>
public static int RequiredServerSecurity { get { lock (gate) return Math.Max(MSCO.MinServerSecurity, minServerLevel); } }
/// <summary>Neue Ankündigung seit dem letzten Abruf (für einen Hinweis im Spiel).</summary>
public static event Action<Announcement> NewAnnouncement;
static readonly HashSet<string> seen = new HashSet<string>();
static readonly List<Announcement> fresh = new List<Announcement>(); // neu, noch nicht gemeldet (Hauptthread)
static bool firstLoad = true;
/// <summary>Jedes Frame aufrufen (MultiplayerManager): lädt beim Start und dann alle 5 Minuten neu.</summary>
public static void Tick()
{
Action<Announcement> handler = NewAnnouncement;
List<Announcement> report = null;
lock (gate)
if (fresh.Count > 0) { report = new List<Announcement>(fresh); fresh.Clear(); }
if (report != null && handler != null)
foreach (Announcement a in report)
try { handler(a); } catch { }
if (fetching || Time.realtimeSinceStartup < nextFetch) return;
Refresh();
}
/// <summary>Sofort neu laden (z.B. beim Öffnen des Server-Browsers).</summary>
public static void Refresh()
{
if (fetching) return;
fetching = true;
nextFetch = Time.realtimeSinceStartup + RefreshSeconds;
var thread = new Thread(() =>
{
try
{
string error;
string text = WinHttp.Get(ServerBrowser.MasterUrl + "/api/status?for=game", 8000, out error);
if (text != null) Apply(text);
}
catch { }
finally { fetching = false; }
});
thread.IsBackground = true;
thread.Start();
}
static void Apply(string text)
{
var root = MiniJson.Parse(text) as Dictionary<string, object>;
if (root == null) return;
object v;
var m = root.TryGetValue("maintenance", out v) ? v as Dictionary<string, object> : null;
var sec = root.TryGetValue("security", out v) ? v as Dictionary<string, object> : null;
var list = new List<Announcement>();
var items = root.TryGetValue("announcements", out v) ? v as List<object> : null;
if (items != null)
foreach (object item in items)
{
var a = item as Dictionary<string, object>;
if (a == null) continue;
list.Add(new Announcement
{
Id = MiniJson.Str(a, "id", ""),
Title = Clip(MiniJson.Str(a, "title", ""), 120),
Text = Clip(MiniJson.Str(a, "text", ""), 600),
Level = MiniJson.Str(a, "level", "info"),
});
if (list.Count >= 5) break;
}
lock (gate)
{
announcements = list;
maintenance = m != null && MiniJson.Bool(m, "active");
maintenanceMessage = m != null ? Clip(MiniJson.Str(m, "message", ""), 500) : "";
if (sec != null) minServerLevel = Math.Max(0, Math.Min(1000, MiniJson.Int(sec, "minServerLevel")));
foreach (Announcement a in list)
if (seen.Add(a.Id) && !firstLoad) fresh.Add(a);
firstLoad = false;
}
}
/// <summary>Kein Steuerzeichen/Rich-Text aus dem Netz in die Oberfläche (Unity-GUI wertet &lt;color&gt; usw. aus).</summary>
static string Clip(string s, int max)
{
if (string.IsNullOrEmpty(s)) return "";
var sb = new System.Text.StringBuilder(Math.Min(s.Length, max));
foreach (char c in s)
{
if (sb.Length >= max) break;
if (c == '<') sb.Append('‹');
else if (c == '>') sb.Append('›');
else if (c == '\n' || !char.IsControl(c)) sb.Append(c);
}
return sb.ToString();
}
}
}
+3 -4
View File
@@ -1,5 +1,4 @@
using System;
using MSCLoader;
using Steamworks;
namespace MSCO
@@ -107,7 +106,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 +163,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 +177,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;
}
}
}
}
+115 -1
View File
@@ -3,13 +3,33 @@ 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.
/// </summary>
public static class UiTheme
{
static Font dynamicFont;
static bool fontSearched;
/// <summary>
/// Dynamische Schrift (eingebautes Arial) für alle MSCO-Oberflächen: Schriftgröße/Fett gehen nur mit dynamischen
/// Schriften - sonst schreibt Unity jedes Mal eine Warnung ins Log (400-mal in einer Sitzung, mit Rucklern).
/// </summary>
public static Font DynamicFont
{
get
{
if (!fontSearched)
{
fontSearched = true;
try { dynamicFont = Resources.GetBuiltinResource(typeof(Font), "Arial.ttf") as Font; } catch { }
if (dynamicFont != null && !dynamicFont.dynamic) dynamicFont = null;
}
return dynamicFont;
}
}
public static readonly Color Background = Hex(0x16, 0x16, 0x1a, 0.97f);
public static readonly Color Panel = Hex(0x0f, 0x0f, 0x12, 0.98f);
public static readonly Color Row = Hex(0x22, 0x22, 0x28);
@@ -26,8 +46,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 +83,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);
@@ -79,6 +190,7 @@ namespace MSCO
public static GUIStyle Button(Color normal, Color hover, Color text, int fontSize = 13, bool bold = true)
{
var s = new GUIStyle(GUI.skin.button);
if (DynamicFont != null) s.font = DynamicFont;
s.normal.background = Rounded(normal);
s.hover.background = Rounded(hover);
s.active.background = Rounded(hover);
@@ -95,6 +207,7 @@ namespace MSCO
public static GUIStyle Field(int fontSize = 14)
{
var s = new GUIStyle(GUI.skin.textField);
if (DynamicFont != null) s.font = DynamicFont;
s.normal.background = Rounded(FieldColor);
s.hover.background = Rounded(FieldFocus);
s.focused.background = Rounded(FieldFocus);
@@ -109,6 +222,7 @@ namespace MSCO
public static GUIStyle Label(int fontSize, Color color, FontStyle font = FontStyle.Normal, TextAnchor align = TextAnchor.UpperLeft)
{
var s = new GUIStyle(GUI.skin.label);
if (DynamicFont != null) s.font = DynamicFont;
s.fontSize = fontSize;
s.fontStyle = font;
s.normal.textColor = color;
+356
View File
@@ -0,0 +1,356 @@
using System;
using System.Collections;
using System.Collections.Generic;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Admin-Menü, Reiter VEHICLE: Füllstände und Teile des Satsuma aus der "Database" des Spiels anzeigen und zum Testen
/// reparieren/zerstören. Jedes Teil hat dort ein Objekt mit der FSM "Data" (Installed, Bolted, Damaged, teils Wear);
/// die Füllstände stehen in Tank, Ölwanne, Brems- und Kupplungszylinder. Die Werte überträgt VehicleStateSync an alle.
/// </summary>
public static class VehicleAdmin
{
public sealed class Fluid
{
public string Label;
public FsmFloat Value;
public float Max;
}
public sealed class Part
{
public string Name, Category;
public FsmBool Damaged, Installed, Bolted;
public FsmFloat Wear;
// Schrauben des eingebauten Teils (BoltCheck zählt die Festigkeit, die Schrauben haben je eine Stufe 0-8)
public PlayMakerFSM BoltCheck;
public FsmFloat Tightness;
public float MaxTightness;
public readonly List<PlayMakerFSM> Bolts = new List<PlayMakerFSM>();
public bool HasBolts { get { return Bolts.Count > 0 || Tightness != null; } }
// Einbau: das lose Teil (ThisPart) und der Trigger mit der FSM "Assembly" am Fahrzeug
public FsmGameObject ThisPart, Trigger;
public bool CanInstall
{
get
{
return Installed != null && !Installed.Value && ThisPart != null && ThisPart.Value != null
&& Trigger != null && Trigger.Value != null;
}
}
}
static readonly List<Fluid> fluids = new List<Fluid>();
static readonly List<Part> parts = new List<Part>();
static float scannedAt = -100f;
public static List<Fluid> Fluids { get { Refresh(false); return fluids; } }
public static List<Part> Parts { get { Refresh(false); return parts; } }
// Füllstand: Teil in der Database, Variable, Anzeige, Maximum (Tank: aus MaxCapacity)
static readonly string[][] FluidDefs =
{
new[] { "FuelTank", "FuelLevel", "Fuel", "36" },
new[] { "Oilpan", "Oil", "Engine oil", "3" },
new[] { "BrakeMasterCylinder", "BrakeFluidF", "Brake fluid (front)", "1" },
new[] { "BrakeMasterCylinder", "BrakeFluidR", "Brake fluid (rear)", "1" },
new[] { "ClutchMasterCylinder", "ClutchFluid", "Clutch fluid", "0.5" },
new[] { "Radiator", "Water", "Coolant", "6" },
};
/// <summary>
/// Database neu einlesen (höchstens alle 30 s, außer erzwungen - nach jeder Aktion). Das Durchsuchen aller FSMs
/// dauert spürbar (Ruckler bei offenem Admin-Menü); die angezeigten Werte selbst sind ohnehin live.
/// </summary>
public static void Refresh(bool force)
{
if (!force && Time.realtimeSinceStartup - scannedAt < 30f) return;
scannedAt = Time.realtimeSinceStartup;
fluids.Clear();
parts.Clear();
if (!MenuHelper.InGame) return;
var byName = new Dictionary<string, PlayMakerFSM>();
var partsByDb = new Dictionary<GameObject, Part>();
foreach (PlayMakerFSM fsm in Resources.FindObjectsOfTypeAll<PlayMakerFSM>())
{
if (fsm == null || fsm.FsmName != "Data" || fsm.gameObject.hideFlags != HideFlags.None) continue;
Transform t = fsm.transform;
if (t.root.name != "Database" || t.parent == null || t.parent.parent != t.root) continue;
string category = t.parent.name;
if (category == "DatabaseOrders" || category == "PlayerDatabase") continue; // Bestellungen/Spieler: keine Fahrzeugteile
byName[t.name] = fsm;
FsmBool installed = fsm.FsmVariables.FindFsmBool("Installed");
FsmBool damaged = fsm.FsmVariables.FindFsmBool("Damaged");
FsmFloat wear = fsm.FsmVariables.FindFsmFloat("Wear");
var part = new Part
{
Name = Pretty(t.name),
Category = category.Replace("Database", "").Replace("Parts", ""),
Damaged = damaged, Installed = installed, Wear = wear,
Bolted = fsm.FsmVariables.FindFsmBool("Bolted"),
ThisPart = fsm.FsmVariables.FindFsmGameObject("ThisPart"),
Trigger = fsm.FsmVariables.FindFsmGameObject("Trigger"),
};
partsByDb[fsm.gameObject] = part;
if (damaged != null || wear != null || part.Bolted != null) parts.Add(part);
}
FindBolts(partsByDb);
parts.Sort((a, b) => string.Compare(a.Category + a.Name, b.Category + b.Name, StringComparison.Ordinal));
foreach (string[] def in FluidDefs)
{
PlayMakerFSM fsm;
if (!byName.TryGetValue(def[0], out fsm)) continue;
FsmFloat value = fsm.FsmVariables.FindFsmFloat(def[1]);
if (value == null) continue;
float max = float.Parse(def[3], System.Globalization.CultureInfo.InvariantCulture);
FsmFloat capacity = fsm.FsmVariables.FindFsmFloat("MaxCapacity");
if (def[0] == "FuelTank" && capacity != null && capacity.Value > 0f) max = capacity.Value;
fluids.Add(new Fluid { Label = def[2], Value = value, Max = max });
}
}
/// <summary>
/// Zu jedem Database-Teil das eingebaute Teil: dessen BoltCheck zeigt mit "db_ThisPart" auf den Database-Eintrag,
/// die Schrauben (FSM "Screw") liegen darunter.
/// </summary>
static void FindBolts(Dictionary<GameObject, Part> partsByDb)
{
foreach (PlayMakerFSM fsm in UnityEngine.Object.FindObjectsOfType<PlayMakerFSM>())
{
if (fsm == null || fsm.FsmName != "BoltCheck") continue;
FsmGameObject db = fsm.FsmVariables.FindFsmGameObject("db_ThisPart");
Part part;
if (db == null || db.Value == null || !partsByDb.TryGetValue(db.Value, out part)) continue;
part.BoltCheck = fsm;
part.Tightness = fsm.FsmVariables.FindFsmFloat("Tightness");
part.MaxTightness = ClampMax(fsm);
foreach (PlayMakerFSM bolt in fsm.GetComponentsInChildren<PlayMakerFSM>())
if (bolt != null && bolt.FsmName == "Screw" && bolt.FsmVariables.FindFsmInt("Stage") != null) part.Bolts.Add(bolt);
if (part.MaxTightness <= 0f) part.MaxTightness = Mathf.Max(1, part.Bolts.Count) * 8f;
}
}
/// <summary>Höchste Festigkeit eines Teils: das FloatClamp im BoltCheck ("Bolts OFF").</summary>
static float ClampMax(PlayMakerFSM boltCheck)
{
try
{
foreach (FsmState state in boltCheck.FsmStates)
{
if (!state.ActionsLoaded) state.LoadActions();
if (state.Actions == null) continue;
foreach (FsmStateAction a in state.Actions)
{
if (a == null || a.GetType().Name != "FloatClamp") continue;
var max = a.GetType().GetField("maxValue") != null ? a.GetType().GetField("maxValue").GetValue(a) as FsmFloat : null;
if (max != null && max.Value > 0f) return max.Value;
}
}
}
catch { }
return 0f;
}
/// <summary>
/// Eingebautes Teil exakt auf seinen Halter (pivot_...) setzen - dort sitzt es im Spiel immer bei Position/Drehung 0.
/// Nicht bei Teilen, die selbst eingedreht werden (Zündkerze, Ölfilter: Tiefe = Stufe), und nicht bei Türen/Hauben
/// (Scharnier). Gibt true zurück, wenn es verrutscht war.
/// </summary>
public static bool SnapToPivot(GameObject part)
{
if (part == null) return false;
Transform t = part.transform;
if (t.parent == null || !t.parent.name.StartsWith("pivot_")) return false;
if (part.GetComponent<HingeJoint>() != null) return false;
foreach (PlayMakerFSM fsm in part.GetComponents<PlayMakerFSM>()) if (fsm.FsmName == "Screw") return false;
if (t.localPosition.sqrMagnitude < 0.0004f * 0.0004f && Quaternion.Angle(t.localRotation, Quaternion.identity) < 0.05f) return false;
t.localPosition = Vector3.zero;
t.localRotation = Quaternion.identity;
Rigidbody rb = part.GetComponent<Rigidbody>();
if (rb != null && !rb.isKinematic)
{
rb.velocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
}
return true;
}
/// <summary>Alle Schrauben eines Teils ganz fest (true) oder ganz lose (false) - wie mit dem Werkzeug.</summary>
public static int SetBolts(Part p, bool tight)
{
int changed = 0;
foreach (PlayMakerFSM bolt in p.Bolts)
{
if (bolt == null || !bolt.gameObject.activeInHierarchy) continue;
FsmInt stage = bolt.FsmVariables.FindFsmInt("Stage");
FsmState dispatch = bolt.Fsm.GetState("Stages 2") ?? bolt.Fsm.GetState("Stages");
if (stage == null || dispatch == null) continue;
int target = tight ? 8 : 0;
if (stage.Value == target) continue;
stage.Value = target;
FsmSync.SwitchStateSilently(bolt, dispatch); // Schraube an ihre Stelle, Zähler des Teils (Array) aktualisieren
changed++;
}
if (p.Tightness != null)
{
float target = tight ? p.MaxTightness : 0f;
if (Mathf.Abs(p.Tightness.Value - target) > 0.01f) { p.Tightness.Value = target; changed++; }
}
if (tight && p.BoltCheck != null && SnapToPivot(p.BoltCheck.gameObject)) changed++;
return changed;
}
public static int SetAllBolts(bool tight)
{
Refresh(true);
int n = 0;
foreach (Part p in parts)
if (p.HasBolts && (p.Installed == null || p.Installed.Value) && SetBolts(p, tight) > 0) n++;
return n;
}
/// <summary>"RadiatorHose1" -> "Radiator Hose 1", "Drumbrake_RR" -> "Drumbrake RR".</summary>
static string Pretty(string name)
{
var sb = new System.Text.StringBuilder();
for (int i = 0; i < name.Length; i++)
{
char c = name[i];
if (c == '_') { sb.Append(' '); continue; }
if (i > 0 && ((char.IsUpper(c) && char.IsLower(name[i - 1])) || (char.IsDigit(c) && !char.IsDigit(name[i - 1])))) sb.Append(' ');
sb.Append(c);
}
return sb.ToString();
}
public static void SetFluid(Fluid f, bool full)
{
if (f.Value != null) f.Value.Value = full ? f.Max : 0f;
}
public static void Repair(Part p)
{
if (p.Damaged != null) p.Damaged.Value = false;
if (p.Wear != null) p.Wear.Value = 100f;
}
public static void Break(Part p)
{
if (p.Damaged != null) p.Damaged.Value = true;
if (p.Wear != null) p.Wear.Value = 0f;
}
// ---------------------------------------------------------------- Einbauen
/// <summary>
/// Teil einbauen, wie es das Spiel beim Laden eines Spielstands macht: der Trigger am Fahrzeug bekommt das Teil
/// (FSM "Assembly", Variable Part) und läuft durch seinen Einbau-Zustand - das setzt das Teil exakt auf seinen
/// Halter (pivot_...), aktiviert die Schrauben und trägt es in der Database als eingebaut ein.
/// Gibt null zurück oder den Grund, warum es nicht geht. Die anderen Spieler bauen es genauso ein.
/// </summary>
public static string Install(Part p)
{
if (!p.CanInstall) return "can't be installed";
GameObject trigger = p.Trigger.Value, part = p.ThisPart.Value;
if (trigger.transform.parent != null && !trigger.transform.parent.gameObject.activeInHierarchy)
return "install the part it mounts on first";
if (part.transform.parent != null && part.transform.parent.name == "ItemPivot") return "drop it first (it is held in a hand)";
PlayMakerFSM assembly = Assembly(trigger);
if (assembly == null || assembly.FsmVariables.FindFsmGameObject("Part") == null) return "no mount point found";
if (assembly.Fsm.GetState("Check Assembly") == null && assembly.Fsm.GetState("Assemble") == null) return "no mount point found";
bool wasActive = trigger.activeInHierarchy;
Run(InstallRoutine(assembly, part));
if (wasActive) // bei den anderen Spielern genauso (der Trigger ist dort auch aktiv, solange das Teil fehlt)
FsmSync.Outbox.Add(new FsmEventMsg
{
Path = FsmSync.PathOf(trigger.transform), FsmName = "Assembly", EventName = InstallEvent, State = "",
Position = trigger.transform.position
});
return null;
}
/// <summary>Event-Name der Einbau-Nachricht an die anderen Spieler (läuft über FsmSync).</summary>
public const string InstallEvent = "MSCO_INSTALL";
/// <summary>Einbau-Nachricht eines Mitspielers: zum Trigger das Teil aus der Database holen und einbauen.</summary>
public static void InstallRemote(PlayMakerFSM assembly)
{
FsmGameObject db = assembly.FsmVariables.FindFsmGameObject("db_ThisPart");
if (db == null || db.Value == null) return;
PlayMakerFSM data = null;
foreach (PlayMakerFSM f in db.Value.GetComponents<PlayMakerFSM>()) if (f.FsmName == "Data") data = f;
if (data == null) return;
FsmBool installed = data.FsmVariables.FindFsmBool("Installed");
FsmGameObject part = data.FsmVariables.FindFsmGameObject("ThisPart");
if (installed == null || installed.Value || part == null || part.Value == null) return;
Run(InstallRoutine(assembly, part.Value));
}
static PlayMakerFSM Assembly(GameObject trigger)
{
foreach (PlayMakerFSM f in trigger.GetComponents<PlayMakerFSM>()) if (f.FsmName == "Assembly") return f;
return null;
}
static IEnumerator InstallRoutine(PlayMakerFSM assembly, GameObject part)
{
GameObject trigger = assembly.gameObject;
bool started = trigger.activeSelf && assembly.enabled;
part.SetActive(true);
trigger.SetActive(true);
assembly.enabled = true;
if (!started) yield return null; // FSM erst starten lassen, sonst setzt ihr Start den Zustand zurück
if (assembly == null || part == null) yield break;
assembly.FsmVariables.FindFsmGameObject("Part").Value = part;
FsmBool setup = assembly.FsmVariables.FindFsmBool("Setup");
FsmState check = assembly.Fsm.GetState("Check Assembly");
if (setup != null && check != null)
{
setup.Value = true; // wie beim Laden: "Check Assembly" sieht Setup und baut über den passenden Weg ein
FsmSync.SwitchStateSilently(assembly, check);
}
else FsmSync.SwitchStateSilently(assembly, assembly.Fsm.GetState("Assemble"));
yield return new WaitForSeconds(0.6f);
if (part != null) SnapToPivot(part);
Refresh(true); // Schrauben des Teils sind jetzt aktiv
}
// Coroutinen ohne eigenes MonoBehaviour im Aufrufer
sealed class Runner : MonoBehaviour { }
static Runner runner;
static void Run(IEnumerator routine)
{
if (runner == null)
{
var go = new GameObject("MSCO_AdminRunner");
UnityEngine.Object.DontDestroyOnLoad(go);
runner = go.AddComponent<Runner>();
}
runner.StartCoroutine(routine);
}
public static int RepairAll()
{
Refresh(true);
int n = 0;
foreach (Part p in parts)
{
if (p.Installed != null && !p.Installed.Value) continue;
bool needs = (p.Damaged != null && p.Damaged.Value) || (p.Wear != null && p.Wear.Value < 100f);
if (!needs) continue;
Repair(p);
n++;
}
return n;
}
}
}
+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;
}
+314 -31
View File
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.IO;
using System.Text;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
@@ -21,13 +20,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,32 +51,67 @@ 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;
// Schraube mit Funktion (z.B. Ablassschraube der Ölwanne: offen = Öl läuft aus): die Stufe wird als Zustand
// übertragen und über die Stufen-Logik der Schraube angewendet (Position, Dichtung, Zähler des Teils)
public FsmState BoltDispatch;
public bool InitialSent;
public bool Functional; // Schraube schaltet etwas (Ablassschraube): Welt-Autorität gibt den Stand einmal vor
public bool Exact; // jede Änderung senden (Schrauben-Stufen, Festigkeit der Teile)
}
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)
public void Update(ObjectSync objects, int localId, Action<MsgType, byte[]> send, bool worldAuthority = false)
{
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 +121,30 @@ namespace MSCO
nextCheck = Time.time + CheckInterval;
outgoing.Clear();
ResolveSatsuma(objects);
foreach (Var v in vars.Values)
{
if (!Changed(v)) continue;
// Teil wird fester (eigenes Werkzeug): genau auf seinen Halter setzen (verrutschte Teile, z.B. nach der Sync)
if (v.Exact && v.Type == 1 && v.Fsm != null && v.Fsm.FsmName == "BoltCheck" && GetFloat(v) > v.LastF + 0.01f)
VehicleAdmin.SnapToPivot(v.Fsm.gameObject);
// 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 && !v.Exact;
if (v.Functional && worldAuthority && !v.InitialSent)
{
// Einmal den eigenen Stand vorgeben, damit alle gleich sind (vorher auseinandergelaufene Schrauben)
v.InitialSent = true;
Remember(v);
outgoing.Add(v);
continue;
}
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 +168,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 +193,159 @@ 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);
RegisterTightness(fsm, root);
if (RegisterBolt(fsm, root)) continue;
// FSMs, die Größen/Positionen setzen (z.B. Füllstands-Anzeigen), NICHT anfassen: ein übertragener Wert
// kann dort eine Größe 0 ergeben - Unity stürzt dann in der Physik ab ("cleaning the mesh failed")
if (TouchesTransform(fsm)) continue;
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)
/// <summary>
/// Schrauben, deren Stufen etwas ein-/ausschalten (ActivateGameObject - z.B. die Ablassschraube: Stufe 0-5 = das
/// Leck "Oil" ist an). Sonst laufen Schrauben über FsmSync (Ereignisse): ging eins verloren, war die Ablassschraube
/// bei einem offen und beim anderen zu - bei ihm lief dann endlos Öl aus (der Ölstand kam ja ständig nach).
/// </summary>
/// <summary>
/// Festigkeit eines Teils (BoltCheck bzw. bei aufgeschraubten Teilen wie dem Ölfilter "Use"): zählt das Werkzeug
/// beim Schrauben hoch - beim Mitspieler drehte sich bisher nur die Schraube, das Teil blieb "lose" bzw. "fest".
/// </summary>
void RegisterTightness(PlayMakerFSM fsm, string root)
{
if (variable == null || string.IsNullOrEmpty(variable.Name) || !Wanted(variable.Name)) return;
int key = Hash(prefix + variable.Name);
if (fsm.FsmName != "BoltCheck" && fsm.FsmName != "Use") return;
FsmFloat tightness = fsm.FsmVariables.FindFsmFloat("Tightness");
if (tightness == null) return;
if (fsm.FsmName == "Use" && fsm.gameObject.GetComponents<PlayMakerFSM>().Length > 0)
{
bool screwed = false;
foreach (PlayMakerFSM other in fsm.gameObject.GetComponents<PlayMakerFSM>()) if (other.FsmName == "Screw") screwed = true;
if (!screwed) return;
}
string name = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName + "|Tightness";
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 };
if (vars.TryGetValue(key, out existing) && existing.Variable == tightness) return;
var v = new Var { Key = key, Name = name, Root = root, Variable = tightness, Type = 1, Fsm = fsm, Exact = true };
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))
{
pending.Remove(key);
byte[] value = new byte[stored.Length - 1];
Array.Copy(stored, 1, value, 0, value.Length);
Apply(v, stored[0], value);
}
}
/// <summary>
/// Jede Schraube (FSM "Screw" mit Stufe 0-8): Stufe als Zustand übertragen, angewendet über die Stufen-Logik der
/// Schraube. Schrauben, die etwas schalten (Ablassschraube), gibt die Welt-Autorität nach dem Beitreten einmal vor.
/// </summary>
bool RegisterBolt(PlayMakerFSM fsm, string root)
{
if (fsm.FsmName != "Screw") return false;
FsmInt stage = fsm.FsmVariables.FindFsmInt("Stage");
if (stage == null) return false;
FsmState dispatch = fsm.Fsm.GetState("Stages 2") ?? fsm.Fsm.GetState("Stages");
if (dispatch == null) return false;
bool switches = false;
try
{
foreach (FsmState state in fsm.FsmStates)
{
if (!state.ActionsLoaded) state.LoadActions();
if (state.Actions == null) continue;
foreach (FsmStateAction a in state.Actions)
if (a != null && a.GetType().Name == "ActivateGameObject") { switches = true; break; }
if (switches) break;
}
}
catch { return false; }
string name = FsmSync.PathOf(fsm.transform) + "|" + fsm.FsmName + "|Stage";
int key = Hash(name);
Var existing;
if (vars.TryGetValue(key, out existing) && existing.Variable == stage) return true;
var v = new Var { Key = key, Name = name, Root = root, Variable = stage, Type = 2, Fsm = fsm, BoltDispatch = dispatch, Functional = switches, Exact = true };
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))
{
pending.Remove(key);
byte[] value = new byte[stored.Length - 1];
Array.Copy(stored, 1, value, 0, value.Length);
Apply(v, stored[0], value);
}
return true;
}
static readonly string[] TransformActionWords = { "Scale", "Position", "Rotate", "Rotation", "Translate", "Parent" };
static bool TouchesTransform(PlayMakerFSM fsm)
{
try
{
foreach (FsmState state in fsm.FsmStates)
{
if (!state.ActionsLoaded) state.LoadActions();
if (state.Actions == null) continue;
foreach (FsmStateAction action in state.Actions)
{
if (action == null) continue;
string type = action.GetType().Name;
foreach (string word in TransformActionWords)
if (type.Contains(word)) return true;
}
}
}
catch { return true; } // im Zweifel nicht anfassen
return false;
}
void Register(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, 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 +371,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 +457,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,8 +468,25 @@ namespace MSCO
if (type != v.Type) return;
switch (type)
{
case 1: ((FsmFloat)v.Variable).Value = BitConverter.ToSingle(value, 0); break;
case 2: ((FsmInt)v.Variable).Value = BitConverter.ToInt32(value, 0); break;
case 1:
float f = BitConverter.ToSingle(value, 0);
if (float.IsNaN(f) || float.IsInfinity(f)) return; // nie ungültige Werte übernehmen
bool tighter = v.Exact && v.Fsm != null && v.Fsm.FsmName == "BoltCheck" && f > GetFloat(v) + 0.01f;
SetFloat(v, f);
if (tighter) VehicleAdmin.SnapToPivot(v.Fsm.gameObject); // vom Mitspieler festgezogen: Teil auf den Halter
break;
case 2:
int n = BitConverter.ToInt32(value, 0);
if (v.BoltDispatch != null)
{
if (n < 0 || n > 8 || ((FsmInt)v.Variable).Value == n) { Remember(v); return; }
((FsmInt)v.Variable).Value = n;
if (v.Fsm != null && v.Fsm.gameObject.activeInHierarchy) FsmSync.SwitchStateSilently(v.Fsm, v.BoltDispatch);
if (FsmSync.Debug) MscoLog.Info("[MSCO] << BOLT " + v.Name + " -> " + n);
break;
}
((FsmInt)v.Variable).Value = n;
break;
default: ((FsmBool)v.Variable).Value = value[0] != 0; break;
}
Remember(v); // nicht als eigene Änderung zurückschicken
+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;
}
}
}
+217
View File
@@ -0,0 +1,217 @@
using System;
using System.IO;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
{
/// <summary>
/// Admin-Menü, Reiter WORLD: Uhrzeit und Wetter umstellen. Beides läuft im Spiel über PlayMaker:
/// - Uhr: die Sonnen-FSM hat je 2 Stunden einen Zustand ("00-02" ... "22-00") und die Zahl "Time"; beim Laden
/// setzt das Spiel "Time" und wählt in "State 1" den passenden Zustand - genau so stellen wir die Zeit.
/// - Wetter: die FSM "Weather" (Wolkenfront) mit den Zuständen "No weather", "Rain" und "Thunder"; bei Regen
/// wird die Front über den Admin gesetzt, damit es dort auch regnet.
/// Die anderen Spieler bekommen den Befehl über eine MSCO-Nachricht und führen ihn genauso aus.
/// </summary>
public static class WorldAdmin
{
public const string MessageId = "msco.world";
public const byte KindHour = 1, KindWeather = 2;
public static readonly string[] WeatherStates = { "No weather", "Rain", "Thunder" };
public static readonly string[] WeatherEvents = { "NORAIN", "RAIN", "THUNDER" };
public static readonly string[] WeatherNames = { "clear", "rain", "thunder" };
static PlayMakerFSM sun, weather;
static float searchedAt = -100f;
static void Find()
{
if (sun != null && weather != null) return;
if (Time.realtimeSinceStartup - searchedAt < 15f) return; // nicht ständig suchen, falls es sie nicht gibt
searchedAt = Time.realtimeSinceStartup;
foreach (PlayMakerFSM f in SceneCache.Fsms(60f))
{
if (f == null) continue;
if (sun == null && f.FsmVariables.FindFsmInt("Time") != null && f.Fsm.GetState("12-14") != null
&& f.Fsm.GetState("22-00") != null && f.Fsm.GetState("State 1") != null && f.Fsm.GetState("Next day") != null)
sun = f;
if (weather == null && f.FsmName == "Weather" && f.Fsm.GetState("No weather") != null && f.Fsm.GetState("Rain") != null)
weather = f;
}
}
// ---------------------------------------------------------------- Anzeige
/// <summary>Aktueller 2-Stunden-Block der Uhr (Startstunde) oder -1.</summary>
public static int CurrentHour
{
get
{
Find();
string s = sun != null ? sun.ActiveStateName : null;
int h;
if (s != null && s.Length == 5 && s[2] == '-' && int.TryParse(s.Substring(0, 2), out h)) return h;
return -1;
}
}
/// <summary>Aktuelles Wetter (0 klar, 1 Regen, 2 Gewitter) oder -1.</summary>
public static int CurrentWeather
{
get
{
Find();
if (weather == null) return -1;
FsmInt type = weather.FsmVariables.FindFsmInt("WeatherType");
if (type == null) return -1;
FsmState check = weather.Fsm.GetState("State 2");
for (int i = 0; i < WeatherEvents.Length; i++)
{
int value;
if (check != null && IntCompareValue(check, WeatherEvents[i], out value) && value == type.Value) return i;
}
return -1;
}
}
// ---------------------------------------------------------------- Ausführen
/// <summary>Befehl lokal ausführen. Gibt null zurück oder einen Fehlertext.</summary>
public static string Apply(byte kind, int value, Vector3 position)
{
Find();
try
{
if (kind == KindHour) return SetHour(value);
if (kind == KindWeather) return SetWeather(value, position);
}
catch (Exception e)
{
MscoLog.Error("[MSCO] world admin: " + e);
return "failed: " + e.Message;
}
return "unknown command";
}
static string SetHour(int hour)
{
if (sun == null) return "Clock not found.";
int start = ((hour % 24 + 24) % 24) / 2 * 2;
string block = start.ToString("00") + "-" + ((start + 2) % 24).ToString("00");
FsmState target = sun.Fsm.GetState(block);
if (target == null) return "Clock state " + block + " not found.";
// wie beim Laden: "Time" setzen, "State 1" wählt daraus den Zustand (und stellt Licht/Farben ein)
FsmState dispatch = sun.Fsm.GetState("State 1");
int timeValue;
if (dispatch != null && IntCompareValue(dispatch, block, out timeValue))
{
sun.FsmVariables.FindFsmInt("Time").Value = timeValue;
FsmSync.SwitchStateSilently(sun, dispatch);
}
else FsmSync.SwitchStateSilently(sun, target);
return null;
}
static string SetWeather(int kind, Vector3 position)
{
if (weather == null) return "Weather not found.";
if (kind < 0 || kind >= WeatherStates.Length) return "Unknown weather.";
FsmState state = weather.Fsm.GetState(WeatherStates[kind]);
if (state == null) return "Weather state not found.";
FsmSync.SwitchStateSilently(weather, state);
if (kind > 0) MoveFrontOver(position);
return null;
}
/// <summary>
/// Wolkenfront über eine Stelle setzen: "Move clouds" setzt jedes Bild die Position aus PosX/PosZ - also diese
/// beiden so wählen, dass die Front über der Stelle steht (Koordinatenraum aus der SetPosition-Aktion).
/// </summary>
static void MoveFrontOver(Vector3 position)
{
FsmState move = weather.Fsm.GetState("Move clouds");
if (move == null) return;
if (!move.ActionsLoaded) move.LoadActions();
if (move.Actions == null) return;
foreach (FsmStateAction a in move.Actions)
{
if (a == null || a.GetType().Name != "SetPosition") continue;
Type t = a.GetType();
var owner = Field(t, a, "gameObject") as FsmOwnerDefault;
var x = Field(t, a, "x") as FsmFloat;
var z = Field(t, a, "z") as FsmFloat;
object space = Field(t, a, "space");
GameObject go = owner != null ? weather.Fsm.GetOwnerDefaultTarget(owner) : null;
if (go == null) continue;
bool local = space is Space && (Space)space == Space.Self && go.transform.parent != null;
Vector3 p = local ? go.transform.parent.InverseTransformPoint(position) : position;
SetVar(x, p.x);
SetVar(z, p.z);
go.transform.position = new Vector3(position.x, go.transform.position.y, position.z);
return;
}
}
static void SetVar(FsmFloat field, float value)
{
if (field == null || field.IsNone || string.IsNullOrEmpty(field.Name)) return;
FsmFloat v = weather.FsmVariables.FindFsmFloat(field.Name);
if (v != null) v.Value = value;
}
static object Field(Type t, object o, string name)
{
var f = t.GetField(name);
return f != null ? f.GetValue(o) : null;
}
/// <summary>In einem Verteiler-Zustand die Zahl finden, bei der IntCompare das Event auslöst ("gleich").</summary>
static bool IntCompareValue(FsmState state, string eventName, out int value)
{
value = 0;
if (!state.ActionsLoaded) state.LoadActions();
if (state.Actions == null) return false;
foreach (FsmStateAction a in state.Actions)
{
if (a == null || a.GetType().Name != "IntCompare") continue;
Type t = a.GetType();
var equal = Field(t, a, "equal") as FsmEvent;
var number = Field(t, a, "integer2") as FsmInt;
if (equal == null || number == null || equal.Name != eventName) continue;
value = number.Value;
return true;
}
return false;
}
// ---------------------------------------------------------------- Netz
public static byte[] Build(byte kind, int value, Vector3 position)
{
using (var ms = new MemoryStream())
using (var w = new BinaryWriter(ms))
{
w.Write(kind); w.Write(value);
w.Write(position.x); w.Write(position.y); w.Write(position.z);
w.Flush();
return ms.ToArray();
}
}
public static void Receive(byte[] data)
{
if (!MenuHelper.InGame || data == null || data.Length < 17) return;
using (var r = new PacketReader(data))
{
byte kind = r.ReadByte();
int value = r.ReadInt32();
var pos = new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
string error = Apply(kind, value, pos);
if (error != null) MscoLog.Warning("[MSCO] world admin from other player: " + error);
}
}
}
}
+3 -4
View File
@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
@@ -163,7 +162,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 +174,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;
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
using System.Reflection;
using System.Text;
using MSCO;
using UnityEngine;
[assembly: AssemblyTitle("Hello Mod")]
[assembly: AssemblyProduct("Hello Mod")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: AssemblyInformationalVersion("1.0.0")]
namespace HelloMod
{
/// <summary>
/// Beispiel-Mod: zeigt im Spiel oben links einen Hinweis. Mit H winkst du - alle anderen Spieler, die die Mod auch
/// haben, sehen "&lt;Name&gt; waves!".
/// </summary>
public class HelloMod : MscoMod
{
public override string ID { get { return "hellomod"; } }
public override string Name { get { return "Hello Mod"; } }
public override string Version { get { return "1.0.0"; } }
public override string Author { get { return "MBM"; } }
bool inGame;
string lastWave = "";
float waveUntil;
public override void OnLoad() { Log("loaded"); }
public override void OnMenuLoad() { inGame = false; }
public override void OnGameLoad() { inGame = true; }
public override void Update()
{
if (!inGame || !IsInSession || !Input.GetKeyDown(KeyCode.H)) return;
Send(Encoding.UTF8.GetBytes("wave"));
Show("You wave!");
}
public override void OnMessage(int fromPlayerId, byte[] data)
{
if (Encoding.UTF8.GetString(data) != "wave") return;
string name;
Show((Players.TryGetValue(fromPlayerId, out name) ? name : "Someone") + " waves!");
}
public override void OnPlayerJoined(int playerId, string name) { Show(name + " joined - press H to wave"); }
void Show(string text)
{
lastWave = text;
waveUntil = Time.realtimeSinceStartup + 4f;
}
public override void OnGUI()
{
if (!inGame) return;
GUI.Label(new Rect(20, 60, 400, 24), "Hello Mod: press H to wave");
if (Time.realtimeSinceStartup < waveUntil) GUI.Label(new Rect(20, 84, 400, 24), lastWave);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Beispiel-Mod für MSCO. Bauen: msbuild HelloMod.csproj -p:Configuration=Release
Ergebnis bin\Release\HelloMod.dll nach <Spielordner>\MSCO\Mods kopieren (oder auf dem Server nach data/mods).
GamePath anpassen, wenn My Summer Car woanders liegt.
-->
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Release</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<OutputType>Library</OutputType>
<RootNamespace>HelloMod</RootNamespace>
<AssemblyName>HelloMod</AssemblyName>
<TargetFrameworkVersion>v3.5</TargetFrameworkVersion>
<TargetFrameworkProfile>Unity Full v3.5</TargetFrameworkProfile>
<LangVersion>13</LangVersion>
<OutputPath>bin\$(Configuration)\</OutputPath>
<DebugType>none</DebugType>
<Optimize>true</Optimize>
<GamePath Condition=" '$(GamePath)' == '' ">F:\SteamLibrary\steamapps\common\My Summer Car</GamePath>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="UnityEngine">
<HintPath>$(GamePath)\mysummercar_Data\Managed\UnityEngine.dll</HintPath>
<Private>False</Private>
</Reference>
<!-- MSCO selbst (die Mod-Schnittstelle MscoMod) -->
<Reference Include="MSCO">
<HintPath>$(GamePath)\MSCO\MSCO.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="HelloMod.cs" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
+269
View File
@@ -0,0 +1,269 @@
using System.Collections.Generic;
using System.Reflection;
using HutongGames.PlayMaker;
using MSCO;
using UnityEngine;
[assembly: AssemblyTitle("Show Bolt Size")]
[assembly: AssemblyProduct("Show Bolt Size")]
[assembly: AssemblyDescription("Shows the size of the bolt you look at, so you pick the right wrench.")]
[assembly: AssemblyVersion("1.0.4.0")]
[assembly: AssemblyFileVersion("1.0.4.0")]
[assembly: AssemblyInformationalVersion("1.0.4")]
namespace ShowBoltSize
{
/// <summary>
/// Zeigt unter dem Fadenkreuz die Größe der Schraube, auf die man schaut ("10 mm"). Jede Schraube hat die FSM "Screw"
/// mit der Variable "BoltSize". Deren Kollider sind nur mit Werkzeug in der Hand aktiv - deshalb kein Raycast, sondern:
/// die Schraube, die am nächsten an der Blickrichtung liegt (bis 1,3 m). Nur Anzeige, ändert nichts am Spiel.
/// </summary>
public class ShowBoltSize : MscoMod
{
public override string ID { get { return "showboltsize"; } }
public override string Name { get { return "Show Bolt Size"; } }
public override string Version { get { return "1.0.4"; } }
public override string Author { get { return "MBM"; } }
const float MaxDistance = 1.5f;
const float AimTolerance = 0.05f; // so weit (m) darf die Schraube neben der Blicklinie liegen
const float ScanInterval = 5f;
sealed class Bolt
{
public Transform Transform;
public Renderer Renderer; // sichtbarer Schraubenkopf (Kind-Objekt) - sein Mittelpunkt statt des Drehpunkts
public FsmFloat SizeF;
public FsmInt SizeI;
}
readonly List<Bolt> bolts = new List<Bolt>();
bool inGame;
float nextScan;
string label;
GUIStyle style;
Camera camera;
bool reported;
int diagnostics; // die ersten Male ins Log schreiben, was in Blickrichtung liegt
float nextDiagnostic;
bool shownOnce;
// Das Spiel selbst: Schraubenschlüssel/Ratsche in der Hand -> FPSCamera/2Spanner/Raycast, FSM "Check" mit "BoltSize"
// (die Größe der Schraube, auf die das Werkzeug gerade zielt). Die Variable "BoltSize" der Schrauben ist oft 0.
PlayMakerFSM toolCheck;
FsmFloat toolSize;
FsmInt toolSizeI;
float lastToolSize = -1f;
int toolLogs;
bool dumped;
float dumpAt = -1f;
public override void OnMenuLoad() { inGame = false; bolts.Clear(); label = null; camera = null; reported = false; toolCheck = null; toolSize = null; toolSizeI = null; }
public override void OnGameLoad() { inGame = true; nextScan = 0f; }
/// <summary>
/// Spielerkamera: Camera.main gibt es in My Summer Car nicht immer (die Kamera des Spielers ist "FPSCamera").
/// </summary>
Camera FindCamera()
{
if (camera != null && camera.enabled && camera.gameObject.activeInHierarchy) return camera;
camera = null;
foreach (Camera c in Camera.allCameras)
if (c.name == "FPSCamera") { camera = c; break; }
if (camera == null) camera = Camera.main;
return camera;
}
static Vector3 Center(Bolt b) { return b.Renderer != null ? b.Renderer.bounds.center : b.Transform.position; }
public override void Update()
{
label = null;
if (!inGame) return;
Camera cam = FindCamera();
if (cam == null) return;
if (Time.realtimeSinceStartup >= nextScan) Scan(cam);
float fromTool = ToolSize(cam);
// Die Schraube, die am nächsten an der Blicklinie liegt (Abstand zur Linie, nicht Winkel)
Transform eye = cam.transform;
Vector3 eyePos = eye.position, forward = eye.forward;
Bolt best = null, nearest = null;
float bestOff = float.MaxValue, nearestOff = float.MaxValue;
for (int i = 0; i < bolts.Count; i++)
{
Bolt b = bolts[i];
if (b.Transform == null || !b.Transform.gameObject.activeInHierarchy) continue;
Vector3 to = Center(b) - eyePos;
float along = Vector3.Dot(to, forward);
if (along < 0.05f || along > MaxDistance + 0.5f) continue;
float off = Vector3.Cross(forward, to).magnitude; // Abstand zur Blicklinie
if (off < nearestOff) { nearestOff = off; nearest = b; }
if (along <= MaxDistance && off < AimTolerance && off < bestOff) { bestOff = off; best = b; }
}
if (fromTool > 0f)
{
label = Format(fromTool); // was das Spiel für das Werkzeug ermittelt - immer richtig
}
else if (best != null)
{
label = Describe(best);
if (!shownOnce) { shownOnce = true; Log("first bolt: " + Info(best, eyePos, forward) + " -> " + label); }
}
else if (nearest != null && diagnostics < 8 && Time.realtimeSinceStartup >= nextDiagnostic)
{
// Hilft bei der Fehlersuche: was liegt in Blickrichtung, warum wird nichts angezeigt?
nextDiagnostic = Time.realtimeSinceStartup + 4f;
if (nearestOff < 0.3f) { diagnostics++; Log("nearest bolt (not shown): " + Info(nearest, eyePos, forward)); }
}
}
/// <summary>
/// Größe aus dem Werkzeug des Spielers (nur mit Schraubenschlüssel/Ratsche in der Hand), sonst 0. Beim ersten Mal
/// wird außerdem einmal die Werkzeug-FSM in MSCO smdump.txt geschrieben (Fehlersuche).
/// </summary>
float ToolSize(Camera cam)
{
if (toolCheck == null)
{
// Werkzeug-FSM suchen - auch inaktive Objekte (ohne Werkzeug in der Hand ist sie aus), höchstens alle 5 s
if (Time.realtimeSinceStartup < nextToolSearch) return 0f;
nextToolSearch = Time.realtimeSinceStartup + 5f;
toolCheck = FindToolCheck(cam);
if (toolCheck == null)
{
if (!toolMissingLogged) { toolMissingLogged = true; Log("tool FSM (Spanner/Raycast 'Check') not found yet"); }
return 0f;
}
toolSize = toolCheck.FsmVariables.FindFsmFloat("BoltSize");
toolSizeI = toolCheck.FsmVariables.FindFsmInt("BoltSize");
Log("tool found: " + PathOf(toolCheck.transform) + " / " + toolCheck.FsmName + (toolSize != null ? " with float BoltSize" : toolSizeI != null ? " with int BoltSize" : " without BoltSize"));
}
if (!toolCheck.gameObject.activeInHierarchy || !toolCheck.enabled) return 0f;
if (!dumped)
{
if (dumpAt < 0f) dumpAt = Time.realtimeSinceStartup + 2f; // erst laufen lassen, dann sind die Aktionen geladen
else if (Time.realtimeSinceStartup >= dumpAt)
{
dumped = true;
try { Log("tool dump: " + FsmDump.Write("spanner#0|raycast")); }
catch (System.Exception e) { Log("tool dump failed: " + e.Message); }
}
}
float value = toolSize != null ? toolSize.Value : toolSizeI != null ? toolSizeI.Value : 0f;
if (value != lastToolSize && toolLogs < 12)
{
lastToolSize = value;
toolLogs++;
FsmFloat wrench = FsmVariables.GlobalVariables.FindFsmFloat("ToolWrenchSize");
Log("tool BoltSize=" + value + " (state " + toolCheck.ActiveStateName + ", wrench size " + (wrench != null ? wrench.Value.ToString() : "?") + ")");
}
return value;
}
float nextToolSearch;
bool toolMissingLogged;
/// <summary>"Check"-FSM unter ...Spanner/Raycast (Schraubenschlüssel/Ratsche des Spielers), auch wenn sie gerade aus ist.</summary>
static PlayMakerFSM FindToolCheck(Camera cam)
{
Transform raycast = cam.transform.Find("2Spanner/Raycast");
if (raycast != null)
foreach (PlayMakerFSM f in raycast.GetComponents<PlayMakerFSM>())
if (f.FsmName == "Check") return f;
foreach (PlayMakerFSM f in Resources.FindObjectsOfTypeAll<PlayMakerFSM>())
{
if (f == null || f.FsmName != "Check" || f.gameObject.hideFlags != HideFlags.None) continue;
Transform t = f.transform;
if (t.name == "Raycast" && t.parent != null && t.parent.name.IndexOf("Spanner", System.StringComparison.OrdinalIgnoreCase) >= 0) return f;
}
return null;
}
static string PathOf(Transform t)
{
string path = t.name;
for (Transform p = t.parent; p != null; p = p.parent) path = p.name + "/" + path;
return path;
}
/// <summary>Alle Zahlen-Variablen der FSMs auf der Schraube und darunter (wo steckt die Größe?).</summary>
static string Numbers(Transform t)
{
var sb = new System.Text.StringBuilder();
foreach (PlayMakerFSM f in t.GetComponentsInChildren<PlayMakerFSM>(true))
{
sb.Append(" [").Append(f.name).Append('/').Append(f.FsmName);
foreach (FsmFloat v in f.FsmVariables.FloatVariables) sb.Append(' ').Append(v.Name).Append('=').Append(v.Value.ToString("0.###"));
foreach (FsmInt v in f.FsmVariables.IntVariables) sb.Append(' ').Append(v.Name).Append('=').Append(v.Value);
sb.Append(']');
}
return sb.ToString();
}
static string Info(Bolt b, Vector3 eyePos, Vector3 forward)
{
Vector3 to = Center(b) - eyePos;
float raw = b.SizeF != null ? b.SizeF.Value : (b.SizeI != null ? b.SizeI.Value : -1);
return b.Transform.name + " under " + (b.Transform.parent != null ? b.Transform.parent.name : "-")
+ ", distance " + Vector3.Dot(to, forward).ToString("0.00") + " m, off the line " + (Vector3.Cross(forward, to).magnitude * 100f).ToString("0") + " cm"
+ ", angle " + Vector3.Angle(forward, to).ToString("0.0") + "°, BoltSize=" + raw + (b.Renderer != null ? "" : " (no renderer)")
+ ", scale " + b.Transform.localScale.ToString("0.###") + " / world " + b.Transform.lossyScale.ToString("0.###")
+ (b.Renderer != null ? ", mesh size " + (b.Renderer.bounds.size * 1000f).ToString("0") + " mm" : "")
+ ", FSMs:" + Numbers(b.Transform);
}
/// <summary>
/// Schrauben aus MSCOs gemeinsamer Liste aller FSMs holen (SceneCache) - nicht selbst das ganze Spiel durchsuchen,
/// das ließ das Spiel alle paar Sekunden kurz hängen.
/// </summary>
void Scan(Camera cam)
{
nextScan = Time.realtimeSinceStartup + ScanInterval;
bolts.Clear();
foreach (PlayMakerFSM fsm in SceneCache.Fsms(30f))
{
if (fsm == null || fsm.FsmName != "Screw") continue;
FsmFloat f = fsm.FsmVariables.FindFsmFloat("BoltSize");
FsmInt i = fsm.FsmVariables.FindFsmInt("BoltSize");
if (f == null && i == null) continue;
bolts.Add(new Bolt { Transform = fsm.transform, SizeF = f, SizeI = i, Renderer = fsm.GetComponentInChildren<Renderer>() });
}
if (!reported)
{
reported = true;
Log("camera " + cam.name + ", " + bolts.Count + " bolts with a size");
}
}
/// <summary>Größe in mm. Das Spiel speichert sie je nach Schraube als 10 oder als 1.0 (= 10 mm).</summary>
static string Describe(Bolt b)
{
float value = b.SizeF != null ? b.SizeF.Value : b.SizeI.Value;
if (value <= 0f) return "? mm"; // Größe unbekannt - aber die Schraube wurde erkannt
return Format(value);
}
static string Format(float value)
{
float mm = value < 2.5f ? value * 10f : value;
return Mathf.RoundToInt(mm) + " mm";
}
public override void OnGUI()
{
if (label == null) return;
if (style == null)
{
style = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontSize = 16, fontStyle = FontStyle.Bold };
style.normal.textColor = Color.white;
}
var r = new Rect(Screen.width / 2f - 50f, Screen.height / 2f + 22f, 100f, 26f);
Color old = GUI.color;
GUI.color = new Color(0f, 0f, 0f, 0.6f);
GUI.DrawTexture(r, Texture2D.whiteTexture);
GUI.color = old;
GUI.Label(r, label, style);
}
}
}
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
MSCO-Mod "Show Bolt Size". Bauen: msbuild ShowBoltSize.csproj -p:Configuration=Release
Ergebnis bin\Release\ShowBoltSize.dll - im Launcher unter Mods hochladen oder nach <Spielordner>\MSCO\Mods kopieren.
GamePath anpassen, wenn My Summer Car woanders liegt.
-->
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Release</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<OutputType>Library</OutputType>
<RootNamespace>ShowBoltSize</RootNamespace>
<AssemblyName>ShowBoltSize</AssemblyName>
<TargetFrameworkVersion>v3.5</TargetFrameworkVersion>
<TargetFrameworkProfile>Unity Full v3.5</TargetFrameworkProfile>
<LangVersion>13</LangVersion>
<OutputPath>bin\$(Configuration)\</OutputPath>
<DebugType>none</DebugType>
<Optimize>true</Optimize>
<GamePath Condition=" '$(GamePath)' == '' ">F:\SteamLibrary\steamapps\common\My Summer Car</GamePath>
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="UnityEngine">
<HintPath>$(GamePath)\mysummercar_Data\Managed\UnityEngine.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="PlayMaker">
<HintPath>$(GamePath)\mysummercar_Data\Managed\PlayMaker.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="MSCO">
<HintPath>$(GamePath)\MSCO\MSCO.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="ShowBoltSize.cs" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
BIN
View File
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
Third-party libraries shipped with MSCO
=======================================
0Harmony.dll - Harmony 1.2.0.1 by Andreas Pardeike
https://github.com/pardeike/Harmony - MIT License
Copyright (c) 2017 Andreas Pardeike
MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including without
limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above
copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

+301
View File
@@ -0,0 +1,301 @@
# Neue Version automatisch auf den Keymaster hochladen (msco.mbm.wtf -> /download, Launcher-Updates).
# Läuft nach dem Release-Build von selbst (MSCO.csproj bzw. MSCO.Launcher.csproj) - oder von Hand:
# Windows: powershell -ExecutionPolicy Bypass -File release.ps1 -Product mod -File bin\Release\MSCO.dll
# Mac: pwsh release.ps1 -Product mod -File bin/Release/MSCO.dll (PowerShell 7: brew install powershell)
#
# Braucht den Release-Token des Keymasters (RELEASE_TOKEN in dessen .env) und den privaten Signier-Schlüssel.
# Beide liegen verschlüsselt beim Entwickler:
# Windows: %USERPROFILE%\.msco\*.dpapi (Windows DPAPI - nur dein Windows-Konto kann sie lesen)
# macOS: im Schlüsselbund (Keychain, Einträge "MSCO release-token" / "MSCO release-signing-key")
# Einrichten: "-SaveToken" (Token), Schlüssel einmal mit "-NewSigningKey" erzeugen bzw. auf einem weiteren Rechner mit
# "-ImportSigningKey <backup>" aus der Sicherung ("-ExportSigningKey <datei>", mit eigenem Passwort) übernehmen.
# MSCO_RELEASE_TOKEN (Umgebungsvariable) ersetzt den gespeicherten Token, MSCO_NO_RELEASE=1 schaltet Hochladen ab.
#
# Signatur: jede Datei wird signiert (RSA-3072, SHA-256, "MSCO-RELEASE|produkt|version|sha256") - der Launcher und
# das Setup installieren nur, was zu ihrem eingebauten öffentlichen Schlüssel passt. Selbst wer den Keymaster oder
# den Token übernimmt, kann so keine fremde Datei als MSCO-Update verteilen.
# Gibt es die Version auf dem Keymaster schon, passiert nichts - also einfach die Version erhöhen und bauen.
param(
[ValidateSet("mod", "launcher", "installer")][string]$Product,
[string]$File,
[switch]$SaveToken,
[switch]$NewSigningKey,
[string]$ExportSigningKey = "",
[string]$ImportSigningKey = "",
[string]$Version = "",
[string]$Changelog = ""
)
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
# ---------------------------------------------------------------- Ablage der Geheimnisse (Windows / macOS / Linux)
$onWindows = ($PSVersionTable.PSEdition -eq "Desktop") -or ($IsWindows -eq $true)
$onMac = ($IsMacOS -eq $true)
$homeDir = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME }
# Nicht in AppData: das virtualisieren App-Pakete (z.B. die Claude-App) - ein normales Terminal sähe die Dateien nicht
$secretDir = Join-Path $homeDir ".msco"
if ($onWindows) { Add-Type -AssemblyName System.Security }
function Get-SecretFile([string]$name) {
$new = Join-Path $secretDir "$name.dpapi"
if (Test-Path -LiteralPath $new) { return $new }
if ($env:APPDATA) {
$old = Join-Path $env:APPDATA "MSCO\$name.dpapi" # frühere Ablage
if (Test-Path -LiteralPath $old) { return $old }
}
return $new
}
function Get-SecretLocation([string]$name) {
if ($onWindows) { return Get-SecretFile $name }
if ($onMac) { return "macOS Keychain (MSCO $name)" }
return (Join-Path $secretDir $name)
}
# Gespeichertes Geheimnis lesen (Bytes) - $null, wenn es keins gibt
function Read-Secret([string]$name, [string]$entropyText) {
if ($onWindows) {
$file = Get-SecretFile $name
if (-not (Test-Path -LiteralPath $file)) { return $null }
$entropy = [Text.Encoding]::UTF8.GetBytes($entropyText)
return [Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($file), $entropy, [Security.Cryptography.DataProtectionScope]::CurrentUser)
}
if ($onMac) {
$value = & security find-generic-password -a $env:USER -s "MSCO $name" -w 2>$null
if ($LASTEXITCODE -ne 0 -or -not $value) { return $null }
return [Convert]::FromBase64String(($value | Out-String).Trim())
}
$file = Join-Path $secretDir $name
if (-not (Test-Path -LiteralPath $file)) { return $null }
return [Convert]::FromBase64String([IO.File]::ReadAllText($file).Trim())
}
function Write-Secret([string]$name, [string]$entropyText, [byte[]]$data) {
if ($onWindows) {
New-Item -ItemType Directory -Force -Path $secretDir | Out-Null
$entropy = [Text.Encoding]::UTF8.GetBytes($entropyText)
$file = Join-Path $secretDir "$name.dpapi"
[IO.File]::WriteAllBytes($file, [Security.Cryptography.ProtectedData]::Protect($data, $entropy, [Security.Cryptography.DataProtectionScope]::CurrentUser))
return
}
if ($onMac) {
# -U: vorhandenen Eintrag ersetzen; der Wert liegt danach verschlüsselt im Anmelde-Schlüsselbund
& security add-generic-password -U -a $env:USER -s "MSCO $name" -l "MSCO $name" -w ([Convert]::ToBase64String($data))
if ($LASTEXITCODE -ne 0) { throw "Could not store MSCO $name in the keychain" }
return
}
# Linux: Datei nur für den eigenen Benutzer lesbar (kein Schlüsselbund vorausgesetzt)
New-Item -ItemType Directory -Force -Path $secretDir | Out-Null
$file = Join-Path $secretDir $name
[IO.File]::WriteAllText($file, [Convert]::ToBase64String($data))
& chmod 600 $file
}
# RSA: Windows PowerShell 5.1 braucht RSACng (SHA-256), PowerShell 7 (auch macOS) nimmt RSA.Create()
function New-Rsa([int]$bits = 0) {
if ($PSVersionTable.PSEdition -eq "Desktop") {
if ($bits -gt 0) { return New-Object System.Security.Cryptography.RSACng $bits }
return New-Object System.Security.Cryptography.RSACng
}
if ($bits -gt 0) { return [System.Security.Cryptography.RSA]::Create($bits) }
return [System.Security.Cryptography.RSA]::Create()
}
$tokenName = "release-token"; $tokenEntropy = "MSCO release token v1"
$keyName = "release-signing-key"; $keyEntropy = "MSCO release signing key v1"
# Passwort für Backups: verdeckt fragen - oder aus MSCO_BACKUP_PASSWORD (für automatische Backups)
function Get-BackupPassword([string]$prompt) {
if ($env:MSCO_BACKUP_PASSWORD) { return $env:MSCO_BACKUP_PASSWORD }
$secure = Read-Host $prompt -AsSecureString
return (New-Object System.Net.NetworkCredential('', $secure)).Password
}
function Get-Pbkdf2Key([string]$password, [byte[]]$salt) {
$kdf = New-Object Security.Cryptography.Rfc2898DeriveBytes($password, $salt, 300000, [Security.Cryptography.HashAlgorithmName]::SHA256)
return $kdf.GetBytes(32)
}
# ---------------------------------------------------------------- Einrichten / Sichern
# Token einmal verschlüsselt ablegen: fragt verdeckt danach (oder nimmt MSCO_RELEASE_TOKEN)
if ($SaveToken) {
$plain = $env:MSCO_RELEASE_TOKEN
if (-not $plain) {
$secure = Read-Host "Release token" -AsSecureString
$plain = (New-Object System.Net.NetworkCredential('', $secure)).Password
}
if (-not $plain -or $plain.Length -lt 24) { Write-Host "Token too short - nothing saved"; exit 1 }
Write-Secret $tokenName $tokenEntropy ([Text.Encoding]::UTF8.GetBytes($plain))
Write-Host "Release token saved encrypted: $(Get-SecretLocation $tokenName)"
exit 0
}
# Neues Schlüsselpaar (nur einmal - der öffentliche Schlüssel ist in Launcher und Setup eingebaut)
if ($NewSigningKey) {
if ($null -ne (Read-Secret $keyName $keyEntropy)) { Write-Host "There already is a signing key ($(Get-SecretLocation $keyName)) - not replaced"; exit 1 }
$rsa = New-Rsa 3072
Write-Secret $keyName $keyEntropy ([Text.Encoding]::UTF8.GetBytes($rsa.ToXmlString($true)))
Write-Host "Signing key saved encrypted: $(Get-SecretLocation $keyName)"
Write-Host "PUBLIC KEY (put into Launcher/Setup):"
Write-Host $rsa.ToXmlString($false)
exit 0
}
# Sicherung des privaten Schlüssels mit eigenem Passwort (AES-256, PBKDF2) - z.B. auf dem NAS / USB-Stick
if ($ExportSigningKey) {
$private = Read-Secret $keyName $keyEntropy
if ($null -eq $private) { Write-Host "No signing key ($(Get-SecretLocation $keyName))"; exit 1 }
$plainPw = Get-BackupPassword "Password for the backup"
if ($plainPw.Length -lt 12) { Write-Host "Use at least 12 characters"; exit 1 }
# Zweimal fragen: ein Tippfehler machte das Backup sonst unbrauchbar
$plainPw2 = Get-BackupPassword "Repeat the password"
if ($plainPw2 -ne $plainPw) { Write-Host "The passwords do not match - nothing written"; exit 1 }
$salt = New-Object byte[] 16; [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($salt)
$aes = [Security.Cryptography.Aes]::Create(); $aes.Key = Get-Pbkdf2Key $plainPw $salt; $aes.GenerateIV()
$enc = $aes.CreateEncryptor().TransformFinalBlock($private, 0, $private.Length)
$out = "MSCO-SIGNING-KEY-BACKUP v1`n" + [Convert]::ToBase64String($salt) + "`n" + [Convert]::ToBase64String($aes.IV) + "`n" + [Convert]::ToBase64String($enc)
try {
$dir = Split-Path -Parent $ExportSigningKey
if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
[IO.File]::WriteAllText($ExportSigningKey, $out)
} catch {
Write-Host "Could not write $ExportSigningKey : $($_.Exception.Message)"
Write-Host "Network drive? Drive letters like Z: are not visible in a PowerShell window that runs as administrator."
Write-Host "Use a normal (not elevated) PowerShell window, or the network path, e.g. \\server\share\folder\msco-signing-key-backup.txt"
exit 1
}
# Gegenprobe: Datei lesen und mit dem Passwort wieder entschlüsseln
$lines = [IO.File]::ReadAllText($ExportSigningKey).Split("`n")
$aes2 = [Security.Cryptography.Aes]::Create(); $aes2.Key = Get-Pbkdf2Key $plainPw ([Convert]::FromBase64String($lines[1])); $aes2.IV = [Convert]::FromBase64String($lines[2])
$check = [Convert]::FromBase64String($lines[3]); $check = $aes2.CreateDecryptor().TransformFinalBlock($check, 0, $check.Length)
if ([Convert]::ToBase64String($check) -ne [Convert]::ToBase64String($private)) { Write-Host "Verification FAILED - the backup is not usable"; exit 1 }
Write-Host "Backup written and verified: $ExportSigningKey (AES-256, PBKDF2-SHA256 300000) - keep it and the password safe"
exit 0
}
# Sicherung übernehmen (zweiter Rechner, z.B. Mac, oder neuer PC): Datei + Passwort -> verschlüsselte Ablage dieses Rechners
if ($ImportSigningKey) {
if (-not (Test-Path -LiteralPath $ImportSigningKey)) { Write-Host "Not found: $ImportSigningKey (network drive? see -ExportSigningKey)"; exit 1 }
$lines = [IO.File]::ReadAllText($ImportSigningKey).Split("`n")
if ($lines.Count -lt 4 -or $lines[0].Trim() -ne "MSCO-SIGNING-KEY-BACKUP v1") { Write-Host "This is not an MSCO signing key backup"; exit 1 }
$plainPw = Get-BackupPassword "Password of the backup"
try {
$aes = [Security.Cryptography.Aes]::Create(); $aes.Key = Get-Pbkdf2Key $plainPw ([Convert]::FromBase64String($lines[1])); $aes.IV = [Convert]::FromBase64String($lines[2])
$enc = [Convert]::FromBase64String($lines[3].Trim())
$private = $aes.CreateDecryptor().TransformFinalBlock($enc, 0, $enc.Length)
$rsa = New-Rsa
$rsa.FromXmlString([Text.Encoding]::UTF8.GetString($private))
} catch { Write-Host "Wrong password or damaged backup"; exit 1 }
$current = Read-Secret $keyName $keyEntropy
if ($null -ne $current) {
if ([Convert]::ToBase64String($current) -eq [Convert]::ToBase64String($private)) { Write-Host "This key is already installed"; exit 0 }
Write-Host "There already is a different signing key ($(Get-SecretLocation $keyName)) - not replaced"; exit 1
}
Write-Secret $keyName $keyEntropy $private
Write-Host "Signing key restored: $(Get-SecretLocation $keyName)"
exit 0
}
# ---------------------------------------------------------------- Hochladen
if (-not $Product -or -not $File) { Write-Host "Usage: release.ps1 -Product mod|launcher|installer -File <file> | -SaveToken | -NewSigningKey | -ExportSigningKey <file> | -ImportSigningKey <file>"; exit 1 }
if ($env:MSCO_NO_RELEASE -eq "1") { Write-Host "MSCO release: MSCO_NO_RELEASE=1 - not uploading."; exit 0 }
$token = $env:MSCO_RELEASE_TOKEN
if (-not $token) {
try {
$stored = Read-Secret $tokenName $tokenEntropy
if ($null -ne $stored) { $token = [Text.Encoding]::UTF8.GetString($stored) }
} catch { Write-Host "MSCO release: the stored release token cannot be decrypted (other user/PC?) - not uploading."; exit 0 }
}
if (-not $token) {
Write-Host "MSCO release: no release token ($(Get-SecretLocation $tokenName) or MSCO_RELEASE_TOKEN) - set it with 'release.ps1 -SaveToken'. Not uploading."
exit 0
}
$keymaster = if ($env:MSCO_KEYMASTER) { $env:MSCO_KEYMASTER.TrimEnd("/") } else { "https://msco.mbm.wtf" }
if (-not (Test-Path $File)) { Write-Host "MSCO release: $File not found"; exit 1 }
# Version: aus MSCO.cs (Mod) bzw. aus der Datei (Produktversion)
if (-not $Version) {
$source = Join-Path $here "MSCO.cs"
if ($Product -eq "mod" -and (Test-Path $source)) {
$m = Select-String -Path $source -Pattern 'ModVersion\s*=\s*"([^"]+)"' | Select-Object -First 1
if ($m) { $Version = $m.Matches[0].Groups[1].Value }
}
if (-not $Version) { $Version = ((Get-Item $File).VersionInfo.ProductVersion -split '\+')[0] }
}
if (-not $Version) { Write-Host "MSCO release: version unknown - pass -Version"; exit 1 }
# Changelog: Abschnitt "## <Version>" aus CHANGELOG.md (Text ohne Markdown, gekürzt)
if (-not $Changelog) {
$log = Join-Path $here "CHANGELOG.md"
if (Test-Path $log) {
$lines = Get-Content $log -Encoding UTF8
$start = [Array]::FindIndex($lines, [Predicate[string]] { param($l) $l -match "^##\s+$([regex]::Escape($Version))\b" })
if ($start -ge 0) {
$text = @()
for ($i = $start + 1; $i -lt $lines.Count -and $lines[$i] -notmatch '^##\s'; $i++) {
$l = $lines[$i].Trim() -replace '\*\*', '' -replace '`', ''
if ($l) { $text += $l }
}
$Changelog = ($text -join " ")
if ($Changelog.Length -gt 3500) { $Changelog = $Changelog.Substring(0, 3500) + " …" }
}
}
}
# Signieren: "MSCO-RELEASE|<produkt>|<version>|<sha256>" - so passt die Signatur nur zu genau dieser Datei, diesem
# Produkt und dieser Version (keine alte Version als neue, keine Mod-DLL als Launcher)
$bytes = [System.IO.File]::ReadAllBytes((Resolve-Path $File))
$sha = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)).Replace("-", "").ToLowerInvariant()
$signature = ""
try {
$privateBytes = Read-Secret $keyName $keyEntropy
} catch { Write-Host "MSCO release: the signing key cannot be decrypted (other user/PC?) - not uploading"; exit 0 }
if ($null -eq $privateBytes) {
Write-Host "MSCO release: no signing key ($(Get-SecretLocation $keyName)) - import it with 'release.ps1 -ImportSigningKey <backup>'. Not uploading."
exit 0
}
try {
$rsa = New-Rsa
$rsa.FromXmlString([Text.Encoding]::UTF8.GetString($privateBytes))
$message = [Text.Encoding]::UTF8.GetBytes("MSCO-RELEASE|$Product|$Version|$sha")
$signature = [Convert]::ToBase64String($rsa.SignData($message, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1))
$privateBytes = $null
} catch { Write-Host "MSCO release: signing failed ($($_.Exception.Message)) - not uploading"; exit 0 }
Add-Type -AssemblyName System.Net.Http
$client = New-Object System.Net.Http.HttpClient
$client.Timeout = [TimeSpan]::FromMinutes(10)
$client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $token)
# Multipart von Hand: Windows PowerShell (.NET Framework) schreibt name=... ohne Anführungszeichen und die Boundary in
# Anführungszeichen - das lehnt der Parser des Keymasters (Node/undici) ab.
$boundary = "----MSCO" + [Guid]::NewGuid().ToString("N")
$utf8 = New-Object System.Text.UTF8Encoding($false)
$body = New-Object System.IO.MemoryStream
function Write-Text([string]$text) { $b = $utf8.GetBytes($text); $body.Write($b, 0, $b.Length) }
foreach ($field in @(@("product", $Product), @("version", $Version), @("changelog", $Changelog), @("signature", $signature))) {
Write-Text "--$boundary`r`nContent-Disposition: form-data; name=`"$($field[0])`"`r`nContent-Type: text/plain; charset=utf-8`r`n`r`n$($field[1])`r`n"
}
Write-Text "--$boundary`r`nContent-Disposition: form-data; name=`"file`"; filename=`"$([System.IO.Path]::GetFileName($File))`"`r`nContent-Type: application/octet-stream`r`n`r`n"
$body.Write($bytes, 0, $bytes.Length)
Write-Text "`r`n--$boundary--`r`n"
$form = New-Object System.Net.Http.ByteArrayContent(,$body.ToArray())
$form.Headers.TryAddWithoutValidation("Content-Type", "multipart/form-data; boundary=$boundary") | Out-Null
try {
$res = $client.PostAsync("$keymaster/api/releases/upload", $form).GetAwaiter().GetResult()
$body = $res.Content.ReadAsStringAsync().GetAwaiter().GetResult()
} catch {
Write-Host "MSCO release: keymaster not reachable ($($_.Exception.Message)) - not uploaded"
exit 0
}
switch ([int]$res.StatusCode) {
200 { Write-Host "MSCO release: $Product $Version uploaded to $keymaster" }
409 { Write-Host "MSCO release: $Product $Version is already on the keymaster - raise the version to publish a new one" }
default { Write-Host "MSCO release: upload failed (HTTP $([int]$res.StatusCode)): $body" }
}
exit 0