C#:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Plugins;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("HydroRust", "belisario-afk + copilot", "5.0.6")]
[Description("Boat racing with automatic queue, battle mode, kits, voting, track editor, time trials.")]
public class HydroRust : RustPlugin
{
#region Permissions & Constants
private const string PERM_ADMIN = "hydrorust.admin";
private const string PERM_PLAYER = "hydrorust.player";
private const string DATA_TRACKS = "HydroRust_Tracks";
private const string DATA_STATS = "HydroRust_PlayerStats";
private const string DATA_ROTATION = "HydroRust_RotationIndex";
private const float EditorRadiusStep = 0.5f;
#endregion
#region Config
private enum TrackSelectionMode { CurrentSelected, Random, Rotation }
private class KitItem
{
[JsonProperty] public string ShortName;
[JsonProperty] public int Amount;
[JsonProperty] public ulong SkinId;
}
private class KitDefinition
{
[JsonProperty] public string Name;
[JsonProperty] public List<KitItem> Items = new List<KitItem>();
}
private class PluginConfig
{
[JsonProperty("HudUpdateIntervalSeconds")] public float HudUpdateIntervalSeconds = 0.3f;
[JsonProperty("RaceCountdownSeconds")] public int RaceCountdownSeconds = 10;
[JsonProperty("RaceTimeoutSeconds")] public int RaceTimeoutSeconds = 600;
[JsonProperty("Physics.MaxSpeedMultiplier")] public float MaxSpeedMultiplier = 2.0f;
[JsonProperty("Physics.BoostForce")] public float BoostForce = 20f;
[JsonProperty("Physics.BoostDurationSeconds")] public float BoostDurationSeconds = 1.5f;
[JsonProperty("Physics.BoostCooldownSeconds")] public float BoostCooldownSeconds = 4f;
[JsonProperty("Boats.AllowedBoatPrefabs")] public List<string> AllowedBoatPrefabs = new List<string>
{
"assets/content/vehicles/boats/rowboat/rowboat.prefab",
"assets/content/vehicles/boats/rhib/rhib.prefab"
};
[JsonProperty("Boats.SpawnNewRaceBoats")] public bool SpawnNewRaceBoats = true;
[JsonProperty("Controls.OverrideBoatControls")] public bool OverrideBoatControls = true;
[JsonProperty("Controls.ForwardForce")] public float ControlsForwardForce = 6f;
[JsonProperty("Controls.ReverseForce")] public float ControlsReverseForce = 4f;
[JsonProperty("Controls.SteerTorque")] public float ControlsSteerTorque = 3f;
[JsonProperty("Controls.LinearDrag")] public float ControlsLinearDrag = 0.15f;
[JsonProperty("Controls.BoostKeySprint")] public bool ControlsBoostKeySprint = true;
[JsonProperty("Controls.BoostForce")] public float ControlsBoostForce = 12f;
[JsonProperty("Controls.BoostMaxSpeedMultiplier")] public float ControlsBoostMaxSpeedMultiplier = 2.5f;
[JsonProperty("Visuals.Enabled")] public bool VisualsEnabled = true;
[JsonProperty("Visuals.CheckpointPrefab")] public string VisualsCheckpointPrefab = "assets/bundled/prefabs/modding/events/twitch/br_sphere_green.prefab";
[JsonProperty("Visuals.FinishPrefab")] public string VisualsFinishPrefab = "assets/bundled/prefabs/modding/events/twitch/br_sphere_purple.prefab";
[JsonProperty("Visuals.BoostPrefab")] public string VisualsBoostPrefab = "assets/bundled/prefabs/modding/events/twitch/br_sphere_red.prefab";
[JsonProperty("Visuals.MarkerBaseScale")] public float VisualsMarkerBaseScale = 1.0f;
[JsonProperty("Visuals.CheckpointScaleMultiplier")] public float CheckpointScaleMultiplier = 1.0f;
[JsonProperty("Visuals.FinishScaleMultiplier")] public float FinishScaleMultiplier = 1.2f;
[JsonProperty("Visuals.BoostScaleMultiplier")] public float BoostScaleMultiplier = 1.0f;
[JsonProperty("Visuals.ScaleWithRadius")] public bool ScaleWithRadius = true;
[JsonProperty("Visuals.RadiusToScaleFactor")] public float RadiusToScaleFactor = 0.08f;
[JsonProperty("Visuals.DefaultCheckpointColor")] public string DefaultCheckpointColor = "0 0.8 1 0.8";
[JsonProperty("Visuals.DefaultFinishColor")] public string DefaultFinishColor = "0 1 0 0.8";
[JsonProperty("Visuals.DefaultBoostColor")] public string DefaultBoostColor = "1 0.8 0 0.8";
[JsonProperty("Lobby.Position")] public Vector3 LobbyPosition = Vector3.zero;
[JsonProperty("Lobby.RotationYaw")] public float LobbyRotationYaw = 0f;
[JsonProperty("Boost.MarkerRespawnSeconds")] public float BoostMarkerRespawnSeconds = 10f;
[JsonProperty("Physics.MaxUpwardVelocity")] public float MaxUpwardVelocity = 4f;
[JsonProperty("Physics.MaxDownwardVelocity")] public float MaxDownwardVelocity = 35f;
[JsonProperty("Physics.UprightStrength")] public float UprightStrength = 2.0f;
[JsonProperty("Physics.AirborneUpwardDamping")] public float AirborneUpwardDamping = 0.6f;
[JsonProperty("Physics.ExtraGravity")] public float ExtraGravity = 2.0f;
[JsonProperty("Airtime.MaxAllowedSeconds")] public float MaxAllowedAirtimeSeconds = 3f;
[JsonProperty("Wipeout.RespawnIfBoatDestroyed")] public bool RespawnIfBoatDestroyed = true;
[JsonProperty("Wipeout.RespawnIfPlayerDrowned")] public bool RespawnIfPlayerDrowned = true;
[JsonProperty("Wipeout.RespawnHeightBelowBoat")] public float RespawnHeightBelowBoat = 10f;
[JsonProperty("AutoRace.Enabled")] public bool AutoRaceEnabled = true;
[JsonProperty("AutoRace.SlotSize")] public int AutoRaceSlotSize = 10;
[JsonProperty("AutoRace.QueueStartDelaySeconds")] public float AutoRaceQueueStartDelaySeconds = 15f;
[JsonProperty("AutoRace.TrackSelectionMode")] public TrackSelectionMode AutoRaceTrackSelectionMode = TrackSelectionMode.CurrentSelected;
[JsonProperty("AutoRace.TrackRotationList")] public List<string> AutoRaceRotationList = new List<string>();
[JsonProperty("BattleRace.Enabled")] public bool BattleRaceEnabled = true;
[JsonProperty("BattleRace.VoteDurationSeconds")] public int BattleRaceVoteDurationSeconds = 10;
[JsonProperty("BattleRace.DefaultKit")] public string BattleRaceDefaultKit = "Standard";
[JsonProperty("BattleRace.ClearKitItemsAfterRace")] public bool BattleRaceClearKitItemsAfterRace = true;
[JsonProperty("BattleRace.EnableMountedWeaponUse")] public bool BattleRaceEnableMountedWeaponUse = true;
[JsonProperty("BattleRace.Kits")] public List<KitDefinition> BattleRaceKits = new List<KitDefinition>
{
new KitDefinition
{
Name = "Standard",
Items = new List<KitItem>
{
new KitItem{ ShortName="rifle.ak", Amount=1, SkinId=0 },
new KitItem{ ShortName="pistol.python", Amount=1, SkinId=0 },
new KitItem{ ShortName="ammo.rifle", Amount=120, SkinId=0 },
new KitItem{ ShortName="ammo.pistol", Amount=60, SkinId=0 },
new KitItem{ ShortName="medical.syringe", Amount=8, SkinId=0 }
}
}
};
}
private PluginConfig config;
private Quaternion LobbyRotation => Quaternion.Euler(0f, (config?.LobbyRotationYaw ?? 0f), 0f);
#endregion
#region Data Structures
[JsonObject(MemberSerialization.OptIn)]
private class Checkpoint
{
[JsonProperty] public Vector3 Position;
[JsonProperty] public float Radius;
[JsonProperty] public string Color;
}
[JsonObject(MemberSerialization.OptIn)]
private class BoostZone
{
[JsonProperty] public Vector3 Position;
[JsonProperty] public float Radius;
[JsonProperty] public float StrengthMultiplier;
[JsonProperty] public float DurationSeconds;
[JsonProperty] public string Color;
}
[JsonObject(MemberSerialization.OptIn)]
private class TrackDefinition
{
[JsonProperty] public string Name;
[JsonProperty] public Vector3 StartPosition;
[JsonProperty] public float StartYaw;
[JsonProperty] public float StartRadius = 12f;
[JsonProperty] public Vector3 FinishPosition;
[JsonProperty] public float FinishRadius = 12f;
[JsonProperty] public string FinishColor;
[JsonProperty] public List<Checkpoint> Checkpoints = new List<Checkpoint>();
[JsonProperty] public List<BoostZone> BoostZones = new List<BoostZone>();
[JsonProperty] public int Laps = 1;
[JsonProperty] public string Designer = "Unknown";
[JsonProperty] public string ImageKey = "";
[JsonIgnore] public Quaternion StartRotation
{
get => Quaternion.Euler(0f, StartYaw, 0f);
set => StartYaw = value.eulerAngles.y;
}
}
[JsonObject(MemberSerialization.OptIn)]
private class TrackStats
{
[JsonProperty] public float BestTimeSeconds = -1f;
[JsonProperty] public int Wins;
[JsonProperty] public int CompletedRaces;
}
[JsonObject(MemberSerialization.OptIn)]
private class PlayerStats
{
[JsonProperty] public ulong PlayerId;
[JsonProperty] public Dictionary<string, TrackStats> TrackStats =
new Dictionary<string, TrackStats>(StringComparer.OrdinalIgnoreCase);
}
private class TrackBuilder
{
public string Name;
public TrackDefinition Track = new TrackDefinition();
}
private enum RaceState { None, Staging, Voting, Countdown, Running, Finished }
private enum RaceMode { Normal, Battle }
private class RaceParticipant
{
public BasePlayer Player;
public BaseBoat Boat;
public int CurrentCheckpointIndex;
public int CurrentLap;
public float TimeElapsed;
public bool Finished;
public float FinishTime;
public Dictionary<int, float> ActiveBoosts = new Dictionary<int, float>();
public Dictionary<int, float> BoostCooldowns = new Dictionary<int, float>();
public float AirborneTime;
public List<ItemId> GivenBattleItemIds = new List<ItemId>();
}
private class RaceSession
{
public TrackDefinition Track;
public RaceState State = RaceState.None;
public float CountdownRemaining;
public float TimeSinceStart;
public Dictionary<ulong, RaceParticipant> Participants = new Dictionary<ulong, RaceParticipant>();
public List<BaseBoat> SpawnedBoats = new List<BaseBoat>();
public Timer CountdownTimer;
public Timer VoteTimer;
public RaceMode Mode = RaceMode.Normal;
public Dictionary<ulong, RaceMode> Votes = new Dictionary<ulong, RaceMode>();
public string BattleKitUsed = "";
}
private class TimeTrialSession
{
public TrackDefinition Track;
public BasePlayer Player;
public BaseBoat Boat;
public int CurrentCheckpointIndex;
public int CurrentLap;
public float TimeElapsed;
public bool Finished;
public Dictionary<int, float> ActiveBoosts = new Dictionary<int, float>();
public Dictionary<int, float> BoostCooldowns = new Dictionary<int, float>();
public float AirborneTime;
}
private class MarkerInfo
{
public BaseEntity Entity;
public TrackDefinition Track;
public int CheckpointIndex = -1;
public int BoostIndex = -1;
public bool IsFinish;
}
private enum EditorSelectType { None, Start, Finish, Checkpoint, Boost }
private class EditorState
{
public bool Active;
public TrackDefinition SelectedTrack;
public EditorSelectType SelectedType = EditorSelectType.None;
public int SelectedCheckpointIndex = -1;
public int SelectedBoostIndex = -1;
public bool MovingMarker;
}
#endregion
#region State
private readonly Dictionary<string, TrackDefinition> tracks = new Dictionary<string, TrackDefinition>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<ulong, PlayerStats> playerStats = new Dictionary<ulong, PlayerStats>();
private TrackBuilder currentTrackBuilder;
private RaceSession currentRace;
private readonly Dictionary<ulong, TimeTrialSession> activeTimeTrials = new Dictionary<ulong, TimeTrialSession>();
private Timer raceTickTimer;
private Timer timeTrialTickTimer;
private Timer controlTickTimer;
private Timer editorTickTimer;
private Timer autoRaceMonitor;
private readonly List<MarkerInfo> activeTrackMarkers = new List<MarkerInfo>();
private readonly Dictionary<ulong, EditorState> editorStates = new Dictionary<ulong, EditorState>();
private readonly Dictionary<string, HashSet<int>> disabledBoostMarkers = new Dictionary<string, HashSet<int>>(StringComparer.OrdinalIgnoreCase);
private readonly LinkedList<ulong> raceQueue = new LinkedList<ulong>();
private int rotationIndex = 0;
#endregion
#region Init / Unload
[PluginReference] private Plugin HydroLobby;
private void EnsureConfig()
{
if (config == null)
{
PrintWarning("Config null; creating defaults.");
config = new PluginConfig();
}
if (config.AllowedBoatPrefabs == null || config.AllowedBoatPrefabs.Count == 0)
{
config.AllowedBoatPrefabs = new List<string>
{
"assets/content/vehicles/boats/rowboat/rowboat.prefab",
"assets/content/vehicles/boats/rhib/rhib.prefab"
};
}
}
private void LoadConfigSafe()
{
try
{
if (!Config.Exists())
{
LoadDefaultConfig();
SaveConfigSafe();
}
else
{
config = Config.ReadObject<PluginConfig>();
}
}
catch (Exception e)
{
PrintWarning($"LoadConfig failed: {e.Message}");
config = null;
}
EnsureConfig();
SaveConfigSafe();
}
protected override void LoadDefaultConfig()
{
config = new PluginConfig();
PrintWarning("Created default HydroRust config.");
}
private void SaveConfigSafe()
{
try
{
if (config != null)
Config.WriteObject(config, true);
}
catch (Exception e)
{
PrintWarning($"SaveConfig failed: {e.Message}");
}
}
private void Init()
{
permission.RegisterPermission(PERM_ADMIN, this);
permission.RegisterPermission(PERM_PLAYER, this);
LoadConfigSafe();
LoadDataFiles();
LoadRotationIndex();
}
private void OnServerInitialized()
{
EnsureConfig();
StartTickTimers();
StartAutoRaceMonitor();
var players = BasePlayer.activePlayerList;
if (players != null)
{
foreach (var player in players)
{
if (player == null) continue;
EnsurePlayerPermission(player);
TryAddToQueueFromLobby(player);
}
}
}
private void Unload()
{
StopTickTimers();
autoRaceMonitor?.Destroy();
SaveDataFiles();
SaveRotationIndex();
SaveConfigSafe();
DestroyTrackMarkers();
currentRace = null;
activeTimeTrials.Clear();
}
private void EnsurePlayerPermission(BasePlayer player)
{
if (player == null) return;
if (!permission.UserHasPermission(player.UserIDString, PERM_PLAYER))
permission.GrantUserPermission(player.UserIDString, PERM_PLAYER, null);
}
private void OnPlayerConnected(BasePlayer player)
{
EnsureConfig();
EnsurePlayerPermission(player);
timer.Once(2f, () => TryAddToQueueFromLobby(player));
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
editorStates.Remove(player.userID);
currentRace?.Participants.Remove(player.userID);
activeTimeTrials.Remove(player.userID);
RemoveFromQueue(player.userID);
}
private void OnPlayerRespawn(BasePlayer player)
{
timer.Once(0.5f, () => TryAddToQueueFromLobby(player));
}
private void TryAddToQueueFromLobby(BasePlayer player)
{
EnsureConfig();
if (config == null) return;
if (player == null || !player.IsConnected) return;
if (!config.AutoRaceEnabled) return;
Vector3 lobbyPos = GetLobbyPosition();
if (lobbyPos == Vector3.zero) return;
if (Vector3.Distance(player.transform.position, lobbyPos) <= 20f)
EnqueuePlayer(player.userID);
}
#endregion
#region Data Load/Save
private void LoadDataFiles()
{
try
{
var tracksData = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<string, TrackDefinition>>(DATA_TRACKS);
if (tracksData != null)
{
foreach (var kvp in tracksData)
{
var t = kvp.Value;
if (t == null) continue;
if (string.IsNullOrEmpty(t.FinishColor))
t.FinishColor = config?.DefaultFinishColor ?? "0 1 0 0.8";
for (int i = 0; i < t.Checkpoints.Count; i++)
if (string.IsNullOrEmpty(t.Checkpoints[i].Color))
t.Checkpoints[i].Color = config?.DefaultCheckpointColor ?? "0 0.8 1 0.8";
for (int i = 0; i < t.BoostZones.Count; i++)
if (string.IsNullOrEmpty(t.BoostZones[i].Color))
t.BoostZones[i].Color = config?.DefaultBoostColor ?? "1 0.8 0 0.8";
tracks[kvp.Key] = t;
}
}
}
catch (Exception e) { PrintWarning($"Failed to load tracks data: {e.Message}"); }
try
{
var statsData = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<ulong, PlayerStats>>(DATA_STATS);
if (statsData != null)
foreach (var kvp in statsData)
if (kvp.Value != null)
playerStats[kvp.Key] = kvp.Value;
}
catch (Exception e) { PrintWarning($"Failed to load stats data: {e.Message}"); }
}
private void SaveDataFiles()
{
try
{
Interface.Oxide.DataFileSystem.WriteObject(DATA_TRACKS, new Dictionary<string, TrackDefinition>(tracks, StringComparer.OrdinalIgnoreCase), true);
Interface.Oxide.DataFileSystem.WriteObject(DATA_STATS, new Dictionary<ulong, PlayerStats>(playerStats), true);
}
catch (Exception e)
{
PrintWarning($"SaveDataFiles error: {e.Message}");
}
}
private void LoadRotationIndex()
{
try
{
rotationIndex = Interface.Oxide.DataFileSystem.ReadObject<int>(DATA_ROTATION);
}
catch { rotationIndex = 0; }
}
private void SaveRotationIndex()
{
try
{
Interface.Oxide.DataFileSystem.WriteObject(DATA_ROTATION, rotationIndex);
}
catch (Exception e)
{
PrintWarning($"SaveRotationIndex error: {e.Message}");
}
}
private PlayerStats GetOrCreatePlayerStats(ulong id)
{
if (!playerStats.TryGetValue(id, out var ps))
{
ps = new PlayerStats { PlayerId = id };
playerStats[id] = ps;
}
return ps;
}
private TrackStats GetOrCreateTrackStats(PlayerStats ps, string trackName)
{
if (ps == null) return null;
if (!ps.TrackStats.TryGetValue(trackName, out var ts))
{
ts = new TrackStats();
ps.TrackStats[trackName] = ts;
}
return ts;
}
private void UpdatePlayerStatsOnFinish(ulong id, string trackName, float time)
{
var ps = GetOrCreatePlayerStats(id);
var ts = GetOrCreateTrackStats(ps, trackName);
if (ts == null) return;
if (ts.BestTimeSeconds < 0f || time < ts.BestTimeSeconds)
ts.BestTimeSeconds = time;
ts.CompletedRaces++;
SaveDataFiles();
}
#endregion
#region Timers
private void StartTickTimers()
{
StopTickTimers();
raceTickTimer = timer.Every(0.1f, RaceTick);
timeTrialTickTimer = timer.Every(0.1f, TimeTrialTick);
controlTickTimer = timer.Every(0.05f, ControlTick);
editorTickTimer = timer.Every(0.05f, EditorTick);
}
private void StopTickTimers()
{
raceTickTimer?.Destroy();
timeTrialTickTimer?.Destroy();
controlTickTimer?.Destroy();
editorTickTimer?.Destroy();
raceTickTimer = timeTrialTickTimer = controlTickTimer = editorTickTimer = null;
}
private void StartAutoRaceMonitor()
{
autoRaceMonitor?.Destroy();
autoRaceMonitor = timer.Every(3f, AutoRaceCheck);
}
#endregion
#region Queue & Auto Race
private void EnqueuePlayer(ulong id)
{
if (currentRace != null && currentRace.State != RaceState.Finished)
{
if (currentRace.Participants.ContainsKey(id)) return;
}
if (raceQueue.Contains(id)) return;
raceQueue.AddLast(id);
}
private void RemoveFromQueue(ulong id)
{
var node = raceQueue.Find(id);
if (node != null) raceQueue.Remove(node);
}
private TrackDefinition ResolveAutoRaceTrack()
{
EnsureConfig();
if (tracks.Count == 0) return null;
var mode = config.AutoRaceTrackSelectionMode;
switch (mode)
{
case TrackSelectionMode.CurrentSelected:
var selected = editorStates.Values.FirstOrDefault(s => s.SelectedTrack != null)?.SelectedTrack;
return selected ?? tracks.Values.First();
case TrackSelectionMode.Random:
return tracks.Values.ElementAt(UnityEngine.Random.Range(0, tracks.Count));
case TrackSelectionMode.Rotation:
if (config.AutoRaceRotationList == null || config.AutoRaceRotationList.Count == 0)
return tracks.Values.ElementAt(rotationIndex % tracks.Count);
var valid = config.AutoRaceRotationList.Where(n => tracks.ContainsKey(n)).ToList();
if (valid.Count == 0) return tracks.Values.First();
if (rotationIndex >= valid.Count) rotationIndex = 0;
var name = valid[rotationIndex];
rotationIndex = (rotationIndex + 1) % valid.Count;
SaveRotationIndex();
return tracks[name];
}
return tracks.Values.First();
}
private void AutoRaceCheck()
{
EnsureConfig();
if (config == null) return;
if (!config.AutoRaceEnabled) return;
if (currentRace != null && currentRace.State != RaceState.Finished) return;
int slotSize = Mathf.Max(1, config.AutoRaceSlotSize);
if (raceQueue.Count == 0) return;
if (raceQueue.Count >= slotSize)
{
BeginAutoStage(slotSize);
}
else
{
if (config.AutoRaceQueueStartDelaySeconds > 0f)
{
if (_pendingDelayedStart == null)
{
_pendingDelayedStart = timer.Once(config.AutoRaceQueueStartDelaySeconds, () =>
{
_pendingDelayedStart = null;
if (currentRace == null || currentRace.State == RaceState.Finished)
{
if (raceQueue.Count > 0 && raceQueue.Count < slotSize)
BeginAutoStage(Math.Min(slotSize, raceQueue.Count));
}
});
}
}
}
}
private Timer _pendingDelayedStart;
private void BeginAutoStage(int takeCount)
{
var track = ResolveAutoRaceTrack();
if (track == null)
{
PrintWarning("AutoRace: No track available.");
return;
}
currentRace = new RaceSession
{
Track = track,
State = RaceState.Staging,
CountdownRemaining = config?.RaceCountdownSeconds ?? 10
};
int added = 0;
foreach (var id in raceQueue.ToList())
{
var player = BasePlayer.FindByID(id);
if (player != null && player.IsConnected)
{
currentRace.Participants[id] = new RaceParticipant { Player = player };
added++;
raceQueue.Remove(id);
if (added >= takeCount) break;
}
else raceQueue.Remove(id);
}
BroadcastToRace($"Auto race staged on {track.Name}. Voting for mode begins!");
if ((config?.VisualsEnabled ?? false))
{
DestroyTrackMarkers();
SpawnTrackMarkers(track);
}
StartBattleVote();
}
private void StartBattleVote()
{
EnsureConfig();
if (currentRace == null) return;
if (!(config?.BattleRaceEnabled ?? false))
{
currentRace.Mode = RaceMode.Normal;
StartCountdownPhase();
return;
}
currentRace.State = RaceState.Voting;
currentRace.Votes.Clear();
BroadcastToRace($"Vote race mode: /hydro vote normal OR /hydro vote battle ({config?.BattleRaceVoteDurationSeconds ?? 10}s).");
currentRace.VoteTimer?.Destroy();
currentRace.VoteTimer = timer.Once(config?.BattleRaceVoteDurationSeconds ?? 10, FinishVoteAndProceed);
}
private void FinishVoteAndProceed()
{
if (currentRace == null || currentRace.State != RaceState.Voting) return;
int battle = currentRace.Votes.Values.Count(v => v == RaceMode.Battle);
int normal = currentRace.Votes.Values.Count(v => v == RaceMode.Normal);
currentRace.Mode = (battle > normal) ? RaceMode.Battle : RaceMode.Normal;
BroadcastToRace($"Race mode chosen: {currentRace.Mode}");
StartCountdownPhase();
}
private void StartCountdownPhase()
{
if (currentRace == null) return;
currentRace.State = RaceState.Countdown;
currentRace.CountdownRemaining = config?.RaceCountdownSeconds ?? 10;
BroadcastToRace($"Race ({currentRace.Mode}) starting in {currentRace.CountdownRemaining} seconds...");
currentRace.CountdownTimer?.Destroy();
currentRace.CountdownTimer = timer.Every(1f, () =>
{
if (currentRace == null || currentRace.State != RaceState.Countdown) return;
currentRace.CountdownRemaining--;
if (currentRace.CountdownRemaining <= 0)
{
currentRace.CountdownTimer?.Destroy();
currentRace.CountdownTimer = null;
BeginRace();
}
else BroadcastToRace($"{currentRace.Mode} race starting in {currentRace.CountdownRemaining}...");
});
}
#endregion
#region Airtime / Wipeout
private void HandleAirtimeAndWipeout(TrackDefinition track, RaceParticipant part, BaseBoat boat, float delta)
{
if (part == null || part.Player == null) return;
if (boat == null || boat.IsDestroyed)
{
if (config?.RespawnIfBoatDestroyed ?? true)
RespawnParticipantAtLastCheckpoint(track, part);
return;
}
bool airborne = IsBoatAirborne(boat);
if (airborne)
{
part.AirborneTime += delta;
if (part.AirborneTime > (config?.MaxAllowedAirtimeSeconds ?? 3f))
{
RespawnParticipantAtLastCheckpoint(track, part);
part.AirborneTime = 0f;
}
}
else part.AirborneTime = 0f;
if ((config?.RespawnIfPlayerDrowned ?? true) && part.Player.GetMounted() == null)
{
float diff = boat.transform.position.y - part.Player.transform.position.y;
if (diff > (config?.RespawnHeightBelowBoat ?? 10f))
RespawnParticipantAtLastCheckpoint(track, part);
}
}
private void HandleAirtimeAndWipeout(TrackDefinition track, TimeTrialSession session, BaseBoat boat, float delta)
{
if (session == null || session.Player == null) return;
if (boat == null || boat.IsDestroyed)
{
if (config?.RespawnIfBoatDestroyed ?? true)
RespawnTimeTrialAtLastCheckpoint(track, session);
return;
}
bool airborne = IsBoatAirborne(boat);
if (airborne)
{
session.AirborneTime += delta;
if (session.AirborneTime > (config?.MaxAllowedAirtimeSeconds ?? 3f))
{
RespawnTimeTrialAtLastCheckpoint(track, session);
session.AirborneTime = 0f;
}
}
else session.AirborneTime = 0f;
if ((config?.RespawnIfPlayerDrowned ?? true) && session.Player.GetMounted() == null)
{
float diff = boat.transform.position.y - session.Player.transform.position.y;
if (diff > (config?.RespawnHeightBelowBoat ?? 10f))
RespawnTimeTrialAtLastCheckpoint(track, session);
}
}
private Vector3 GetCheckpointPosition(TrackDefinition track, int checkpointIndex)
{
if (track == null) return Vector3.zero;
if (checkpointIndex <= 0 || track.Checkpoints.Count == 0) return track.StartPosition;
int idx = Math.Min(checkpointIndex - 1, track.Checkpoints.Count - 1);
return track.Checkpoints[idx].Position;
}
private Quaternion GetCheckpointRotation(TrackDefinition track, int checkpointIndex)
{
if (track == null) return Quaternion.identity;
if (checkpointIndex <= 0 || track.Checkpoints.Count == 0) return track.StartRotation;
int idx = Math.Min(checkpointIndex - 1, track.Checkpoints.Count - 1);
Vector3 from = track.Checkpoints[idx].Position;
Vector3 to = (idx + 1 < track.Checkpoints.Count) ? track.Checkpoints[idx + 1].Position : track.FinishPosition;
Vector3 dir = to - from; dir.y = 0f;
if (dir.sqrMagnitude < 0.01f) return track.StartRotation;
return Quaternion.LookRotation(dir.normalized, Vector3.up);
}
private void RespawnParticipantAtLastCheckpoint(TrackDefinition track, RaceParticipant part)
{
if (track == null || part == null || part.Player == null) return;
Vector3 cpPos = GetCheckpointPosition(track, part.CurrentCheckpointIndex);
Quaternion cpRot = GetCheckpointRotation(track, part.CurrentCheckpointIndex);
part.Boat?.Kill();
var boat = SpawnBoatForPlayer(part.Player, cpPos, cpRot);
StabilizeBoat(boat);
part.Boat = boat;
part.AirborneTime = 0f;
part.Player.ChatMessage("<color=#ff8080>Respawned.</color>");
}
private void RespawnTimeTrialAtLastCheckpoint(TrackDefinition track, TimeTrialSession session)
{
if (track == null || session == null || session.Player == null) return;
Vector3 cpPos = GetCheckpointPosition(track, session.CurrentCheckpointIndex);
Quaternion cpRot = GetCheckpointRotation(track, session.CurrentCheckpointIndex);
session.Boat?.Kill();
var boat = SpawnBoatForPlayer(session.Player, cpPos, cpRot);
StabilizeBoat(boat);
session.Boat = boat;
session.AirborneTime = 0f;
session.Player.ChatMessage("<color=#ff8080>Respawned.</color>");
}
#endregion
#region Boost & Controls
private void TickBoosts(TrackDefinition track, BaseBoat boat,
Dictionary<int, float> activeBoosts, Dictionary<int, float> boostCooldowns,
float delta, BasePlayer player)
{
if (boat == null || boat.IsDestroyed || track == null) return;
foreach (var idx in activeBoosts.Keys.ToList())
{
activeBoosts[idx] -= delta;
if (activeBoosts[idx] <= 0f) activeBoosts.Remove(idx);
}
foreach (var idx in boostCooldowns.Keys.ToList())
{
boostCooldowns[idx] -= delta;
if (boostCooldowns[idx] <= 0f) boostCooldowns.Remove(idx);
}
var rb = GetBoatRigidbody(boat);
if (rb == null) return;
Vector3 boatPos = boat.transform.position;
for (int i = 0; i < track.BoostZones.Count; i++)
{
var zone = track.BoostZones[i];
if (Vector3.Distance(boatPos, zone.Position) <= zone.Radius)
{
if (boostCooldowns.ContainsKey(i) || activeBoosts.ContainsKey(i)) continue;
float duration = zone.DurationSeconds > 0 ? zone.DurationSeconds : (config?.BoostDurationSeconds ?? 1.5f);
float strength = zone.StrengthMultiplier > 0 ? zone.StrengthMultiplier : 1f;
activeBoosts[i] = duration;
boostCooldowns[i] = (config?.BoostCooldownSeconds ?? 4f);
Vector3 forward = boat.transform.forward.normalized;
rb.AddForce(forward * (config?.BoostForce ?? 20f) * strength, ForceMode.VelocityChange);
float maxSpeed = GetBaseBoatSpeed(boat) * (config?.MaxSpeedMultiplier ?? 2f) * strength;
if (rb.velocity.magnitude > maxSpeed)
rb.velocity = rb.velocity.normalized * maxSpeed;
player?.ChatMessage("<color=#00ffff>BOOST!</color>");
DisableBoostMarkerTemporarily(track, i, config?.BoostMarkerRespawnSeconds ?? 10f);
}
}
if (rb.velocity.sqrMagnitude > 0.01f)
{
float baseMax = GetBaseBoatSpeed(boat) * (config?.MaxSpeedMultiplier ?? 2f);
if (rb.velocity.magnitude > baseMax)
rb.velocity = rb.velocity.normalized * baseMax;
}
}
private Rigidbody GetBoatRigidbody(BaseBoat boat)
{
return boat?.GetComponent<Rigidbody>() ?? boat?.GetComponentInChildren<Rigidbody>();
}
private float GetBaseBoatSpeed(BaseBoat boat)
{
if (boat == null) return 10f;
string prefab = boat.ShortPrefabName?.ToLowerInvariant() ?? "";
if (prefab.Contains("rowboat")) return 8f;
if (prefab.Contains("rhib")) return 14f;
return 10f;
}
private bool IsBoatAirborne(BaseBoat boat)
{
if (boat == null) return false;
return !Physics.Raycast(boat.transform.position + Vector3.up * 0.5f, Vector3.down, out _, 3f,
LayerMask.GetMask("Terrain", "World", "Water"));
}
private void ApplyArcadeControls(BasePlayer player, BaseBoat boat, float deltaTime)
{
if (player?.serverInput == null || boat == null || boat.IsDestroyed) return;
if (!(config?.OverrideBoatControls ?? true)) return;
var rb = GetBoatRigidbody(boat);
if (rb == null) return;
var input = player.serverInput;
bool forward = input.IsDown(BUTTON.FORWARD);
bool backward = input.IsDown(BUTTON.BACKWARD);
bool left = input.IsDown(BUTTON.LEFT);
bool right = input.IsDown(BUTTON.RIGHT);
bool boostKey = (config?.ControlsBoostKeySprint ?? true) ? input.IsDown(BUTTON.SPRINT) : input.IsDown(BUTTON.RELOAD);
Vector3 forwardDir = boat.transform.forward;
rb.velocity *= (1f - (config?.ControlsLinearDrag ?? 0.15f) * deltaTime);
float baseSpeed = GetBaseBoatSpeed(boat);
float maxSpeed = baseSpeed * (config?.MaxSpeedMultiplier ?? 2f);
if (forward) rb.AddForce(forwardDir * (config?.ControlsForwardForce ?? 6f), ForceMode.Acceleration);
if (backward) rb.AddForce(-forwardDir * (config?.ControlsReverseForce ?? 4f), ForceMode.Acceleration);
float steer = 0f;
if (left) steer -= 1f;
if (right) steer += 1f;
if (Mathf.Abs(steer) > 0.01f)
rb.AddTorque(Vector3.up * steer * (config?.ControlsSteerTorque ?? 3f), ForceMode.Acceleration);
if (boostKey)
{
float boostMax = baseSpeed * (config?.ControlsBoostMaxSpeedMultiplier ?? 2.5f);
rb.AddForce(forwardDir * (config?.ControlsBoostForce ?? 12f), ForceMode.Acceleration);
if (rb.velocity.magnitude > boostMax)
rb.velocity = rb.velocity.normalized * boostMax;
}
else if (rb.velocity.magnitude > maxSpeed)
rb.velocity = rb.velocity.normalized * maxSpeed;
var vel = rb.velocity;
Vector3 up = boat.transform.up;
float tilt = Vector3.Angle(up, Vector3.up);
if (tilt > 10f)
{
Vector3 axis = Vector3.Cross(up, Vector3.up);
float strength = Mathf.Min(1.0f, (config?.UprightStrength ?? 2f)) * (tilt / 90f);
rb.AddTorque(axis.normalized * (-strength), ForceMode.Acceleration);
if (tilt > 25f) rb.AddForce(Vector3.down * (config?.ExtraGravity ?? 2f), ForceMode.Acceleration);
}
if (IsBoatAirborne(boat) && vel.y > 0f)
vel.y *= Mathf.Clamp01(1f - (config?.AirborneUpwardDamping ?? 0.6f));
vel.y = Mathf.Clamp(vel.y, -(config?.MaxDownwardVelocity ?? 35f), (config?.MaxUpwardVelocity ?? 4f));
rb.velocity = vel;
}
#endregion
#region Track Logic
private void HandleCheckpointLogic(TrackDefinition track, RaceParticipant part)
{
if (part?.Boat == null || track == null || track.Checkpoints.Count == 0) return;
int cpIndex = part.CurrentCheckpointIndex;
if (cpIndex >= track.Checkpoints.Count) return;
var cp = track.Checkpoints[cpIndex];
if (Vector3.Distance(part.Boat.transform.position, cp.Position) <= cp.Radius)
{
part.CurrentCheckpointIndex++;
if (part.CurrentCheckpointIndex >= track.Checkpoints.Count)
{
part.CurrentCheckpointIndex = 0;
part.CurrentLap++;
part.Player?.ChatMessage($"<color=#ffff00>Lap {part.CurrentLap}/{track.Laps}</color>");
}
else part.Player?.ChatMessage($"Checkpoint {part.CurrentCheckpointIndex}/{track.Checkpoints.Count}");
}
}
private void HandleCheckpointLogic(TrackDefinition track, TimeTrialSession session)
{
if (session?.Boat == null || track == null || track.Checkpoints.Count == 0) return;
int cpIndex = session.CurrentCheckpointIndex;
if (cpIndex >= track.Checkpoints.Count) return;
var cp = track.Checkpoints[cpIndex];
if (Vector3.Distance(session.Boat.transform.position, cp.Position) <= cp.Radius)
{
session.CurrentCheckpointIndex++;
if (session.CurrentCheckpointIndex >= track.Checkpoints.Count)
{
session.CurrentCheckpointIndex = 0;
session.CurrentLap++;
session.Player?.ChatMessage($"<color=#ffff00>Lap {session.CurrentLap}/{track.Laps}</color>");
}
else session.Player?.ChatMessage($"Checkpoint {session.CurrentCheckpointIndex}/{track.Checkpoints.Count}");
}
}
private bool HasFinishedTrack(TrackDefinition track, RaceParticipant part)
{
if (part?.Boat == null || track == null || part.CurrentLap < track.Laps) return false;
return Vector3.Distance(part.Boat.transform.position, track.FinishPosition) <= track.FinishRadius;
}
private bool HasFinishedTrack(TrackDefinition track, TimeTrialSession session)
{
if (session?.Boat == null || track == null || session.CurrentLap < track.Laps) return false;
return Vector3.Distance(session.Boat.transform.position, track.FinishPosition) <= track.FinishRadius;
}
#endregion
#region Race Flow
private void BeginRace()
{
if (currentRace == null) return;
int count = Math.Min(currentRace.Participants.Count, config?.AutoRaceSlotSize ?? 10);
int index = 0;
foreach (var kvp in currentRace.Participants.ToList())
{
var part = kvp.Value;
var player = part.Player;
if (player == null || !player.IsConnected) continue;
Vector3 startPos = GetStartSlotPosition(currentRace.Track, index, count);
Quaternion startRot = currentRace.Track.StartRotation;
TeleportTo(startPos, startRot, player);
if (config?.SpawnNewRaceBoats ?? true)
{
var boat = SpawnBoatForPlayer(player, startPos, startRot);
if (boat != null)
{
part.Boat = boat;
currentRace.SpawnedBoats.Add(boat);
EnableMountedCombatIfBattle(boat);
}
}
else
{
part.Boat = GetPlayerBoat(player);
MountPlayerInBoatDriverSeat(player, part.Boat);
EnableMountedCombatIfBattle(part.Boat);
}
index++;
if (index >= count) break;
}
DistributeBattleKitsIfNeeded();
currentRace.State = RaceState.Running;
BroadcastToRace(currentRace.Mode == RaceMode.Battle
? "<color=#ff4444>BATTLE GO!</color>"
: "<color=#00ff00>GO!</color>");
}
private void EnableMountedCombatIfBattle(BaseBoat boat)
{
if (currentRace == null || currentRace.Mode != RaceMode.Battle) return;
if (!(config?.BattleRaceEnableMountedWeaponUse ?? true) || boat == null) return;
var mounts = boat.GetComponentsInChildren<BaseMountable>();
foreach (var m in mounts)
TryEnableItemsOnMount(m);
}
private void TryEnableItemsOnMount(BaseMountable m)
{
if (m == null) return;
SetBoolFieldOrProperty(m, "disableItemUsage", false);
SetBoolFieldOrProperty(m, "canWieldItems", true);
SetBoolFieldOrProperty(m, "canHideHeldEntity", false);
}
private void SetBoolFieldOrProperty(object obj, string name, bool value)
{
if (obj == null) return;
var type = obj.GetType();
var fi = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (fi != null && fi.FieldType == typeof(bool))
{
try { fi.SetValue(obj, value); } catch { }
return;
}
var pi = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (pi != null && pi.CanWrite && pi.PropertyType == typeof(bool))
{
try { pi.SetValue(obj, value, null); } catch { }
}
}
private void DistributeBattleKitsIfNeeded()
{
if (currentRace == null || currentRace.Mode != RaceMode.Battle) return;
var kits = config?.BattleRaceKits;
if (kits == null || kits.Count == 0) return;
var kit = kits.FirstOrDefault(k => k.Name.Equals(config.BattleRaceDefaultKit, StringComparison.OrdinalIgnoreCase))
?? kits.FirstOrDefault();
if (kit == null) return;
currentRace.BattleKitUsed = kit.Name;
foreach (var part in currentRace.Participants.Values)
{
var player = part.Player;
if (player == null || !player.IsConnected) continue;
foreach (var itemDef in kit.Items)
{
var item = ItemManager.CreateByName(itemDef.ShortName, itemDef.Amount, itemDef.SkinId);
if (item == null) continue;
if (!player.inventory.GiveItem(item))
{
item.Remove();
// ... [Code preview truncated due to length. Full source in attached file] ...