C#:
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Plugins;
using Oxide.Game.Rust.Cui;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("TimedDoorVault", "ZIm", "1.0.0")]
[Description("Swap armored doors for bank vault doors (/vaultdoor). Link a Smart Alarm as a timed unlock trigger (/doortrigger).")]
public class TimedDoorVault : RustPlugin
{
private const string PermUse = "timeddoorvault.use";
private const string VaultPrefab = "assets/bundled/prefabs/modding/asset_store/bankheist_package/bankheist_vol03/prefabs/door.vault.static.prefab";
private const string ArmoredPrefab = "assets/prefabs/building/door.hinged/door.hinged.toptier.prefab";
private const string DoubleArmoredPrefab = "assets/prefabs/building/door.double.hinged/door.double.hinged.toptier.prefab";
private const string SmartAlarmPrefab = "assets/prefabs/deployable/playerioents/app/smartalarm/smartalarm.prefab";
private const string LaptopPrefab = "assets/bundled/prefabs/modding/events/twitch/wipe_laptop.prefab";
private const string AlarmItemShortname = "smart.alarm";
private const ulong SkinTag = 777000111UL; // entity marker so we recognise our own swaps
private const string FxBeep = "assets/prefabs/locks/keypad/effects/lock.code.updated.prefab";
private const string FxUnlock = "assets/prefabs/locks/keypad/effects/lock.code.unlock.prefab";
private const string FxDenied = "assets/prefabs/locks/keypad/effects/lock.code.denied.prefab";
private const string UiName = ".VaultUnlockUI";
// Optional: status bars via the free AdvancedStatus plugin (by IIIaKa) when installed.
[PluginReference] private Plugin AdvancedStatus;
private Configuration config;
private StoredData data = new StoredData();
private bool HasStatus => config.UseAdvancedStatus && AdvancedStatus != null && AdvancedStatus.IsLoaded;
private class OpenTicker
{
public Timer Tick;
public BasePlayer Player;
}
// doorId -> hold-open countdown display (built-in popup mode only)
private readonly Dictionary<ulong, OpenTicker> openTickers = new Dictionary<ulong, OpenTicker>();
private static double UnixNow => System.DateTimeOffset.UtcNow.ToUnixTimeSeconds();
private class UnlockSequence
{
public ulong DoorId;
public BasePlayer Starter;
public int SecondsLeft;
public Timer Tick;
}
// doorId -> running sequence
private readonly Dictionary<ulong, UnlockSequence> active = new Dictionary<ulong, UnlockSequence>();
#region Config
private class Configuration
{
[JsonProperty("Vault door health - matches an armored wall (0 = leave prefab default)")]
public float Health = 2000f;
[JsonProperty("Opening / Cracking - seconds the unlock sequence takes")]
public int TriggerSeconds = 20;
[JsonProperty("Holding Open - seconds the vault stays open before closing")]
public int RelockSeconds = 30;
[JsonProperty("Lock After - re-lock the code lock when the vault closes (false = shuts but stays unlocked)")]
public bool LockAfter = true;
[JsonProperty("Radius from Vault door - max distance to link a trigger terminal (meters)")]
public float LinkRadius = 10f;
[JsonProperty("Anyone can use the terminal (false = only players authed on the code lock, owner, or admins)")]
public bool AllowAnyoneToTrigger = false;
[JsonProperty("Use AdvancedStatus bars for countdowns (false = built-in on-screen bar)")]
public bool UseAdvancedStatus = true;
[JsonProperty("Alignment - forward/back offset (meters)")]
public float OffsetForward = -0.9f;
[JsonProperty("Alignment - left/right offset (meters)")]
public float OffsetRight = 0.54f;
[JsonProperty("Alignment - up/down offset (meters)")]
public float OffsetUp = 0.222f;
[JsonProperty("Alignment - yaw rotation offset (degrees)")]
public float YawOffset = 0f;
[JsonProperty("Code lock - forward offset")]
public float LockOffsetForward = -0.35f;
[JsonProperty("Code lock - right offset")]
public float LockOffsetRight = 0.60f;
[JsonProperty("Code lock - up offset")]
public float LockOffsetUp = 0f;
}
protected override void LoadDefaultConfig() => config = new Configuration();
protected override void LoadConfig()
{
base.LoadConfig();
try
{
config = Config.ReadObject<Configuration>();
if (config == null) throw new JsonException();
SaveConfig();
}
catch
{
PrintWarning("Config corrupt, loading defaults.");
LoadDefaultConfig();
}
}
protected override void SaveConfig() => Config.WriteObject(config);
#endregion
#region Data
private class OriginalDoor
{
public string Prefab;
public float X, Y, Z;
public float RotX, RotY, RotZ, RotW;
public ulong OwnerId;
public ulong PartnerId; // legacy double support; kept for old data
}
private class StoredData
{
public Dictionary<ulong, OriginalDoor> Swapped = new Dictionary<ulong, OriginalDoor>();
public Dictionary<ulong, ulong> Partner = new Dictionary<ulong, ulong>();
// smart alarm net id -> vault door net id
public Dictionary<ulong, ulong> Triggers = new Dictionary<ulong, ulong>();
// open vault door net id -> unix time it must shut and re-lock
// (persisted so a plugin reload or restart can never leave a vault open)
public Dictionary<ulong, double> RelockAt = new Dictionary<ulong, double>();
}
private void LoadData()
{
try { data = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(Name) ?? new StoredData(); }
catch { data = new StoredData(); }
if (data.Swapped == null) data.Swapped = new Dictionary<ulong, OriginalDoor>();
if (data.Partner == null) data.Partner = new Dictionary<ulong, ulong>();
if (data.Triggers == null) data.Triggers = new Dictionary<ulong, ulong>();
if (data.RelockAt == null) data.RelockAt = new Dictionary<ulong, double>();
}
private void SaveData() => Interface.Oxide.DataFileSystem.WriteObject(Name, data);
#endregion
#region Hooks
private void Init()
{
permission.RegisterPermission(PermUse, this);
LoadData();
}
private void OnServerInitialized()
{
if (config.Health > 0f)
{
foreach (var ent in BaseNetworkable.serverEntities)
{
var door = ent as Door;
if (door == null || door.IsDestroyed) continue;
if (door.PrefabName == VaultPrefab && door.skinID == SkinTag)
ApplyHealth(door);
}
}
// Sweep pending relocks: overdue ones shut now, future ones re-schedule.
foreach (var kv in new List<KeyValuePair<ulong, double>>(data.RelockAt))
{
double remaining = kv.Value - UnixNow;
if (remaining <= 0) DoRelock(kv.Key);
else { ulong id = kv.Key; timer.Once((float)remaining, () => DoRelock(id)); }
}
}
private void Unload()
{
foreach (var seq in new List<UnlockSequence>(active.Values))
{
seq.Tick?.Destroy();
if (seq.Starter != null) CuiHelper.DestroyUi(seq.Starter, UiName);
}
active.Clear();
foreach (var id in new List<ulong>(openTickers.Keys)) StopOpenTicker(id);
}
// One handler for all our tracked entities: vault doors, alarms, laptops.
private void OnEntityKill(BaseEntity entity)
{
ulong id = entity?.net?.ID.Value ?? 0;
if (id == 0) return;
bool changed = false;
if (entity is Door)
{
CancelSequence(id);
OriginalDoor rec;
if (data.Swapped.TryGetValue(id, out rec))
{
if (rec.PartnerId != 0) data.Partner.Remove(rec.PartnerId);
data.Swapped.Remove(id);
changed = true;
}
else if (data.Partner.Remove(id))
{
changed = true;
}
// Unlink any triggers pointing at this door.
foreach (var key in new List<ulong>(data.Triggers.Keys))
if (data.Triggers[key] == id) { data.Triggers.Remove(key); changed = true; }
}
else if (data.Triggers.Remove(id))
{
changed = true;
}
if (changed) SaveData();
}
// E on a linked trigger (laptop terminal, or a legacy alarm) starts the unlock.
private void OnPlayerInput(BasePlayer player, InputState input)
{
if (player == null || input == null || !input.WasJustPressed(BUTTON.USE)) return;
if (data.Triggers.Count == 0) return;
var ent = GetLookEntity<BaseEntity>(player, 3f);
if (ent == null) return;
ulong doorId;
if (!data.Triggers.TryGetValue(ent.net.ID.Value, out doorId)) return;
var door = BaseNetworkable.serverEntities.Find(new NetworkableId(doorId)) as Door;
if (door == null || door.IsDestroyed)
{
data.Triggers.Remove(ent.net.ID.Value);
SaveData();
return;
}
TryStartSequence(player, door, ent);
}
#endregion
#region Commands
[ChatCommand("vaultdoor")]
private void CmdVaultDoor(BasePlayer player, string command, string[] args)
{
if (!player.IsAdmin && !permission.UserHasPermission(player.UserIDString, PermUse))
{
player.ChatMessage("You don't have permission to do that.");
return;
}
var target = GetLookEntity<Door>(player, 5f) ?? FindNearbyDoor(player, 4f);
if (target == null)
{
player.ChatMessage("Look at a door (within 5m) and try again.");
return;
}
if (target.PrefabName == ArmoredPrefab)
{
SwapToVault(player, target);
}
else if (target.PrefabName == DoubleArmoredPrefab)
{
player.ChatMessage("Double doors aren't supported — only single armored doors can be swapped.");
}
else if (target.PrefabName == VaultPrefab && target.skinID == SkinTag)
{
RevertToArmored(player, target);
}
else
{
player.ChatMessage("That's not an armored door (or one of my vault doors). Only <color=#ffa500>armored doors</color> can be swapped.");
}
}
[ChatCommand("doortrigger")]
private void CmdDoorTrigger(BasePlayer player, string command, string[] args)
{
if (!player.IsAdmin && !permission.UserHasPermission(player.UserIDString, PermUse))
{
player.ChatMessage("You don't have permission to do that.");
return;
}
var ent = GetLookEntity<BaseEntity>(player, 5f);
// Looking at one of our laptop terminals: unlink, remove it, refund the alarm.
if (ent != null && ent.PrefabName == LaptopPrefab && ent.skinID == SkinTag)
{
data.Triggers.Remove(ent.net.ID.Value);
SaveData();
ent.Kill();
var refund = ItemManager.CreateByName(AlarmItemShortname, 1);
if (refund != null) player.GiveItem(refund, BaseEntity.GiveItemReason.PickedUp);
player.ChatMessage("Terminal removed — Smart Alarm refunded to your inventory.");
return;
}
var alarm = ent as SmartAlarm ?? GetLookEntity<SmartAlarm>(player, 5f);
if (alarm == null)
{
player.ChatMessage("Look at a <color=#ffa500>Smart Alarm</color> (within 5m) and try again.");
return;
}
// Find the nearest vault door to link.
Door nearest = null;
float closest = config.LinkRadius;
foreach (var kv in data.Swapped)
{
var door = BaseNetworkable.serverEntities.Find(new NetworkableId(kv.Key)) as Door;
if (door == null || door.IsDestroyed) continue;
float d = Vector3.Distance(door.transform.position, alarm.transform.position);
if (d < closest) { closest = d; nearest = door; }
}
if (nearest == null)
{
player.ChatMessage($"No vault door within {config.LinkRadius}m of this alarm. Swap one first with /vaultdoor.");
return;
}
// Carrier-pattern: the alarm becomes the hack terminal (wipe laptop).
Vector3 pos = alarm.transform.position;
// The laptop model faces the opposite way to the alarm's mount - flip it.
Quaternion rot = alarm.transform.rotation * Quaternion.Euler(0f, 180f, 0f);
ulong prevLink;
data.Triggers.TryGetValue(alarm.net.ID.Value, out prevLink); // legacy alarm links migrate
data.Triggers.Remove(alarm.net.ID.Value);
var laptop = GameManager.server.CreateEntity(LaptopPrefab, pos, rot);
if (laptop == null)
{
// Laptop prop unavailable - fall back to the alarm itself as trigger.
data.Triggers[alarm.net.ID.Value] = nearest.net.ID.Value;
SaveData();
player.ChatMessage($"Linked (laptop prop unavailable, alarm kept). Press <color=#ffa500>E</color> on it for the {config.TriggerSeconds}s unlock.");
return;
}
laptop.skinID = SkinTag;
laptop.OwnerID = player.userID;
laptop.Spawn();
alarm.Kill();
data.Triggers[laptop.net.ID.Value] = nearest.net.ID.Value;
SaveData();
player.ChatMessage($"Terminal deployed and linked to the vault door {closest:0.0}m away. Press <color=#ffa500>E</color> on the laptop for the {config.TriggerSeconds}s unlock. /doortrigger it again to remove.");
}
[ConsoleCommand("vaultdoor")]
private void CmdVaultDoorConsole(ConsoleSystem.Arg arg)
{
BasePlayer player = arg.Player();
if (player == null) return;
CmdVaultDoor(player, "vaultdoor", new string[0]);
}
#endregion
#region Unlock sequence
private void TryStartSequence(BasePlayer player, Door door, BaseEntity trigger)
{
if (active.ContainsKey(door.net.ID.Value)) return; // already running
var codeLock = door.GetSlot(BaseEntity.Slot.Lock) as CodeLock;
if (!config.AllowAnyoneToTrigger && !player.IsAdmin)
{
bool authed = door.OwnerID == player.userID
|| (codeLock != null && codeLock.whitelistPlayers != null && codeLock.whitelistPlayers.Contains(player.userID));
if (!authed)
{
Effect.server.Run(FxDenied, door.transform.position);
player.ChatMessage("You're not authorised on this vault.");
return;
}
}
if (door.IsOpen())
{
player.ChatMessage("The vault is already open.");
return;
}
var seq = new UnlockSequence
{
DoorId = door.net.ID.Value,
Starter = player,
SecondsLeft = config.TriggerSeconds
};
active[seq.DoorId] = seq;
// Legacy smart-alarm triggers get their light turned on for the duration.
if (trigger is SmartAlarm)
{
trigger.SetFlagLocal(BaseEntity.Flags.On, true);
trigger.SendNetworkUpdate();
}
// AdvancedStatus bars are timestamp-driven - one call, it animates itself.
if (HasStatus)
{
double now = UnixNow;
AdvancedStatus.Call("CreateBar", player, new Dictionary<string, object>
{
["Plugin"] = Name,
["Id"] = "vaultcrack",
["BarType"] = "TimeProgressCounter",
["Text"] = "CRACKING VAULT",
["Main_Color"] = "#7F1D1D",
["Progress_Color"] = "#EF4444",
["Text_Color"] = "#FFFFFF",
["SubText_Color"] = "#FFFFFF",
["Image_Color"] = "#FF6B6B",
["TimeStampStart"] = now,
["TimeStamp"] = now + config.TriggerSeconds,
["TimeStampDestroy"] = now + config.TriggerSeconds
});
}
seq.Tick = timer.Every(1f, () =>
{
var d = BaseNetworkable.serverEntities.Find(new NetworkableId(seq.DoorId)) as Door;
if (d == null || d.IsDestroyed)
{
CancelSequence(seq.DoorId);
return;
}
if (seq.SecondsLeft > 0)
{
Effect.server.Run(FxBeep, d.transform.position);
if (!HasStatus) ShowCountdown(seq.Starter, "CRACKING VAULT", "0.94 0.27 0.27 0.9", "1 0.55 0.55 1", seq.SecondsLeft, config.TriggerSeconds);
seq.SecondsLeft--;
return;
}
// Time's up - open the vault.
CompleteSequence(seq, d, trigger);
});
}
private void CompleteSequence(UnlockSequence seq, Door door, BaseEntity trigger)
{
CancelSequence(seq.DoorId);
var codeLock = door.GetSlot(BaseEntity.Slot.Lock) as CodeLock;
if (codeLock != null)
{
codeLock.SetFlagLocal(BaseEntity.Flags.Locked, false);
codeLock.SendNetworkUpdate();
}
Effect.server.Run(FxUnlock, door.transform.position);
door.SetOpen(true);
if (trigger is SmartAlarm && !trigger.IsDestroyed)
{
trigger.SetFlagLocal(BaseEntity.Flags.On, false);
trigger.SendNetworkUpdate();
}
// Re-lock countdown as a status bar (no chat spam).
if (HasStatus && seq.Starter != null)
{
double now = UnixNow;
AdvancedStatus.Call("CreateBar", seq.Starter, new Dictionary<string, object>
{
["Plugin"] = Name,
["Id"] = "vaultopen",
["BarType"] = "TimeCounter",
["Text"] = "VAULT OPEN",
["Main_Color"] = "#14532D",
["Progress_Color"] = "#22C55E",
["Text_Color"] = "#FFFFFF",
["SubText_Color"] = "#FFFFFF",
["Image_Color"] = "#4ADE80",
["TimeStampStart"] = now,
["TimeStamp"] = now + config.RelockSeconds,
["TimeStampDestroy"] = now + config.RelockSeconds
});
}
// Auto close + re-lock: deadline persisted so reloads/restarts can't lose it.
ulong doorId = door.net.ID.Value;
data.RelockAt[doorId] = UnixNow + config.RelockSeconds;
SaveData();
timer.Once(config.RelockSeconds, () => DoRelock(doorId));
// Built-in popup mode: green hold-open countdown for the starter.
if (!HasStatus && seq.Starter != null)
{
var ticker = new OpenTicker { Player = seq.Starter };
int left = config.RelockSeconds;
ticker.Tick = timer.Every(1f, () =>
{
if (left <= 0 || ticker.Player == null || !ticker.Player.IsConnected)
{
StopOpenTicker(doorId);
return;
}
ShowCountdown(ticker.Player, "VAULT OPEN", "0.13 0.77 0.37 0.9", "0.6 1 0.7 1", left, config.RelockSeconds);
left--;
});
openTickers[doorId] = ticker;
}
}
private void StopOpenTicker(ulong doorId)
{
OpenTicker ticker;
if (!openTickers.TryGetValue(doorId, out ticker)) return;
ticker.Tick?.Destroy();
if (ticker.Player != null) CuiHelper.DestroyUi(ticker.Player, UiName);
openTickers.Remove(doorId);
}
private void DoRelock(ulong doorId)
{
StopOpenTicker(doorId);
if (!data.RelockAt.Remove(doorId)) return; // already handled
SaveData();
var d = BaseNetworkable.serverEntities.Find(new NetworkableId(doorId)) as Door;
if (d == null || d.IsDestroyed) return;
d.SetOpen(false);
if (config.LockAfter)
{
var cl = d.GetSlot(BaseEntity.Slot.Lock) as CodeLock;
if (cl != null)
{
cl.SetFlagLocal(BaseEntity.Flags.Locked, true);
cl.SendNetworkUpdate();
}
}
Effect.server.Run(FxUnlock, d.transform.position);
}
private void CancelSequence(ulong doorId)
{
UnlockSequence seq;
if (!active.TryGetValue(doorId, out seq)) return;
seq.Tick?.Destroy();
if (seq.Starter != null)
{
CuiHelper.DestroyUi(seq.Starter, UiName);
if (HasStatus) AdvancedStatus.Call("DeleteBar", seq.Starter, "vaultcrack", Name);
}
active.Remove(doorId);
}
private void ShowCountdown(BasePlayer player, string text, string barColor, string textColor, int seconds, int total)
{
if (player == null || !player.IsConnected) return;
// DestroyUi inside the element = atomic in-place replace, no flicker.
var container = new CuiElementContainer();
container.Add(new CuiElement
{
Parent = "Overlay",
Name = UiName,
DestroyUi = UiName,
Components =
{
new CuiImageComponent { Color = "0 0 0 0.8" },
new CuiRectTransformComponent { AnchorMin = "0.5 0", AnchorMax = "0.5 0", OffsetMin = "-120 120", OffsetMax = "120 150" }
}
});
float progress = 1f - (seconds / (float)total);
container.Add(new CuiPanel
{
RectTransform = { AnchorMin = "0 0", AnchorMax = $"{Mathf.Clamp01(progress)} 0.15" },
Image = { Color = barColor }
}, UiName);
container.Add(new CuiLabel
{
RectTransform = { AnchorMin = "0 0.2", AnchorMax = "1 1" },
Text =
{
Text = $"{text} — {seconds}s",
Align = TextAnchor.MiddleCenter,
FontSize = 14,
Font = "robotocondensed-bold.ttf",
Color = textColor
}
}, UiName);
CuiHelper.AddUi(player, container);
}
#endregion
#region Core (swap / revert)
private void SwapToVault(BasePlayer player, Door armored)
{
Vector3 basePos = armored.transform.position;
Quaternion baseRot = armored.transform.rotation;
ulong ownerId = armored.OwnerID != 0 ? armored.OwnerID : player.userID;
var original = new OriginalDoor
{
Prefab = armored.PrefabName,
X = basePos.x, Y = basePos.y, Z = basePos.z,
RotX = baseRot.x, RotY = baseRot.y, RotZ = baseRot.z, RotW = baseRot.w,
OwnerId = ownerId
};
Quaternion finalRot = baseRot * Quaternion.Euler(0f, config.YawOffset, 0f);
Vector3 finalPos = basePos
+ finalRot * Vector3.forward * config.OffsetForward
+ finalRot * Vector3.right * config.OffsetRight
+ Vector3.up * config.OffsetUp;
var vault = GameManager.server.CreateEntity(VaultPrefab, finalPos, finalRot) as Door;
if (vault == null)
{
player.ChatMessage("Vault door prefab failed to spawn - nothing was changed.");
return;
}
vault.skinID = SkinTag;
vault.OwnerID = ownerId;
vault.Spawn();
ApplyHealth(vault);
TransferLock(armored, vault, toVault: true);
armored.Kill();
vault.SendNetworkUpdateImmediate();
data.Swapped[vault.net.ID.Value] = original;
SaveData();
player.ChatMessage("Swapped to a <color=#ffa500>Vault Door</color>. /vaultdoor again to revert. Mount a Smart Alarm nearby and /doortrigger to add a timed unlock.");
}
private void RevertToArmored(BasePlayer player, Door vault)
{
ulong id = vault.net.ID.Value;
ulong primaryId;
if (data.Partner.TryGetValue(id, out primaryId))
{
var primary = BaseNetworkable.serverEntities.Find(new NetworkableId(primaryId)) as Door;
if (primary != null && !primary.IsDestroyed) vault = primary;
}
OriginalDoor original;
Vector3 pos;
Quaternion rot;
ulong ownerId;
string prefab = ArmoredPrefab;
Door partner = null;
if (data.Swapped.TryGetValue(vault.net.ID.Value, out original))
{
pos = new Vector3(original.X, original.Y, original.Z);
rot = new Quaternion(original.RotX, original.RotY, original.RotZ, original.RotW);
ownerId = original.OwnerId;
if (!string.IsNullOrEmpty(original.Prefab)) prefab = original.Prefab;
if (original.PartnerId != 0)
partner = BaseNetworkable.serverEntities.Find(new NetworkableId(original.PartnerId)) as Door;
}
else
{
rot = vault.transform.rotation * Quaternion.Euler(0f, -config.YawOffset, 0f);
pos = vault.transform.position
- vault.transform.rotation * Vector3.forward * config.OffsetForward
- vault.transform.rotation * Vector3.right * config.OffsetRight
- Vector3.up * config.OffsetUp;
ownerId = vault.OwnerID;
}
var armored = GameManager.server.CreateEntity(prefab, pos, rot) as Door;
if (armored == null)
{
player.ChatMessage("Armored door failed to spawn - nothing was changed.");
return;
}
armored.OwnerID = ownerId != 0 ? ownerId : player.userID;
armored.Spawn();
TransferLock(vault, armored, toVault: false);
if (original != null && original.PartnerId != 0) data.Partner.Remove(original.PartnerId);
data.Swapped.Remove(vault.net.ID.Value);
SaveData();
vault.Kill();
if (partner != null && !partner.IsDestroyed) partner.Kill();
armored.SendNetworkUpdateImmediate();
player.ChatMessage("Reverted to an <color=#ffa500>Armored Door</color>.");
}
private void TransferLock(Door from, Door to, bool toVault)
{
var baseLock = from.GetSlot(BaseEntity.Slot.Lock) as BaseLock;
if (baseLock == null || baseLock.IsDestroyed) return;
from.SetSlot(BaseEntity.Slot.Lock, null);
baseLock.SetParent(to, to.GetSlotAnchorName(BaseEntity.Slot.Lock));
to.SetSlot(BaseEntity.Slot.Lock, baseLock);
baseLock.transform.localPosition = Vector3.zero;
if (toVault)
{
baseLock.transform.localPosition += new Vector3(
config.LockOffsetRight, config.LockOffsetUp, config.LockOffsetForward);
}
baseLock.SendNetworkUpdateImmediate();
}
private void ApplyHealth(Door door)
{
if (config.Health <= 0f) return;
SetHealth(door);
NextTick(() => SetHealth(door));
timer.Once(0.5f, () => SetHealth(door));
timer.Once(2f, () => SetHealth(door));
}
private void SetHealth(Door door)
{
if (door == null || door.IsDestroyed) return;
door.InitializeHealth(config.Health, config.Health);
door._maxHealth = config.Health;
door.health = config.Health;
door.SendNetworkUpdate();
}
// Walk every ray hit (ignoring trigger volumes) and take the closest T.
private T GetLookEntity<T>(BasePlayer player, float distance) where T : BaseEntity
{
var hits = Physics.RaycastAll(player.eyes.HeadRay(), distance, -1, QueryTriggerInteraction.Ignore);
T best = null;
float closest = float.MaxValue;
foreach (var hit in hits)
{
var ent = hit.GetEntity() as T;
if (ent != null && hit.distance < closest)
{
closest = hit.distance;
best = ent;
}
}
return best;
}
private Door FindNearbyDoor(BasePlayer player, float radius)
{
var list = new List<BaseEntity>();
Vis.Entities(player.transform.position, radius, list, Rust.Layers.Mask.Construction);
Door best = null;
float closest = float.MaxValue;
foreach (var ent in list)
{
var door = ent as Door;
if (door == null || door.IsDestroyed) continue;
bool swappable = door.PrefabName == ArmoredPrefab
|| (door.PrefabName == VaultPrefab && door.skinID == SkinTag);
if (!swappable) continue;
float d = Vector3.Distance(door.transform.position, player.transform.position);
if (d < closest) { closest = d; best = door; }
}
return best;
}
#endregion
#region Align tuning (admin)
[ConsoleCommand("timeddoorvault.align")]
private void CmdAlign(ConsoleSystem.Arg arg)
{
BasePlayer player = arg.Player();
if (player == null || !player.IsAdmin) return;
if (arg.Args == null || arg.Args.Length < 4)
{
arg.ReplyWith("Usage: timeddoorvault.align <forward> <right> <up> <yaw>\n" +
$"Current: {config.OffsetForward} {config.OffsetRight} {config.OffsetUp} {config.YawOffset}\n" +
"Applies to the NEXT swap (revert + re-swap to see it).");
return;
}
float fwd, right, up, yaw;
if (!float.TryParse(arg.GetString(0), out fwd) || !float.TryParse(arg.GetString(1), out right) ||
!float.TryParse(arg.GetString(2), out up) || !float.TryParse(arg.GetString(3), out yaw))
{
arg.ReplyWith("All four values must be numbers.");
return;
}
config.OffsetForward = fwd;
config.OffsetRight = right;
config.OffsetUp = up;
config.YawOffset = yaw;
SaveConfig();
arg.ReplyWith($"Saved: forward={fwd} right={right} up={up} yaw={yaw}. Revert + re-swap a door to apply.");
}
#endregion
}
}