The Beam shim (`TightBeam.cs`)
Copy this single file into your mod project (namespace TightBeam.Api). It is Unity-free, has no
dependencies, and every call is a no-op when TightBeam is not installed - so you can ship it
unconditionally. See the Modder API for how to use it. (Or reference TightBeam.Api.dll
instead of copying.)
using System;using System.Collections.Generic;using System.Reflection;
namespace TightBeam.Api{ /// <summary> /// TightBeam's cross-mod control API for the player's flashlight. Reference TightBeam.Api.dll OR drop this single /// file into your mod. Drive the beam from any mod: on/off, brightness, range, colour, plus Blink/Flicker/Pulse and /// scoped per-field overrides (e.g. dim it in a dark room, flicker it near power). /// /// Every call is a zero-overhead no-op when TightBeam is not installed and lights up automatically when it is, so /// you can ship this unconditionally with no hard dependency. All calls MUST be on the Unity main thread. /// /// <code> /// using TightBeam.Api; /// Beam.Blink(3); // event blink /// var ov = Beam.BeginOverride("MyMod"); // scoped, per-field override /// ov.SetIntensity(1f).SetSpotAngle(28f); // dim + narrow while in a zone /// ov.Dispose(); // release -> beam returns to the player's own settings /// </code> /// </summary> public static class Beam { private static bool _bound; private static int _probeAttempts; private static readonly List<Action> _pending = new List<Action>();
private static Func<bool> _isOn; private static Func<float> _getIntensity, _getRange, _getSpotAngle; private static Func<float[]> _getColor; private static Action<bool> _setOn; private static Action _toggle, _stopFlicker, _stopPulse; private static Action<float> _setIntensity, _setRange, _setSpotAngle; private static Action<float, float, float, float> _setColor; private static Action<int, float> _blink; private static Action<float, float, float> _flicker, _pulse; private static Action<float, float> _tempIntensity; private static Action<float, float, float, float, float> _tempColor; private static Func<string, int> _beginOverride; private static Action<int> _endOverride, _clrI, _clrR, _clrA, _clrC; private static Action<int, float> _ovI, _ovR, _ovA; private static Action<int, float, float, float, float> _ovC; private static Action<Action<bool>> _registerToggled;
private static int _abi; private static Func<bool> _isMultiplayer; private static Func<ulong> _getLocalSteamId; private static Func<ulong[]> _getRemoteIds; private static Func<ulong, bool> _remoteHasTightBeam, _isRemoteRendered; private static Func<ulong, float[]> _getRemoteBeam, _getRemoteBeamPose; private static Action<Action<ulong, bool>> _registerRemoteToggled; private static readonly ulong[] _noIds = new ulong[0];
/// <summary>Fires whenever the flashlight actually transitions on/off (player key or any mod's SetOn/Toggle).</summary> public static event Action<bool> OnToggled;
/// <summary>Fires when ANOTHER player's beam goes on or off: (steamId, isOn).</summary> public static event Action<ulong, bool> OnRemoteToggled;
/// <summary>True only when the TightBeam host is installed AND bound. You rarely need this - the API is a safe no-op when absent.</summary> public static bool Available { get { EnsureBound(); return _bound; } }
/// <summary>ABI version of the host that is installed: 0 when absent, 1 for local-only builds, 2 and up once /// the other players' beams can be read.</summary> public static int AbiVersion { get { EnsureBound(); return _abi; } }
public static bool IsOn { get { EnsureBound(); return _isOn != null && _isOn(); } } public static float Intensity { get { EnsureBound(); return _getIntensity?.Invoke() ?? 0f; } } public static float Range { get { EnsureBound(); return _getRange?.Invoke() ?? 0f; } } public static float SpotAngle { get { EnsureBound(); return _getSpotAngle?.Invoke() ?? 0f; } } public static void GetColor(out float r, out float g, out float b, out float a) { EnsureBound(); var c = _getColor?.Invoke(); if (c != null && c.Length >= 4) { r = c[0]; g = c[1]; b = c[2]; a = c[3]; } else { r = g = b = a = 0f; } }
public static void TurnOn() => SetOn(true); public static void TurnOff() => SetOn(false); public static void Toggle() => Do(() => _toggle?.Invoke()); public static void SetOn(bool on) => Do(() => _setOn?.Invoke(on));
public static void SetIntensity(float value) => Do(() => _setIntensity?.Invoke(value)); public static void SetRange(float meters) => Do(() => _setRange?.Invoke(meters)); public static void SetSpotAngle(float degrees) => Do(() => _setSpotAngle?.Invoke(degrees)); public static void SetColor(float r, float g, float b, float a = 1f) => Do(() => _setColor?.Invoke(r, g, b, a)); public static void SetColorHex(string hex) { if (TryHex(hex, out var r, out var g, out var b, out var a)) SetColor(r, g, b, a); }
public static void Blink(int times, float intervalSeconds = 0.12f) => Do(() => _blink?.Invoke(times, intervalSeconds)); public static void Flicker(float strength01, float durationSeconds, float frequencyHz = 14f) => Do(() => _flicker?.Invoke(strength01, durationSeconds, frequencyHz)); public static void StopFlicker() => Do(() => _stopFlicker?.Invoke()); public static void Pulse(float amplitude01, float periodSeconds, float durationSeconds) => Do(() => _pulse?.Invoke(amplitude01, periodSeconds, durationSeconds)); public static void StopPulse() => Do(() => _stopPulse?.Invoke());
public static void SetTemporaryIntensity(float value, float seconds, float fadeSeconds = 0.25f) => Do(() => _tempIntensity?.Invoke(value, seconds)); public static void SetTemporaryColor(float r, float g, float b, float seconds, float a = 1f, float fadeSeconds = 0.25f) => Do(() => _tempColor?.Invoke(r, g, b, a, seconds));
// ----- other players' beams (host ABI 2+) ----------------------------------------------------------------- // Read-only by design. Every player is the sole author of their own beam, so you drive the LOCAL beam as // usual and the state replicates on its own - a blackout you apply here is what the other players see. // Players are identified by SteamID64. Everything degrades to "nothing there" against an older host.
/// <summary>True while in a session with at least one other player.</summary> public static bool IsMultiplayer { get { EnsureBound(); return _isMultiplayer != null && _isMultiplayer(); } }
/// <summary>The local player's SteamID64, or 0 when it is not known yet.</summary> public static ulong LocalSteamId { get { EnsureBound(); return _getLocalSteamId?.Invoke() ?? 0UL; } }
/// <summary>Every other player whose beam is being tracked. Never null; empty when alone or absent.</summary> public static ulong[] RemoteIds { get { EnsureBound(); return _getRemoteIds?.Invoke() ?? _noIds; } }
/// <summary>Whether this player is running TightBeam and sharing their beam. False means TryGetRemote still /// answers, but with local defaults rather than their real settings.</summary> public static bool RemoteHasTightBeam(ulong steamId) { EnsureBound(); return _remoteHasTightBeam != null && _remoteHasTightBeam(steamId); }
/// <summary>Whether that player's beam is actually being drawn, as opposed to lit but culled by distance or /// the beam cap.</summary> public static bool IsRemoteRendered(ulong steamId) { EnsureBound(); return _isRemoteRendered != null && _isRemoteRendered(steamId); }
/// <summary>One player's beam as it is being drawn. False when that player is unknown.</summary> public static bool TryGetRemote(ulong steamId, out RemoteBeamState state) { EnsureBound(); state = default; var v = _getRemoteBeam?.Invoke(steamId); if (v == null || v.Length < 8) return false; state = new RemoteBeamState { IsOn = v[0] != 0f, Intensity = v[1], Range = v[2], SpotAngle = v[3], R = v[4], G = v[5], B = v[6], A = v[7], }; return true; }
/// <summary>Where one player's beam starts and which way it points, for questions like "is that beam on me". /// False when the beam is not currently drawn.</summary> public static bool TryGetRemotePose(ulong steamId, out float px, out float py, out float pz, out float fx, out float fy, out float fz) { EnsureBound(); px = py = pz = fx = fy = fz = 0f; var v = _getRemoteBeamPose?.Invoke(steamId); if (v == null || v.Length < 6) return false; px = v[0]; py = v[1]; pz = v[2]; fx = v[3]; fy = v[4]; fz = v[5]; return true; }
/// <summary>Begin a scoped, per-field override for one owner. Dispose() (or scope exit) releases it.</summary> public static OverrideHandle BeginOverride(string ownerId) { EnsureBound(); int token = _beginOverride?.Invoke(ownerId ?? "?") ?? 0; return new OverrideHandle(token); }
internal static void OvIntensity(int t, float v) => Do(() => _ovI?.Invoke(t, v)); internal static void OvRange(int t, float v) => Do(() => _ovR?.Invoke(t, v)); internal static void OvSpotAngle(int t, float v) => Do(() => _ovA?.Invoke(t, v)); internal static void OvColor(int t, float r, float g, float b, float a) => Do(() => _ovC?.Invoke(t, r, g, b, a)); internal static void ClrIntensity(int t) => Do(() => _clrI?.Invoke(t)); internal static void ClrRange(int t) => Do(() => _clrR?.Invoke(t)); internal static void ClrSpotAngle(int t) => Do(() => _clrA?.Invoke(t)); internal static void ClrColor(int t) => Do(() => _clrC?.Invoke(t)); internal static void EndOverride(int t) => Do(() => _endOverride?.Invoke(t));
private static void Do(Action a) { EnsureBound(); if (_bound) a(); else _pending.Add(a); } private static void NotifyToggled(bool on) { try { OnToggled?.Invoke(on); } catch { } } private static void NotifyRemoteToggled(ulong id, bool on) { try { OnRemoteToggled?.Invoke(id, on); } catch { } }
private static void EnsureBound() { if (_bound) return; try { Type t = FindBridge((_probeAttempts++ % 30) == 0); if (t == null) return; object abi = t.GetField("AbiVersion", BindingFlags.Public | BindingFlags.Static)?.GetValue(null); if (abi is int v && v < 1) return; _abi = abi is int av ? av : 1;
_isOn = G<Func<bool>>(t, "IsOn"); _getIntensity = G<Func<float>>(t, "GetIntensity"); _getRange = G<Func<float>>(t, "GetRange"); _getSpotAngle = G<Func<float>>(t, "GetSpotAngle"); _getColor = G<Func<float[]>>(t, "GetColor"); _setOn = G<Action<bool>>(t, "SetOn"); _toggle = G<Action>(t, "Toggle"); _setIntensity = G<Action<float>>(t, "SetIntensity"); _setRange = G<Action<float>>(t, "SetRange"); _setSpotAngle = G<Action<float>>(t, "SetSpotAngle"); _setColor = G<Action<float, float, float, float>>(t, "SetColor"); _blink = G<Action<int, float>>(t, "Blink"); _flicker = G<Action<float, float, float>>(t, "Flicker"); _stopFlicker = G<Action>(t, "StopFlicker"); _pulse = G<Action<float, float, float>>(t, "Pulse"); _stopPulse = G<Action>(t, "StopPulse"); _tempIntensity = G<Action<float, float>>(t, "TempIntensity"); _tempColor = G<Action<float, float, float, float, float>>(t, "TempColor"); _beginOverride = G<Func<string, int>>(t, "BeginOverride"); _endOverride = G<Action<int>>(t, "EndOverride"); _ovI = G<Action<int, float>>(t, "OvIntensity"); _ovR = G<Action<int, float>>(t, "OvRange"); _ovA = G<Action<int, float>>(t, "OvSpotAngle"); _ovC = G<Action<int, float, float, float, float>>(t, "OvColor"); _clrI = G<Action<int>>(t, "ClrIntensity"); _clrR = G<Action<int>>(t, "ClrRange"); _clrA = G<Action<int>>(t, "ClrSpotAngle"); _clrC = G<Action<int>>(t, "ClrColor"); _registerToggled = G<Action<Action<bool>>>(t, "RegisterToggledListener");
// ABI 2 additions. They resolve to null against an older host, which is exactly what makes every // member above degrade to "nothing there" instead of throwing. _isMultiplayer = G<Func<bool>>(t, "IsMultiplayer"); _getLocalSteamId = G<Func<ulong>>(t, "GetLocalSteamId"); _getRemoteIds = G<Func<ulong[]>>(t, "GetRemoteBeamIds"); _remoteHasTightBeam = G<Func<ulong, bool>>(t, "RemoteHasTightBeam"); _getRemoteBeam = G<Func<ulong, float[]>>(t, "GetRemoteBeam"); _getRemoteBeamPose = G<Func<ulong, float[]>>(t, "GetRemoteBeamPose"); _isRemoteRendered = G<Func<ulong, bool>>(t, "IsRemoteBeamRendered"); _registerRemoteToggled = G<Action<Action<ulong, bool>>>(t, "RegisterRemoteToggledListener");
// Gate on a v1 field. Keying this on a v2 field would make this shim refuse to bind to a v1 host // and silently break every consumer that ships against an older TightBeam. if (_setOn == null) return; // partial table - retry next call _bound = true; _registerToggled?.Invoke(NotifyToggled); _registerRemoteToggled?.Invoke(NotifyRemoteToggled); for (int i = 0; i < _pending.Count; i++) { try { _pending[i](); } catch { } } _pending.Clear(); } catch { } }
private static T G<T>(Type t, string field) where T : class => t.GetField(field, BindingFlags.Public | BindingFlags.Static)?.GetValue(null) as T;
private static Type FindBridge(bool scan) { Type t = Type.GetType("TightBeam.Bridge.FlashlightBridge, TightBeam", false); if (t != null || !scan) return t; foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies()) { try { t = asm.GetType("TightBeam.Bridge.FlashlightBridge", false); if (t != null) return t; } catch { } } return null; }
private static bool TryHex(string hex, out float r, out float g, out float b, out float a) { r = g = b = a = 1f; if (string.IsNullOrEmpty(hex)) return false; if (hex[0] == '#') hex = hex.Substring(1); if (hex.Length < 6) return false; try { r = Convert.ToInt32(hex.Substring(0, 2), 16) / 255f; g = Convert.ToInt32(hex.Substring(2, 2), 16) / 255f; b = Convert.ToInt32(hex.Substring(4, 2), 16) / 255f; a = hex.Length >= 8 ? Convert.ToInt32(hex.Substring(6, 2), 16) / 255f : 1f; return true; } catch { return false; } } }
/// <summary>Another player's beam as it is being drawn on this machine.</summary> public struct RemoteBeamState { public bool IsOn; public float Intensity, Range, SpotAngle; public float R, G, B, A; }
/// <summary>A scoped, per-field override on the flashlight for one owner. Set fields to drive the beam; Clear or /// Dispose to release them and let the player's own settings show through. Safe no-op if TightBeam is absent.</summary> public struct OverrideHandle : IDisposable { private readonly int _token; internal OverrideHandle(int token) { _token = token; } public OverrideHandle SetIntensity(float v) { Beam.OvIntensity(_token, v); return this; } public OverrideHandle SetRange(float m) { Beam.OvRange(_token, m); return this; } public OverrideHandle SetSpotAngle(float deg) { Beam.OvSpotAngle(_token, deg); return this; } public OverrideHandle SetColor(float r, float g, float b, float a = 1f) { Beam.OvColor(_token, r, g, b, a); return this; } public OverrideHandle ClearIntensity() { Beam.ClrIntensity(_token); return this; } public OverrideHandle ClearRange() { Beam.ClrRange(_token); return this; } public OverrideHandle ClearSpotAngle() { Beam.ClrSpotAngle(_token); return this; } public OverrideHandle ClearColor() { Beam.ClrColor(_token); return this; } public void Dispose() { Beam.EndOverride(_token); } }}