0.4.0: Mitspieler in der Welt, BSO-Konto (Spiel-Ticket)

- Mitspieler als Bus/Spielfigur mit Namensschild, weich bewegt (RemotePlayers, Models)
- Ohne gueltiges Spiel-Ticket vom Launcher kein Multiplayer; Name kommt aus dem Konto
- Protokoll 2 (Hello mit Ticket), IPv6-Adressen
- Nach Szenenwechsel erst nach 3 s neu anlegen (Absturz im Test 0.3.0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 18:10:48 +01:00
co-authored by Claude Opus 5.5
parent 35f2d1330d
commit 51c1ebc938
12 changed files with 397 additions and 10 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<LangVersion>latest</LangVersion>
<RootNamespace>BSO</RootNamespace>
<AssemblyName>BSO</AssemblyName>
<Version>0.2.0</Version>
<Version>0.4.0</Version>
<PlatformTarget>x64</PlatformTarget>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>disable</Nullable>
+3 -1
View File
@@ -19,7 +19,7 @@ namespace BSO
/// </summary>
public static unsafe class Bootstrap
{
public const string ModVersion = "0.2.0";
public const string ModVersion = "0.4.0";
static HostApi* host;
static int frameErrors;
@@ -60,8 +60,10 @@ namespace BSO
BorderlessWindow.Update();
LocalPlayer.Update();
Multiplayer.Update();
RemotePlayers.Update();
PositionHud.Update();
}
RemotePlayers.Draw();
PositionHud.Draw();
}
catch (Exception e)
+5 -1
View File
@@ -13,6 +13,8 @@ namespace BSO
public string Server { get; set; } = "127.0.0.1:7777";
/// <summary>Beim Spielstart automatisch verbinden (sonst F8).</summary>
public bool AutoConnect { get; set; }
/// <summary>Spiel-Ticket der BSO-Website - schreibt der Launcher bei SPIELEN hinein (24 h gueltig).</summary>
public string Ticket { get; set; }
public static Config Current { get; private set; } = new();
@@ -26,7 +28,9 @@ namespace BSO
if (File.Exists(ConfigFile)) Current = JsonSerializer.Deserialize<Config>(File.ReadAllText(ConfigFile), Options) ?? new Config();
// Immer zurueckschreiben: neue Einstellungen erscheinen so auch in alten Dateien
File.WriteAllText(ConfigFile, JsonSerializer.Serialize(Current, Options));
Bootstrap.Log($"Einstellungen: Name \"{Current.Name}\", Server {Current.Server}, AutoConnect {Current.AutoConnect}");
var ticket = BSO.Net.Ticket.Read(Current.Ticket);
Bootstrap.Log($"Einstellungen: Name \"{Current.Name}\", Server {Current.Server}, AutoConnect {Current.AutoConnect}, " +
(ticket == null ? "kein Spiel-Ticket" : $"Ticket fuer {ticket.Username} bis {ticket.Expires.ToLocalTime():dd.MM. HH:mm}"));
}
catch (Exception e)
{
+8
View File
@@ -31,6 +31,14 @@ namespace BSO
return list;
}
/// <summary>Platz einer Entity in der Tabelle, -1 = nicht (mehr) da.</summary>
public static int SlotOf(nint ptr)
{
if (ptr == 0 || !Vision.TryGetEntities(out nint* data, out int count)) return -1;
for (int i = 0; i < count; i++) if (data[i] == ptr) return i;
return -1;
}
/// <summary>Steht in Platz slot noch dasselbe Objekt? Dann lebt es noch und darf gelesen werden.</summary>
public static bool StillThere(int slot, nint ptr)
{
+4
View File
@@ -107,8 +107,11 @@ namespace BSO
Bootstrap.Log(character != null ? $"Spieler = KeyControlledCharacter in Platz {character.Value.Slot}" : "Spielfigur weg - Spieler sitzt im Bus");
Character.Set(character);
Character.Refresh();
if (character != null) Models.RememberCharacter(character.Value.Ptr);
}
if (Models.Bus == null && nearestCar != null) Models.RememberFallbackVehicle(nearestCar.Value.Ptr);
// Bus nur festlegen, wenn man drinsitzt (keine Spielfigur), und nur wechseln, wenn der alte fehlt oder weit weg ist
bool driving = character == null;
bool keep = Bus.Valid && (!driving || (Bus.Pos - Camera).Length < RelockDistance);
@@ -116,6 +119,7 @@ namespace BSO
Bootstrap.Log($"Eigener Bus = CarVehicle in Platz {nearestCar.Value.Slot}, {bestDist / 100:0.0} m von der Kamera");
Bus.Set(nearestCar);
Bus.Refresh();
Models.RememberBus(nearestCar.Value.Ptr);
}
static void UpdateSpeed()
+63
View File
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
namespace BSO
{
/// <summary>
/// Modelldatei einer Entity finden (z.B. Models\Vehicles\Citaro\Citaro12m\...\.model). Die .pak-Dateien des Spiels sind
/// verschluesselt und VisBaseEntity_cl::GetMesh ist nicht exportiert - also: Entity -> Mesh (Zeiger in der Entity) ->
/// Dateiname (char* im Mesh) durchsuchen. Jeder Zeiger wird vorher geprueft (Mem).
/// </summary>
public static class Models
{
/// <summary>Modell des eigenen Busses / der eigenen Figur - werden fuer die Mitspieler benutzt.</summary>
public static string Bus { get; private set; }
public static string Character { get; private set; }
static readonly Dictionary<nint, string> byEntity = new();
/// <summary>Solange man noch nicht im eigenen Bus sass: irgendein Fahrzeug aus der Szene.</summary>
static string fallbackVehicle;
public static string Vehicle => Bus ?? fallbackVehicle;
public static void RememberBus(nint entity) => Bus ??= Remember(entity, "Bus");
public static void RememberCharacter(nint entity) => Character ??= Remember(entity, "Spielfigur");
public static void RememberFallbackVehicle(nint entity) => fallbackVehicle ??= Remember(entity, "Fahrzeug (Ersatz, bis du im Bus sitzt)");
static string Remember(nint entity, string what)
{
string file = Find(entity);
Bootstrap.Log(file != null ? $"Modell {what}: {file}" : $"Modell {what}: nicht gefunden");
return file;
}
/// <summary>Modelldatei der Entity oder null.</summary>
public static string Find(nint entity)
{
if (byEntity.TryGetValue(entity, out string known)) return known;
string found = null;
for (int off = 8; off < 0x400 && found == null; off += 8)
{
if (!Mem.TryReadPtr(entity + off, out nint p) || p == 0 || Mem.IsImage(p)) continue;
found = ModelString(p); // Dateiname direkt in der Entity
for (int off2 = 0; off2 < 0x100 && found == null; off2 += 8)
{
// ... oder im Mesh, auf das die Entity zeigt
if (!Mem.TryReadPtr(p + off2, out nint q) || q == 0 || Mem.IsImage(q)) continue;
found = ModelString(q);
}
}
byEntity[entity] = found;
return found;
}
static string ModelString(nint p)
{
if (!Mem.IsReadable(p, 8)) return null;
string s = Mem.ReadAscii(p, 260);
if (s.Length < 8 || !s.EndsWith(".model", StringComparison.OrdinalIgnoreCase)) return null;
foreach (char c in s) if (c < 0x20 || c > 0x7e) return null;
return s;
}
}
}
+80 -4
View File
@@ -21,8 +21,49 @@ namespace BSO
{
public int Id;
public string Name;
public PlayerState State;
public PlayerState State; // zuletzt empfangen
public bool HasState;
// Weiche Bewegung: vom angezeigten Stand zum neuen Paket gleiten, so lange wie seit dem letzten Paket vergangen ist
PlayerState from, to;
long startTicks, durationTicks = 1;
public void Receive(PlayerState s)
{
long now = Stopwatch.GetTimestamp();
from = HasState ? Display : s;
to = s;
long since = now - startTicks;
durationTicks = Math.Clamp(since, Stopwatch.Frequency / 20, Stopwatch.Frequency / 4); // 50..250 ms
startTicks = now;
State = s;
HasState = true;
}
/// <summary>Stand fuer dieses Bild (zwischen den 10 Paketen pro Sekunde weich weiterbewegt).</summary>
public PlayerState Display
{
get
{
if (from.Mode != to.Mode) return to; // Ein-/Aussteigen: nicht gleiten
float t = Math.Clamp((Stopwatch.GetTimestamp() - startTicks) / (float)durationTicks, 0, 1);
PlayerState s = to;
s.X = from.X + (to.X - from.X) * t;
s.Y = from.Y + (to.Y - from.Y) * t;
s.Z = from.Z + (to.Z - from.Z) * t;
s.Yaw = Angle(from.Yaw, to.Yaw, t);
s.Pitch = Angle(from.Pitch, to.Pitch, t);
s.Roll = Angle(from.Roll, to.Roll, t);
return s;
}
}
/// <summary>Winkel (Grad) auf dem kuerzeren Weg mischen - sonst dreht sich der Bus bei 359 -> 1 einmal ganz herum.</summary>
static float Angle(float a, float b, float t)
{
float d = ((b - a) % 360 + 540) % 360 - 180;
return a + d * t;
}
}
const int ConnectTimeoutMs = 5000;
@@ -85,8 +126,32 @@ namespace BSO
public static string ServerAddress => Config.Current.Server;
/// <summary>
/// Gueltiges Spiel-Ticket vom Launcher? Ohne gibt es keinen Multiplayer - der Server prueft es zusaetzlich (die Mod
/// allein liesse sich austricksen), hier gibt es nur die verstaendliche Meldung vorab.
/// </summary>
public static string AccountProblem
{
get
{
var t = Ticket.Read(Config.Current.Ticket);
if (t == null) return "Nicht ueber den BSO Launcher gestartet - Multiplayer gesperrt";
if (t.Expired) return "Spiel-Ticket abgelaufen - im BSO Launcher neu auf SPIELEN druecken";
return null;
}
}
/// <summary>Name im Spiel: aus dem Konto (Ticket), sonst aus BSO.json.</summary>
public static string PlayerName => Ticket.Read(Config.Current.Ticket)?.Username ?? Config.Current.Name;
static void Connect()
{
if (AccountProblem is string problem)
{
LastError = problem;
Bootstrap.Log("Nicht verbunden: " + problem);
return;
}
(string host, int port) = ParseAddress(ServerAddress);
Bootstrap.Log($"Verbinde mit {host}:{port} ...");
LastError = "";
@@ -114,7 +179,8 @@ namespace BSO
{
w.Write(Protocol.Version);
w.Write(Bootstrap.ModVersion);
w.Write(Config.Current.Name ?? "");
w.Write(PlayerName ?? "");
w.Write(Config.Current.Ticket ?? "");
}));
// Erst nach Welcome gilt man als verbunden - bis dahin bleibt der Status "Verbinde"
Status = NetStatus.Connected;
@@ -167,8 +233,7 @@ namespace BSO
case MsgType.PlayerState:
int id = r.ReadInt32();
if (!Players.TryGetValue(id, out Remote p2)) return; // PlayerJoined kommt immer vorher
p2.State = PlayerState.Read(r);
p2.HasState = true;
p2.Receive(PlayerState.Read(r));
break;
case MsgType.Pong:
@@ -196,6 +261,17 @@ namespace BSO
static (string, int) ParseAddress(string address)
{
address = (address ?? "").Trim();
// IPv6: [::1]:7777 oder [::1]
if (address.StartsWith("["))
{
int end = address.IndexOf(']');
if (end > 1)
{
string host6 = address.Substring(1, end - 1);
return address.Length > end + 2 && address[end + 1] == ':' && int.TryParse(address.Substring(end + 2), out int p6)
? (host6, p6) : (host6, Protocol.DefaultPort);
}
}
int colon = address.LastIndexOf(':');
if (colon > 0 && int.TryParse(address.Substring(colon + 1), out int port)) return (address.Substring(0, colon), port);
return (address.Length > 0 ? address : "127.0.0.1", Protocol.DefaultPort);
+4 -1
View File
@@ -59,8 +59,11 @@ namespace BSO
{
switch (Multiplayer.Status)
{
case Multiplayer.NetStatus.Offline when Multiplayer.AccountProblem != null:
text.Add(Multiplayer.AccountProblem);
return;
case Multiplayer.NetStatus.Offline:
text.Add($"Offline - [F8] verbinden mit {Multiplayer.ServerAddress}");
text.Add($"Offline - [F8] verbinden mit {Multiplayer.ServerAddress} als {Multiplayer.PlayerName}");
if (Multiplayer.LastError.Length > 0) text.Add(" " + Multiplayer.LastError);
return;
case Multiplayer.NetStatus.Connecting:
+125
View File
@@ -0,0 +1,125 @@
using System.Collections.Generic;
using System.Linq;
using BSO.Net;
namespace BSO
{
/// <summary>
/// Mitspieler in der Welt: je Spieler eine eigene Entity mit dem Modell des eigenen Busses bzw. der eigenen Spielfigur
/// (Models), jedes Bild auf den weich bewegten Stand gesetzt (Remote.Display), darueber ein Namensschild. Die Entities
/// sind nur Grafik (VisBaseEntity_cl, keine Physik): man faehrt durch sie hindurch.
/// </summary>
public static class RemotePlayers
{
const float TagHeightBus = 420, TagHeightFoot = 215; // cm ueber dem Ursprung
const float TagMaxDistance = 30000; // weiter weg: kein Schild (300 m)
class Shown
{
public nint Entity;
public int Slot = -1;
public PlayerMode Mode;
public string Model;
public byte[] Tag;
public Vec3 TagPos;
public bool TagVisible;
}
static readonly Dictionary<int, Shown> shown = new();
static bool warned;
// Nach einem Szenenwechsel (Entity von der Engine entfernt) erst nach einer Pause neu anlegen - mitten im Abbau
// der Welt eine Entity anzulegen liess das Spiel abstuerzen (Test 0.3.0: zweimal neu angelegt, dann Ende)
static readonly System.Diagnostics.Stopwatch sceneCalm = System.Diagnostics.Stopwatch.StartNew();
const int RespawnDelayMs = 3000;
public static void Update()
{
if (!Vision.CanSpawn)
{
if (!warned) { warned = true; Bootstrap.Log("Mitspieler in der Welt: Engine-Funktionen fehlen - nur Liste"); }
return;
}
// Szene weg oder man ist nicht in der Welt (Menue, Laden): die Engine raeumt unsere Entities mit weg - nur vergessen,
// nichts anfassen und nichts anlegen
PlayerState me = LocalPlayer.State;
if (!LocalPlayer.InScene || me.Mode == PlayerMode.Menu)
{
if (shown.Count > 0) Bootstrap.Log("Nicht in der Welt - Mitspieler-Modelle vergessen");
shown.Clear();
sceneCalm.Restart();
return;
}
// Gegangen oder getrennt
foreach (int id in shown.Keys.Where(id => !Multiplayer.Players.ContainsKey(id)).ToList())
Remove(id);
foreach (Multiplayer.Remote p in Multiplayer.Players.Values)
{
if (!p.HasState || p.State.Mode == PlayerMode.Menu) { Remove(p.Id); continue; }
PlayerState s = p.Display;
string model = s.Mode == PlayerMode.InBus ? Models.Vehicle : Models.Character;
shown.TryGetValue(p.Id, out Shown v);
// Ein-/Aussteigen oder besseres Modell bekannt (eigener Bus statt Ersatzfahrzeug): neu anlegen
if (v != null && (v.Mode != s.Mode || v.Model != model)) { Remove(p.Id); v = null; }
// Von der Engine entfernt? (z.B. beim Neuladen der Stadt) - nach einer Pause neu anlegen
if (v != null && v.Entity != 0 && !Entities.StillThere(v.Slot, v.Entity))
{
Bootstrap.Log($"{p.Name}: Modell von der Engine entfernt - lege es nach {RespawnDelayMs / 1000} s neu an");
shown.Remove(p.Id);
v = null;
sceneCalm.Restart();
}
var pos = new Vec3(s.X, s.Y, s.Z);
if (v == null)
{
if (sceneCalm.ElapsedMilliseconds < RespawnDelayMs) continue;
v = new Shown { Mode = s.Mode, Model = model, Tag = Vision.ToAscii(p.Name) };
shown[p.Id] = v;
if (model != null) Spawn(p, v, pos);
else Bootstrap.Log($"{p.Name}: noch kein Modell fuer \"{s.Mode}\" bekannt - nur Namensschild");
}
if (v.Entity != 0)
{
Vision.SetPosition(v.Entity, pos);
Vision.SetOrientation(v.Entity, new Vec3(s.Yaw, s.Pitch, s.Roll));
}
float dist = me.Mode == PlayerMode.Menu ? 0 : s.DistanceTo(me);
v.TagVisible = dist < TagMaxDistance;
v.TagPos = new Vec3(s.X, s.Y, s.Z + (s.Mode == PlayerMode.InBus ? TagHeightBus : TagHeightFoot));
v.Tag = Vision.ToAscii(me.Mode == PlayerMode.Menu ? p.Name : $"{p.Name} ({dist / 100:0} m)");
}
}
static void Spawn(Multiplayer.Remote p, Shown v, Vec3 pos)
{
v.Entity = Vision.CreateEntity("VisBaseEntity_cl", pos, v.Model);
v.Slot = Entities.SlotOf(v.Entity);
if (v.Entity == 0 || v.Slot < 0)
{
Bootstrap.Log($"{p.Name}: Entity mit {v.Model} liess sich nicht anlegen");
v.Entity = 0;
return;
}
Bootstrap.Log($"{p.Name} erscheint in der Welt ({v.Mode}, {v.Model}, Platz {v.Slot})");
}
static void Remove(int id)
{
if (!shown.Remove(id, out Shown v)) return;
if (v.Entity != 0 && Entities.StillThere(v.Slot, v.Entity)) Vision.RemoveEntity(v.Entity);
}
/// <summary>Namensschilder - bei jedem Durchlauf wie der Text oben links (sonst flackern sie).</summary>
public static void Draw()
{
if (!Vision.CanSpawn || !LocalPlayer.InScene) return;
foreach (Shown v in shown.Values)
if (v.TagVisible) Vision.DrawText3D(v.Tag, v.TagPos);
}
}
}
+2 -2
View File
@@ -10,13 +10,13 @@ namespace BSO.Net
public static class Protocol
{
/// <summary>Bei jeder inkompatiblen Aenderung erhoehen - der Server lehnt andere Versionen ab.</summary>
public const int Version = 1;
public const int Version = 2; // 2: Hello mit Spiel-Ticket
public const int DefaultPort = 7777;
}
public enum MsgType : byte
{
Hello = 1, // Client -> Server: int Protokoll, string Mod-Version, string Name
Hello = 1, // Client -> Server: int Protokoll, string Mod-Version, string Name, string Spiel-Ticket (vom Launcher)
Welcome = 2, // Server -> Client: int eigene ID, string Servername, int n, je [int ID, string Name]
Reject = 3, // Server -> Client: string Grund
PlayerState = 4, // Client -> Server: PlayerState; Server -> Client: int ID + PlayerState
+62
View File
@@ -0,0 +1,62 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace BSO.Net
{
/// <summary>
/// Spiel-Ticket der BSO-Website: base64url(JSON {u, id, iat, exp}) + "." + base64url(RSA-SHA256-Signatur). Der Launcher
/// holt es beim Spielen und schreibt es in BSO.json, die Mod schickt es im Hello mit. Die Mod liest es nur (Name, Ablauf),
/// pruefen kann es nur der Server mit dem oeffentlichen Schluessel der Website (Verify) - wer die Mod veraendert, kommt so
/// trotzdem nicht ohne gueltiges Konto auf einen Server.
/// </summary>
public sealed class Ticket
{
public string Username { get; private set; }
public string AccountId { get; private set; }
public DateTime Expires { get; private set; }
public bool Expired => DateTime.UtcNow >= Expires;
/// <summary>Inhalt lesen, ohne die Signatur zu pruefen. null = kein Ticket / kaputt.</summary>
public static Ticket Read(string ticket)
{
try
{
if (string.IsNullOrEmpty(ticket) || ticket.Length > 4096) return null;
string[] parts = ticket.Split('.');
if (parts.Length != 2) return null;
using var doc = JsonDocument.Parse(FromB64(parts[0]));
var r = doc.RootElement;
return new Ticket
{
Username = r.GetProperty("u").GetString(),
AccountId = r.GetProperty("id").GetString(),
Expires = DateTimeOffset.FromUnixTimeSeconds(r.GetProperty("exp").GetInt64()).UtcDateTime
};
}
catch { return null; }
}
/// <summary>Signatur pruefen (Server). Gueltig und nicht abgelaufen -> Ticket, sonst null mit Grund.</summary>
public static Ticket Verify(string ticket, RSA publicKey, out string error)
{
error = null;
Ticket t = Read(ticket);
if (t == null) { error = "Kein gueltiges BSO-Konto - bitte ueber den BSO Launcher starten"; return null; }
string[] parts = ticket.Split('.');
bool ok;
try { ok = publicKey.VerifyData(Encoding.ASCII.GetBytes(parts[0]), FromB64(parts[1]), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); }
catch { ok = false; }
if (!ok) { error = "Ungueltiges Spiel-Ticket - bitte ueber den BSO Launcher starten"; return null; }
if (t.Expired) { error = "Spiel-Ticket abgelaufen - bitte im BSO Launcher neu auf SPIELEN druecken"; return null; }
return t;
}
static byte[] FromB64(string s)
{
s = s.Replace('-', '+').Replace('_', '/');
return Convert.FromBase64String(s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='));
}
}
}
+40
View File
@@ -37,6 +37,15 @@ namespace BSO
static delegate* unmanaged<nint> getTimer;
static delegate* unmanaged<nint, float> getTime;
// Mitspieler in der Welt (RemotePlayers) - optional: fehlt etwas, laeuft der Rest trotzdem
static nint game; // Vision::Game (VisGame_cl)
static delegate* unmanaged<nint, byte*, float*, byte*, byte*, nint> createEntity;
static delegate* unmanaged<nint, float, float, float, void> setPosition;
static delegate* unmanaged<nint, float, float, float, void> setOrientation;
static delegate* unmanaged<nint, void> removeEntity;
static delegate* unmanaged<nint, byte*, float*, short, int> drawMessage3D;
public static bool CanSpawn { get; private set; }
public static bool Init()
{
if (tried) return Loaded;
@@ -58,10 +67,41 @@ namespace BSO
Loaded = message != 0 && camera != 0 && entityTable != 0 && print != null && getMainCamera != null && getPosition != null
&& getOrientation != null && getTimer != null && getTime != null;
game = Export(module, "?Game@Vision@@2VVisGame_cl@@A");
// VisBaseEntity_cl* VisGame_cl::CreateEntity(const char* klasse, const VisVector_cl& pos, const char* modell, const char* parameter)
createEntity = (delegate* unmanaged<nint, byte*, float*, byte*, byte*, nint>)Export(module, "?CreateEntity@VisGame_cl@@QEAAPEAVVisBaseEntity_cl@@PEBDAEBVVisVector_cl@@00@Z");
setPosition = (delegate* unmanaged<nint, float, float, float, void>)Export(module, "?SetPosition@VisObject3D_cl@@QEAAXMMM@Z");
setOrientation = (delegate* unmanaged<nint, float, float, float, void>)Export(module, "?SetOrientation@VisObject3D_cl@@QEAAXMMM@Z");
removeEntity = (delegate* unmanaged<nint, void>)Export(module, "?Remove@VisBaseEntity_cl@@QEAAXXZ");
// int VisGame_cl::DrawMessage3D(const char* text, const VisVector_cl& pos, short) - wie Print nur fuer dieses Bild
drawMessage3D = (delegate* unmanaged<nint, byte*, float*, short, int>)Export(module, "?DrawMessage3D@VisGame_cl@@QEAAHPEBDAEBVVisVector_cl@@F@Z");
CanSpawn = game != 0 && createEntity != null && setPosition != null && setOrientation != null && removeEntity != null && drawMessage3D != null;
Bootstrap.Log(Loaded ? "Vision Engine gefunden, alle Funktionen da" : "Vision Engine: Funktionen fehlen");
return Loaded;
}
/// <summary>Neue Entity mit Modell (nur Grafik, keine Physik). 0 = fehlgeschlagen.</summary>
public static nint CreateEntity(string className, Vec3 pos, string modelFile)
{
float* v = stackalloc float[4] { pos.X, pos.Y, pos.Z, 0 }; // VisVector_cl (3 float, Platz fuer Ausrichtung)
byte[] cls = ToAscii(className), model = ToAscii(modelFile);
byte* none = stackalloc byte[1] { 0 };
fixed (byte* c = cls, m = model) return createEntity(game, c, v, m, none);
}
public static void SetPosition(nint obj, Vec3 p) => setPosition(obj, p.X, p.Y, p.Z);
public static void SetOrientation(nint obj, Vec3 yawPitchRoll) => setOrientation(obj, yawPitchRoll.X, yawPitchRoll.Y, yawPitchRoll.Z);
/// <summary>Entity zum Entfernen vormerken (die Engine raeumt sie am Ende des Bildes weg).</summary>
public static void RemoveEntity(nint entity) => removeEntity(entity);
/// <summary>Text an einer Stelle in der Welt, fuer dieses Bild (Namensschild). text mit ToAscii vorbereitet.</summary>
public static void DrawText3D(byte[] text, Vec3 pos)
{
float* v = stackalloc float[4] { pos.X, pos.Y, pos.Z, 0 };
fixed (byte* t = text) drawMessage3D(game, t, v, 0);
}
static nint Export(nint module, string name)
{
if (NativeLibrary.TryGetExport(module, name, out nint address)) return address;