37 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
52 changed files with 5255 additions and 515 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
{
+186
View File
@@ -1,5 +1,191 @@
# 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**
+93
View File
@@ -42,6 +42,99 @@ namespace MSCO
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)
{
+42 -1
View File
@@ -1,10 +1,11 @@
using UnityEngine;
using System;
namespace MSCO
{
/// <summary>
/// Alle MSCO-Befehle an einer Stelle - genutzt von der eigenen F7-Konsole (ohne "mp" davor)
/// und von der MSCLoader-Konsole ("mp ...").
/// (früher auch aus der MSCLoader-Konsole - MSCO läuft jetzt ohne MSCLoader).
/// </summary>
public static class Commands
{
@@ -17,12 +18,16 @@ namespace MSCO
"disconnect - leave the server / game\n" +
"host [port] / join <ip> [port] - direct LAN game (no Steam)\n" +
"save - save the game (host / singleplayer)\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" +
"clear - clear this console";
@@ -115,6 +120,39 @@ namespace MSCO
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";
@@ -142,6 +180,9 @@ namespace MSCO
// 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"));
-1
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
+130 -3
View File
@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
@@ -33,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;
@@ -58,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;
@@ -92,6 +114,14 @@ namespace MSCO
}
}
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;
@@ -113,9 +143,106 @@ namespace MSCO
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)
+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;
}
}
}
+49 -6
View File
@@ -4,7 +4,6 @@ using System.IO;
using System.Reflection;
using Harmony;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
@@ -133,6 +132,7 @@ namespace MSCO
static void SwitchStatePrefix(Fsm __instance, FsmState toState)
{
PerfStats.SwitchStateCalls++;
if (!Enabled || applying > 0 || toState == null) return;
try
{
@@ -167,18 +167,25 @@ namespace MSCO
}
}
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;
}
@@ -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;
@@ -291,6 +299,12 @@ namespace MSCO
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;
@@ -302,6 +316,35 @@ namespace MSCO
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)
{
+25 -5
View File
@@ -40,6 +40,7 @@ namespace MSCO
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;
}
@@ -102,10 +103,13 @@ namespace MSCO
/// <summary>Jedes Frame: Klick (linke Maustaste / Benutzen) in der Nähe einer Tür = wir bewegen sie gerade.</summary>
public void NoteInteraction(Vector3 playerPos)
{
if (!Input.GetMouseButtonDown(0) && !Input.GetKeyDown(KeyCode.F)) return;
// 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 || (h.Rb.worldCenterOfMass - playerPos).sqrMagnitude > InteractRange * InteractRange) continue;
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
}
@@ -183,10 +187,17 @@ namespace MSCO
if (j == null || !seen.Add(j)) continue;
Rigidbody rb = j.GetComponent<Rigidbody>();
Rigidbody body = j.connectedBody;
if (rb == null || body == null || !IsVehicle(body.transform.root.name)) continue;
int key = Hash(FsmSync.PathOf(j.transform) + "|" + FsmSync.PathOf(body.transform));
if (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.transform.root.name, Joint = j, Rb = rb };
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>();
@@ -197,6 +208,15 @@ namespace MSCO
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;
+17 -2
View File
@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using System.Reflection;
using Harmony;
using MSCLoader;
using HutongGames.PlayMaker;
using UnityEngine;
namespace MSCO
@@ -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,6 +76,16 @@ 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>Globale Spielvariable "PlayerInMenu": true = Zeiger frei, Mausblick aus.</summary>
static FsmBool PlayerInMenu()
{
try { return FsmVariables.GlobalVariables.FindFsmBool("PlayerInMenu"); }
catch { return null; }
}
/// <summary>
@@ -137,6 +151,7 @@ namespace MSCO
// 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;
+31 -2
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
@@ -31,6 +30,13 @@ namespace MSCO
}
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");
@@ -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)
{
+342 -62
View File
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using UnityEngine;
namespace MSCO
@@ -10,7 +10,7 @@ namespace MSCO
/// </summary>
public class LobbyUI
{
enum Page { P2P, ServerBrowser, Favorites }
enum Page { P2P, ServerBrowser, Favorites, Mods }
// Vollbild, automatisch skaliert: gebaut für RefHeight x RefWidth, vergrößert/verkleinert passend zur Auflösung
const float RefHeight = 760f;
@@ -33,10 +33,10 @@ namespace MSCO
float ListHeight(float reserved, float min) { return Mathf.Max(min, contentHeight - reserved); }
static readonly Color Background = Hex(0x17, 0x17, 0x1b);
static readonly Color Sidebar = Hex(0x0f, 0x0f, 0x12);
static readonly Color Field = Hex(0x24, 0x24, 0x2a);
static readonly Color FieldFocus = Hex(0x2e, 0x2e, 0x36);
static readonly Color Background = Hex(0x11, 0x11, 0x14);
static readonly Color Sidebar = Hex(0x0c, 0x0c, 0x0f);
static readonly Color Field = Hex(0x1b, 0x1b, 0x20);
static readonly Color FieldFocus = Hex(0x24, 0x24, 0x2a);
static readonly Color Accent = Hex(0xf2, 0x6b, 0x1d);
static readonly Color AccentHover = Hex(0xff, 0x82, 0x3a);
static readonly Color Secondary = Hex(0x2c, 0x2c, 0x33);
@@ -54,7 +54,7 @@ namespace MSCO
bool browserLoaded;
string selectedServer; // "adresse:port" des geöffneten Server-Profils
string searchInput = "";
bool showSteam = true, showNonSteam = true, hideFull;
bool showSteam = true, showNonSteam = true, hideFull, showVanilla = true, showModded = true;
readonly Dictionary<string, string> nicknameEdits = new Dictionary<string, string>(); // Favoriten-Tab: Adresse -> bearbeiteter Kurzname
string favoriteAddInput = "";
Vector2 favoritesScroll;
@@ -65,13 +65,21 @@ namespace MSCO
bool stylesReady;
Texture2D texBackground, texSidebar, texAccent, texOnline, texPending, texMuted, texRow;
Texture2D texDivider, texGold, texBarBack, texBarFill, texBarFull;
Texture2D texDivider, texGold, texBarBack, texBarFill, texBarFull, texGlow;
GUIStyle scrollbarStyle, scrollThumbStyle;
GUIStyle rowHoverBox, pingGoodStyle, pingMidStyle, pingBadStyle;
List<ServerListEntry> shownAll, shownServers; // Stand des letzten Layout-Durchgangs
string layoutError;
GUIStyle dangerStyle;
GUIStyle windowBox, sidebarBox, rowBox, accentBox, pillStyle, pillMutedStyle, chipOnStyle, chipOffStyle;
GUIStyle featuredRowBox, pillFeaturedStyle;
GUIStyle featuredRowBox, pillFeaturedStyle, pillDangerStyle, newsTitleStyle, newsBoxStyle, newsTextStyle, newsCloseStyle;
Texture2D texNewsInfo, texNewsWarning, texNewsCritical;
// Ankündigungen/Wartung vom Keymaster - nur im Layout-Durchgang übernehmen (Liste kann sich im Hintergrund ändern)
List<Announcement> shownNews;
bool shownMaintenance;
string shownMaintenanceText;
readonly HashSet<string> dismissedNews = new HashSet<string>();
GUIStyle betaPillStyle, betaNoteStyle;
GUIStyle logoStyle, taglineStyle, navStyle, navActiveStyle, titleStyle, textStyle, captionStyle,
fieldStyle, primaryStyle, secondaryStyle, statusStyle, tabStyle, headerCellStyle,
rowNameStyle, rowTagStyle, badgeStyle, toggleStyle, placeholderStyle;
@@ -105,6 +113,7 @@ namespace MSCO
if (Minimized && !mp.IsInSession && (mp.DisconnectNotice != null || mp.PendingJoinHost != null || mp.PendingVersionWarning != null))
Minimized = false;
if (Minimized && mp.IsReconnecting) Minimized = false;
if (Minimized && mp.PendingMods != null) Minimized = false;
if (Minimized) { DrawMinimized(mp); return; }
ScreenRect = new Rect(0, 0, Screen.width, Screen.height);
DrawScaled(mp);
@@ -121,8 +130,11 @@ namespace MSCO
// Getrennt / Neu-Verbinden: eigenes Fenster in der Bildmitte, alles andere gesperrt
if (disconnected) { DrawPrompt(mp, false); return; }
if (mp.IsReconnecting) { DrawPrompt(mp, true); return; }
if (mp.PendingMods != null && mp.IsInSession) { DrawModsPrompt(mp); return; }
var window = new Rect(0, 0, vw, vh);
GUI.Box(window, GUIContent.none, windowBox);
float glowWidth = Mathf.Min(vw * 0.9f, S(1300));
GUI.DrawTexture(new Rect(S(SidebarWidth) + (vw - S(SidebarWidth) - glowWidth) / 2f, -S(60), glowWidth, S(420)), texGlow);
DrawSidebar(new Rect(0, 0, S(SidebarWidth), vh - S(StatusHeight)));
@@ -131,14 +143,21 @@ namespace MSCO
float width = Mathf.Min(S(MaxContentWidth), free - S(96f));
var content = new Rect(S(SidebarWidth) + Mathf.Round((free - width) / 2f), S(40f), width, vh - S(StatusHeight) - S(72f));
contentHeight = content.height;
GUIStyle oldBar = GUI.skin.verticalScrollbar, oldThumb = GUI.skin.verticalScrollbarThumb;
GUI.skin.verticalScrollbar = scrollbarStyle;
GUI.skin.verticalScrollbarThumb = scrollThumbStyle;
GUILayout.BeginArea(content);
if (!versionWarning && !pendingJoin && !mp.IsInSession) DrawAnnouncements();
if (versionWarning) DrawVersionWarning(mp);
else if (pendingJoin) DrawJoinConfirm(mp);
else if (mp.IsInSession) DrawSession(mp);
else if (page == Page.P2P) DrawP2P(mp);
else if (page == Page.Favorites) DrawFavorites(mp);
else if (page == Page.Mods) DrawMods();
else DrawServerBrowser(mp);
GUILayout.EndArea();
GUI.skin.verticalScrollbar = oldBar;
GUI.skin.verticalScrollbarThumb = oldThumb;
DrawStatusBar(mp, new Rect(0, vh - S(StatusHeight), vw, S(StatusHeight)));
@@ -182,6 +201,9 @@ namespace MSCO
textX = r.x + S(72);
}
GUI.Label(new Rect(textX, r.y + S(24), r.xMax - textX, S(40)), "MSCO", logoStyle);
// Beta-Abzeichen direkt hinter dem Schriftzug
float logoWidth = logoStyle.CalcSize(new GUIContent("MSCO")).x;
GUI.Box(new Rect(textX + logoWidth + S(6), r.y + S(36), S(40), S(18)), "BETA", betaPillStyle);
GUI.Label(new Rect(r.x + S(22), r.y + S(70), r.width - S(30), S(20)), "MY SUMMER CAR ONLINE", taglineStyle);
GUI.DrawTexture(new Rect(r.x + S(20), r.y + S(98), r.width - S(40), S(1)), texDivider);
@@ -192,6 +214,12 @@ namespace MSCO
y += S(44);
int favCount = Favorites.All.Count;
if (NavButton(new Rect(r.x, y, r.width, S(42)), "Favorites" + (favCount > 0 ? " (" + favCount + ")" : ""), page == Page.Favorites)) page = Page.Favorites;
y += S(44);
int modCount = ModLoader.All.Count;
if (NavButton(new Rect(r.x, y, r.width, S(42)), "Mods" + (modCount > 0 ? " (" + modCount + ")" : ""), page == Page.Mods)) page = Page.Mods;
y += S(54);
GUI.Label(new Rect(r.x + S(22), y, r.width - S(40), S(64)),
"MSCO is still in beta - bugs can happen. If something goes wrong, please tell us.", betaNoteStyle);
// Eigenes Hauptmenü: Spiel beenden (die Knöpfe des Spiel-Menüs sind ausgeblendet)
if (quitConfirm)
@@ -206,8 +234,10 @@ namespace MSCO
bool NavButton(Rect r, string text, bool active)
{
if (active) GUI.Box(new Rect(r.x + S(4), r.y + S(8), S(4), r.height - S(16)), GUIContent.none, accentBox); // Akzentbalken links
return GUI.Button(r, text, active ? navActiveStyle : navStyle);
var item = new Rect(r.x + S(12), r.y, r.width - S(24), r.height);
bool clicked = GUI.Button(item, text, active ? navActiveStyle : navStyle);
if (active) GUI.Box(new Rect(item.x + S(8), item.y + S(11), S(3), item.height - S(22)), GUIContent.none, accentBox); // Akzentbalken
return clicked;
}
void DrawP2P(MultiplayerManager mp)
@@ -277,6 +307,10 @@ namespace MSCO
showNonSteam = Chip(showNonSteam, "NON-STEAM");
GUILayout.Space(S(6));
hideFull = Chip(hideFull, "HIDE FULL");
GUILayout.Space(S(12));
showVanilla = Chip(showVanilla, "VANILLA");
GUILayout.Space(S(6));
showModded = Chip(showModded, "MODDED");
GUILayout.EndHorizontal();
GUILayout.Space(S(6));
@@ -285,14 +319,16 @@ namespace MSCO
shownServers = all.FindAll(s =>
(s.RequireSteam ? showSteam : showNonSteam) &&
(!hideFull || s.Players < s.MaxPlayers) &&
(s.Modded ? showModded : showVanilla) &&
(search.Length == 0 || (s.Name ?? "").ToLower().Contains(search) || (s.Owner ?? "").ToLower().Contains(search) ||
(s.Tags != null && s.Tags.Exists(t => (t ?? "").Contains(search)))));
List<ServerListEntry> servers = shownServers;
string shownError = Event.current.type == EventType.Layout ? (layoutError = browser.Error) : layoutError;
// Tabellenkopf
GUILayout.Space(S(6));
Rect header = GUILayoutUtility.GetRect(S(10), S(26), GUILayout.ExpandWidth(true));
GUI.Box(header, GUIContent.none, rowBox);
GUI.DrawTexture(new Rect(header.x, header.yMax - S(1), header.width, S(1)), texDivider);
GUI.Label(new Rect(header.x + S(12), header.y, header.width * 0.48f, header.height), "SERVER NAME", headerCellStyle);
GUI.Label(new Rect(header.x + header.width * 0.5f, header.y, S(70), header.height), "PLAYERS", headerCellStyle);
GUI.Label(new Rect(header.x + header.width * 0.62f, header.y, S(70), header.height), "TYPE", headerCellStyle);
@@ -323,11 +359,13 @@ namespace MSCO
Favorites.Favorite fav = Favorites.FindByAddress(s.Address, s.Port);
if (s.Featured) nameX += Pill(nameX, row, "FEATURED", pillFeaturedStyle);
if (fav != null) nameX += Pill(nameX, row, fav.Nickname, pillMutedStyle);
if (s.Modded) nameX += Pill(nameX, row, s.Mods.Count > 0 ? "MODS " + s.Mods.Count : "MODDED", pillStyle);
if (s.IsLocal)
{
// Server auf diesem PC (localhost)
nameX += Pill(nameX, row, "LOCAL", pillStyle);
}
if (s.Outdated) nameX += Pill(nameX, row, "OUTDATED", pillDangerStyle); // fehlende Sicherheitsupdates
if (!string.IsNullOrEmpty(s.Version) && !MSCO.Compatible(s.Version))
{
nameX += Pill(nameX, row, "v" + s.Version, pillStyle); // abweichende Version
@@ -378,7 +416,52 @@ namespace MSCO
static bool CanJoin(MultiplayerManager mp, ServerListEntry s)
{
return s.Players < s.MaxPlayers && (!s.RequireSteam || mp.SteamAvailable);
return !s.Outdated && s.Players < s.MaxPlayers && (!s.RequireSteam || mp.SteamAvailable);
}
/// <summary>
/// Oben im MSCO-Menü: Wartungshinweis und Ankündigungen der MSCO-Admins (Keymaster → Wartung &amp; News).
/// Ankündigungen lassen sich mit × für diese Sitzung ausblenden.
/// </summary>
void DrawAnnouncements()
{
if (shownNews == null || Event.current.type == EventType.Layout)
{
shownNews = SiteStatus.Announcements.FindAll(a => !dismissedNews.Contains(a.Id));
if (shownNews.Count > 3) shownNews.RemoveRange(3, shownNews.Count - 3);
shownMaintenance = SiteStatus.Maintenance;
shownMaintenanceText = SiteStatus.MaintenanceMessage;
}
if (shownMaintenance)
NewsBox("MAINTENANCE", pillDangerStyle, texNewsCritical, "MSCO is in maintenance right now",
string.IsNullOrEmpty(shownMaintenanceText) ? "Some things may not work for a while." : shownMaintenanceText, null);
foreach (Announcement a in shownNews)
NewsBox(a.Level == "critical" ? "IMPORTANT" : a.Level == "warning" ? "NOTICE" : "INFO",
a.Level == "critical" ? pillDangerStyle : a.Level == "warning" ? pillFeaturedStyle : pillMutedStyle,
a.Level == "critical" ? texNewsCritical : a.Level == "warning" ? texNewsWarning : texNewsInfo, a.Title, a.Text, a.Id);
if (shownMaintenance || shownNews.Count > 0) GUILayout.Space(S(10));
}
void NewsBox(string label, GUIStyle pill, Texture2D stripe, string title, string text, string id)
{
GUILayout.BeginVertical(newsBoxStyle);
GUILayout.BeginHorizontal();
// Abzeichen und Titel gleich hoch und mittig - sonst sitzt der Titel tiefer als das Abzeichen
GUILayout.Label(label, pill, GUILayout.Height(S(22)), GUILayout.ExpandWidth(false));
GUILayout.Space(S(10));
GUILayout.Label(title, newsTitleStyle, GUILayout.MinHeight(S(22)), GUILayout.ExpandWidth(true));
if (id != null && GUILayout.Button(new GUIContent("×", "Hide"), newsCloseStyle, GUILayout.Width(S(24)), GUILayout.Height(S(22))))
dismissedNews.Add(id);
GUILayout.EndHorizontal();
if (!string.IsNullOrEmpty(text)) GUILayout.Label(text, newsTextStyle);
GUILayout.EndVertical();
// Farbiger Balken links (Art der Ankündigung)
if (Event.current.type == EventType.Repaint)
{
Rect box = GUILayoutUtility.GetLastRect();
GUI.DrawTexture(new Rect(box.x, box.y + S(10), S(3), box.height - S(20)), stripe);
}
GUILayout.Space(S(8));
}
void DrawNameField(MultiplayerManager mp, float width)
@@ -576,13 +659,14 @@ namespace MSCO
GUILayout.BeginVertical();
GUILayout.Label(s.Name, titleStyle);
GUILayout.BeginHorizontal();
Rect badgeRow = GUILayoutUtility.GetRect(S(10), S(20), GUILayout.ExpandWidth(true));
float bx = badgeRow.x;
Rect badgeRow = GUILayoutUtility.GetRect(S(10), S(22), GUILayout.ExpandWidth(true));
float bx = badgeRow.x + titleStyle.padding.left;
if (s.Featured) bx += Pill(bx, badgeRow, "FEATURED", pillFeaturedStyle);
bool official = s.Tags != null && s.Tags.Contains("official"); // vergibt nur ein MSCO-Admin
if (official) bx += Pill(bx, badgeRow, "OFFICIAL", pillStyle);
bx += Pill(bx, badgeRow, s.RequireSteam ? "STEAM" : "NON-STEAM", s.RequireSteam ? pillStyle : pillMutedStyle);
if (s.IsLocal) bx += Pill(bx, badgeRow, "LOCAL", pillStyle);
if (s.Outdated) bx += Pill(bx, badgeRow, "OUTDATED", pillDangerStyle);
if (!MSCO.Compatible(s.Version)) bx += Pill(bx, badgeRow, "v" + s.Version, pillStyle);
if (s.Tags != null)
foreach (string tag in s.Tags)
@@ -595,10 +679,13 @@ namespace MSCO
Heart(new Rect(heartSlot.x + S(11), heartSlot.y + S(11), S(22), S(22)), s);
GUILayout.Space(S(8));
GUI.enabled = CanJoin(mp, s);
if (GUILayout.Button(s.Players >= s.MaxPlayers ? "SERVER FULL" : "JOIN SERVER", primaryStyle, GUILayout.Height(S(44)), GUILayout.Width(S(180))))
if (GUILayout.Button(s.Outdated ? "UPDATE REQUIRED" : s.Players >= s.MaxPlayers ? "SERVER FULL" : "JOIN SERVER", primaryStyle, GUILayout.Height(S(44)), GUILayout.Width(S(180))))
mp.JoinServer(s.Address, s.Port, s.Name, s.MaxPlayers);
GUI.enabled = true;
GUILayout.EndHorizontal();
if (s.Outdated)
GUILayout.Label("This server is missing MSCO security updates" + (string.IsNullOrEmpty(s.ServerVersion) ? "" : " (server " + s.ServerVersion + ")") +
" - you can join again once its owner has updated it.", textStyle);
if (!string.IsNullOrEmpty(s.Description)) GUILayout.Label(s.Description, textStyle);
GUILayout.Space(S(10));
@@ -628,38 +715,84 @@ namespace MSCO
GUILayout.Space(S(10));
}
// Infos links, Spielerliste rechts
// Zwei Karten gleicher Höhe: Infos links, Spielerliste rechts
bool versionOk = MSCO.Compatible(s.Version);
GUILayout.BeginHorizontal();
GUILayout.BeginVertical(GUILayout.Width(S(250)));
InfoLine("HOSTED BY", string.IsNullOrEmpty(s.Owner) ? "unknown" : s.Owner, rowNameStyle);
InfoLine("VERSION", "v" + s.Version + (versionOk ? "" : " (you: v" + MSCO.ModVersion + ")"), versionOk ? rowNameStyle : badgeStyle);
InfoLine("STEAM", s.RequireSteam ? "Required" : "Not required", rowNameStyle);
InfoLine("ONLINE FOR", s.UptimeSeconds >= 0 ? FormatUptime(s.UptimeSeconds) : "-", rowNameStyle);
InfoLine("PING", s.Ping < 0 ? "-" : s.Ping + " ms", PingStyle(s.Ping));
InfoLine("ADDRESS", s.Address + ":" + s.Port, rowNameStyle);
GUILayout.EndVertical();
GUILayout.Space(S(16));
float cardHeight = Mathf.Clamp(contentHeight - S(250), S(240), S(380));
Rect area = GUILayoutUtility.GetRect(S(10), cardHeight, GUILayout.ExpandWidth(true));
float gap = S(16), leftWidth = Mathf.Round(area.width * 0.44f);
var info = new Rect(area.x, area.y, leftWidth, area.height);
var list = new Rect(area.x + leftWidth + gap, area.y, area.width - leftWidth - gap, area.height);
GUI.Box(info, GUIContent.none, rowBox);
GUI.Box(list, GUIContent.none, rowBox);
GUILayout.BeginVertical(rowBox, GUILayout.ExpandWidth(true), GUILayout.MinHeight(S(150)));
GUILayout.Space(S(6));
GUILayout.Label("PLAYERS " + s.Players + " / " + s.MaxPlayers, captionStyle);
profileScroll = GUILayout.BeginScrollView(profileScroll, GUILayout.Height(ListHeight(S(330f), S(130f))));
EnsureProfileStyles();
float x = info.x + S(20), w = info.width - S(40), y = info.y + S(16);
GUI.Label(new Rect(x, y, w, S(18)), "SERVER INFO", captionStyle);
y += S(28);
y = ProfileRow(x, y, w, "HOSTED BY", string.IsNullOrEmpty(s.Owner) ? "unknown" : s.Owner, profileValueStyle);
y = ProfileRow(x, y, w, "VERSION", "v" + s.Version + (versionOk ? "" : " (you: v" + MSCO.ModVersion + ")"), versionOk ? profileValueStyle : badgeStyle);
y = ProfileRow(x, y, w, "STEAM", s.RequireSteam ? "Required" : "Not required", profileValueStyle);
y = ProfileRow(x, y, w, "ONLINE FOR", s.UptimeSeconds >= 0 ? FormatUptime(s.UptimeSeconds) : "-", profileValueStyle);
y = ProfileRow(x, y, w, "PING", s.Ping < 0 ? "-" : s.Ping + " ms", PingStyle(s.Ping));
y = ProfileRow(x, y, w, "ADDRESS", s.Address + ":" + s.Port, profileValueStyle);
if (s.Mods.Count > 0) ProfileRow(x, y, w, "MODS", string.Join(", ", s.Mods.ToArray()), profileWrapStyle);
// Spieler
float lx = list.x + S(20), lw = list.width - S(40), ly = list.y + S(16);
GUI.Label(new Rect(lx, ly, lw, S(18)), "PLAYERS " + s.Players + " / " + s.MaxPlayers, captionStyle);
ly += S(30);
var listArea = new Rect(lx, ly, lw, list.yMax - ly - S(14));
if (s.PlayerNames == null || s.PlayerNames.Count == 0)
GUILayout.Label("Nobody online right now.", textStyle);
else
foreach (string name in s.PlayerNames)
{
Rect line = GUILayoutUtility.GetRect(S(10), S(24), GUILayout.ExpandWidth(true));
GUI.DrawTexture(new Rect(line.x + S(2), line.y + S(8), S(8), S(8)), texOnline);
GUI.Label(new Rect(line.x + S(18), line.y, line.width - S(18), line.height), name, rowNameStyle);
GUI.Label(listArea, "Nobody online right now.", profileEmptyStyle);
}
else
{
float rowH = S(32);
var inner = new Rect(0, 0, lw - S(14), rowH * s.PlayerNames.Count);
profileScroll = GUI.BeginScrollView(listArea, profileScroll, inner);
for (int i = 0; i < s.PlayerNames.Count; i++)
{
var line = new Rect(0, i * rowH, inner.width, rowH);
if (i > 0) GUI.DrawTexture(new Rect(line.x, line.y, line.width, S(1)), texDivider);
GUI.DrawTexture(new Rect(line.x + S(2), line.y + (rowH - S(8)) / 2f, S(8), S(8)), texOnline);
GUI.Label(new Rect(line.x + S(20), line.y, line.width - S(20), rowH), s.PlayerNames[i], profileValueStyle);
}
GUI.EndScrollView();
}
GUILayout.EndScrollView();
GUILayout.EndVertical();
GUILayout.EndHorizontal();
GUILayout.FlexibleSpace();
}
GUIStyle profileValueStyle, profileWrapStyle, profileCaptionStyle, profileEmptyStyle;
void EnsureProfileStyles()
{
if (profileValueStyle != null && profileValueStyle.fontSize == P(15)) return;
profileValueStyle = new GUIStyle(rowNameStyle) { fontSize = P(15), wordWrap = false, clipping = TextClipping.Clip, alignment = TextAnchor.MiddleLeft };
profileWrapStyle = new GUIStyle(profileValueStyle) { wordWrap = true, fontSize = P(13), alignment = TextAnchor.UpperLeft };
profileWrapStyle.normal.textColor = Muted;
profileCaptionStyle = new GUIStyle(captionStyle) { alignment = TextAnchor.MiddleLeft };
profileCaptionStyle.margin = new RectOffset();
profileEmptyStyle = new GUIStyle(textStyle) { alignment = TextAnchor.MiddleCenter };
}
/// <summary>Info-Zeile der Server-Karte: Bezeichnung links, Wert daneben, feine Linie darunter. Gibt das nächste y zurück.</summary>
float ProfileRow(float x, float y, float w, string caption, string value, GUIStyle valueStyle)
{
const float captionWidth = 120f;
float valueWidth = w - S(captionWidth);
float h = S(36);
if (valueStyle.wordWrap) h = Mathf.Max(h, valueStyle.CalcHeight(new GUIContent(value), valueWidth) + S(16));
GUI.Label(new Rect(x, y, S(captionWidth), S(36)), caption, profileCaptionStyle);
var valueRect = valueStyle.wordWrap ? new Rect(x + S(captionWidth), y + S(9), valueWidth, h - S(9)) : new Rect(x + S(captionWidth), y, valueWidth, S(36));
GUIStyle style = valueStyle;
if (!valueStyle.wordWrap && valueStyle.alignment != TextAnchor.MiddleLeft)
style = new GUIStyle(valueStyle) { alignment = TextAnchor.MiddleLeft };
GUI.Label(valueRect, value, style);
GUI.DrawTexture(new Rect(x, y + h, w, S(1)), texDivider);
return y + h + S(1);
}
/// <summary>Eine Info-Zeile: Bezeichnung links (grau), Wert rechts.</summary>
void InfoLine(string caption, string value, GUIStyle valueStyle)
{
@@ -808,6 +941,85 @@ namespace MSCO
if (GUI.Button(new Rect(r.xMax - S(32) - S(170), r.yMax - S(28) - S(42), S(170), S(42)), "OK", primaryStyle)) mp.ClearDisconnectNotice();
}
// ---------------------------------------------------------------- Mods
Vector2 modsScroll;
/// <summary>Server verlangt Mods, die hier fehlen: zeigen, was geladen würde, und fragen.</summary>
void DrawModsPrompt(MultiplayerManager mp)
{
if (texDim == null) texDim = Solid(new Color(0f, 0f, 0f, 0.6f));
GUI.DrawTexture(new Rect(0, 0, vw, vh), texDim);
List<MultiplayerManager.ServerModInfo> list = mp.PendingMods;
float height = S(250) + S(46) * Mathf.Min(list.Count, 6);
var r = new Rect(Mathf.Round((vw - S(620)) / 2f), Mathf.Round((vh - height) / 2f), S(620), height);
UiTheme.DrawShadow(r);
GUI.Box(r, GUIContent.none, windowBox);
GUI.DrawTexture(new Rect(r.x, r.y, r.width, S(4)), texAccent);
float x = r.x + S(32), w = r.width - S(64), y = r.y + S(26);
GUI.Label(new Rect(x, y, w, S(34)), "THIS SERVER USES MODS", titleStyle);
y += S(44);
GUI.Label(new Rect(x, y, w, S(44)), "To play here you need these mods. Mods are programs - they run on your PC. " +
"Only install them from servers you trust.", textStyle);
y += S(52);
for (int i = 0; i < list.Count && i < 6; i++)
{
MultiplayerManager.ServerModInfo m = list[i];
var row = new Rect(x, y, w, S(40));
GUI.Box(row, GUIContent.none, rowBox);
GUI.Label(new Rect(row.x + S(14), row.y, w * 0.55f, row.height), m.Name + (string.IsNullOrEmpty(m.Version) ? "" : " " + m.Version), rowNameStyle);
GUI.Label(new Rect(row.x + w * 0.55f, row.y, w * 0.45f - S(14), row.height),
(m.Size / 1024) + " KB · SHA-256 " + (m.Sha256.Length > 12 ? m.Sha256.Substring(0, 12) + "…" : m.Sha256), headerCellStyle);
y += S(46);
}
if (list.Count > 6) GUI.Label(new Rect(x, y - S(6), w, S(20)), "+ " + (list.Count - 6) + " more", textStyle);
float by = r.yMax - S(28) - S(42);
if (GUI.Button(new Rect(r.xMax - S(32) - S(220), by, S(220), S(42)), "INSTALL & JOIN", primaryStyle)) mp.AcceptServerMods();
if (GUI.Button(new Rect(r.xMax - S(32) - S(220) - S(12) - S(140), by, S(140), S(42)), "CANCEL", secondaryStyle)) mp.DeclineServerMods();
}
/// <summary>Seite "Mods": geladene MSCO-Mods, Ordner öffnen.</summary>
void DrawMods()
{
GUILayout.BeginHorizontal();
GUILayout.Label("Mods", titleStyle, GUILayout.ExpandWidth(true));
if (GUILayout.Button("OPEN FOLDER", tabStyle, GUILayout.Height(S(28)), GUILayout.Width(S(130))))
{
try
{
System.IO.Directory.CreateDirectory(ModLoader.Folder);
System.Diagnostics.Process.Start("explorer.exe", "\"" + ModLoader.Folder + "\"");
}
catch { }
}
GUILayout.EndHorizontal();
GUILayout.Label("Put MSCO mods (.dll) into MSCO\\Mods in the game folder and restart the game - or manage them in the MSCO Launcher. " +
"Mods from a server are downloaded when you join it (you are asked first) and only run on that server.", textStyle);
GUILayout.Space(S(12));
modsScroll = GUILayout.BeginScrollView(modsScroll, GUILayout.Height(ListHeight(S(150f), S(140f))));
if (ModLoader.All.Count == 0) GUILayout.Label("No mods installed.", textStyle);
foreach (ModLoader.LoadedMod m in ModLoader.All)
{
Rect row = GUILayoutUtility.GetRect(S(10), S(52), GUILayout.ExpandWidth(true));
var box = new Rect(row.x, row.y + S(3), row.width, row.height - S(6));
GUI.Box(box, GUIContent.none, rowBox);
GUI.DrawTexture(new Rect(box.x + S(14), box.y + (box.height - S(8)) / 2f, S(8), S(8)),
m.Error != null ? texPending : m.Active ? texOnline : texMuted);
string name = "?", version = "", author = "";
try { name = m.Mod.Name; version = m.Mod.Version; author = m.Mod.Author; } catch { }
float nx = box.x + S(32);
if (m.FromServer) nx += Pill(nx, new Rect(box.x, box.y, box.width, box.height), "SERVER", pillMutedStyle);
GUI.Label(new Rect(nx, box.y, box.width * 0.5f, box.height), name + " " + version, rowNameStyle);
string state = m.Error != null ? "turned off: " + m.Error : m.Active ? "active" : m.FromServer ? "only on its server" : "off";
GUI.Label(new Rect(box.x + box.width * 0.55f, box.y, box.width * 0.45f - S(14), box.height),
(string.IsNullOrEmpty(author) ? "" : "by " + author + " · ") + state, headerCellStyle);
}
GUILayout.EndScrollView();
}
bool newGameConfirm;
/// <summary>Host: eigenen Spielstand weiterspielen oder ein neues Spiel anlegen (mit Sicherheitsabfrage).</summary>
@@ -933,8 +1145,16 @@ namespace MSCO
// Fenster und Seitenleiste mit feinem Rand (Seitenleiste: ihr Rand trennt sie vom Inhalt)
windowBox = UiTheme.Card(Background, P(10));
sidebarBox = UiTheme.Card(Sidebar, P(10));
rowBox = UiTheme.Panel9(Field, P(UiTheme.Radius));
rowHoverBox = UiTheme.Card(FieldFocus, P(UiTheme.Radius));
rowBox = UiTheme.Card(Field, P(10));
rowHoverBox = new GUIStyle(rowBox);
rowHoverBox.normal.background = UiTheme.RoundedEx(FieldFocus, FieldFocus, new Color(Accent.r, Accent.g, Accent.b, 0.45f), true, P(10), P(10) * 2 + 4);
texGlow = Glow(new Color(Accent.r, Accent.g, Accent.b, 0.16f));
scrollbarStyle = new GUIStyle();
scrollbarStyle.fixedWidth = S(8);
scrollbarStyle.margin = new RectOffset(P(6), 0, 0, 0);
scrollThumbStyle = UiTheme.Panel9(new Color(1f, 1f, 1f, 0.14f), P(3));
scrollThumbStyle.fixedWidth = S(6);
scrollThumbStyle.hover.background = UiTheme.Rounded(new Color(1f, 1f, 1f, 0.24f), P(3));
texDivider = Solid(new Color(1f, 1f, 1f, 0.07f));
texGold = Solid(Gold);
texBarBack = Solid(new Color(1f, 1f, 1f, 0.1f));
@@ -955,14 +1175,26 @@ namespace MSCO
pillFeaturedStyle = new GUIStyle(pillStyle);
pillFeaturedStyle.normal.background = UiTheme.Rounded(Gold, P(4));
pillFeaturedStyle.normal.textColor = Hex(0x1a, 0x12, 0x06);
featuredRowBox = UiTheme.Panel9(Color.Lerp(Field, Gold, 0.16f), P(UiTheme.Radius));
pillDangerStyle = new GUIStyle(pillStyle);
pillDangerStyle.normal.background = UiTheme.Rounded(new Color(UiTheme.Danger.r, UiTheme.Danger.g, UiTheme.Danger.b, 0.25f), P(4));
pillDangerStyle.normal.textColor = UiTheme.DangerHover;
featuredRowBox = UiTheme.Card(Color.Lerp(Field, Gold, 0.14f), P(10));
chipOnStyle = Button(new Color(Accent.r, Accent.g, Accent.b, 0.22f), new Color(Accent.r, Accent.g, Accent.b, 0.32f), AccentHover, P(11), FontStyle.Bold);
chipOffStyle = Button(Field, FieldFocus, Muted, P(11), FontStyle.Bold);
chipOnStyle = Button(new Color(Accent.r, Accent.g, Accent.b, 0.2f), new Color(Accent.r, Accent.g, Accent.b, 0.3f), AccentHover, P(11), FontStyle.Bold, P(11), new Color(Accent.r, Accent.g, Accent.b, 0.55f));
chipOffStyle = Button(Field, FieldFocus, Muted, P(11), FontStyle.Bold, P(11), UiTheme.Border);
chipOffStyle.hover.textColor = Color.white;
chipOnStyle.padding = chipOffStyle.padding = new RectOffset(P(12), P(12), P(0), P(0));
logoStyle = Label(P(28), Color.white, FontStyle.Bold);
taglineStyle = Label(P(10), Muted, FontStyle.Bold);
betaPillStyle = UiTheme.Panel9(UiTheme.Accent, 4);
if (UiTheme.DynamicFont != null) betaPillStyle.font = UiTheme.DynamicFont;
betaPillStyle.normal.textColor = new Color(0.1f, 0.07f, 0.02f);
betaPillStyle.fontSize = P(10);
betaPillStyle.fontStyle = FontStyle.Bold;
betaPillStyle.alignment = TextAnchor.MiddleCenter;
betaNoteStyle = Label(P(11), Muted, FontStyle.Normal);
betaNoteStyle.wordWrap = true;
titleStyle = Label(P(24), Color.white, FontStyle.Bold);
textStyle = Label(P(13), Muted, FontStyle.Normal);
textStyle.wordWrap = true;
@@ -992,50 +1224,98 @@ namespace MSCO
toggleStyle.fontSize = P(11);
toggleStyle.fontStyle = FontStyle.Bold;
navStyle = Button(Sidebar, Secondary, Muted, P(14), FontStyle.Normal);
navStyle = Button(Sidebar, new Color(1f, 1f, 1f, 0.05f), Muted, P(14), FontStyle.Normal, P(10));
navStyle.hover.textColor = Color.white; // Hover: Text hell (vorher blieb er grau)
navStyle.alignment = TextAnchor.MiddleLeft;
navStyle.padding = new RectOffset(P(26), P(10), P(0), P(0));
navActiveStyle = Button(Secondary, Secondary, Color.white, P(14), FontStyle.Bold);
navStyle.padding = new RectOffset(P(22), P(10), P(0), P(0));
navActiveStyle = Button(Color.Lerp(Sidebar, Accent, 0.12f), Color.Lerp(Sidebar, Accent, 0.16f), Color.white, P(14), FontStyle.Bold, P(10), new Color(Accent.r, Accent.g, Accent.b, 0.25f));
navActiveStyle.alignment = TextAnchor.MiddleLeft;
navActiveStyle.padding = new RectOffset(P(26), P(10), P(0), P(0));
navActiveStyle.padding = new RectOffset(P(22), P(10), P(0), P(0));
primaryStyle = Button(Accent, AccentHover, Color.white, P(14), FontStyle.Bold);
UiTheme.ApplyPrimaryGradient(primaryStyle);
dangerStyle = Button(UiTheme.Danger, UiTheme.DangerHover, Color.white, P(13), FontStyle.Bold);
secondaryStyle = Button(Secondary, SecondaryHover, Color.white, P(14), FontStyle.Bold);
tabStyle = Button(Sidebar, Secondary, Muted, P(11), FontStyle.Bold);
primaryStyle = Button(Accent, AccentHover, Color.white, P(14), FontStyle.Bold, P(10));
primaryStyle.normal.background = primaryStyle.focused.background = UiTheme.RoundedEx(AccentHover, Accent, Color.clear, false, P(10));
primaryStyle.hover.background = primaryStyle.active.background = primaryStyle.onNormal.background =
UiTheme.RoundedEx(Color.Lerp(AccentHover, Color.white, 0.12f), AccentHover, Color.clear, false, P(10));
dangerStyle = Button(UiTheme.Danger, UiTheme.DangerHover, Color.white, P(13), FontStyle.Bold, P(10));
secondaryStyle = Button(Hex(0x22, 0x22, 0x28), Hex(0x2a, 0x2a, 0x31), Color.white, P(14), FontStyle.Bold, P(10), UiTheme.Border);
tabStyle = Button(Hex(0x1b, 0x1b, 0x20), Hex(0x24, 0x24, 0x2a), Muted, P(11), FontStyle.Bold, P(10), UiTheme.Border);
tabStyle.hover.textColor = Color.white;
fieldStyle = UiTheme.Field(P(14));
fieldStyle.normal.background = UiTheme.RoundedEx(Hex(0x16, 0x16, 0x1a), Hex(0x16, 0x16, 0x1a), UiTheme.Border, true, P(10), P(10) * 2 + 4);
fieldStyle.hover.background = UiTheme.RoundedEx(Hex(0x19, 0x19, 0x1e), Hex(0x19, 0x19, 0x1e), new Color(1f, 1f, 1f, 0.14f), true, P(10), P(10) * 2 + 4);
fieldStyle.focused.background = UiTheme.RoundedEx(Hex(0x19, 0x19, 0x1e), Hex(0x19, 0x19, 0x1e), Accent, true, P(10), P(10) * 2 + 4);
fieldStyle.border = new RectOffset(P(10) + 2, P(10) + 2, P(10) + 2, P(10) + 2);
// Ankündigungen: Karte mit Innenabstand, Titel fett auf Höhe des Abzeichens, Text gut lesbar
newsBoxStyle = UiTheme.Card(Field, P(10));
newsBoxStyle.padding = new RectOffset(P(18), P(12), P(12), P(12));
newsTitleStyle = Label(P(15), Color.white, FontStyle.Bold);
newsTitleStyle.alignment = TextAnchor.MiddleLeft;
newsTitleStyle.wordWrap = true;
newsTextStyle = Label(P(13), new Color(0.86f, 0.86f, 0.9f), FontStyle.Normal);
newsTextStyle.wordWrap = true;
newsTextStyle.margin = new RectOffset(0, 0, P(6), 0);
newsCloseStyle = Label(P(18), Muted, FontStyle.Normal);
newsCloseStyle.alignment = TextAnchor.MiddleCenter;
newsCloseStyle.hover.textColor = Color.white;
texNewsInfo = Solid(Hex(0x5a, 0xa0, 0xff));
texNewsWarning = Solid(Gold);
texNewsCritical = Solid(UiTheme.Danger);
stylesReady = true;
}
static GUIStyle Label(int size, Color color, FontStyle font)
{
var s = new GUIStyle(GUI.skin.label);
if (UiTheme.DynamicFont != null) s.font = UiTheme.DynamicFont;
s.fontSize = size;
s.fontStyle = font;
s.normal.textColor = color;
return s;
}
static GUIStyle Button(Color normal, Color hover, Color text, int size, FontStyle font)
/// <summary>Knopf mit runden Ecken (9-Slice, siehe UiTheme), optional mit feinem Rand wie .btn auf der Website.</summary>
static GUIStyle Button(Color normal, Color hover, Color text, int size, FontStyle font, int radius = UiTheme.Radius, Color border = default(Color))
{
var s = new GUIStyle(GUI.skin.button);
// abgerundete Ecken (9-Slice), siehe UiTheme
s.normal.background = UiTheme.Rounded(normal);
s.hover.background = UiTheme.Rounded(hover);
s.active.background = UiTheme.Rounded(hover);
s.focused.background = UiTheme.Rounded(normal);
s.normal.textColor = s.hover.textColor = s.active.textColor = s.focused.textColor = text;
s.border = new RectOffset(UiTheme.Radius + 2, UiTheme.Radius + 2, UiTheme.Radius + 2, UiTheme.Radius + 2);
if (UiTheme.DynamicFont != null) s.font = UiTheme.DynamicFont;
bool withBorder = border.a > 0f;
int h = radius * 2 + 4;
Texture2D n = UiTheme.RoundedEx(normal, normal, border, withBorder, radius, h);
Texture2D o = UiTheme.RoundedEx(hover, hover, withBorder ? new Color(border.r, border.g, border.b, Mathf.Min(1f, border.a * 1.8f)) : border, withBorder, radius, h);
s.normal.background = n;
s.focused.background = n;
s.hover.background = o;
s.active.background = o;
s.onNormal.background = o;
s.normal.textColor = s.hover.textColor = s.active.textColor = s.focused.textColor = s.onNormal.textColor = text;
s.border = new RectOffset(radius + 2, radius + 2, radius + 2, radius + 2);
s.fontSize = size;
s.fontStyle = font;
return s;
}
/// <summary>Weicher runder Schein (Mitte deckend, nach außen durchsichtig) - für den orangen Schimmer oben.</summary>
static Texture2D Glow(Color color)
{
const int w = 128, h = 64;
var tex = new Texture2D(w, h, TextureFormat.ARGB32, false);
tex.wrapMode = TextureWrapMode.Clamp;
tex.filterMode = FilterMode.Bilinear;
for (int y = 0; y < h; y++)
for (int x = 0; x < w; x++)
{
float dx = (x + 0.5f) / w * 2f - 1f, dy = (y + 0.5f) / h * 2f - 1f;
float d = Mathf.Clamp01(Mathf.Sqrt(dx * dx + dy * dy));
float a = 1f - d;
tex.SetPixel(x, y, new Color(color.r, color.g, color.b, color.a * a * a));
}
tex.Apply();
tex.hideFlags = HideFlags.HideAndDontSave;
return tex;
}
static Texture2D Solid(Color color)
{
var tex = new Texture2D(1, 1);
+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.
+70 -66
View File
@@ -1,22 +1,37 @@
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
{
/// <summary>
/// Version der Mod (MSCLoader / MSC Launcher). Kleine Updates: nur die dritte Stelle erhöhen (2.8.1, 2.8.2 ...) -
/// sie bleiben mit 2.8-Servern und -Mitspielern kompatibel.
/// 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.8.1";
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 und eine neue Mod-Version 2.9.0 o.ä.
/// auch GameServer.ModVersion im Server.
/// </summary>
public const string ProtocolVersion = "2.8";
/// <summary>Passt eine andere Version (Server, Host, Mitspieler) zu uns? Verglichen werden nur die ersten zwei Stellen.</summary>
/// <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;
/// <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;
/// <summary>Passt eine andere Version (Server, Host, Mitspieler) zu uns? Verglichen wird die Netzwerk-Version.</summary>
public static bool Compatible(string other)
{
return Protocol(other) == ProtocolVersion;
@@ -30,78 +45,67 @@ namespace MSCO
return parts.Length >= 2 ? parts[0] + "." + parts[1] : version.Trim();
}
public override string ID => "MSCO"; // Your (unique) mod ID
public override string Name => "MSCO"; // Your mod name
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 { } }
bool commandAdded;
public override void ModSetup()
{
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);
/// <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>Taste zum Ein-/Aussteigen als Beifahrer (in den MSCLoader-Einstellungen änderbar).</summary>
internal static SettingsKeybind PassengerKey;
/// <summary>MSCO-Konsole (Standard F7) und Chat (Standard T), ebenfalls änderbar.</summary>
internal static SettingsKeybind ConsoleKey, ChatKey;
private void Mod_Settings()
/// <summary>Tastenbelegung, gespeichert in den PlayerPrefs (früher in den MSCLoader-Einstellungen).</summary>
public class KeyBinding
{
// 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);
// Nicht F8: im Pausenmenü öffnet F8 ein Test-Menü des Spiels. Neue ID, damit alte F8-Belegungen nicht übernommen werden
ConsoleKey = Keybind.Add("console_f7", "Open MSCO console", KeyCode.F7);
public readonly string Id, Description;
public readonly KeyCode Default;
bool loaded;
KeyCode key;
public KeyBinding(string id, string description, KeyCode defaultKey)
{
Id = id;
Description = description;
Default = defaultKey;
}
private void Mod_OnMenuLoad()
string Pref { get { return "MSCO_Key_" + Id; } }
public KeyCode Key
{
MscoLog.Info("Welcome to MSCO by mbm.wtf");
// Netzwerk + Lobby leben in einem eigenen Objekt, das Szenenwechsel überlebt
MultiplayerManager.Ensure();
if (!commandAdded)
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
key = KeyCode.None;
if (string.IsNullOrEmpty(text)) return false;
try
{
key = (KeyCode)System.Enum.Parse(typeof(KeyCode), text.Trim(), true);
return true;
}
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
catch { return false; }
}
}
}
+61 -114
View File
@@ -1,152 +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="WheelSpin.cs" />
<Compile Include="DamageSync.cs" />
<Compile Include="WorldVarSync.cs" />
<Compile Include="TrafficSync.cs" />
<Compile Include="HingeSync.cs" />
<Compile Include="ControlSync.cs" />
<Compile Include="Toasts.cs" />
<Compile Include="PlayerNeeds.cs" />
<Compile Include="Favorites.cs" />
<Compile Include="MpCommand.cs" />
<Compile Include="Commands.cs" />
<Compile Include="MscoLog.cs" />
<Compile Include="ItemSpawner.cs" />
<Compile Include="Cheats.cs" />
<Compile Include="InputLock.cs" />
<Compile Include="Overlays.cs" />
<Compile Include="SafetyPatches.cs" />
<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="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="GameCharacter.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>
<!-- 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;
}
}
}
+26 -3
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);
+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 F7-Konsole.</summary>
public class MpCommand : ConsoleCommand
{
public override string Name => "mp";
public override string Help => "MSCO commands, e.g. mp help | mp connect <host[:port]> | mp save (or press F7)";
public override void Run(string[] args)
{
Commands.Execute(args, line => ModConsole.Print("[MSCO] " + line));
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ using UnityEngine;
namespace MSCO
{
/// <summary>
/// Zentrales Log der Mod: alle Meldungen landen in der MSCO-Konsole (F7), nicht in der MSCLoader-Konsole.
/// 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>
+408 -32
View File
@@ -1,7 +1,6 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.IO;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
@@ -72,6 +71,24 @@ namespace MSCO
const float NeedsInterval = 10f;
void ToastJoined(string name) { toasts.Add(name, "joined the game", UiTheme.Online); }
/// <summary>
/// Steam-Startoptionen mit "--doorstop-mono-debug-enabled true" (vom MSCLoader-Entwicklermodus): das ganze Spiel läuft
/// mit Mono-Debugger und spürbar langsamer. Einmal deutlich melden.
/// </summary>
void WarnDebugLaunchOptions()
{
string[] args = System.Environment.GetCommandLineArgs();
int i = System.Array.FindIndex(args, a => a.Equals("--doorstop-mono-debug-enabled", System.StringComparison.OrdinalIgnoreCase));
if (i < 0 || i + 1 >= args.Length || !(args[i + 1] == "true" || args[i + 1] == "1")) return;
MscoLog.Warning("[MSCO] The game runs with the Mono debugger (Steam launch options: --doorstop-mono-debug-enabled true) - " +
"that costs FPS. In Steam: My Summer Car -> Properties -> General -> Launch options: delete everything.");
toasts.Add("Debug mode costs FPS", "Clear the Steam launch options of My Summer Car", UiTheme.Gold);
}
const string OutdatedServerText = "This server is outdated - it is missing MSCO security updates. You can join again once its owner has updated it (web panel -> Update).";
const string OutdatedHostText = "The host uses an outdated MSCO version that is missing security updates. Ask them to update " +
"MSCO (start the game once through the MSCO Launcher), then join again.";
void ToastLeft(string name) { toasts.Add(name, "left the game", UiTheme.Muted); }
readonly Overlays overlays = new Overlays();
bool isAdmin; // Server hat uns als Admin gemeldet
@@ -109,6 +126,14 @@ namespace MSCO
{
Application.runInBackground = true;
playerName = PlayerPrefs.GetString(PrefName, System.Environment.UserName);
WarnDebugLaunchOptions();
// Neue Ankündigung der MSCO-Admins, während man spielt: kurz einblenden und in die Konsole (F7)
SiteStatus.NewAnnouncement += a =>
{
toasts.Add(a.Title, a.Level == "critical" ? "Important announcement" : "MSCO announcement",
a.Level == "critical" ? UiTheme.Danger : a.Level == "warning" ? UiTheme.Gold : UiTheme.Accent);
MscoLog.Info("[MSCO] Announcement: " + a.Title + (string.IsNullOrEmpty(a.Text) ? "" : " - " + a.Text));
};
objects.Send = SendGame;
// Einkäufe / verbrauchte Items bei uns: allen melden
objects.ItemAppeared = b =>
@@ -193,6 +218,7 @@ namespace MSCO
objects.LocalId = localId;
AddPlayer(localId, playerName, true);
SetStatus("Hosting on port " + port + " - waiting for players...");
ModLoader.SessionStart();
}
catch (System.Exception e)
{
@@ -339,6 +365,7 @@ namespace MSCO
void MeasureFps()
{
float dt = Time.unscaledDeltaTime;
PerfStats.Frame(dt);
fpsFrames++;
fpsTimer += dt;
frameMsWorstWindow = Mathf.Max(frameMsWorstWindow, dt * 1000f);
@@ -366,10 +393,22 @@ namespace MSCO
}
// grün ab 50, gelb ab 30, sonst rot
fpsStyle.normal.textColor = fpsValue < 0f ? UiTheme.Muted : fpsValue >= 50f ? UiTheme.Online : fpsValue >= 30f ? UiTheme.Pending : UiTheme.Danger;
var content = new GUIContent(fpsValue < 0f ? "FPS ..." : "FPS " + Mathf.RoundToInt(fpsValue));
float width = fpsStyle.CalcSize(content).x;
GUI.Box(new Rect(rightEdge - width, 8, width, 24), content, fpsStyle);
int shown = fpsValue < 0f ? -1 : Mathf.RoundToInt(fpsValue);
if (shown != fpsShown || fpsContent.text == null)
{
fpsShown = shown;
fpsContent.text = shown < 0 ? "FPS ..." : "FPS " + shown;
fpsWidth = fpsStyle.CalcSize(fpsContent).x;
}
GUI.Box(new Rect(rightEdge - fpsWidth, 8, fpsWidth, 24), fpsContent, fpsStyle);
}
// Anzeige-Texte zwischengespeichert (nur bei Änderung neu gebaut, siehe DrawFps/DrawPing)
readonly GUIContent fpsContent = new GUIContent(), pingContent = new GUIContent();
int fpsShown = int.MinValue;
float fpsWidth;
string pingShown;
float pingTextWidth;
/// <summary>Text für Anzeige und Konsole.</summary>
public string PingText
@@ -378,7 +417,10 @@ namespace MSCO
{
if (session == null) return "not connected";
if (session.IsHost) return "you are the host";
return pingMs < 0f ? "measuring..." : Mathf.RoundToInt(pingMs) + " ms";
if (pingMs < 0f) return "measuring...";
int ms = Mathf.RoundToInt(pingMs);
if (ms != pingTextMs || pingTextCache == null) { pingTextMs = ms; pingTextCache = ms + " ms"; }
return pingTextCache;
}
}
@@ -396,12 +438,20 @@ namespace MSCO
// Farbe nach Qualität: grün < 80 ms, gelb < 150 ms, sonst rot
Color color = pingMs < 0f ? UiTheme.Muted : pingMs < 80f ? UiTheme.Online : pingMs < 150f ? UiTheme.Pending : UiTheme.Danger;
pingStyle.normal.textColor = color;
var content = new GUIContent("PING " + PingText);
float width = pingStyle.CalcSize(content).x;
GUI.Box(new Rect(Screen.width - width - 12, 8, width, 24), content, pingStyle);
string ping = PingText; // ändert sich nur, wenn eine neue Messung da ist
if (ping != pingShown)
{
pingShown = ping;
pingContent.text = "PING " + ping;
pingTextWidth = pingStyle.CalcSize(pingContent).x;
}
float width = pingTextWidth;
GUI.Box(new Rect(Screen.width - width - 12, 8, width, 24), pingContent, pingStyle);
pingWidth = width + 8;
}
float pingWidth; // Platz, den die Ping-Anzeige belegt (FPS steht links daneben)
int pingTextMs = int.MinValue;
string pingTextCache;
// ---------------------------------------------------------------- Cheats (Admin-Menü)
@@ -456,6 +506,20 @@ namespace MSCO
}
/// <summary>Admin: Satsuma neben sich holen (z.B. wenn er verschwunden oder im Boden ist).</summary>
/// <summary>Admin: sich selbst zu einem Ort teleportieren (Haus, Laden ... siehe Cheats.Places).</summary>
public void TeleportToPlace(string label, string objectName)
{
if (!CanCheat || localPlayer == null) return;
Vector3 pos;
if (!Cheats.FindPlace(objectName, out pos)) { overlays.Log(label + " not found (only works in the game)"); return; }
passenger.Exit(localPlayer, playerCamera);
if (Cheats.Teleport(localPlayer, pos))
{
overlays.Log("Teleported to " + label);
AnnounceCheat("teleported to " + label);
}
}
public void BringCar()
{
if (!CanCheat || localPlayer == null) return;
@@ -554,6 +618,25 @@ namespace MSCO
}
/// <summary>Cheats für alle sichtbar im Chat melden.</summary>
/// <summary>Admin-Menü (Reiter VEHICLE): Änderung am Fahrzeug im Chat melden.</summary>
public void VehicleCheat(string what)
{
if (CanCheat) AnnounceCheat(what);
}
/// <summary>Admin: Uhrzeit (kind 1, Stunde) oder Wetter (kind 2: 0 klar, 1 Regen, 2 Gewitter) für alle umstellen.</summary>
public void WorldCheat(byte kind, int value)
{
if (!CanCheat) return;
Vector3 pos = localPlayer != null ? localPlayer.position : Vector3.zero;
string error = WorldAdmin.Apply(kind, value, pos);
if (error != null) { overlays.Log(error); return; }
SendModMessage(WorldAdmin.MessageId, WorldAdmin.Build(kind, value, pos));
AnnounceCheat(kind == WorldAdmin.KindHour
? "set the time to " + value.ToString("00") + ":00"
: "set the weather to " + WorldAdmin.WeatherNames[value]);
}
void AnnounceCheat(string what)
{
string text = OwnName() + " " + what + " (admin)";
@@ -612,6 +695,18 @@ namespace MSCO
if (host != null) JoinServer(host, port);
}
/// <summary>JOIN-Link aus dem Browser, während das Spiel schon läuft (über den Launcher, siehe JoinLinks).</summary>
void CheckJoinRequest()
{
string host;
int port;
if (!JoinLinks.TryTakeRequest(out host, out port)) return;
PendingJoinHost = host;
PendingJoinPort = port;
if (IsInSession || !MenuHelper.InMenu)
toasts.Add("Join link: " + host + ":" + port, "leave this game - you join automatically in the main menu", UiTheme.Accent);
}
public void CancelPendingJoin()
{
PendingJoinHost = null;
@@ -686,6 +781,13 @@ namespace MSCO
}
string name = string.IsNullOrEmpty(displayName) ? info.Name : displayName;
int max = info.MaxPlayers > 0 ? info.MaxPlayers : maxPlayers;
// Fehlen dem Server Sicherheitsupdates, nicht beitreten (auch nicht nach Rückfrage)
if (info.Security < SiteStatus.RequiredServerSecurity)
{
DisconnectNotice = OutdatedServerText;
SetStatus("Server " + name + " is outdated (security level " + info.Security + ", required " + SiteStatus.RequiredServerSecurity + ")");
yield break;
}
if (!MSCO.Compatible(info.Version))
{
@@ -858,6 +960,7 @@ namespace MSCO
objects.LocalId = localId;
AddPlayer(localId, OwnName(), true);
SetStatus("Steam lobby ready - invite your friends!");
ModLoader.SessionStart();
}
void OnSteamLobbyEntered(Steamworks.CSteamID lobby, Steamworks.CSteamID owner)
@@ -975,6 +1078,12 @@ namespace MSCO
void Update()
{
CheckHitch();
SiteStatus.Tick(); // Ankündigungen, Wartung, Mindest-Sicherheitsstufe vom Keymaster
CheckJoinRequest();
// JOIN im Server-Browser (Launcher/Website): ohne Rückfrage verbinden, sobald das Hauptmenü steht.
// Server-Mods fragen weiterhin extra nach (das sind Programme).
if (PendingJoinHost != null && !IsInSession && !checkingServer && MenuHelper.InMenu && Time.timeSinceLevelLoad > 1f)
ConfirmPendingJoin();
MeasureFps();
// Menü-Buttons des Spiels nicht durch unser Fenster hindurch auslösen (z.B. Changelog)
// Solange das Multiplayer-Fenster offen ist, reagiert das Spielmenü (CONTINUE, QUIT ...) gar nicht;
@@ -1103,7 +1212,7 @@ namespace MSCO
public string DisconnectNotice { get; private set; }
// ---------------------------------------------------------------- Über Neuladen hinweg merken
// Beim Wechsel ins Hauptmenü lädt MSCLoader alle Mods neu - dabei ginge der Kick-Grund bzw. "bitte neu
// Beim Wechsel ins Hauptmenü wird die Sitzung neu aufgebaut - dabei ginge der Kick-Grund bzw. "bitte neu
// verbinden" verloren, bevor man das Menü überhaupt sieht. Deshalb in den PlayerPrefs sichern.
const string PrefNotice = "MSCO_PendingNotice";
const string PrefReconnect = "MSCO_PendingReconnect";
@@ -1244,6 +1353,7 @@ namespace MSCO
if (r.Contains("kick")) return "KICKED";
if (r.Contains("shutting down") || r.Contains("server stopped") || r.Contains("shut down")) return "SERVER CLOSED";
if (r.Contains("could not reconnect")) return "RECONNECT FAILED";
if (r.Contains("outdated")) return "UPDATE REQUIRED"; // Server/Host ohne Sicherheitsupdates
return "DISCONNECTED";
}
}
@@ -1313,6 +1423,10 @@ namespace MSCO
connectingToServer = false;
readySent = false;
pendingSnapshot = null;
PendingMods = null;
downloadingMods.Clear();
deferredStart = null;
ModLoader.SessionEnd();
SteamLobby.CancelAuthTicket();
passenger.Exit(localPlayer, playerCamera); // Beifahrer: neben das Auto stellen
foreach (PlayerInfo p in players.Values) DestroyAvatar(p);
@@ -1339,7 +1453,7 @@ namespace MSCO
if (wasClientInGame)
{
SetStatus("Disconnected from host: " + reason);
PersistPendingState(); // MSCLoader lädt die Mods im Menü neu - vorher sichern
PersistPendingState(); // das Menü baut alles neu auf - vorher sichern
Application.LoadLevel(MenuHelper.MenuScene);
}
}
@@ -1353,7 +1467,7 @@ namespace MSCO
|| t == MsgType.Chat || t == MsgType.VarSync || t == MsgType.SpawnItem
|| t == MsgType.ItemAppeared || t == MsgType.ObjDestroy || t == MsgType.VehicleDamage
|| t == MsgType.TrafficStates || t == MsgType.WorldVars || t == MsgType.HingeStates
|| t == MsgType.VehicleControls;
|| t == MsgType.VehicleControls || t == MsgType.ModMessage;
}
void OnMessage(int peerId, Message m)
@@ -1430,6 +1544,9 @@ namespace MSCO
w.Write(false); // kein dedizierter Server
w.Write(list.Count);
foreach (PlayerInfo p in list) { w.Write(p.Id); w.Write(p.Name); }
// Unsere Sicherheitsstufe (aktuelle Mods treten veralteten Hosts nicht bei)
w.Write(MSCO.SecurityLevel);
w.Write(MSCO.ModVersion);
}));
session.SendToAll(MsgType.PlayerJoined, NetSession.Build(w => { w.Write(peerId); w.Write(name); }), peerId);
ToastJoined(name);
@@ -1469,13 +1586,55 @@ namespace MSCO
int id = r.ReadInt32();
AddPlayer(id, r.ReadString(), id == localId);
}
// Sicherheitsstufe des Servers/Hosts (ältere schicken sie nicht = 0): darunter gleich wieder gehen
int level = r.BaseStream.Position < r.BaseStream.Length ? r.ReadInt32() : 0;
if (level < SiteStatus.RequiredServerSecurity)
{
MscoLog.Warning("[MSCO] " + (dedicated ? "Server" : "Host") + " has security level " + level + ", required " +
SiteStatus.RequiredServerSecurity + " - leaving");
serverReason = dedicated ? OutdatedServerText : OutdatedHostText;
reconnectMessage = null; // nicht immer wieder versuchen
session.Close("Outdated " + (dedicated ? "server" : "host"));
OnSessionEnded(session.CloseReason);
return;
}
handshakeDone = true;
reconnectMessage = null; // (wieder) verbunden
reconnectAttempts = 0;
SetStatus(dedicated ? "Connected to server - loading..." : "Connected - waiting for host to start...");
if (PendingMods != null) SetStatus("This server uses mods - confirm to download them");
ModLoader.SessionStart();
break;
}
case MsgType.ModList:
{
var list = new List<ServerModInfo>();
int n = r.ReadInt32();
for (int i = 0; i < n && i < 64; i++)
list.Add(new ServerModInfo { Id = r.ReadString(), Name = r.ReadString(), Version = r.ReadString(), Sha256 = r.ReadString(), Size = r.ReadInt32() });
ResolveServerMods(list);
break;
}
case MsgType.ModFilePart:
{
string id = r.ReadString();
int total = r.ReadInt32(), offset = r.ReadInt32(), len = r.ReadInt32();
byte[] part = r.ReadBytes(len);
ServerModInfo info = downloadingMods.Find(x => x.Id == id);
if (info == null || total != info.Size || total > MaxModBytes || offset < 0 || offset + part.Length > total) break;
if (info.Buffer == null) info.Buffer = new byte[total];
System.Buffer.BlockCopy(part, 0, info.Buffer, offset, part.Length);
info.Received += part.Length;
SetStatus("Downloading mods... " + ModDownloadPercent() + "%");
break;
}
case MsgType.ModFileDone:
FinishModDownload(r.ReadString());
break;
case MsgType.Reject:
{
string why = r.ReadString();
@@ -1549,7 +1708,12 @@ namespace MSCO
case MsgType.SaveRequest:
// Server möchte den aktuellen Spielstand (für neue Spieler / zum Sichern)
StartCoroutine(UploadSave(r.ReadInt32()));
{
int requestId = r.ReadInt32();
// Neuere Server sagen, ob es eilt (jemand tritt bei) - sonst darf auf einen ruhigen Moment gewartet werden
bool urgent = r.BaseStream.Position >= r.BaseStream.Length || r.ReadBoolean();
StartCoroutine(UploadSave(requestId, urgent));
}
break;
case MsgType.Reconnect:
@@ -1604,34 +1768,64 @@ namespace MSCO
}
case MsgType.StartGame:
{
if (!receivingSave) return;
receivingSave = false;
// Später beigetreten: nach dem Laden neben den Host setzen
teleportToHost = r.BaseStream.Length > 0 && r.ReadBoolean();
// Server mit "neues Spiel" (Panel): kein Spielstand - hier ein neues Spiel starten (Server ab 2.9.3)
bool serverNewGame = r.BaseStream.Length - r.BaseStream.Position >= 1 && r.ReadBoolean();
// Fehlen noch Mods des Servers: erst nach Zustimmung + Download ins Spiel
if (PendingMods != null || ModsDownloading)
{
deferredStart = serverNewGame;
break;
}
BeginServerGame(serverNewGame);
break;
}
}
}
/// <summary>Spielstand ist da: ins Spiel (bzw. bei einem neuen Server-Spiel ein neues Spiel anlegen).</summary>
void BeginServerGame(bool serverNewGame)
{
if (serverNewGame)
{
SetStatus("The server starts a new game - creating a fresh world...");
MscoLog.Info("[MSCO] Server has a new game - starting a fresh world");
MenuHelper.StartNewGame();
break;
return;
}
SetStatus(dedicated ? "Loading server world..." : "Loading host's game...");
MenuHelper.StartGame();
break;
}
}
void HandleGameMessage(int from, MsgType type, BinaryReader r)
{
if (from == localId) return;
if (type == MsgType.ModMessage)
{
string modId = r.ReadString();
int len = r.ReadInt32();
if (len < 0 || len > MaxModMessageBytes) return;
if (modId == WorldAdmin.MessageId)
{
// Admin: Uhrzeit/Wetter. Der dedizierte Server lässt das nur von Admins durch; im P2P-Spiel nur vom Host
if (dedicated || from == NetSession.HostId) WorldAdmin.Receive(r.ReadBytes(len));
return;
}
if (modId.StartsWith("msco.")) return; // reserviert für MSCO selbst
ModLoader.Message(modId, from, r.ReadBytes(len));
return;
}
if (type == MsgType.Chat)
{
string text = r.ReadString();
PlayerInfo sender;
if (dedicated && from == NetSession.HostId) overlays.AddChat("", text, true); // Server/Admin-Meldung
else if (text.StartsWith("* ")) overlays.AddChat("", text.Substring(2), true);
// "* ..." = Meldung (z.B. Admin-Aktion): auf dem dedizierten Server filtert der Server, im P2P-Spiel nur vom Host
else if (text.StartsWith("* ") && (dedicated || from == NetSession.HostId)) overlays.AddChat("", text.Substring(2), true);
else overlays.AddChat(players.TryGetValue(from, out sender) ? sender.Name : "Player " + from, text);
return;
}
@@ -1698,6 +1892,117 @@ namespace MSCO
}
/// <summary>Spiel-Nachricht an alle anderen (Client: über den Host).</summary>
// ---------------------------------------------------------------- MSCO-Mods
public sealed class ServerModInfo
{
public string Id, Name, Version, Sha256;
public int Size, Received;
public byte[] Buffer;
}
const int MaxModBytes = 20 * 1024 * 1024, MaxModMessageBytes = 60000;
/// <summary>Mods, die der Server verlangt und die hier fehlen - warten auf Zustimmung des Spielers (null = keine).</summary>
public List<ServerModInfo> PendingMods { get; private set; }
readonly List<ServerModInfo> downloadingMods = new List<ServerModInfo>();
readonly List<string> serverModShas = new List<string>();
bool? deferredStart;
public bool ModsDownloading { get { return downloadingMods.Count > 0; } }
int ModDownloadPercent()
{
long total = 0, done = 0;
foreach (ServerModInfo m in downloadingMods) { total += m.Size; done += m.Received; }
return total > 0 ? (int)(done * 100 / total) : 100;
}
/// <summary>Liste vom Server: vorhandene Mods (gleiche Prüfsumme) einschalten, fehlende zum Bestätigen merken.</summary>
void ResolveServerMods(List<ServerModInfo> list)
{
serverModShas.Clear();
var missing = new List<ServerModInfo>();
foreach (ServerModInfo m in list)
{
if (string.IsNullOrEmpty(m.Id) || m.Id.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || m.Id.Contains("..")) continue;
serverModShas.Add(m.Sha256);
if (ModLoader.FindBySha(m.Sha256) != null) continue;
// Schon einmal geladen (gleiche Datei auf der Platte)?
string file = ModLoader.ServerFile(m.Id);
if (File.Exists(file) && ModLoader.Sha256Of(File.ReadAllBytes(file)) == m.Sha256 && ModLoader.Load(file, true) != null) continue;
missing.Add(m);
}
ModLoader.ActivateServerMods(serverModShas);
PendingMods = missing.Count > 0 ? missing : null;
if (PendingMods != null) MscoLog.Info("[MSCO] The server uses " + list.Count + " mod(s), " + missing.Count + " missing");
}
/// <summary>Spieler stimmt zu: fehlende Mods vom Server laden.</summary>
public void AcceptServerMods()
{
if (PendingMods == null || session == null) return;
downloadingMods.Clear();
downloadingMods.AddRange(PendingMods);
PendingMods = null;
foreach (ServerModInfo m in downloadingMods)
{
string id = m.Id;
session.SendTo(NetSession.HostId, MsgType.ModFileRequest, NetSession.Build(w => w.Write(id)));
}
SetStatus("Downloading mods... 0%");
}
/// <summary>Spieler lehnt ab: ohne die Mods geht es auf diesem Server nicht.</summary>
public void DeclineServerMods()
{
PendingMods = null;
serverReason = "This server needs its mods - you declined to install them.";
reconnectMessage = null;
if (session != null) session.Close("Declined the server's mods");
OnSessionEnded("Declined the server's mods");
}
void FinishModDownload(string id)
{
ServerModInfo m = downloadingMods.Find(x => x.Id == id);
if (m == null) return;
downloadingMods.Remove(m);
if (m.Buffer == null || m.Received < m.Size || ModLoader.Sha256Of(m.Buffer) != m.Sha256)
{
MscoLog.Warning("[MSCO] Mod " + m.Name + " arrived damaged (checksum mismatch) - not loaded");
serverReason = "The mod " + m.Name + " could not be downloaded correctly - try again.";
if (session != null) session.Close("Mod download failed");
OnSessionEnded("Mod download failed");
return;
}
try
{
Directory.CreateDirectory(ModLoader.ServerFolder);
File.WriteAllBytes(ModLoader.ServerFile(m.Id), m.Buffer);
}
catch (System.Exception e) { MscoLog.Warning("[MSCO] Could not save mod " + m.Name + ": " + e.Message); }
if (ModLoader.Load(ModLoader.ServerFile(m.Id), true) == null)
MscoLog.Warning("[MSCO] Mod " + m.Name + " could not be started - see the log");
ModLoader.ActivateServerMods(serverModShas);
if (MenuHelper.InMenu) ModLoader.MenuLoad();
if (downloadingMods.Count > 0) return;
SetStatus("Mods installed - loading the world...");
if (deferredStart != null)
{
bool newGame = deferredStart.Value;
deferredStart = null;
BeginServerGame(newGame);
}
}
/// <summary>Für MscoMod.Send: Daten einer Mod an dieselbe Mod bei allen anderen Spielern.</summary>
public bool SendModMessage(string modId, byte[] data)
{
if (session == null || !handshakeDone || string.IsNullOrEmpty(modId) || data == null || data.Length > MaxModMessageBytes) return false;
SendGame(MsgType.ModMessage, NetSession.Build(w => { w.Write(modId); w.Write(data.Length); w.Write(data); }));
return true;
}
void SendGame(MsgType type, byte[] payload)
{
if (session == null) return;
@@ -1710,9 +2015,11 @@ namespace MSCO
PlayerInfo AddPlayer(int id, string name, bool isLocal)
{
PlayerInfo p;
if (!players.TryGetValue(id, out p)) players[id] = p = new PlayerInfo { Id = id };
bool isNew = !players.TryGetValue(id, out p);
if (isNew) players[id] = p = new PlayerInfo { Id = id };
p.Name = name;
p.IsLocal = isLocal;
if (isNew && !isLocal) ModLoader.PlayerJoined(id, name);
return p;
}
@@ -1720,6 +2027,7 @@ namespace MSCO
{
PlayerInfo p;
if (!players.TryGetValue(id, out p)) return;
if (!p.IsLocal) ModLoader.PlayerLeft(id, p.Name);
DestroyAvatar(p);
objects.ReleaseAllOf(id);
players.Remove(id);
@@ -1794,6 +2102,7 @@ namespace MSCO
void OnLevelWasLoaded(int level)
{
MenuHelper.SceneChanged();
menuButtonsHidden = false; // neues Hauptmenü: Knöpfe wieder ausblenden
nextPresenceUpdate = 0f; // Discord gleich nach dem Szenenwechsel aktualisieren
if (MenuHelper.InGame)
@@ -1934,12 +2243,12 @@ namespace MSCO
PartStart();
try { objects.Update(localPlayer, Time.deltaTime); }
catch (System.Exception e) { ReportError("object sync", e); }
PartStart();
PartEnd("object sync");
PartStart();
try { vehicleFx.Update(objects, SendGame, Time.deltaTime, IsWorldAuthority); }
catch (System.Exception e) { ReportError("vehicle lights/sound", e); }
PartStart();
PartEnd("vehicle lights/sound");
PartStart();
try { worldSync.Update(IsWorldAuthority, SendGame, Time.unscaledDeltaTime); }
catch (System.Exception e) { ReportError("time/weather sync", e); }
PartEnd("time/weather sync");
@@ -1962,7 +2271,7 @@ namespace MSCO
// Gesamtzustand der Fahrzeuge (Tank, Öl, Verschleiß ...)
PartStart();
try { if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame); }
try { if (objects.ScanComplete) vehicleState.Update(objects, localId, SendGame, IsWorldAuthority); }
catch (System.Exception e) { ReportError("vehicle state sync", e); }
PartEnd("vehicle state sync");
// Unfallschäden (verbeulte Karosserie)
@@ -2024,6 +2333,7 @@ namespace MSCO
}
// Abgefangene Schraub-/Montage-Aktionen verschicken
PartStart();
try
{
FsmSync.Enabled = ready;
@@ -2054,23 +2364,39 @@ namespace MSCO
{
double ms = (partWatch.ElapsedTicks - partStart) * 1000.0 / System.Diagnostics.Stopwatch.Frequency;
frameMsco += ms;
PerfStats.Add(name, ms);
if (ms > slowestMs) { slowestMs = ms; slowestPart = name; }
}
/// <summary>Zu Beginn jedes Bilds: war das letzte Bild ein Hänger? Dann Ursache melden (höchstens alle 20 s).</summary>
int lastGcCount = -1;
int hitchesSinceReport;
void CheckHitch()
{
float dt = Time.unscaledDeltaTime;
if (dt > HitchSeconds && MenuHelper.InGame && Time.realtimeSinceStartup >= nextHitchReport)
// Lief in diesem Frame der Garbage Collector? (hält bei großem Speicher das ganze Spiel an)
int gc = System.GC.CollectionCount(0);
bool gcRan = lastGcCount >= 0 && gc != lastGcCount;
lastGcCount = gc;
if (dt > HitchSeconds && MenuHelper.InGame)
{
hitchesSinceReport++;
if (Time.realtimeSinceStartup >= nextHitchReport)
{
nextHitchReport = Time.realtimeSinceStartup + 20f;
int total = Mathf.RoundToInt(dt * 1000f);
string more = hitchesSinceReport > 1 ? " [" + hitchesSinceReport + " hitches since the last report]" : "";
hitchesSinceReport = 0;
// Speichern friert genau den Frame ein, in dem es ausgelöst wurde - also auch so weit zurück schauen
bool saving = Time.realtimeSinceStartup - lastSaveTriggered < dt + 5f;
if (frameMsco > dt * 1000f * 0.4f)
MscoLog.Warning("[MSCO] Hitch " + total + " ms - caused by MSCO: " + slowestPart + " " + slowestMs.ToString("0") +
" ms (MSCO total " + frameMsco.ToString("0") + " ms)");
" ms (MSCO total " + frameMsco.ToString("0") + " ms)" + (gcRan ? ", garbage collection ran" : "") + more);
else
MscoLog.Info("[MSCO] Hitch " + total + " ms - not MSCO (MSCO used " + frameMsco.ToString("0") + " ms)" +
(Time.realtimeSinceStartup - lastSaveTriggered < 5f ? " - the game was saving" : " - the game itself (loading, saving, physics)"));
(saving ? " - the game was saving" : gcRan ? " - garbage collection (memory cleanup)" : " - the game itself (loading, physics)") + more);
}
}
frameMsco = 0;
slowestMs = 0;
@@ -2143,7 +2469,7 @@ namespace MSCO
/// </summary>
System.Collections.IEnumerator ApplyWorldSnapshot(byte[] snapshot)
{
var r = new BinaryReader(new MemoryStream(snapshot));
var r = new PacketReader(snapshot);
if (r.ReadBoolean())
{
@@ -2176,7 +2502,7 @@ namespace MSCO
for (int i = 0; i < eventCount; i++)
{
byte[] payload = r.ReadBytes(r.ReadInt32());
FsmSync.Apply(FsmEventMsg.Deserialize(new BinaryReader(new MemoryStream(payload))));
FsmSync.Apply(FsmEventMsg.Deserialize(new PacketReader(payload)));
if ((i + 1) % SnapshotEventsPerFrame == 0) yield return null; // nicht alles in einem Frame
}
@@ -2201,7 +2527,7 @@ namespace MSCO
for (int i = 0; i < spawns; i++)
{
byte[] payload = r.ReadBytes(r.ReadInt32());
try { if (ApplySpawn(new BinaryReader(new MemoryStream(payload)))) spawned++; }
try { if (ApplySpawn(new PacketReader(payload))) spawned++; }
catch (System.Exception e) { MscoLog.Warning("Could not spawn a saved item: " + e.Message); }
}
}
@@ -2214,7 +2540,7 @@ namespace MSCO
for (int i = 0; i < appeared; i++)
{
byte[] payload = r.ReadBytes(r.ReadInt32());
try { ApplyAppeared(new BinaryReader(new MemoryStream(payload))); }
try { ApplyAppeared(new PacketReader(payload)); }
catch (System.Exception e) { MscoLog.Warning("Could not restore a bought item: " + e.Message); }
}
}
@@ -2258,9 +2584,30 @@ namespace MSCO
/// genau die aktuelle Welt - Schrauben, Teile, alles - statt eines alten Stands mit nachgespielten Aktionen.
/// Der eigene Spielstand ist davon nicht betroffen: er liegt gesichert im Backup und kommt im Hauptmenü zurück.
/// </summary>
System.Collections.IEnumerator UploadSave(int requestId)
System.Collections.IEnumerator UploadSave(int requestId, bool urgent)
{
if (!MenuHelper.InGame || !dedicated || session == null) yield break;
// Speichern lässt das Spiel einige Sekunden hängen: bei einer Routine-Anfrage nicht mitten in schneller Fahrt,
// sondern einen ruhigen Moment abwarten (höchstens 20 s - der Server wartet länger)
if (!urgent)
{
float giveUp = Time.realtimeSinceStartup + 20f;
while (Time.realtimeSinceStartup < giveUp && localPlayer != null && session != null)
{
Vector3 from = localPlayer.position;
float t0 = Time.realtimeSinceStartup;
while (Time.realtimeSinceStartup < t0 + 0.5f) yield return null;
if (localPlayer == null) break;
float speed = (localPlayer.position - from).magnitude / Mathf.Max(0.05f, Time.realtimeSinceStartup - t0);
if (speed < 3f) break;
}
if (session == null || !MenuHelper.InGame) yield break;
}
// Erst den Hinweis zeigen, dann speichern (sonst friert das Bild ein, bevor man ihn sieht)
centerMessage = "Saving the world for the server ...";
centerMessageUntil = Time.realtimeSinceStartup + 4f;
yield return null;
yield return null;
lastSaveTriggered = Time.realtimeSinceStartup;
PlayMakerFSM.BroadcastEvent("SAVEGAME");
float until = Time.realtimeSinceStartup + 3f; // das Spiel schreibt die Dateien über mehrere Frames
@@ -2396,6 +2743,24 @@ namespace MSCO
// ---------------------------------------------------------------- GUI
void OnGUI()
{
PartStart();
// Eigene dynamische Schrift (siehe UiTheme.DynamicFont); danach wieder die bisherige
GUISkin skin = GUI.skin;
Font previousFont = skin.font;
Font font = UiTheme.DynamicFont;
if (font != null) skin.font = font;
try { DrawGui(); }
finally
{
skin.font = previousFont;
PartEnd("interface (OnGUI)");
}
}
string centerMessageSource, centerMessageUpper; // Großschreibung nur einmal pro Meldung (nicht jedes Frame)
void DrawGui()
{
InputLock.KeepCursorFree();
if (MenuHelper.InMenu)
@@ -2413,7 +2778,8 @@ namespace MSCO
{
int oldSize = savingStyle.fontSize;
savingStyle.fontSize = 30;
GUI.Label(new Rect(0, Screen.height / 2f - 40, Screen.width, 80), centerMessage.ToUpper(), savingStyle);
if (!ReferenceEquals(centerMessage, centerMessageSource)) { centerMessageSource = centerMessage; centerMessageUpper = centerMessage.ToUpper(); }
GUI.Label(new Rect(0, Screen.height / 2f - 40, Screen.width, 80), centerMessageUpper, savingStyle);
savingStyle.fontSize = oldSize;
}
@@ -2422,7 +2788,7 @@ namespace MSCO
overlays.Draw(this, session != null);
}
/// <summary>F7 Konsole, T Chat (Tasten in den MSCLoader-Einstellungen änderbar).</summary>
/// <summary>F7 Konsole, T Chat (Tasten mit "bind" in der Konsole änderbar).</summary>
void UpdateOverlayKeys()
{
if (MSCO.ConsoleKey != null && MSCO.ConsoleKey.GetKeybindDown()) overlays.ToggleConsole();
@@ -2433,12 +2799,22 @@ namespace MSCO
InputLock.Set(overlays.AnyOpen && MenuHelper.InGame);
}
string hudText;
int hudTextKey;
void DrawHud()
{
// Statuszeile oben in der Mitte (links oben ist die Spielanzeige)
string state = IsFullyConnected ? "CONNECTED" : IsPeerReady ? "SYNCING..." : "WAITING";
string text = "MSCO • " + state + " • " + (dedicated ? "Server" : IsHost ? "Host" : "Client") + " • " +
// Text nur neu bauen, wenn sich etwas geändert hat (nicht jedes Frame neue Strings)
int key = (state.Length * 31 + players.Count) * 100003 + objects.Count + (dedicated ? 7 : IsHost ? 3 : 1) * 1000000007;
if (hudText == null || key != hudTextKey)
{
hudTextKey = key;
hudText = "MSCO BETA • " + state + " • " + (dedicated ? "Server" : IsHost ? "Host" : "Client") + " • " +
players.Count + " player(s) • " + objects.Count + " synced objects";
}
string text = hudText;
var rect = new Rect((Screen.width - 520) / 2f, 6, 520, 24);
if (hudBox == null) hudBox = UiTheme.Panel9(new Color(0.06f, 0.06f, 0.07f, 0.85f), 8);
GUI.Box(rect, GUIContent.none, hudBox);
+118 -19
View File
@@ -52,7 +52,12 @@ namespace MSCO
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]
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
@@ -62,7 +67,7 @@ namespace MSCO
public BinaryReader Reader()
{
return new BinaryReader(new MemoryStream(Payload));
return new PacketReader(Payload);
}
}
@@ -195,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;
@@ -210,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)
{
@@ -227,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
{
@@ -258,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);
stream.Write(frame, 0, frame.Length); // blockierend, damit große Dateien komplett rausgehen
}
catch (Exception e)
bool tooMuch;
lock (gate)
{
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 { }
}
}
@@ -308,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); }
@@ -414,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
{
@@ -563,8 +636,17 @@ 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)
{
if (building)
{
// Build innerhalb von Build: eigener Puffer
using (var ms = new MemoryStream())
using (var w = new BinaryWriter(ms))
{
@@ -573,6 +655,23 @@ namespace MSCO
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>
public static byte[] WithSender(int senderId, byte[] payload)
+11 -4
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
@@ -812,15 +811,23 @@ namespace MSCO
return attached;
}
static bool IsAttached(SyncedBody b)
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)
// 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 && j.connectedBody.transform.root.name != "PLAYER") return true;
{
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;
}
+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);
}
}
}
+377 -112
View File
@@ -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>();
@@ -71,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;
}
@@ -210,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]);
@@ -232,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;
}
@@ -268,10 +280,81 @@ 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(720, Screen.width - 40), height = Mathf.Min(580, Screen.height - 40);
var rect = new Rect((Screen.width - width) / 2f, (Screen.height - height) / 2f, width, height);
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);
@@ -282,53 +365,56 @@ 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.Space(6);
// Reiter
GUILayout.BeginHorizontal();
string[] tabs = { "PLAYERS", "ITEMS", "BANS" + (bannedIds != null && bannedIds.Count > 0 ? " (" + bannedIds.Count + ")" : ""), "WORLD" };
for (int i = 0; i < tabs.Length; i++)
// 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))
{
if (GUILayout.Button(tabs[i], adminTab == i ? primary : secondary, GUILayout.Height(30), GUILayout.Width(130))) adminTab = i;
GUILayout.Space(6);
mp.RequestAdminList();
VehicleAdmin.Refresh(true);
}
GUILayout.EndHorizontal();
GUILayout.Space(10);
if (adminTab == 1) DrawAdminItems(mp);
else if (adminTab == 2) DrawAdminBans(mp);
else if (adminTab == 3) DrawAdminWorld(mp);
// 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)
{
// Cheats für den eigenen Spieler (nur im Spiel)
if (mp.CanCheat)
{
GUILayout.BeginHorizontal();
if (GUILayout.Button("+1.000 MK", primary, GUILayout.Height(30))) mp.CheatMoney(1000f);
GUILayout.Space(6);
if (GUILayout.Button("+10.000 MK", primary, GUILayout.Height(30))) mp.CheatMoney(10000f);
GUILayout.Space(6);
if (GUILayout.Button("REFILL NEEDS", primary, GUILayout.Height(30))) mp.CheatNeeds();
GUILayout.Space(6);
if (GUILayout.Button("SATSUMA TO ME", primary, GUILayout.Height(30))) mp.BringCar();
GUILayout.EndHorizontal();
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);
}
GUILayout.Label("REASON FOR KICK / BAN (optional)", caption);
adminReason = GUILayout.TextField(adminReason, 100, field, GUILayout.Height(32));
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);
@@ -338,49 +424,202 @@ 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;
if (mp.CanCheat)
{
GUI.enabled = !self;
if (GUILayout.Button("GOTO", secondary, GUILayout.Height(30), GUILayout.Width(70))) mp.GotoPlayer(p.Id);
GUILayout.Space(6);
if (GUILayout.Button("BRING", secondary, GUILayout.Height(30), GUILayout.Width(70))) mp.BringPlayer(p.Id);
GUILayout.Space(12);
}
if (mp.IsDedicated)
{
// Admin geben/nehmen (braucht Steam; der Server meldet, wenn es nicht geht)
GUI.enabled = !self;
if (GUILayout.Button(p.IsAdmin ? "REMOVE ADMIN" : "MAKE ADMIN", secondary, GUILayout.Height(30), GUILayout.Width(120)))
mp.SetAdmin(p.Id, !p.IsAdmin);
GUILayout.Space(12);
}
GUI.enabled = !self && !p.IsAdmin;
if (GUILayout.Button("KICK", secondary, GUILayout.Height(30), GUILayout.Width(70)))
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(70)))
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)
@@ -388,28 +627,59 @@ namespace MSCO
GUILayout.Label("Items can only be spawned in the game.", small);
return;
}
GUILayout.Label("Click an item to spawn it in front of you. Everyone sees it, and it is saved on the server.", small);
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 = 4;
for (int i = 0; i < ItemSpawner.Items.Length; i += perRow)
const int perRow = 5;
for (int i = 0; i < items.Count; i += perRow)
{
GUILayout.BeginHorizontal();
for (int k = i; k < Mathf.Min(i + perRow, ItemSpawner.Items.Length); k++)
{
string item = ItemSpawner.Items[k];
if (GUILayout.Button(item.ToUpper(), secondary, GUILayout.Height(34), GUILayout.Width(155))) mp.SpawnItem(item);
GUILayout.Space(6);
}
GUILayout.EndHorizontal();
GUILayout.Space(6);
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);
@@ -418,31 +688,30 @@ namespace MSCO
GUILayout.Label("Everyone reconnects after a reset or restore. The current state is backed up first.", small);
GUILayout.Space(8);
GUILayout.BeginHorizontal();
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 (GUILayout.Button("REALLY RESET?", danger, GUILayout.Height(32), GUILayout.Width(170))) { mp.WorldAction(5, null); confirmWorld = null; }
GUILayout.Space(6);
if (GUILayout.Button("CANCEL", secondary, GUILayout.Height(32), GUILayout.Width(100))) confirmWorld = null;
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 (GUILayout.Button("RESET WORLD", danger, GUILayout.Height(32), GUILayout.Width(170))) confirmWorld = "reset";
GUILayout.Space(10);
if (GUILayout.Button("BACKUP NOW", primary, GUILayout.Height(32), GUILayout.Width(150))) mp.WorldAction(6, null);
GUILayout.Space(6);
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 (GUILayout.Button("REALLY?", danger, GUILayout.Height(32), GUILayout.Width(100))) { mp.WorldAction(8, null); confirmWorld = null; }
GUILayout.Space(4);
if (GUILayout.Button("CANCEL", secondary, GUILayout.Height(32), GUILayout.Width(90))) confirmWorld = null;
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 (GUILayout.Button("SET RESET POINT", secondary, GUILayout.Height(32), GUILayout.Width(170))) confirmWorld = "point";
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
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)", small);
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);
@@ -450,18 +719,15 @@ namespace MSCO
{
foreach (string name in backupNames)
{
GUILayout.BeginHorizontal(rowStyle, GUILayout.Height(40));
GUILayout.Label(PrettyBackup(name), chatName);
GUILayout.FlexibleSpace();
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 (GUILayout.Button("REALLY?", danger, GUILayout.Height(28), GUILayout.Width(100))) { mp.WorldAction(7, name); confirmWorld = null; }
GUILayout.Space(4);
if (GUILayout.Button("CANCEL", secondary, GUILayout.Height(28), GUILayout.Width(90))) confirmWorld = null;
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 (GUILayout.Button("RESTORE", primary, GUILayout.Height(28), GUILayout.Width(100))) confirmWorld = name;
GUILayout.EndHorizontal();
GUILayout.Space(4);
else if (RightButton(ref right, row, 100, "RESTORE", secondary)) confirmWorld = name;
}
}
GUILayout.EndScrollView();
@@ -485,14 +751,13 @@ namespace MSCO
else if (bannedIds.Count == 0) GUILayout.Label("Nobody is banned.", small);
else
{
EnsureAdminStyles();
foreach (string id in bannedIds)
{
GUILayout.BeginHorizontal(rowStyle, GUILayout.Height(40));
GUILayout.Label(id.StartsWith("name:") ? id.Substring(5) + " (no Steam)" : "SteamID " + id, chatName);
GUILayout.FlexibleSpace();
if (GUILayout.Button("UNBAN", primary, GUILayout.Height(28), GUILayout.Width(90))) mp.Unban(id);
GUILayout.EndHorizontal();
GUILayout.Space(4);
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();
+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();
}
}
}
+34 -3
View File
@@ -4,8 +4,9 @@ using UnityEngine;
namespace MSCO
{
/// <summary>
/// Mitfahren: Wer mit dem Kopf im Fahrzeug ist (Kabine, offene Tür, Ladefläche ...), wird genau dort festgehalten und
/// fährt mit - egal ob schon jemand fährt oder das Auto noch steht. Aussteigen mit Leertaste oder der Beifahrer-Taste.
/// 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>
@@ -15,7 +16,8 @@ namespace MSCO
const float AutoEnterRange = 1.0f; // automatisch: Kopf so nah am Beifahrersitz ...
const float AutoEnterSide = 0.6f; // ... und seitlich höchstens so weit außerhalb des Sitzes - an der
// geschlossenen Tür steht man weiter draußen, nur in der offenen Tür so nah
const float AutoEnterDwell = 0.35f; // ... so lange (nicht beim bloßen Vorbeilaufen)
const float 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;
@@ -40,6 +42,10 @@ namespace MSCO
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; } }
@@ -101,6 +107,7 @@ namespace MSCO
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
@@ -111,6 +118,7 @@ namespace MSCO
}
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;
@@ -124,6 +132,26 @@ namespace MSCO
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)
{
@@ -280,6 +308,9 @@ namespace MSCO
dwellStart = -1f;
driverHeads.Clear();
innerBounds.Clear();
lastVehiclePos.Clear();
vehicleSpeed.Clear();
lastSpeedSample = -1f;
SetMovementEnabled(null, true);
}
+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();
}
}
}
+4 -2
View File
@@ -6,7 +6,7 @@ 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("MBM")]
[assembly: AssemblyProduct("MSCO")]
@@ -33,4 +33,6 @@ using System.Runtime.InteropServices;
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("2.8.0.0")]
[assembly: AssemblyFileVersion("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)]
+87 -29
View File
@@ -1,49 +1,86 @@
# 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ü: 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 eigene Figur im MSC-Stil (Karohemd, Jeans, Schnurrbart, Kappe – je Spieler etwas anders) mit
Blickrichtung, Laufen, Ducken/Lehnen, Sitzen, gehaltenem Item und 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
- **F7** – MSCO-Konsole mit allen Befehlen
- **/admin** – Admin-Menü für Server-Admins bzw. den P2P-Host: Spieler kicken/bannen, GOTO/BRING (teleportieren),
Cheats (+1.000 / +10.000 MK, Bedürfnisse auffüllen, Satsuma zu sich holen) – Cheats werden im Chat angezeigt
- **P** – als Beifahrer ein-/aussteigen
- Alle Tasten sind in den MSCLoader-Einstellungen der Mod änderbar
- **/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
@@ -56,13 +93,16 @@ unter https://msco.mbm.wtf/keys.
|---|---|
| Server fehlt im Browser | REFRESH drücken; Filter (STEAM / NON-STEAM) prüfen; der Server muss von außen erreichbar sein |
| „This server requires Steam“ | Spiel über Steam starten |
| Warnung „Version mismatch“ | Mod und Server auf dieselbe Version bringen (Releases) |
| 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 F7-Konsole direkt, in der MSCLoader-Konsole mit `mp` davor (z.B. `mp connect ...`):
In der F7-Konsole:
| Befehl | Beschreibung |
|---|---|
@@ -71,37 +111,53 @@ In der F7-Konsole direkt, in der MSCLoader-Konsole mit `mp` davor (z.B. `mp conn
| `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 |
| `bind [aktion taste]` | Tasten anzeigen / ändern (`passenger`, `console`, `chat`) |
| `clear` | Konsole leeren |
## Selbst bauen
- 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 (MSC Launcher / MSCLoader)
### Updates veröffentlichen
- **Kleines Update** (Fehlerbehebungen, neue Funktionen, Netzwerk unverändert): in `MSCO.cs` nur die dritte Stelle von
`ModVersion` erhöhen (`2.8.1` → `2.8.2`), Release bauen und `bin/Release/MSCO.dll` hochladen. Spieler mit 2.8.x
spielen weiter zusammen, Server 2.8 müssen nicht aktualisiert werden.
`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 |
@@ -109,7 +165,9 @@ msbuild MSCO.csproj -p:Configuration=Release
| `ObjectSync.cs` | Items/Teile/Fahrzeuge mit Besitzer-System |
| `VehicleStateSync.cs`, `VehicleFx.cs`, `Passenger.cs` | Fahrzeugzustand, Lichter/Sound, Beifahrer |
| `FsmSync.cs`, `DoorSync.cs`, `WorldSync.cs`, `MoneySync.cs` | Montage-Events, Türen, Zeit/Wetter, Geld |
| `RemotePlayer.cs` | 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, Rich Presence |
| `SafetyPatches.cs`, `InputLock.cs`, `SceneCache.cs` | Absturzschutz, Eingabesperre, geteilte FSM-Suche |
@@ -118,7 +176,7 @@ msbuild MSCO.csproj -p:Configuration=Release
- 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).
+35 -1
View File
@@ -2,7 +2,6 @@ using System;
using System.Reflection;
using Harmony;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
@@ -43,6 +42,41 @@ namespace MSCO
{
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)
-1
View File
@@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.IO;
using MSCLoader;
using UnityEngine;
namespace MSCO
+71 -8
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,16 +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>
@@ -104,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;
@@ -115,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()
{
@@ -146,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;
@@ -172,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>
@@ -198,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();
@@ -215,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();
}
}
}
-1
View File
@@ -1,5 +1,4 @@
using System;
using MSCLoader;
using Steamworks;
namespace MSCO
+23
View File
@@ -10,6 +10,26 @@ namespace MSCO
/// </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);
@@ -170,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);
@@ -186,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);
@@ -200,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;
}
}
}
+120 -4
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
@@ -61,6 +60,12 @@ namespace MSCO
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>();
@@ -104,7 +109,7 @@ namespace MSCO
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)
@@ -119,13 +124,24 @@ namespace MSCO
ResolveSatsuma(objects);
foreach (Var v in vars.Values)
{
// 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;
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);
outgoing.Add(v);
@@ -187,6 +203,8 @@ namespace MSCO
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;
@@ -206,6 +224,91 @@ namespace MSCO
if (vars.Count != before) MscoLog.Info("[MSCO] Vehicle state: " + vars.Count + " values synced");
}
/// <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 (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 == 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)
@@ -368,9 +471,22 @@ namespace MSCO
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;
case 2: ((FsmInt)v.Variable).Value = BitConverter.ToInt32(value, 0); break;
default: ((FsmBool)v.Variable).Value = value[0] != 0; break;
}
Remember(v); // nicht als eigene Änderung zurückschicken
+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);
}
}
}
}
-1
View File
@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.IO;
using HutongGames.PlayMaker;
using MSCLoader;
using UnityEngine;
namespace MSCO
+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