C#:
#region Using
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Plugins;
using UnityEngine;
using Rust;
#endregion
namespace Oxide.Plugins
{
[Info("TurretsOnBoat", "wGw", "2.0.1")]
[Description("Place AutoTurrets on boats. Left-click while holding the turret. Hardened against collision & stability cascade.")]
public class TurretsOnBoat : RustPlugin
{
#region Fields
private Configuration _config;
private const string PermUse = "turretsonboat.use";
private const string PermAdmin = "turretsonboat.admin";
private const string TurretItemShortname = "autoturret";
private const string TurretDeployedShortname = "autoturret_deployed";
private const string TurretDeployedPrefab = "assets/prefabs/npc/autoturret/autoturret_deployed.prefab";
private const int BuildLayerMask = 1219701497;
private const float PlaceDistance = 6f;
private const float PlaceCooldown = 0.35f;
// boat net ID -> set of live turret entities parented to it
private readonly Dictionary<ulong, HashSet<BaseEntity>> _boatTurrets = new Dictionary<ulong, HashSet<BaseEntity>>();
private readonly Dictionary<ulong, float> _lastPlaceTime = new Dictionary<ulong, float>();
#endregion
#region Config
private class Configuration
{
[JsonProperty("Version (do not edit)")]
public VersionNumber Version { get; set; } = new VersionNumber(2, 0, 1);
[JsonProperty("Max turrets per boat")]
public int MaxPerBoat { get; set; } = 6;
[JsonProperty("Minimum distance between turrets on boat (meters)")]
public float MinDistanceBetweenTurrets { get; set; } = 3.0f;
[JsonProperty("Enable collision protection")]
public bool EnableCollisionProtection { get; set; } = true;
[JsonProperty("Require ownership or be on the boat")]
public bool RequireOwnershipOrOnBoat { get; set; } = true;
}
protected override void LoadDefaultConfig()
{
_config = new Configuration();
Puts("Created fresh default config for TurretsOnBoat.");
}
protected override void LoadConfig()
{
base.LoadConfig();
try
{
_config = Config.ReadObject<Configuration>();
if (_config == null) throw new Exception("Config is null");
}
catch
{
PrintError("Config corrupted or missing — loading defaults.");
LoadDefaultConfig();
}
// Version migration
if (_config.Version < new VersionNumber(2, 0, 1))
_config.Version = new VersionNumber(2, 0, 1);
// Clamp values
if (_config.MaxPerBoat < 1) _config.MaxPerBoat = 1;
if (_config.MaxPerBoat > 20) _config.MaxPerBoat = 20;
if (_config.MinDistanceBetweenTurrets < 1f) _config.MinDistanceBetweenTurrets = 1f;
if (_config.MinDistanceBetweenTurrets > 15f) _config.MinDistanceBetweenTurrets = 15f;
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(_config, true);
#endregion
#region Localization
protected override void LoadDefaultMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["NoPermission"] = "You do not have permission to place turrets on boats.",
["NotAuthorized"] = "You must own this boat or be standing on it!",
["MaxReached"] = "Maximum {0} turrets per boat!",
["TooClose"] = "<color=#FF5555>Turrets must be at least {0:F1}m apart on boats to prevent stability issues!</color>",
["Placed"] = "<color=#55FF55>AutoTurret placed on boat.</color>",
["HoldTurret"] = "Hold an Auto Turret in your hands first.",
["ReloadSuccess"] = "TurretsOnBoat config reloaded.",
["ScanDone"] = "Scan complete. Fixed {0} boat-mounted turrets."
}, this, "en");
}
private string Lang(string key, string userId = null, params object[] args)
{
return string.Format(lang.GetMessage(key, this, userId), args);
}
#endregion
#region Oxide Hooks
private void Init()
{
permission.RegisterPermission(PermUse, this);
permission.RegisterPermission(PermAdmin, this);
}
private void OnServerInitialized()
{
Puts($"TurretsOnBoat v{Version} loaded — Collision protection: {(_config.EnableCollisionProtection ? "ENABLED" : "DISABLED")}");
Puts($"Min turret spacing: {_config.MinDistanceBetweenTurrets:F1}m | Max per boat: {_config.MaxPerBoat}");
// Full scan + harden every existing boat-mounted turret
int fixedCount = ScanAndFixAllBoatTurrets();
if (fixedCount > 0)
Puts($"Hardened {fixedCount} existing boat turrets on load.");
}
private void Unload()
{
_boatTurrets.Clear();
_lastPlaceTime.Clear();
Puts("TurretsOnBoat unloaded — existing turrets remain parented to boats.");
}
private void OnPlayerInput(BasePlayer player, InputState input)
{
if (player == null || !player.IsConnected || player.IsDead() || player.IsSleeping())
return;
if (!input.WasJustPressed(BUTTON.FIRE_PRIMARY))
return;
// Prefer GetActiveItem — more reliable across Build Mode / creative / privilege states
Item item = player.GetActiveItem();
if (item == null || item.info == null || item.info.shortname != TurretItemShortname || item.amount < 1)
{
// Fallback to HeldEntity path (some modes still expose it this way)
var held = player.GetHeldEntity() as HeldEntity;
if (held == null) return;
item = held.GetItem();
if (item == null || item.info == null || item.info.shortname != TurretItemShortname || item.amount < 1)
return;
}
// Only now check permission so we don't silently eat other left-clicks
if (!permission.UserHasPermission(player.UserIDString, PermUse) &&
!permission.UserHasPermission(player.UserIDString, PermAdmin))
{
player.ChatMessage(Lang("NoPermission", player.UserIDString));
return;
}
TryPlaceTurret(player, item);
}
private void TryPlaceTurret(BasePlayer player, Item item)
{
if (player == null || item == null || item.amount < 1) return;
// Simple per-player cooldown so rapid clicks don't spam
float now = Time.realtimeSinceStartup;
if (_lastPlaceTime.TryGetValue(player.userID, out float last) && now - last < PlaceCooldown)
return;
_lastPlaceTime[player.userID] = now;
Ray ray = player.eyes.HeadRay();
RaycastHit hit;
if (!GamePhysics.Trace(ray, 0f, out hit, PlaceDistance, BuildLayerMask, QueryTriggerInteraction.Collide))
return;
var hitEntity = hit.GetEntity();
if (hitEntity == null) return;
var boat = GetRootBoat(hitEntity);
if (boat == null) return;
if (_config.RequireOwnershipOrOnBoat && !IsAuthorizedOnBoat(boat, player))
{
player.ChatMessage(Lang("NotAuthorized", player.UserIDString));
return;
}
ulong boatId = boat.net.ID.Value;
if (!_boatTurrets.TryGetValue(boatId, out var existingTurrets))
{
existingTurrets = new HashSet<BaseEntity>();
_boatTurrets[boatId] = existingTurrets;
}
// Prune dead entries first
existingTurrets.RemoveWhere(t => t == null || t.IsDestroyed);
if (existingTurrets.Count >= _config.MaxPerBoat)
{
player.ChatMessage(Lang("MaxReached", player.UserIDString, _config.MaxPerBoat));
return;
}
Vector3 newPos = hit.point + hit.normal * 0.05f;
// Proximity block — stops the classic stability cascade
foreach (var existing in existingTurrets)
{
if (existing == null || existing.IsDestroyed) continue;
if (Vector3.Distance(newPos, existing.transform.position) < _config.MinDistanceBetweenTurrets)
{
player.ChatMessage(Lang("TooClose", player.UserIDString, _config.MinDistanceBetweenTurrets));
return;
}
}
// Orientation — flat to the hit surface, facing player forward projected onto plane
Vector3 forward = Vector3.ProjectOnPlane(player.eyes.BodyForward(), hit.normal).normalized;
if (forward.sqrMagnitude < 0.01f)
forward = Vector3.Cross(hit.normal, Vector3.right).normalized;
Quaternion rot = Quaternion.LookRotation(forward, hit.normal);
var turret = GameManager.server.CreateEntity(TurretDeployedPrefab, newPos, rot) as BaseEntity;
if (turret == null) return;
turret.OwnerID = player.userID;
turret.SetParent(boat, true, true);
// Harden BEFORE Spawn so physics never sees a dynamic + concave combination
ApplyTurretFix(turret);
turret.Spawn();
// Second pass after Spawn in case any components initialise late
NextTick(() =>
{
if (turret != null && !turret.IsDestroyed)
ApplyTurretFix(turret);
});
existingTurrets.Add(turret);
// Consume the item
item.amount--;
item.MarkDirty();
if (item.amount <= 0)
item.RemoveFromContainer();
player.ChatMessage(Lang("Placed", player.UserIDString));
Puts($"{player.displayName} placed AutoTurret on boat {boatId}");
}
object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
{
if (!_config.EnableCollisionProtection)
return null;
if (entity == null || info == null)
return null;
if (entity.ShortPrefabName != TurretDeployedShortname)
return null;
var parent = entity.GetParentEntity();
if (parent == null || !(parent is BaseVehicle))
return null;
// Aggressive filter for boat physics transfer
// Collision / Fall are the obvious ones.
// Null initiator + any damage is almost always world / physics impulse.
// Generic with no initiator is also common on frontal junk-pile hits.
float collision = info.damageTypes.Get(DamageType.Collision);
float fall = info.damageTypes.Get(DamageType.Fall);
float generic = info.damageTypes.Get(DamageType.Generic);
bool isPhysicsDamage =
collision > 0f ||
fall > 0f ||
(info.Initiator == null && info.damageTypes.Total() > 0f) ||
(generic > 0f && info.Initiator == null) ||
(info.Initiator is BaseVehicle);
if (isPhysicsDamage)
{
// Cancel the damage
return true;
}
return null;
}
private void OnEntityKill(BaseNetworkable entity)
{
if (entity == null) return;
// Turret died
if (entity.ShortPrefabName == TurretDeployedShortname)
{
var turret = entity as BaseEntity;
if (turret == null) return;
var boat = turret.GetParentEntity();
if (boat == null || !(boat is BaseVehicle)) return;
ulong boatId = boat.net.ID.Value;
if (_boatTurrets.TryGetValue(boatId, out var turrets))
{
turrets.Remove(turret);
if (turrets.Count == 0)
_boatTurrets.Remove(boatId);
else
{
// Re-harden remaining siblings to stop cascade
NextTick(() =>
{
if (boat == null || boat.IsDestroyed) return;
foreach (var remaining in turrets)
{
if (remaining != null && !remaining.IsDestroyed)
ApplyTurretFix(remaining);
}
});
}
}
return;
}
// Boat itself died — clean the tracking
if (entity is BaseVehicle)
{
ulong boatId = entity.net.ID.Value;
_boatTurrets.Remove(boatId);
}
}
#endregion
#region Commands
// Optional alternative placement command (works on both Oxide and Carbon)
[ChatCommand("boatturret")]
private void CmdPlaceTurret(BasePlayer player, string command, string[] args)
{
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PermUse) &&
!permission.UserHasPermission(player.UserIDString, PermAdmin))
{
player.ChatMessage(Lang("NoPermission", player.UserIDString));
return;
}
Item item = player.GetActiveItem();
if (item == null || item.info == null || item.info.shortname != TurretItemShortname || item.amount < 1)
{
player.ChatMessage(Lang("HoldTurret", player.UserIDString));
return;
}
TryPlaceTurret(player, item);
}
[ChatCommand("turretsonboat.reload")]
[ConsoleCommand("turretsonboat.reload")]
private void CmdReload(BasePlayer player, string command, string[] args)
{
if (player != null && !permission.UserHasPermission(player.UserIDString, PermAdmin))
{
player.ChatMessage(Lang("NoPermission", player.UserIDString));
return;
}
LoadConfig();
int fixedCount = ScanAndFixAllBoatTurrets();
string msg = Lang("ReloadSuccess") + " " + Lang("ScanDone", null, fixedCount);
if (player != null)
player.ChatMessage(msg);
else
Puts(msg);
}
#endregion
#region Core Methods
private void ApplyTurretFix(BaseEntity turret)
{
if (turret == null || turret.IsDestroyed) return;
// Force every rigidbody kinematic FIRST — this must happen before physics wakes up
var rigidbodies = turret.GetComponentsInChildren<Rigidbody>(true);
for (int i = 0; i < rigidbodies.Length; i++)
{
var rb = rigidbodies[i];
if (rb == null) continue;
rb.isKinematic = true;
rb.interpolation = RigidbodyInterpolation.None;
rb.collisionDetectionMode = CollisionDetectionMode.Discrete;
}
// Kill the concave mesh collider warning when parented to a dynamic boat
// Making them convex is the Unity-recommended fix and is fine for mounted turrets
var meshColliders = turret.GetComponentsInChildren<MeshCollider>(true);
for (int i = 0; i < meshColliders.Length; i++)
{
var mc = meshColliders[i];
if (mc == null) continue;
if (!mc.convex)
mc.convex = true;
}
// StabilityEntity is the real cascade source
var stability = turret.GetComponent<StabilityEntity>();
if (stability != null)
{
stability.grounded = true;
stability.cachedStability = 1f;
}
// Also force any child StabilityEntities
var childStabilities = turret.GetComponentsInChildren<StabilityEntity>(true);
for (int i = 0; i < childStabilities.Length; i++)
{
var s = childStabilities[i];
if (s == null) continue;
s.grounded = true;
s.cachedStability = 1f;
}
turret.SendNetworkUpdateImmediate();
}
private int ScanAndFixAllBoatTurrets()
{
_boatTurrets.Clear();
int fixedCount = 0;
foreach (var ent in BaseNetworkable.serverEntities)
{
if (ent == null || ent.IsDestroyed) continue;
if (ent.ShortPrefabName != TurretDeployedShortname) continue;
var turret = ent as BaseEntity;
if (turret == null) continue;
var parent = turret.GetParentEntity();
if (parent == null || !(parent is BaseVehicle)) continue;
ulong boatId = parent.net.ID.Value;
if (!_boatTurrets.TryGetValue(boatId, out var set))
{
set = new HashSet<BaseEntity>();
_boatTurrets[boatId] = set;
}
set.Add(turret);
ApplyTurretFix(turret);
fixedCount++;
}
return fixedCount;
}
private BaseEntity GetRootBoat(BaseEntity entity)
{
BaseEntity current = entity;
int safety = 0;
while (current != null && safety++ < 12)
{
if (current is BaseVehicle)
return current;
current = current.GetParentEntity();
}
return null;
}
private bool IsAuthorizedOnBoat(BaseEntity boat, BasePlayer player)
{
if (boat == null || player == null) return false;
// Owner
if (boat.OwnerID == player.userID) return true;
// Currently mounted on the boat
var mounted = player.GetMounted();
if (mounted != null)
{
var mountParent = mounted.GetParentEntity();
if (mountParent == boat || mounted == boat)
return true;
}
// Standing on / very close to the boat
if (Vector3.Distance(player.transform.position, boat.transform.position) < 10f)
return true;
return false;
}
#endregion
}
}