C#:
using Facepunch;
using HarmonyLib;
using Oxide.Core;
using ProtoBuf;
using System;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
namespace Oxide.Plugins;
[Info("MlrsMapMarker", "Magic Services // crash", "2.0.3")]
[Description("Shows MLRS target locations on the map with accurate impact tracking")]
public class MlrsMapMarker : RustPlugin {
private static MlrsMapMarker _instance;
private readonly Dictionary<ulong, List<MapNote>> _playerMarkers = new();
private readonly List<StrikeData> _activeStrikes = new();
private readonly Dictionary<MLRS, TargetData> _mlrsTargets = new();
private readonly Dictionary<MLRSRocket, StrikeData> _rocketToStrike = new();
private Timer _cleanupTimer;
private Configuration _config;
private class TargetData {
public Vector3 position;
}
private class StrikeData {
public Vector3 position;
public string playerName;
public float expireTime;
public string grid;
public float firstImpactTime;
public bool hasImpacted;
}
private class Configuration {
public float MarkerDuration = 45f;
public int MarkerIcon = 4;
public int MarkerColor = 3;
public string MarkerLabel = "MLRS Strike!";
public bool ShowChatNotification = true;
public bool ShowPlayerName = true;
public float CleanupInterval = 5f;
public bool ShowGridReference = true;
public bool AutoShowToAllPlayers = true;
}
protected override void LoadDefaultConfig() => _config = new Configuration();
protected override void LoadConfig() {
base.LoadConfig();
try {
_config = Config.ReadObject<Configuration>();
if (_config == null) throw new Exception();
}
catch {
LoadDefaultConfig();
PrintWarning("Creating new config file");
SaveConfig();
}
}
protected override void SaveConfig() => Config.WriteObject(_config);
private void Init() {
_instance = this;
PatchNestedClasses();
}
private void OnServerInitialized() {
_cleanupTimer = timer.Every(_config.CleanupInterval, CleanupExpired);
}
private void Unload() {
_cleanupTimer?.Destroy();
foreach (var kvp in _playerMarkers) {
var markers = kvp.Value;
for (var i = 0; i < markers.Count; i++) markers[i].Dispose();
markers.Clear();
var connectedPlayer = BasePlayer.FindByID(kvp.Key);
if (connectedPlayer is null || !connectedPlayer.IsConnected) continue;
connectedPlayer.State.pings ??= new List<MapNote>();
connectedPlayer.SendPingsToClient();
}
_playerMarkers.Clear();
_activeStrikes.Clear();
_mlrsTargets.Clear();
_rocketToStrike.Clear();
_instance = null;
}
private void OnPlayerDisconnected(BasePlayer player) {
if (!_playerMarkers.TryGetValue(player.userID, out var markers)) return;
for (var i = 0; i < markers.Count; i++) markers[i].Dispose();
markers.Clear();
_playerMarkers.Remove(player.userID);
}
private void OnPlayerPingsSend(BasePlayer player, MapNoteList mapNoteList) {
if (_playerMarkers.TryGetValue(player.userID, out var markers)) mapNoteList.notes.AddRange(markers);
}
private object OnMlrsTargetSet(MLRS mlrs, Vector3 targetPos, BasePlayer player) {
if (mlrs is null) return null;
if (!_mlrsTargets.TryGetValue(mlrs, out var data)) {
data = new TargetData();
_mlrsTargets[mlrs] = data;
}
data.position = targetPos;
return null;
}
private void OnMlrsFired(MLRS mlrs, BasePlayer player) {
if (mlrs is null || !_mlrsTargets.TryGetValue(mlrs, out var targetData)) return;
var impactTime = CalculateImpactTime(mlrs, targetData.position);
var strike = new StrikeData {
position = targetData.position, playerName = _config.ShowPlayerName && player != null ? player.displayName : "Unknown", expireTime = Time.time + _config.MarkerDuration, grid = GetGrid(targetData.position), firstImpactTime = Time.time + impactTime, hasImpacted = false
}
;
_activeStrikes.Add(strike);
if (_config.ShowChatNotification) {
var message = _config.ShowGridReference ? GetMessage("IncomingStrikeWithGrid", null, strike.grid, strike.playerName, impactTime.ToString("F1")) : GetMessage("IncomingStrikeWithCoords", null, FormatPosition(strike.position), strike.playerName, impactTime.ToString("F1"));
Server.Broadcast(message);
}
if (!_config.AutoShowToAllPlayers) return;
for (var i = 0; i < BasePlayer.activePlayerList.Count; i++) UpdateMarkersForPlayer(BasePlayer.activePlayerList[i]);
}
private void OnEntitySpawned(MLRSRocket rocket) {
if (rocket is null || _activeStrikes.Count == 0) return;
StrikeData bestMatch = null;
var closestDist = float.MaxValue;
for (var i = 0; i < _activeStrikes.Count; i++) {
var strike = _activeStrikes[i];
if (strike.hasImpacted) continue;
var dist = (strike.position - rocket.transform.position).sqrMagnitude;
if (dist >= closestDist) continue;
closestDist = dist;
bestMatch = strike;
}
if (bestMatch != null) _rocketToStrike[rocket] = bestMatch;
}
private void OnExplosiveThrown(BasePlayer player, MLRSRocket rocket) {
if (rocket is null || !_rocketToStrike.TryGetValue(rocket, out var strike)) return;
strike.hasImpacted = true;
}
private object OnEntityKill(MLRSRocket rocket) {
if (rocket is null || !_rocketToStrike.TryGetValue(rocket, out var strike)) return null;
strike.hasImpacted = true;
_rocketToStrike.Remove(rocket);
return null;
}
private void PatchNestedClasses() {
foreach (var type in typeof(MlrsMapMarker).GetNestedTypes(BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public)) {
var harmonyPatchAttributes = type.GetCustomAttributes(typeof(HarmonyPatch), true);
if (harmonyPatchAttributes is not {
Length : > 0
}
) continue;
try {
HarmonyInstance.CreateClassProcessor(type).Patch();
}
catch (Exception ex) {
PrintError($"Failed to apply Harmony patch for {type.Name}: {ex}");
}
}
}
[HarmonyPatch(typeof(MLRS), "SetUserTargetHitPos")]
internal static class MlrsTargetPatch {
[HarmonyPostfix]
private static void Postfix(MLRS __instance) {
if (_instance == null) return;
var player = __instance.GetMounted();
var truePos = __instance.TrueHitPos;
if (truePos != Vector3.zero) Interface.CallHook("OnMlrsTargetSet", __instance, truePos, player);
}
}
private float CalculateImpactTime(MLRS mlrs, Vector3 targetPos) {
var startPos = mlrs.firingPoint.position;
var speed = mlrs.rocketSpeed;
var aimDirection = Ballistics.GetAimToTarget(startPos, targetPos, speed, mlrs.vRotMax, mlrs.rocketBaseGravity, mlrs.minRange, out var requiredGravity );
var horizontalDir = new Vector3(aimDirection.x, 0, aimDirection.z).magnitude;
var launchAngle = Mathf.Atan2(aimDirection.y, horizontalDir);
var heightDiff = targetPos.y - startPos.y;
var vSinTheta = speed * Mathf.Sin(launchAngle);
var discriminant = vSinTheta * vSinTheta - 2f * requiredGravity * heightDiff;
if (discriminant < 0) return 10f;
var time = (vSinTheta + Mathf.Sqrt(discriminant)) / requiredGravity;
return Mathf.Max(0.1f, time);
}
private static string GetGrid(Vector3 pos) => MapHelper.PositionToString(pos);
private void UpdateMarkersForPlayer(BasePlayer player) {
if (player is null || !player.IsConnected) return;
ClearMarkersForPlayer(player);
if (!_playerMarkers.TryGetValue(player.userID, out var markers)) {
markers = new List<MapNote>();
_playerMarkers[player.userID] = markers;
}
var currentTime = Time.time;
for (var i = 0; i < _activeStrikes.Count; i++) {
var strike = _activeStrikes[i];
if (strike.expireTime > currentTime) markers.Add(CreateMarker(strike));
}
player.SendPingsToClient();
}
private MapNote CreateMarker(StrikeData strike) {
var note = Pool.Get<MapNote>();
note.isPing = true;
note.worldPosition = strike.position;
note.icon = _config.MarkerIcon;
note.colourIndex = _config.MarkerColor;
var label = _config.MarkerLabel;
if (_config.ShowPlayerName) label += $" (sent by {strike.playerName})";
if (_config.ShowGridReference) label += $"\nGrid: {strike.grid}";
note.label = label;
return note;
}
private void ClearMarkersForPlayer(BasePlayer player) {
if (!_playerMarkers.TryGetValue(player.userID, out var markers)) return;
for (var i = 0; i < markers.Count; i++) markers[i].Dispose();
markers.Clear();
}
private void CleanupExpired() {
var currentTime = Time.time;
for (var i = _activeStrikes.Count - 1; i >= 0; i--) {
if (_activeStrikes[i].expireTime <= currentTime) _activeStrikes.RemoveAt(i);
}
var toRemoveMlrs = new List<MLRS>();
foreach (var kvp in _mlrsTargets) {
if (kvp.Key is null || kvp.Key.IsDestroyed) toRemoveMlrs.Add(kvp.Key);
}
for (var i = 0; i < toRemoveMlrs.Count; i++) _mlrsTargets.Remove(toRemoveMlrs[i]);
var toRemoveRockets = new List<MLRSRocket>();
foreach (var kvp in _rocketToStrike) {
if (kvp.Key is null || kvp.Key.IsDestroyed) toRemoveRockets.Add(kvp.Key);
}
for (var i = 0; i < toRemoveRockets.Count; i++) _rocketToStrike.Remove(toRemoveRockets[i]);
for (var i = 0; i < BasePlayer.activePlayerList.Count; i++) {
var player = BasePlayer.activePlayerList[i];
if (_playerMarkers.ContainsKey(player.userID)) UpdateMarkersForPlayer(player);
}
}
private static string FormatPosition(Vector3 pos) => $"{pos.x:F0}, {pos.z:F0}";
[ChatCommand("mlrs")]
private void CmdMlrsInfo(BasePlayer player, string command, string[] args) {
var currentTime = Time.time;
var activeCount = 0;
for (var i = 0; i < _activeStrikes.Count; i++) {
if (_activeStrikes[i].expireTime > currentTime) activeCount++;
}
if (activeCount == 0) {
player.ChatMessage(GetMessage("NoActiveStrikes", player.UserIDString));
return;
}
player.ChatMessage(GetMessage("ActiveStrikesHeader", player.UserIDString, activeCount));
for (var i = 0; i < _activeStrikes.Count; i++) {
var strike = _activeStrikes[i];
if (strike.expireTime <= currentTime) continue;
var timeToImpact = strike.firstImpactTime > 0 ? Mathf.Max(0, strike.firstImpactTime - currentTime) : 0f;
string timeDisplay;
if (timeToImpact <= 0.5f) timeDisplay = "NOW";
else timeDisplay = timeToImpact.ToString("F1") + "s";
if (_config.ShowPlayerName) {
player.ChatMessage(GetMessage("StrikeInfo", player.UserIDString, strike.grid, strike.playerName, timeDisplay));
}
else {
player.ChatMessage(GetMessage("StrikeInfoNoPlayer", player.UserIDString, strike.grid, timeDisplay));
}
}
}
protected override void LoadDefaultMessages() {
lang.RegisterMessages(new Dictionary<string, string> {
["NoActiveStrikes"]
= "<size=14>:yellowpin: <color=#2C3E50>NO ACTIVE MLRS STRIKES</color></size>\n<size=12><color=#ECF0F1>The battlefield is currently clear</color></size>", ["ActiveStrikesHeader"] = "<size=16>:exclamation: <color=#E74C3C>MLRS STRIKE ALERT</color></size>\n<size=14><color=#F39C12>ACTIVE STRIKES </color><color=#ECF0F1>({0}) INCOMING</color></size>", ["StrikeInfo"] = "<size=12>· <color=#3498DB>Grid: </color><color=#2ECC71>{0}</color> <color=#7F8C8D>| </color><color=#3498DB>Operator: </color><color=#ECF0F1>{1}</color>\n· <color=#3498DB>Impact: </color><color=#E74C3C>{2}</color> <color=#7F8C8D>| </color><color=#3498DB>Status: </color><color=#F39C12>INCOMING</color></size>", ["StrikeInfoNoPlayer"] = "<size=12>· <color=#3498DB>Grid: </color><color=#2ECC71>{0}</color> <color=#7F8C8D>| </color><color=#3498DB>Impact: </color><color=#E74C3C>{1}</color>\n· <color=#3498DB>Status: </color><color=#F39C12>INCOMING</color> <color=#7F8C8D>| </color><color=#3498DB>Threat: </color><color=#E74C3C>HIGH</color></size>", ["IncomingStrikeWithGrid"] = "<size=16>:skull: <color=#E74C3C>MLRS STRIKE INCOMING</color></size>\n<size=12>· <color=#3498DB>Grid: </color><color=#2ECC71>{0}</color> <color=#7F8C8D>| </color><color=#3498DB>Operator: </color><color=#ECF0F1>{1}</color>\n· <color=#3498DB>Impact: </color><color=#E74C3C>{2}</color></size>\n<size=11><color=#E74C3C>:exclamation: SEEK IMMEDIATE SHELTER :exclamation:</color></size>", ["IncomingStrikeWithCoords"] = "<size=16>:skull: <color=#E74C3C>MLRS STRIKE INCOMING</color></size>\n<size=12>· <color=#3498DB>Position: </color><color=#2ECC71>{0}</color> <color=#7F8C8D>| </color><color=#3498DB>Operator: </color><color=#ECF0F1>{1}</color>\n· <color=#3498DB>Impact: </color><color=#E74C3C>{2}</color></size>\n<size=11><color=#E74C3C>:exclamation: SEEK IMMEDIATE SHELTER :exclamation:</color></size>", ["MarkersCleared"] = "<size=14>:coffeecan: <color=#2ECC71>MARKERS CLEARED</color></size>\n<size=12><color=#ECF0F1>All MLRS markers removed</color></size>", ["PlayerMarkersCleared"] = "<size=14>:coffeecan: <color=#2ECC71>YOUR MARKERS CLEARED</color></size>\n<size=12><color=#ECF0F1>All your MLRS markers have been removed</color></size>"
}
, this);
}
private string GetMessage(string key, string userId = null, params object[] args) => string.Format(lang.GetMessage(key, this, userId), args);
}