using System; using System.Collections.Generic; using System.Reflection; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; using Rust; using UnityEngine; namespace Oxide.Plugins { [Info("PlayerBradley", "redl", "1.1.0")] [Description("Позволяет игроку управлять Bradley APC: движение, пулемёт, пушка")] public class PlayerBradley : RustPlugin { #region Константы private const string BradleyPrefab = "assets/prefabs/npc/m2bradley/bradleyapc.prefab"; private const string PermSpawn = "playerbradley.spawn"; private const string PermNoCooldown = "playerbradley.nocooldown"; private const string PermGive = "playerbradley.give"; private const string BradleyCrate = "bradley_crate"; private const string HudName = "PlayerBradley.Hud"; private const int ShotMask = 1220225809; // та же маска, что у ванильного пулемёта Bradley private static readonly string[] ConflictPlugins = { "BradleyControl", "CustomBradleyAI", "BradleyOptions", "BradleyTiers", "BradleyGuards", "BradleyDrops" }; private static PlayerBradley _ins; private readonly Dictionary _controllers = new Dictionary(); private readonly Dictionary _byDriver = new Dictionary(); private readonly Dictionary _exDriverImmunity = new Dictionary(); // Места гибели вызванных танков: ящики Bradley, появившиеся рядом, удаляются (защита от фарма лута). private readonly List> _noLootSpots = new List>(); // Время последнего вызова танка (unix, сек) по SteamID — сохраняется в oxide/data, переживает рестарт. private Dictionary _lastSpawn = new Dictionary(); private bool _dataDirty; private FieldInfo _followingSpline; private MethodInfo _leaveSpline; #endregion #region Конфиг private Configuration _config; private class Configuration { [JsonProperty("Движение")] public MoveSettings Move = new MoveSettings(); [JsonProperty("Пулемёт")] public CoaxSettings Coax = new CoaxSettings(); [JsonProperty("Пушка")] public CannonSettings Cannon = new CannonSettings(); [JsonProperty("Камера")] public CameraSettings Camera = new CameraSettings(); [JsonProperty("Разрешения")] public PermissionSettings Permissions = new PermissionSettings(); [JsonProperty("Общее")] public GeneralSettings General = new GeneralSettings(); [JsonProperty("Вызов танка и магазин")] public SpawnSettings Spawn = new SpawnSettings(); } private class MoveSettings { [JsonProperty("Максимальная скорость")] public float MoveForce = 2000f; [JsonProperty("Скорость поворота")] public float TurnForce = 2000f; [JsonProperty("Сила торможения")] public float BrakeForce = 100f; [JsonProperty("Ограничение скорости (м/с, 0 = без ограничения)")] public float SpeedLimit = 12f; [JsonProperty("Множитель тормоза на ручнике (Space)")] public float HandbrakeMultiplier = 50f; [JsonProperty("Тормозить, когда газ отпущен")] public bool BrakeWhenIdle = true; [JsonProperty("Макс. скорость разворота корпуса (град/сек, 0 = без ограничения)")] public float MaxYawRate = 50f; [JsonProperty("Скорость поворота башни (град/сек)")] public float TurretSpeed = 120f; } private class CoaxSettings { [JsonProperty("Урон за пулю")] public float Damage = 15f; [JsonProperty("Задержка между выстрелами")] public float FireDelay = 0.06667f; [JsonProperty("Максимум выстрелов в очереди")] public int BurstLength = 10; [JsonProperty("Пауза после очереди (сек)")] public float BurstPause = 1f; [JsonProperty("Разброс (градусы)")] public float AimCone = 1.5f; [JsonProperty("Дальность (м)")] public float Range = 300f; [JsonProperty("Патронов в ленте (0 = бесконечно)")] public int MagazineSize = 200; [JsonProperty("Время перезарядки (сек)")] public float ReloadTime = 4f; [JsonProperty("Автоперезарядка при пустой ленте")] public bool AutoReload = true; [JsonProperty("Брать патроны из инвентаря водителя")] public bool UseInventory = true; [JsonProperty("Предмет патронов (shortname)")] public string AmmoItem = "ammo.rifle"; [JsonProperty("Возвращать патроны из ленты в инвентарь при выходе")] public bool ReturnBelt = true; } private class CannonSettings { [JsonProperty("Урон")] public float Damage = 150f; [JsonProperty("Кулдаун (сек)")] public float Cooldown = 3f; [JsonProperty("Скорость снаряда")] public float ProjectileSpeed = 50f; [JsonProperty("Радиус взрыва (м)")] public float ExplosionRadius = 6f; [JsonProperty("Множитель гравитации снаряда (0 = летит прямо)")] public float GravityModifier = 0f; [JsonProperty("Разброс (градусы)")] public float AimCone = 0.5f; [JsonProperty("Отдача")] public bool Recoil = true; [JsonProperty("Брать снаряды из инвентаря водителя")] public bool UseInventory = true; [JsonProperty("Предмет снаряда (shortname)")] public string AmmoItem = "ammo.rocket.basic"; [JsonProperty("Снарядов за выстрел")] public int AmmoPerShot = 1; } private class CameraSettings { [JsonProperty("Режим (turret = на башне, firstperson = люк механика)")] public string Mode = "turret"; [JsonProperty("Префаб сиденья")] public string SeatPrefab = "assets/prefabs/vehicle/seats/snowmobilepassengerseat.prefab"; [JsonProperty("Смещение сиденья для режима turret (x y z)")] public string TurretOffset = "0.33 1.25 -0.52"; [JsonProperty("Смещение сиденья для режима firstperson (x y z)")] public string FirstPersonOffset = "0.55 0.55 2.2"; } private class PermissionSettings { [JsonProperty("Требовать права")] public string Permission = "playerbradley.use"; } private class GeneralSettings { [JsonProperty("Вход по кнопке E")] public bool EnterWithUse = true; [JsonProperty("Дистанция посадки (м)")] public float EnterDistance = 4f; [JsonProperty("После выхода вернуть Bradley под управление ИИ")] public bool ReturnToAI = true; [JsonProperty("Иммунитет бывшего водителя от ИИ (сек)")] public float ExDriverImmunity = 10f; [JsonProperty("Лимит времени управления (сек, 0 = без лимита)")] public float TimeLimit = 0f; [JsonProperty("Множитель урона по водителю")] public float DriverDamageScale = 0.5f; [JsonProperty("Засчитывать урон и убийства водителю")] public bool CreditDriver = true; [JsonProperty("Защитить водителя и Bradley от своих снарядов")] public bool NoSelfDamage = true; [JsonProperty("Показывать HUD")] public bool ShowHud = true; [JsonProperty("Логирование в oxide/logs")] public bool Logging = true; } private class SpawnSettings { [JsonProperty("Кулдаун между вызовами /bradley spawn (сек, 0 = без кулдауна)")] public float Cooldown = 1800f; [JsonProperty("Админы без кулдауна и лимита")] public bool AdminBypass = true; [JsonProperty("Лимит своих танков на карте для /bradley spawn (0 = без лимита)")] public int MaxActive = 1; [JsonProperty("Дистанция появления перед игроком (м)")] public float Distance = 10f; [JsonProperty("Садиться в вызванный танк может только владелец и его команда")] public bool OwnerOnly = true; [JsonProperty("Владелец может водить свой танк без права на управление")] public bool OwnerIgnoresPermission = true; [JsonProperty("Запретить вызов в чужой зоне строительства")] public bool NoBuildingBlocked = true; [JsonProperty("Вызванный танк не стреляет сам и не высаживает учёных")] public bool Passive = true; [JsonProperty("Без ящиков с лутом при уничтожении вызванного танка")] public bool NoLoot = true; } protected override void LoadDefaultConfig() => _config = new Configuration(); protected override void LoadConfig() { base.LoadConfig(); try { _config = Config.ReadObject(); if (_config == null) throw new Exception("пустой конфиг"); } catch (Exception ex) { PrintError($"Ошибка чтения конфига ({ex.Message}), используются значения по умолчанию. Исправьте JSON."); _config = new Configuration(); return; } MigrateSeatOffsets(); if (_config.Spawn == null) _config.Spawn = new SpawnSettings(); SaveConfig(); } // Старые версии ставили сиденье над башней. Если в конфиге остались старые значения // по умолчанию, переносим игрока внутрь танка. Свои значения админа не трогаем. private void MigrateSeatOffsets() { if (_config.Camera == null) _config.Camera = new CameraSettings(); var defaults = new CameraSettings(); if (SameOffset(_config.Camera.TurretOffset, "0 2.35 -0.6") || SameOffset(_config.Camera.TurretOffset, "0.33 1.60 -0.52")) { _config.Camera.TurretOffset = defaults.TurretOffset; Puts($"Смещение сиденья turret обновлено на \"{defaults.TurretOffset}\": игрок теперь сидит внутри башни."); } if (SameOffset(_config.Camera.FirstPersonOffset, "0.55 1.75 2.2") || SameOffset(_config.Camera.FirstPersonOffset, "0.55 0.75 2.2")) { _config.Camera.FirstPersonOffset = defaults.FirstPersonOffset; Puts($"Смещение сиденья firstperson обновлено на \"{defaults.FirstPersonOffset}\": игрок теперь сидит внутри корпуса."); } } private static bool SameOffset(string a, string b) { if (string.IsNullOrEmpty(a)) return false; var pa = a.Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries); var pb = b.Split(' '); if (pa.Length != 3) return false; for (int i = 0; i < 3; i++) { float x, y; if (!float.TryParse(pa[i], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out x)) return false; float.TryParse(pb[i], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out y); if (Mathf.Abs(x - y) > 0.001f) return false; } return true; } protected override void SaveConfig() => Config.WriteObject(_config, true); private Vector3 SeatOffset() { string raw = string.Equals(_config.Camera.Mode, "firstperson", StringComparison.OrdinalIgnoreCase) ? _config.Camera.FirstPersonOffset : _config.Camera.TurretOffset; var parts = (raw ?? string.Empty).Split(new[] { ' ', ',', ';' }, StringSplitOptions.RemoveEmptyEntries); float x, y, z; if (parts.Length == 3 && float.TryParse(parts[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out x) && float.TryParse(parts[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out y) && float.TryParse(parts[2], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out z)) return new Vector3(x, y, z); return new Vector3(0f, 2.35f, -0.6f); } #endregion #region Локализация protected override void LoadDefaultMessages() { var ru = new Dictionary { ["NoPermission"] = "У вас нет прав на управление Bradley.", ["NotFound"] = "Рядом нет Bradley. Подойдите ближе (до {0} м).", ["Occupied"] = "Этим Bradley уже управляет другой игрок.", ["AlreadyDriving"] = "Вы уже управляете Bradley.", ["NotDriving"] = "Вы не управляете Bradley.", ["CantNow"] = "Сейчас нельзя: вы мертвы, спите, ранены или сидите в транспорте.", ["SpawnFailed"] = "Не удалось создать танк, сообщите администратору.", ["Entered"] = "Вы управляете Bradley. W/S — газ, A/D — поворот, Space — ручник, ЛКМ — пулемёт, ПКМ — пушка, R — перезарядка, E или /bradley exit — выйти.", ["Exited"] = "Вы покинули Bradley.", ["Destroyed"] = "Bradley уничтожен!", ["Died"] = "Вы погибли и покинули Bradley.", ["Disconnected"] = "Отключился от сервера.", ["TimeUp"] = "Время управления Bradley истекло.", ["Reloading"] = "Перезарядка пулемёта...", ["Spawned"] = "Танк вызван перед вами. Подойдите и нажмите E.", ["Delivered"] = "Вам доставлен танк! Он перед вами — подойдите и нажмите E.", ["Cooldown"] = "Танк можно вызвать снова через {0}.", ["CooldownReady"] = "Вызов танка доступен.", ["Limit"] = "У вас уже есть танк на карте ({0} из {1}). Новый можно вызвать, когда его уничтожат.", ["NoSpace"] = "Здесь нельзя вызвать танк: нужно свободное место на открытой земле перед вами.", ["BuildingBlocked"] = "Нельзя вызвать танк в чужой зоне строительства.", ["NotOwner"] = "Это чужой танк: сесть может только владелец и его команда.", ["GiveUsage"] = "/bradley give <ник или SteamID> — поставить танк перед игроком", ["Help"] = "/bradley enter — сесть в ближайший танк\n/bradley exit — выйти\n/bradley spawn — вызвать танк перед собой (нужно право, есть кулдаун)\n/bradley cd — когда можно вызвать снова\n/bradley give <игрок> — выдать танк игроку (админ)", ["HudCoax"] = "ПУЛЕМЁТ {0}/{1}", ["HudCoaxReload"] = "ПУЛЕМЁТ: ПЕРЕЗАРЯДКА {0:0.0}с", ["HudCannonReady"] = "ПУШКА: ГОТОВА", ["HudCannonWait"] = "ПУШКА: {0:0.0}с", ["HudSpeed"] = "{0:0} км/ч", ["HudTime"] = "Осталось {0:0}с", ["AmmoInfo"] = "Боеприпасы берутся из вашего инвентаря: пулемёт — {0}, пушка — {1}. R — зарядить ленту.", ["NoCoaxAmmo"] = "Нет патронов для пулемёта в инвентаре ({0}).", ["NoCannonAmmo"] = "Нет снарядов для пушки в инвентаре ({0}).", ["BeltReturned"] = "Патроны из ленты возвращены в инвентарь: {0} шт.", ["HudCoaxInv"] = "ПУЛЕМЁТ {0}/{1} (запас {2})", ["HudCannonReadyInv"] = "ПУШКА: ГОТОВА ({0} шт.)", ["HudCannonWaitInv"] = "ПУШКА: {0:0.0}с ({1} шт.)", ["HudCannonEmpty"] = "ПУШКА: НЕТ СНАРЯДОВ", }; lang.RegisterMessages(ru, this); lang.RegisterMessages(ru, this, "ru"); } private string Msg(string key, BasePlayer player, params object[] args) { string text = lang.GetMessage(key, this, player != null ? player.UserIDString : null); return args.Length > 0 ? string.Format(text, args) : text; } private void Reply(BasePlayer player, string key, params object[] args) { if (player != null && player.IsConnected) player.ChatMessage(Msg(key, player, args)); } // Боеприпасы из инвентаря водителя: 0 = предмет не расходуется (бесконечно). private int CoaxAmmoId => _config.Coax.UseInventory && _config.Coax.MagazineSize > 0 ? AmmoId(_config.Coax.AmmoItem) : 0; private int CannonAmmoId => _config.Cannon.UseInventory && _config.Cannon.AmmoPerShot > 0 ? AmmoId(_config.Cannon.AmmoItem) : 0; private static int AmmoId(string shortname) { var def = string.IsNullOrEmpty(shortname) ? null : ItemManager.FindItemDefinition(shortname); return def != null ? def.itemid : 0; } private static string AmmoName(int itemId) { var def = ItemManager.FindItemDefinition(itemId); return def != null ? def.displayName.english : "∞"; } private static void GiveAmmo(BasePlayer player, int itemId, int amount) { var def = ItemManager.FindItemDefinition(itemId); if (def == null) return; int stack = Mathf.Max(1, def.stackable); while (amount > 0) { int n = Mathf.Min(amount, stack); amount -= n; var item = ItemManager.CreateByItemID(itemId, n); if (item == null) return; if (!player.inventory.GiveItem(item)) item.Drop(player.GetDropPosition(), player.GetDropVelocity()); } } #endregion #region Хуки Oxide private void Init() { _ins = this; if (!string.IsNullOrEmpty(_config.Permissions.Permission)) permission.RegisterPermission(_config.Permissions.Permission, this); permission.RegisterPermission(PermSpawn, this); permission.RegisterPermission(PermNoCooldown, this); permission.RegisterPermission(PermGive, this); cmd.AddChatCommand("bradley", this, nameof(CmdBradley)); LoadData(); _followingSpline = typeof(BradleyAPC).GetField("followingSpine", BindingFlags.Instance | BindingFlags.NonPublic); _leaveSpline = typeof(BradleyAPC).GetMethod("LeaveSpline", BindingFlags.Instance | BindingFlags.NonPublic); } private void OnServerInitialized() { CheckConflicts(); if (_config.Coax.UseInventory && AmmoId(_config.Coax.AmmoItem) == 0) PrintWarning($"Неизвестный предмет патронов пулемёта \"{_config.Coax.AmmoItem}\" — пулемёт стреляет без расхода патронов."); if (_config.Cannon.UseInventory && AmmoId(_config.Cannon.AmmoItem) == 0) PrintWarning($"Неизвестный предмет снаряда пушки \"{_config.Cannon.AmmoItem}\" — пушка стреляет без расхода снарядов."); // Сиденья не сохраняются (enableSaving = false), но на случай горячей перезагрузки // убираем «осиротевшие» сиденья, оставшиеся на Bradley без контроллера. // Сначала собираем, потом удаляем: Kill() во время перебора serverEntities меняет коллекцию. var orphans = new List(); foreach (var net in BaseNetworkable.serverEntities) { var apc = net as BradleyAPC; if (apc == null) continue; foreach (var child in apc.children) { var seat = child as BaseMountable; if (seat != null && !seat.IsDestroyed && seat.PrefabName == _config.Camera.SeatPrefab) orphans.Add(seat); } } foreach (var seat in orphans) seat.Kill(); } private void OnServerSave() { if (_dataDirty) SaveData(); } private void LoadData() { try { _lastSpawn = Interface.Oxide.DataFileSystem.ReadObject>(Name); } catch (Exception ex) { PrintError($"Ошибка чтения oxide/data/{Name}.json ({ex.Message}), кулдауны сброшены."); } if (_lastSpawn == null) _lastSpawn = new Dictionary(); // Старые записи с истёкшим кулдауном не нужны. double now = Now; var expired = new List(); foreach (var kv in _lastSpawn) if (kv.Value + _config.Spawn.Cooldown < now) expired.Add(kv.Key); foreach (var id in expired) _lastSpawn.Remove(id); } private void SaveData() { Interface.Oxide.DataFileSystem.WriteObject(Name, _lastSpawn); _dataDirty = false; } private void OnPluginLoaded(Plugin plugin) { if (plugin != null && Array.IndexOf(ConflictPlugins, plugin.Name) >= 0) CheckConflicts(); } private void Unload() { foreach (var ctl in new List(_controllers.Values)) ReleaseController(ctl, "Exited", forceAI: true); foreach (var player in BasePlayer.activePlayerList) CuiHelper.DestroyUi(player, HudName); if (_dataDirty) SaveData(); _ins = null; } private void OnPlayerInput(BasePlayer player, InputState input) { if (player == null || input == null) return; BradleyController ctl; if (_byDriver.TryGetValue(player.userID, out ctl)) { ctl.ReadInput(input); return; } if (!_config.General.EnterWithUse || !input.WasJustPressed(BUTTON.USE)) return; if (player.isMounted || player.IsDead() || player.IsWounded() || player.IsSleeping()) return; var apc = FindBradleyInFront(player); if (apc == null) return; string deny = DriveDenied(player, apc); if (deny != null) { Reply(player, deny); return; } TryEnter(player, apc); } // Ванильная высадка (E) заменяется своей: безопасная точка рядом с Bradley. private object OnPlayerWantsDismount(BasePlayer player, BaseMountable seat) { var ctl = FindBySeat(seat); if (ctl == null) return null; NextTick(() => { if (ctl.Active) ReleaseController(ctl, "Exited"); }); return false; } private void OnPlayerDismountFailed(BasePlayer player, BaseMountable seat) { var ctl = FindBySeat(seat); if (ctl == null) return; NextTick(() => { if (ctl.Active) ReleaseController(ctl, "Exited"); }); } private object CanMountEntity(BasePlayer player, BaseMountable seat) { var ctl = FindBySeat(seat); if (ctl == null) return null; return ctl.PendingDriver == player ? null : (object)false; } private void OnEntityDismounted(BaseMountable seat, BasePlayer player) { var ctl = FindBySeat(seat); if (ctl == null || ctl.Releasing) return; // Высадили «мимо нас» (смерть, другой плагин и т.п.) NextTick(() => { if (ctl.Active) ReleaseController(ctl, player != null && player.IsDead() ? "Died" : "Exited", teleport: false); }); } private void OnPlayerDisconnected(BasePlayer player, string reason) { BradleyController ctl; if (player != null && _byDriver.TryGetValue(player.userID, out ctl)) ReleaseController(ctl, "Disconnected"); } // Вызывается до OnDied/уничтожения дочерних сущностей: водителя высаживаем, пока сиденье живо. private void OnEntityDeath(BaseCombatEntity entity, HitInfo info) { var apc = entity as BradleyAPC; if (apc != null) { BradleyController ctl; if (_controllers.TryGetValue(apc, out ctl)) ReleaseController(ctl, "Destroyed", destroyed: true); if (apc.OwnerID != 0 && _config.Spawn.NoLoot) _noLootSpots.Add(new KeyValuePair(apc.transform.position, Time.realtimeSinceStartup + 10f)); return; } var player = entity as BasePlayer; if (player != null) { BradleyController ctl; if (_byDriver.TryGetValue(player.userID, out ctl)) ReleaseController(ctl, "Died", teleport: false); } } // Ящики появляются после OnEntityDeath (в BradleyAPC.OnKilled) рядом с местом гибели. private void OnEntitySpawned(LootContainer crate) { if (_noLootSpots.Count == 0 || crate == null || crate.ShortPrefabName != BradleyCrate) return; float now = Time.realtimeSinceStartup; _noLootSpots.RemoveAll(s => s.Value < now); foreach (var spot in _noLootSpots) { if (Vector3.Distance(spot.Key, crate.transform.position) > 20f) continue; NextTick(() => { if (crate != null && !crate.IsDestroyed) crate.Kill(); }); return; } } private void OnEntityKill(BaseNetworkable entity) { var apc = entity as BradleyAPC; if (apc != null) { BradleyController ctl; if (_controllers.TryGetValue(apc, out ctl)) ReleaseController(ctl, "Destroyed", destroyed: true); return; } var seat = entity as BaseMountable; if (seat != null) { var ctl = FindBySeat(seat); if (ctl != null && !ctl.Releasing) ReleaseController(ctl, "Exited"); } } private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { if (entity == null || info == null || _controllers.Count == 0) return null; var apc = entity as BradleyAPC; if (apc != null) { BradleyController ctl; if (_config.General.NoSelfDamage && _controllers.TryGetValue(apc, out ctl) && ctl.Driver != null && (info.Initiator == ctl.Driver || info.Initiator == apc)) return true; return null; } var player = entity as BasePlayer; if (player != null) { BradleyController ctl; if (!_byDriver.TryGetValue(player.userID, out ctl)) return null; if (_config.General.NoSelfDamage && (info.Initiator == player || info.Initiator == ctl.Bradley)) return true; if (_config.General.DriverDamageScale != 1f) info.damageTypes.ScaleAll(_config.General.DriverDamageScale); } return null; } // --- подавление ИИ --- private object OnBradleyApcThink(BradleyAPC apc) { return _controllers.ContainsKey(apc) ? (object)true : null; } private object CanBradleyApcTarget(BradleyAPC apc, BaseEntity target) { if (_controllers.ContainsKey(apc)) return false; if (apc.OwnerID != 0 && _config.Spawn.Passive) return false; var player = target as BasePlayer; if (player == null || _exDriverImmunity.Count == 0) return null; float until; if (_exDriverImmunity.TryGetValue(player.userID, out until)) { if (Time.realtimeSinceStartup < until) return false; _exDriverImmunity.Remove(player.userID); } return null; } private object CanDeployScientists(BradleyAPC apc, BaseEntity attacker, List prefabs, List positions) { return _controllers.ContainsKey(apc) || (apc.OwnerID != 0 && _config.Spawn.Passive) ? (object)false : null; } #endregion #region Команды private void CmdBradley(BasePlayer player, string command, string[] args) { string sub = args.Length > 0 ? args[0].ToLowerInvariant() : "help"; switch (sub) { case "enter": { if (_byDriver.ContainsKey(player.userID)) { Reply(player, "AlreadyDriving"); return; } var apc = FindBradleyInFront(player) ?? FindNearestBradley(player.transform.position, _config.General.EnterDistance + 4f); if (apc == null) { Reply(player, "NotFound", _config.General.EnterDistance + 4f); return; } string deny = DriveDenied(player, apc); if (deny != null) { Reply(player, deny); return; } TryEnter(player, apc); return; } case "exit": { BradleyController ctl; if (!_byDriver.TryGetValue(player.userID, out ctl)) { Reply(player, "NotDriving"); return; } ReleaseController(ctl, "Exited"); return; } case "spawn": { if (!player.IsAdmin && !permission.UserHasPermission(player.UserIDString, PermSpawn)) { Reply(player, "NoPermission"); return; } double left = CooldownLeft(player); if (left > 0) { Reply(player, "Cooldown", FormatTime(left)); return; } int max = _config.Spawn.MaxActive; if (max > 0 && !AdminBypass(player)) { int count = CountOwned(player.userID); if (count >= max) { Reply(player, "Limit", count, max); return; } } string err; var apc = SpawnForPlayer(player, out err); if (apc == null) { Reply(player, err); return; } _lastSpawn[player.userID] = Now; SaveData(); Reply(player, "Spawned"); Log($"{player.displayName} ({player.UserIDString}) вызвал Bradley {apc.net.ID} на {apc.transform.position}"); return; } case "cd": { double left = CooldownLeft(player); if (left > 0) Reply(player, "Cooldown", FormatTime(left)); else Reply(player, "CooldownReady"); return; } case "give": { if (!player.IsAdmin && !permission.UserHasPermission(player.UserIDString, PermGive)) { Reply(player, "NoPermission"); return; } if (args.Length < 2) { Reply(player, "GiveUsage"); return; } player.ChatMessage(GiveTank(string.Join(" ", args, 1, args.Length - 1), $"{player.displayName} ({player.UserIDString})")); return; } default: Reply(player, "Help"); return; } } // Консоль/RCON: pb.spawn — создать Bradley без ИИ (для админов). [ConsoleCommand("pb.spawn")] private void CcmdSpawn(ConsoleSystem.Arg arg) { if (arg.Connection != null && arg.Connection.authLevel < 2) return; if (arg.Args == null || arg.Args.Length < 3) { arg.ReplyWith("pb.spawn "); return; } var pos = new Vector3(arg.GetFloat(0), arg.GetFloat(1), arg.GetFloat(2)); var apc = SpawnBradley(pos, Quaternion.identity, keepAI: false); arg.ReplyWith(apc != null ? $"Bradley {apc.net.ID} создан в {pos}" : "Не удалось создать Bradley"); } // Консоль/RCON/магазин: pb.give — поставить танк перед игроком (без кулдауна и лимита). // Для GameStores и подобных: pb.give %steamid% [ConsoleCommand("pb.give")] private void CcmdGive(ConsoleSystem.Arg arg) { if (arg.Connection != null && arg.Connection.authLevel < 2) return; if (arg.Args == null || arg.Args.Length < 1) { arg.ReplyWith("pb.give "); return; } var by = arg.Connection != null ? $"{arg.Connection.username} ({arg.Connection.userid})" : "консоль"; arg.ReplyWith(GiveTank(string.Join(" ", arg.Args), by)); } // Ставит танк перед игроком и возвращает текст результата (для чата/консоли). private string GiveTank(string query, string by) { string error; var target = FindOnlinePlayer(query, out error); if (target == null) return error; if (target.IsDead() || target.IsSleeping() || target.IsWounded() || target.isMounted) { Log($"Выдача танка игроку {target.displayName} ({target.UserIDString}) от {by} не удалась: игрок мёртв/спит/в транспорте"); return $"Игрок {target.displayName} сейчас не может получить танк (мёртв, спит или в транспорте). Танк НЕ выдан."; } string err; var apc = SpawnForPlayer(target, out err); if (apc == null) { Log($"Выдача танка игроку {target.displayName} ({target.UserIDString}) от {by} не удалась: {err}"); Reply(target, err); return $"Не удалось поставить танк перед {target.displayName}: {Msg(err, null)} Танк НЕ выдан."; } Reply(target, "Delivered"); Log($"{by} выдал Bradley {apc.net.ID} игроку {target.displayName} ({target.UserIDString}) на {apc.transform.position}"); return $"Танк выдан игроку {target.displayName} ({target.UserIDString})."; } private BasePlayer FindOnlinePlayer(string query, out string error) { error = null; query = (query ?? string.Empty).Trim(); if (query.Length == 0) { error = "Не указан игрок."; return null; } ulong id; if (ulong.TryParse(query, out id)) { var byId = BasePlayer.FindByID(id); if (byId != null && byId.IsConnected) return byId; error = $"Игрок {query} не в сети. Танк НЕ выдан."; return null; } BasePlayer found = null; int partial = 0; foreach (var p in BasePlayer.activePlayerList) { if (p == null || string.IsNullOrEmpty(p.displayName)) continue; if (string.Equals(p.displayName, query, StringComparison.OrdinalIgnoreCase)) return p; if (p.displayName.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0) { found = p; partial++; } } if (partial == 1) return found; error = partial == 0 ? $"Игрок «{query}» не найден в сети." : $"Под «{query}» подходит несколько игроков, уточните ник или укажите SteamID."; return null; } #endregion #region API для других плагинов private bool API_IsControlled(BradleyAPC apc) => apc != null && _controllers.ContainsKey(apc); private BasePlayer API_GetDriver(BradleyAPC apc) { BradleyController ctl; return apc != null && _controllers.TryGetValue(apc, out ctl) ? ctl.Driver : null; } private BradleyAPC API_GetBradley(BasePlayer player) { BradleyController ctl; return player != null && _byDriver.TryGetValue(player.userID, out ctl) ? ctl.Bradley : null; } private bool API_TakeControl(BasePlayer player, BradleyAPC apc) => TryEnter(player, apc, silent: true); private void API_Release(BasePlayer player) { BradleyController ctl; if (player != null && _byDriver.TryGetValue(player.userID, out ctl)) ReleaseController(ctl, "Exited"); } // Программный ввод (для ботов/тестов): throttle -1..1, turn -1..1, aim — мировое направление взгляда. private void API_SetInput(BasePlayer player, float throttle, float turn, bool handbrake, bool coax, bool cannon, Vector3 aim) { BradleyController ctl; if (player != null && _byDriver.TryGetValue(player.userID, out ctl)) ctl.SetInput(throttle, turn, handbrake, coax, cannon, false, aim); } private Dictionary API_GetStatus(BradleyAPC apc) { BradleyController ctl; if (apc == null || !_controllers.TryGetValue(apc, out ctl)) return null; return ctl.Status(); } #endregion #region Логика входа/выхода private bool HasUsePermission(BasePlayer player) { string perm = _config.Permissions.Permission; return string.IsNullOrEmpty(perm) || permission.UserHasPermission(player.UserIDString, perm); } private static double Now => (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds; private bool AdminBypass(BasePlayer player) => player.IsAdmin && _config.Spawn.AdminBypass; private bool IgnoresCooldown(BasePlayer player) => AdminBypass(player) || permission.UserHasPermission(player.UserIDString, PermNoCooldown); // Сколько секунд осталось до следующего /bradley spawn (0 = можно). private double CooldownLeft(BasePlayer player) { if (_config.Spawn.Cooldown <= 0f || IgnoresCooldown(player)) return 0; double last; if (!_lastSpawn.TryGetValue(player.userID, out last)) return 0; return Math.Max(0, last + _config.Spawn.Cooldown - Now); } private static string FormatTime(double seconds) { int s = (int)Math.Ceiling(seconds); int h = s / 3600, m = s % 3600 / 60, sec = s % 60; if (h > 0) return $"{h} ч {m} мин"; if (m > 0) return sec > 0 ? $"{m} мин {sec} сек" : $"{m} мин"; return $"{sec} сек"; } private static bool IsOwnerOrTeam(BasePlayer player, BradleyAPC apc) { if (apc.OwnerID == 0) return false; if (apc.OwnerID == player.userID) return true; var team = RelationshipManager.ServerInstance != null ? RelationshipManager.ServerInstance.FindPlayersTeam(player.userID) : null; return team != null && team.members.Contains(apc.OwnerID); } // null = можно садиться, иначе ключ сообщения с причиной отказа. private string DriveDenied(BasePlayer player, BradleyAPC apc) { if (apc.OwnerID != 0) { if (IsOwnerOrTeam(player, apc)) return _config.Spawn.OwnerIgnoresPermission || HasUsePermission(player) ? null : "NoPermission"; if (_config.Spawn.OwnerOnly && !player.IsAdmin) return "NotOwner"; } return HasUsePermission(player) ? null : "NoPermission"; } // Сколько вызванных/купленных танков игрока сейчас на карте. private static int CountOwned(ulong userId) { int count = 0; foreach (var net in BaseNetworkable.serverEntities) { var apc = net as BradleyAPC; if (apc != null && apc.OwnerID == userId && !apc.IsDead() && !apc.IsDestroyed) count++; } return count; } // Точка перед игроком на открытой земле (не в воде, без построек/машин/деревьев). private bool TryGetSpawnPoint(BasePlayer player, out Vector3 pos, out Quaternion rot) { Vector3 fwd = player.eyes.HeadForward(); fwd.y = 0f; if (fwd.sqrMagnitude < 0.01f) fwd = player.transform.forward; fwd.y = 0f; if (fwd.sqrMagnitude < 0.01f) fwd = Vector3.forward; fwd.Normalize(); rot = Quaternion.LookRotation(fwd); pos = Vector3.zero; Vector3 point = player.transform.position + fwd * Mathf.Max(6f, _config.Spawn.Distance); int groundMask = LayerMask.GetMask("Terrain", "World", "Construction", "Deployed", "Default", "Tree"); RaycastHit hit; if (!Physics.Raycast(point + Vector3.up * 20f, Vector3.down, out hit, 60f, groundMask, QueryTriggerInteraction.Ignore)) return false; // Только на землю: не на крышу дома, скалу-монумент или дерево. if (hit.collider == null || hit.collider.gameObject.layer != LayerMask.NameToLayer("Terrain")) return false; Vector3 ground = hit.point; if (TerrainMeta.WaterMap != null && TerrainMeta.WaterMap.GetHeight(ground) > ground.y + 0.5f) return false; if (Mathf.Abs(ground.y - player.transform.position.y) > 8f) return false; int blockMask = LayerMask.GetMask("World", "Construction", "Deployed", "Vehicle Large", "Vehicle World", "Vehicle Detailed", "Default", "Tree", "Player (Server)"); if (Physics.CheckBox(ground + Vector3.up * 1.6f, new Vector3(1.9f, 1.3f, 3.6f), rot, blockMask, QueryTriggerInteraction.Ignore)) return false; pos = ground + Vector3.up; return true; } // Ставит танк игрока перед ним. null + ключ ошибки, если нельзя. private BradleyAPC SpawnForPlayer(BasePlayer player, out string error) { error = null; if (player.isMounted || player.IsDead() || player.IsWounded() || player.IsSleeping()) { error = "CantNow"; return null; } Vector3 pos; Quaternion rot; if (!TryGetSpawnPoint(player, out pos, out rot)) { error = "NoSpace"; return null; } if (_config.Spawn.NoBuildingBlocked && player.IsBuildingBlocked(pos, rot, new Bounds(Vector3.zero, new Vector3(4f, 3f, 7.5f)))) { error = "BuildingBlocked"; return null; } var apc = SpawnBradley(pos, rot, keepAI: false); if (apc == null) { error = "SpawnFailed"; return null; } apc.OwnerID = player.userID; apc.SendNetworkUpdate(); return apc; } private BradleyController FindBySeat(BaseMountable seat) { if (seat == null || _controllers.Count == 0) return null; foreach (var ctl in _controllers.Values) if (ctl.Seat == seat) return ctl; return null; } private BradleyAPC FindBradleyInFront(BasePlayer player) { RaycastHit hit; if (Physics.Raycast(player.eyes.HeadRay(), out hit, _config.General.EnterDistance + 3f, ShotMask, QueryTriggerInteraction.Ignore)) { var apc = hit.GetEntity() as BradleyAPC; if (apc != null && !apc.IsDead() && apc.Distance(player.transform.position) <= _config.General.EnterDistance) return apc; } return null; } private BradleyAPC FindNearestBradley(Vector3 pos, float maxDist) { BradleyAPC best = null; float bestDist = maxDist; var list = Facepunch.Pool.Get>(); Vis.Entities(pos, maxDist + 6f, list); foreach (var apc in list) { if (apc == null || apc.IsDead() || apc.IsDestroyed) continue; float d = apc.Distance(pos); if (d <= bestDist) { bestDist = d; best = apc; } } Facepunch.Pool.FreeUnmanaged(ref list); return best; } private BradleyAPC SpawnBradley(Vector3 pos, Quaternion rot, bool keepAI) { var apc = GameManager.server.CreateEntity(BradleyPrefab, pos, rot) as BradleyAPC; if (apc == null) return null; apc.Spawn(); if (!keepAI) { // Bradley, созданный командой, не ездит сам: стоит, пока в него не сядут. apc.DoAI = false; apc.ClearPath(); } return apc; } private bool TryEnter(BasePlayer player, BradleyAPC apc, bool silent = false) { if (player == null || apc == null || apc.IsDead() || apc.IsDestroyed) return false; if (_byDriver.ContainsKey(player.userID)) { if (!silent) Reply(player, "AlreadyDriving"); return false; } if (player.isMounted || player.IsDead() || player.IsWounded()) { if (!silent) Reply(player, "CantNow"); return false; } BradleyController ctl; if (_controllers.TryGetValue(apc, out ctl) && ctl.Driver != null) { if (!silent) Reply(player, "Occupied"); return false; } if (Interface.CallHook("CanPlayerControlBradley", player, apc) != null) return false; var seat = GameManager.server.CreateEntity(_config.Camera.SeatPrefab, apc.transform.TransformPoint(SeatOffset()), apc.transform.rotation) as BaseMountable; if (seat == null) { PrintError($"Не удалось создать сиденье из префаба \"{_config.Camera.SeatPrefab}\""); return false; } seat.enableSaving = false; seat.SetParent(apc); seat.transform.localPosition = SeatOffset(); seat.transform.localRotation = Quaternion.identity; seat.Spawn(); // Убираем коллайдеры сиденья (чтобы не влиять на физику танка) и запрещаем оружие в руках. foreach (var col in seat.GetComponentsInChildren()) if (!col.isTrigger) UnityEngine.Object.Destroy(col); seat.canWieldItems = false; if (ctl == null) { ctl = apc.gameObject.AddComponent(); ctl.Setup(this, apc); _controllers[apc] = ctl; } ctl.PendingDriver = player; seat.MountPlayer(player); ctl.PendingDriver = null; if (seat.GetMounted() != player) { seat.Kill(); if (ctl.Driver == null && !ctl.Parked) { _controllers.Remove(apc); ctl.Restore(true); UnityEngine.Object.Destroy(ctl); } if (!silent) Reply(player, "CantNow"); return false; } player.UpdateActiveItem(default(ItemId)); ctl.AttachDriver(player, seat); _byDriver[player.userID] = ctl; if (!silent) Reply(player, "Entered"); if (!silent && (CoaxAmmoId != 0 || CannonAmmoId != 0)) Reply(player, "AmmoInfo", AmmoName(CoaxAmmoId), AmmoName(CannonAmmoId)); Log($"{player.displayName} ({player.UserIDString}) сел в Bradley {apc.net.ID} на {apc.transform.position}"); Interface.CallHook("OnPlayerBradleyEntered", player, apc); return true; } private void ReleaseController(BradleyController ctl, string msgKey, bool teleport = true, bool destroyed = false, bool forceAI = false) { if (ctl == null || ctl.Releasing) return; ctl.Releasing = true; var apc = ctl.Bradley; var player = ctl.Driver; var seat = ctl.Seat; try { if (player != null) { _byDriver.Remove(player.userID); CuiHelper.DestroyUi(player, HudName); if (seat != null && !seat.IsDestroyed && seat.GetMounted() == player) seat.DismountPlayer(player, true); if (teleport && player.IsAlive() && !player.isMounted && apc != null) MoveToSafeSpot(player, apc); // Неизрасходованная лента возвращается водителю (при смерти/отключении/уничтожении остаётся в танке). if (!destroyed && msgKey != "Disconnected" && player.IsAlive()) ctl.UnloadBelt(player); if (_config.General.ExDriverImmunity > 0f) _exDriverImmunity[player.userID] = Time.realtimeSinceStartup + _config.General.ExDriverImmunity; if (!string.IsNullOrEmpty(msgKey)) Reply(player, msgKey); Log($"{player.displayName} ({player.UserIDString}) покинул Bradley {(apc != null && apc.net != null ? apc.net.ID.ToString() : "?")}: {msgKey}"); Interface.CallHook("OnPlayerBradleyExited", player, apc); } if (seat != null && !seat.IsDestroyed) seat.Kill(); ctl.DetachDriver(); bool keepParked = !destroyed && !forceAI && !_config.General.ReturnToAI && apc != null && !apc.IsDestroyed; if (keepParked) { ctl.Park(); } else { if (apc != null) _controllers.Remove(apc); if (!destroyed && apc != null && !apc.IsDestroyed) ctl.Restore(true); UnityEngine.Object.Destroy(ctl); } } catch (Exception ex) { PrintError($"Ошибка при выходе из Bradley: {ex}"); if (apc != null) _controllers.Remove(apc); if (player != null) _byDriver.Remove(player.userID); } finally { ctl.Releasing = false; } } private void MoveToSafeSpot(BasePlayer player, BradleyAPC apc) { var t = apc.transform; Vector3 ext = apc.bounds.extents; Vector3[] candidates = { t.position + t.right * (ext.x + 1.5f), t.position - t.right * (ext.x + 1.5f), t.position - t.forward * (ext.z + 1.5f), t.position + t.forward * (ext.z + 1.5f), }; int groundMask = LayerMask.GetMask("Terrain", "World", "Construction", "Deployed", "Default"); int blockMask = LayerMask.GetMask("World", "Construction", "Deployed", "Vehicle Large", "Vehicle World", "Vehicle Detailed", "Default", "Tree"); Vector3 target = Vector3.zero; bool found = false; foreach (var c in candidates) { RaycastHit hit; Vector3 from = c + Vector3.up * 6f; if (!Physics.Raycast(from, Vector3.down, out hit, 20f, groundMask, QueryTriggerInteraction.Ignore)) continue; Vector3 p = hit.point + Vector3.up * 0.1f; if (Physics.CheckCapsule(p + Vector3.up * 0.5f, p + Vector3.up * 1.4f, 0.4f, blockMask, QueryTriggerInteraction.Ignore)) continue; target = p; found = true; break; } if (!found) target = t.position + Vector3.up * (ext.y * 2f + 0.5f); // на крышу, если вокруг всё занято player.PauseFlyHackDetection(5f); player.PauseSpeedHackDetection(5f); player.PauseVehicleNoClipDetection(5f); player.Teleport(target); player.SendNetworkUpdateImmediate(); } private void CheckConflicts() { foreach (var name in ConflictPlugins) { var p = plugins.Find(name); if (p != null && p.IsLoaded) PrintWarning($"Обнаружен плагин {name}: он тоже управляет Bradley. Пока Bradley под контролем игрока, PlayerBradley отключает ему ИИ; если {name} включает ИИ/цели сам — возможен конфликт. См. README."); } } private void Log(string text) { if (!_config.General.Logging) return; Puts(text); LogToFile("usage", $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {text}", this); } #endregion #region HUD private void DrawHud(BasePlayer player, string text) { CuiHelper.DestroyUi(player, HudName); var c = new CuiElementContainer(); c.Add(new CuiPanel { Image = { Color = "0 0 0 0.45" }, RectTransform = { AnchorMin = "0.5 0", AnchorMax = "0.5 0", OffsetMin = "-260 112", OffsetMax = "260 138" }, }, "Hud", HudName); c.Add(new CuiLabel { Text = { Text = text, FontSize = 14, Align = TextAnchor.MiddleCenter, Color = "0.95 0.9 0.6 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, }, HudName); CuiHelper.AddUi(player, c); } #endregion #region Контроллер private class BradleyController : MonoBehaviour { public BradleyAPC Bradley; public BaseMountable Seat; public BasePlayer Driver; public BasePlayer PendingDriver; public bool Releasing; public bool Parked; public bool Active => Driver != null; private PlayerBradley _p; private Configuration _cfg; private bool _origDoAI; private float _origMoveForce, _origTurnForce, _origBrakeForce; // ввод private float _throttle, _turn; private bool _handbrake, _fireCoax, _fireCannon, _reloadPressed; private Vector3 _aimDir = Vector3.forward; // оружие private int _ammo; private float _reloadEnd; private bool _reloading; private float _nextCoax; private int _burst; private float _nextCannon; private float _nextNoAmmoMsg; private float _lastNet, _lastHud, _lastLate, _start; private string _lastHudText; public void Setup(PlayerBradley plugin, BradleyAPC apc) { _p = plugin; _cfg = plugin._config; Bradley = apc; _origDoAI = apc.DoAI; _origMoveForce = apc.moveForceMax; _origTurnForce = apc.turnForce; _origBrakeForce = apc.brakeForce; // С патронами из инвентаря лента изначально пустая — её заряжают на R. _ammo = _p.CoaxAmmoId != 0 ? 0 : _cfg.Coax.MagazineSize; _lastLate = Time.time; TakeOver(); } private void TakeOver() { var apc = Bradley; // Отцепляем дорожный Bradley от сплайна дороги. if (_p._followingSpline != null && (bool)_p._followingSpline.GetValue(apc)) { _p._followingSpline.SetValue(apc, false); if (_p._leaveSpline != null) _p._leaveSpline.Invoke(apc, null); } apc.myRigidBody.isKinematic = false; apc.DoAI = false; apc.ClearPath(); apc.mainGunTarget = null; apc.enabled = false; // останавливает ванильные FixedUpdate (ИИ, движение, стрельба) и LateUpdate (наводка) apc.moveForceMax = _cfg.Move.MoveForce; apc.turnForce = _cfg.Move.TurnForce; apc.brakeForce = _cfg.Move.BrakeForce; apc.desiredAimVector = apc.turretAimVector.sqrMagnitude > 0.01f ? apc.turretAimVector : apc.transform.forward; _aimDir = apc.desiredAimVector; Parked = false; } public void AttachDriver(BasePlayer player, BaseMountable seat) { if (Parked) TakeOver(); Driver = player; Seat = seat; _start = Time.time; _throttle = _turn = 0f; _handbrake = _fireCoax = _fireCannon = false; _burst = 0; _lastHudText = null; enabled = true; } public void DetachDriver() { Driver = null; Seat = null; _reloading = false; _throttle = _turn = 0f; _fireCoax = _fireCannon = false; } public void Park() { // Bradley остаётся на месте без ИИ, на тормозах, пока в него снова не сядут. Parked = true; StopWheels(1f); Bradley.throttle = Bradley.leftThrottle = Bradley.rightThrottle = 0f; Bradley.SendNetworkUpdate(); } public void Restore(bool returnToAI) { var apc = Bradley; if (apc == null || apc.IsDestroyed) return; apc.moveForceMax = _origMoveForce; apc.turnForce = _origTurnForce; apc.brakeForce = _origBrakeForce; apc.mainGunTarget = null; apc.ClearPath(); apc.DoAI = returnToAI && _origDoAI; apc.enabled = true; apc.SendNetworkUpdate(); } public void ReadInput(InputState input) { float throttle = 0f, turn = 0f; if (input.IsDown(BUTTON.FORWARD)) throttle += 1f; if (input.IsDown(BUTTON.BACKWARD)) throttle -= 1f; if (input.IsDown(BUTTON.RIGHT)) turn += 1f; if (input.IsDown(BUTTON.LEFT)) turn -= 1f; Vector3 aim = Quaternion.Euler(input.current.aimAngles) * Vector3.forward; SetInput(throttle, turn, input.IsDown(BUTTON.JUMP), input.IsDown(BUTTON.FIRE_PRIMARY), input.IsDown(BUTTON.FIRE_SECONDARY), input.WasJustPressed(BUTTON.RELOAD), aim); } public void SetInput(float throttle, float turn, bool handbrake, bool coax, bool cannon, bool reload, Vector3 aim) { _throttle = Mathf.Clamp(throttle, -1f, 1f); _turn = Mathf.Clamp(turn, -1f, 1f); _handbrake = handbrake; _fireCoax = coax; _fireCannon = cannon; if (reload) _reloadPressed = true; if (aim.sqrMagnitude > 0.001f) _aimDir = aim.normalized; } private void FixedUpdate() { var apc = Bradley; if (apc == null || apc.IsDestroyed || apc.IsDead()) return; if (Parked || Driver == null) { StopWheels(1f); return; } Move(apc); Weapons(apc); if (_cfg.General.TimeLimit > 0f && Time.time - _start >= _cfg.General.TimeLimit) { var self = this; _p.NextTick(() => { if (self != null && self.Active) _p.ReleaseController(self, "TimeUp"); }); } } private void Move(BradleyAPC apc) { Vector3 vel = apc.myRigidBody.linearVelocity; float speed = vel.magnitude; float fwdSpeed = speed * Mathf.Abs(Vector3.Dot(vel.normalized, apc.transform.forward)); float signedFwd = Vector3.Dot(vel, apc.transform.forward); float turnIn = _turn; float maxYaw = _cfg.Move.MaxYawRate; if (maxYaw > 0f) { var rb = apc.myRigidBody; float yawRate = Vector3.Dot(rb.angularVelocity, apc.transform.up) * Mathf.Rad2Deg; if (turnIn != 0f) turnIn *= Mathf.InverseLerp(maxYaw, maxYaw * 0.6f, yawRate * Mathf.Sign(turnIn)); // плавно снимаем поворот у предела float cap = maxYaw * 1.25f; if (Mathf.Abs(yawRate) > cap) rb.angularVelocity -= apc.transform.up * ((yawRate - Mathf.Sign(yawRate) * cap) * Mathf.Deg2Rad); } float throttle = _throttle; if (_cfg.Move.SpeedLimit > 0f && Mathf.Abs(signedFwd) >= _cfg.Move.SpeedLimit && Mathf.Sign(signedFwd) == Mathf.Sign(throttle)) throttle = 0f; // на пределе скорости — не разгоняем дальше float left = throttle, right = throttle; if (turnIn != 0f) { left = turnIn; right = -turnIn; } left = Mathf.Clamp(left + throttle, -1f, 1f); right = Mathf.Clamp(right + throttle, -1f, 1f); if (turnIn == 0f) { left = throttle; right = throttle; } apc.throttle = throttle; apc.turning = _turn; apc.leftThrottle = left; apc.rightThrottle = right; bool idle = Mathf.Approximately(_throttle, 0f) && Mathf.Approximately(_turn, 0f); float brake = 0f; if (_handbrake) brake = _cfg.Move.HandbrakeMultiplier; else if (idle && _cfg.Move.BrakeWhenIdle) brake = 1f; else if (_cfg.Move.SpeedLimit > 0f && speed > _cfg.Move.SpeedLimit * 1.15f) brake = 1f; apc.brake = brake > 0f; apc.ApplyBrakes(brake); float t = Mathf.InverseLerp(2f, 1f, fwdSpeed); float torque = Mathf.Lerp(apc.moveForceMax, apc.turnForce, t); apc.ScaleSidewaysFriction(1f - Mathf.InverseLerp(5f, 1.5f, fwdSpeed)); if (_handbrake) { left = right = 0f; } apc.SetMotorTorque(left, false, torque); apc.SetMotorTorque(right, true, torque); if (apc.impactDamager != null) apc.impactDamager.damageEnabled = speed > 2f; } private void StopWheels(float brake) { var apc = Bradley; if (apc == null || apc.IsDestroyed) return; apc.SetMotorTorque(0f, false, 0f); apc.SetMotorTorque(0f, true, 0f); apc.ApplyBrakes(brake); if (apc.impactDamager != null) apc.impactDamager.damageEnabled = apc.myRigidBody.linearVelocity.magnitude > 2f; } // Точка, куда смотрит водитель (первое препятствие по лучу взгляда). private Vector3 AimPoint(out bool hitSomething) { Vector3 origin = Driver.eyes.position; Vector3 dir = _aimDir; var hits = Facepunch.Pool.Get>(); GamePhysics.TraceAll(new Ray(origin, dir), 0f, hits, 400f, ShotMask, QueryTriggerInteraction.Ignore); Vector3 point = origin + dir * 400f; hitSomething = false; foreach (var h in hits) { var ent = h.GetEntity(); if (ent != null && (ent == Bradley || ent == Seat || ent == Driver || ent.EqualNetID(Bradley))) continue; point = h.point; hitSomething = true; break; } Facepunch.Pool.FreeUnmanaged(ref hits); return point; } private void Weapons(BradleyAPC apc) { float now = Time.time; if (_reloading && now >= _reloadEnd) FinishReload(); if (_reloadPressed) { _reloadPressed = false; if (!_reloading && _ammo < _cfg.Coax.MagazineSize) StartReload(); } if (!_fireCoax) _burst = 0; if (!_fireCoax && !_fireCannon) return; bool hitSomething; Vector3 target = AimPoint(out hitSomething); if (_fireCoax && !_reloading) { int guard = 0; while (now >= _nextCoax && guard++ < 4) { if (_cfg.Coax.MagazineSize > 0 && _ammo <= 0) { if (_cfg.Coax.AutoReload) StartReload(); break; } FireCoax(apc, target); if (_cfg.Coax.MagazineSize > 0) _ammo--; _burst++; float baseTime = Mathf.Max(_nextCoax, now - Time.fixedDeltaTime); _nextCoax = baseTime + Mathf.Max(0.01f, _cfg.Coax.FireDelay); if (_cfg.Coax.BurstLength > 0 && _burst >= _cfg.Coax.BurstLength) { _burst = 0; _nextCoax = now + _cfg.Coax.BurstPause; } } } if (_fireCannon && now >= _nextCannon) { int shellId = _p.CannonAmmoId; int perShot = _cfg.Cannon.AmmoPerShot; if (shellId != 0 && Driver.inventory.GetAmount(shellId) < perShot) { NoAmmo("NoCannonAmmo", shellId); _nextCannon = now + 0.5f; } else { if (shellId != 0) Driver.inventory.Take(null, shellId, perShot); FireCannon(apc, target); _nextCannon = now + _cfg.Cannon.Cooldown; } } } private void StartReload() { if (_reloading) return; int id = _p.CoaxAmmoId; if (id != 0 && Driver.inventory.GetAmount(id) <= 0) { NoAmmo("NoCoaxAmmo", id); return; } _reloading = true; _reloadEnd = Time.time + _cfg.Coax.ReloadTime; _p.Reply(Driver, "Reloading"); } private void FinishReload() { _reloading = false; int need = _cfg.Coax.MagazineSize - _ammo; if (need <= 0) return; int id = _p.CoaxAmmoId; if (id == 0) { _ammo = _cfg.Coax.MagazineSize; return; } // Патроны списываются в конце перезарядки — сколько есть, но не больше ленты. int got = Driver.inventory.Take(null, id, need); _ammo += got; if (got <= 0) NoAmmo("NoCoaxAmmo", id); } private void NoAmmo(string key, int itemId) { if (Time.time < _nextNoAmmoMsg) return; _nextNoAmmoMsg = Time.time + 3f; _p.Reply(Driver, key, AmmoName(itemId)); } public void UnloadBelt(BasePlayer player) { _reloading = false; int id = _p.CoaxAmmoId; if (id == 0 || !_cfg.Coax.ReturnBelt || _ammo <= 0 || player == null) return; int n = _ammo; _ammo = 0; GiveAmmo(player, id, n); _p.Reply(player, "BeltReturned", n); } private Vector3 BarrelDirection(Vector3 from, Vector3 target) { // Стреляем точно в точку взгляда, если ствол уже довёлся; иначе — по стволу. Vector3 toTarget = (target - from).normalized; return Vector3.Angle(Bradley.turretAimVector, toTarget) <= 6f ? toTarget : Bradley.turretAimVector.normalized; } private void FireCoax(BradleyAPC apc, Vector3 target) { Transform muzzle = apc.coaxMuzzle; Vector3 origin = muzzle.position - muzzle.forward * 0.25f; Vector3 dir = AimConeUtil.GetModifiedAimConeDirection(_cfg.Coax.AimCone, BarrelDirection(origin, target)); Vector3 end = origin + dir * _cfg.Coax.Range; BaseEntity attacker = _cfg.General.CreditDriver && Driver != null ? (BaseEntity)Driver : apc; var hits = Facepunch.Pool.Get>(); GamePhysics.TraceAll(new Ray(origin, dir), 0f, hits, _cfg.Coax.Range, ShotMask); foreach (var hit in hits) { var ent = hit.GetEntity(); if (ent != null && (ent == apc || ent.EqualNetID(apc) || ent == Seat || ent == Driver)) continue; var combat = ent as BaseCombatEntity; if (combat != null) { float dmg = _cfg.Coax.Damage * UnityEngine.Random.Range(0.9f, 1.1f); var info = new HitInfo(attacker, combat, DamageType.Bullet, dmg, hit.point); combat.OnAttacked(info); if (combat is BasePlayer || combat is BaseNpc) { Effect.server.ImpactEffect(new HitInfo { HitPositionWorld = hit.point, HitNormalWorld = -dir, HitMaterial = StringPool.Get("Flesh") }); } } if (ent == null || ent.ShouldBlockProjectiles()) { end = hit.point; break; } } Facepunch.Pool.FreeUnmanaged(ref hits); apc.ClientRPC(RpcTarget.NetworkGroup("CLIENT_FireGun"), true, end); } private void FireCannon(BradleyAPC apc, Vector3 target) { Transform muzzle = apc.CannonMuzzle; Vector3 dir = AimConeUtil.GetModifiedAimConeDirection(_cfg.Cannon.AimCone, BarrelDirection(muzzle.position, target)); if (_cfg.Cannon.Recoil) { Vector3 n = (apc.CannonPitch.transform.rotation * Vector3.back + apc.transform.up * -1f).normalized; apc.myRigidBody.AddForceAtPosition(n * apc.recoilScale, apc.CannonPitch.transform.position, ForceMode.Impulse); } Effect.server.Run(apc.mainCannonMuzzleFlash.resourcePath, apc, StringPool.Get(muzzle.gameObject.name), Vector3.zero, Vector3.zero); var shell = GameManager.server.CreateEntity(apc.mainCannonProjectile.resourcePath, muzzle.position, Quaternion.LookRotation(dir)); if (shell == null) return; var proj = shell.GetComponent(); if (proj != null) { proj.speed = _cfg.Cannon.ProjectileSpeed; proj.gravityModifier = _cfg.Cannon.GravityModifier; proj.ignoreEntity = apc; proj.InitializeVelocity(dir * _cfg.Cannon.ProjectileSpeed); } var exp = shell.GetComponent(); if (exp != null) { exp.creatorEntity = _cfg.General.CreditDriver && Driver != null ? (BaseEntity)Driver : apc; if (_cfg.Cannon.ExplosionRadius > 0f) { exp.explosionRadius = _cfg.Cannon.ExplosionRadius; exp.minExplosionRadius = Mathf.Min(exp.minExplosionRadius, _cfg.Cannon.ExplosionRadius * 0.5f); } float total = 0f; foreach (var d in exp.damageTypes) total += d.amount; if (total > 0f) { float k = _cfg.Cannon.Damage / total; foreach (var d in exp.damageTypes) d.amount *= k; } else { exp.damageTypes.Add(new DamageTypeEntry { type = DamageType.Explosion, amount = _cfg.Cannon.Damage }); } } shell.Spawn(); } private void LateUpdate() { var apc = Bradley; float dt = Time.time - _lastLate; _lastLate = Time.time; if (apc == null || apc.IsDestroyed || apc.IsDead()) return; if (Driver != null && !Parked) { bool hitSomething; Vector3 target = AimPoint(out hitSomething); Vector3 desired = (target - apc.mainTurretEyePos.transform.position).normalized; if (desired.sqrMagnitude > 0.001f) apc.desiredAimVector = desired; } float maxRad = _cfg.Move.TurretSpeed * Mathf.Deg2Rad * Mathf.Max(dt, 0f); apc.turretAimVector = Vector3.RotateTowards(apc.turretAimVector, apc.desiredAimVector, maxRad, 0f); apc.AimWeaponAt(apc.mainTurret, apc.coaxPitch, apc.turretAimVector, -90f, 90f); apc.AimWeaponAt(apc.mainTurret, apc.CannonPitch, apc.turretAimVector, -90f, 7f); apc.desiredTopTurretAimVector = apc.transform.forward; apc.topTurretAimVector = Vector3.Lerp(apc.topTurretAimVector, apc.desiredTopTurretAimVector, dt * 5f); apc.AimWeaponAt(apc.topTurretYaw, apc.topTurretPitch, apc.topTurretAimVector, -360f, 360f, 360f, apc.mainTurret); if (Time.time - _lastNet >= 0.1f) { _lastNet = Time.time; apc.SendNetworkUpdate(); } if (Driver != null && _cfg.General.ShowHud && Driver.IsConnected && Time.time - _lastHud >= 0.25f) { _lastHud = Time.time; UpdateHud(); } } private void UpdateHud() { var p = Driver; int coaxId = _p.CoaxAmmoId, shellId = _p.CannonAmmoId; string coax = _reloading ? _p.Msg("HudCoaxReload", p, Mathf.Max(0f, _reloadEnd - Time.time)) : _cfg.Coax.MagazineSize <= 0 ? _p.Msg("HudCoax", p, "∞", "∞") : coaxId != 0 ? _p.Msg("HudCoaxInv", p, _ammo, _cfg.Coax.MagazineSize, p.inventory.GetAmount(coaxId)) : _p.Msg("HudCoax", p, _ammo, _cfg.Coax.MagazineSize); float cd = _nextCannon - Time.time; string cannon; if (shellId == 0) cannon = cd <= 0f ? _p.Msg("HudCannonReady", p) : _p.Msg("HudCannonWait", p, cd); else { int shells = p.inventory.GetAmount(shellId); cannon = shells < _cfg.Cannon.AmmoPerShot ? _p.Msg("HudCannonEmpty", p) : cd <= 0f ? _p.Msg("HudCannonReadyInv", p, shells) : _p.Msg("HudCannonWaitInv", p, cd, shells); } string speed = _p.Msg("HudSpeed", p, Bradley.myRigidBody.linearVelocity.magnitude * 3.6f); string text = $"{coax} | {cannon} | {speed}"; if (_cfg.General.TimeLimit > 0f) text += " | " + _p.Msg("HudTime", p, Mathf.Max(0f, _cfg.General.TimeLimit - (Time.time - _start))); if (text == _lastHudText) return; _lastHudText = text; _p.DrawHud(p, text); } public Dictionary Status() { var apc = Bradley; return new Dictionary { ["driver"] = Driver != null ? Driver.displayName : null, ["parked"] = Parked, ["speed"] = apc.myRigidBody.linearVelocity.magnitude, ["position"] = apc.transform.position, ["health"] = apc.health, ["ammo"] = _ammo, ["coaxReserve"] = Driver != null && _p.CoaxAmmoId != 0 ? Driver.inventory.GetAmount(_p.CoaxAmmoId) : -1, ["cannonShells"] = Driver != null && _p.CannonAmmoId != 0 ? Driver.inventory.GetAmount(_p.CannonAmmoId) : -1, ["reloading"] = _reloading, ["cannonCooldown"] = Mathf.Max(0f, _nextCannon - Time.time), ["turretAim"] = apc.turretAimVector, ["aiEnabled"] = apc.enabled, }; } } #endregion } }