diff --git a/lua/acf/core/globals.lua b/lua/acf/core/globals.lua index ef8ab410e..697211487 100644 --- a/lua/acf/core/globals.lua +++ b/lua/acf/core/globals.lua @@ -173,7 +173,7 @@ do -- ACF global vars -- The deviation of the input direction from the shaft + the output direction from the shaft cannot exceed this ACF.DefineSetting("MaxDriveshaftAngle", 85, nil, ACF.FloatDataCallback(85, 180, 0)) - ACF.Year = 1945 + ACF.Year = 2026 -- Was 1945. Define this hardcoded for now, always the year in course unless more work on this gets done and does actually get used properly ACF.IllegalDisableTime = 30 -- Time in seconds for an entity to be disabled when it fails ACF.IsLegal ACF.Volume = 1 -- Global volume for ACF sounds ACF.MobilityLinkDistance = 650 -- Maximum distance, in inches, at which mobility-related components will remain linked with each other @@ -195,6 +195,9 @@ do -- ACF global vars ACF.KwToHp = 1.341 -- Kilowatts to horsepower ACF.LToGal = 0.264172 -- Liters to gallons + -- Miscellaneous Sound Stuff + ACF.SpeedOfSound = 343 * ACF.MeterToInch -- in Meters per Second. Source internally uses inches(or units) so we have to convert + -- Actually this would vary as a function of temperature and air pressure, but this should suffice for now -- Fuzes ACF.MinFuzeCaliber = 20 -- Minimum caliber in millimeters that can be fuzed diff --git a/lua/acf/core/utilities/sounds/sounds_cl.lua b/lua/acf/core/utilities/sounds/sounds_cl.lua index 2f2e03416..2db7a3188 100644 --- a/lua/acf/core/utilities/sounds/sounds_cl.lua +++ b/lua/acf/core/utilities/sounds/sounds_cl.lua @@ -1,4 +1,7 @@ local Sounds = ACF.Utilities.Sounds +local _MAXSOUNDS = 16 -- Maximum amount of sounds we're willing to send and have. TODO(TMF): Make this a global! +local map = math.Remap +local clamp = math.Clamp do -- Valid sound check local file = file @@ -27,9 +30,6 @@ do -- Valid sound check end end --- MARCH/TODO: universal ACF constant for speed of sound (maybe it already exists and I don't know :P) -local SpeedOfSound = 343 * 39.37 - local function DistanceToOrigin(Origin) if isentity(Origin) and IsValid(Origin) then return LocalPlayer():EyePos():Distance(Origin:GetPos()) @@ -46,7 +46,7 @@ end local function DoDelayed(Origin, Call, Instant) if Instant then return Call() end - local Delay = DistanceToOrigin(Origin) / SpeedOfSound + local Delay = DistanceToOrigin(Origin) / ACF.SpeedOfSound if Delay > 0.1 then timer.Simple(Delay, function() Call() end) else @@ -130,9 +130,10 @@ do -- Processing adjustable sounds (for example, engine noises) --- @param Path string The path to the sound to be played local to the game's sound folder --- @param Pitch integer The sound's pitch from 0-255 --- @param Volume number A float representing the sound's volume + --- @return Sound CSoundPatch The sound object function Sounds.CreateAdjustableSound(Origin, Path, Pitch, Volume) if not IsValid(Origin) then return end - if Origin.Sound then return end + if not Sounds.IsValidSound(Path) then return end local Sound = CreateSound(Origin, Path) Origin.Sound = Sound @@ -142,7 +143,8 @@ do -- Processing adjustable sounds (for example, engine noises) Sounds.DestroyAdjustableSound(Entity, true) end) - Sounds.UpdateAdjustableSound(Origin, Pitch, Volume) + Sounds.UpdateAdjustableSound(Sound, Pitch, Volume) + return Sound end --- Stops an existing adjustable sound on the origin. @@ -181,6 +183,159 @@ do -- Processing adjustable sounds (for example, engine noises) end) end +-- Fade function taken from: +-- https://dsp.stackexchange.com/questions/37477/understanding-equal-power-crossfades +-- https://dsp.stackexchange.com/questions/14754/equal-power-crossfade +function Sounds.Fade(n, min, mid, max) + local _PI = math.pi + + if n < min or n > max then return 0 end + + if n > mid then + min = mid - (max - mid) + end + + return math.cos((1 - ((n - min) / (mid - min))) * (_PI / 2)) +end + +-- Consider if we actually want to do this too! (commented out for now) +--local SmoothRPM = 0 +--local SmoothThrottle = 0 + +-- This is where the magic to interpolate sounds happen. +-- In order to make yourself a better idea of what this does you can consult the image below: +-- https://i.imgur.com/KaFmaMf.png +local function DoPitchVolumeAtRPM(Origin, Throttle, RPM) + local SoundObjects = Origin.SoundObjects + if not SoundObjects or table.IsEmpty(SoundObjects) then return end + + local fade = Sounds.Fade -- idk if this is faster to do, but given this is a hot path, might as well be... + --SmoothRPM = SmoothRPM * (1 - 0.1) + RPM * 0.1 + --SmoothThrottle = SmoothThrottle * (1 - 0.1) + Throttle * 10 + + -- Sound volumes when throttle is 0 and 100 respectively + -- TODO(TMF): This should be able to be configured from the sound menu or to be a function of the engine's load + local _OFFVOLUME = 0.25 + local _ONVOLUME = 1 + + -- TODO(TMF): Potentially add some mechanism here to check for any differences and only update those + for idx, soundTable in ipairs(SoundObjects) do + if not soundTable.RPM then continue end + Origin.Sound = soundTable.Sound + + local addCurveWidth = soundTable.Width or 0 + local enginePitch = soundTable.Pitch or 1 + local min = idx == 1 and 0 or SoundObjects[clamp(idx - 1 - addCurveWidth, 1, _MAXSOUNDS)].RPM + local mid = soundTable.RPM + local max = idx == #SoundObjects and 1000000 or SoundObjects[clamp(idx + 1 + addCurveWidth, 1, _MAXSOUNDS)].RPM + local curve = fade(RPM, min, mid, max) + local volume = curve * map(Throttle, 0, 100, _OFFVOLUME, _ONVOLUME) * (soundTable.Volume or 1) + local pitch = (RPM / soundTable.RPM) * enginePitch + + Sounds.UpdateAdjustableSound(Origin, pitch, volume) + end +end + +do -- Multiple Engine Sounds(ex. Interpolated sounds) + --- Creates many sounds from a table, and stores their entries in the Origin's entity. + --- Reuses existing methods to create and update sounds. + --- @param Origin table The entity to play the sounds from + --- @param SoundTable table The networked table with nested table containing rpm, sound path, pitch, volume, width and empty sound + function Sounds.CreateMultipleAdjustableSounds(Origin, SoundTable) + local SoundCount = 0 + + for _, sndTable in ipairs(SoundTable) do + if not Sounds.IsValidSound(sndTable.Path) then return end + local Sound = Sounds.CreateAdjustableSound(Origin, + sndTable.Path, + sndTable.Pitch or 100, 0 -- Create the sound deafened + ) + if not Sound then + print("Failed to create sound for entity " .. tostring(Origin) .. ". Sound path does not exist!") + continue + end + sndTable.Sound = Sound + SoundCount = SoundCount + 1 + + Sounds.UpdateAdjustableSound(Origin, sndTable.Pitch or 100, 0) + end + + -- Sort the table by the rpm before moving on, so it can be iterated in sequential order + table.sort(SoundTable, function(a, b) return a.RPM < b.RPM end) + + Origin.SoundObjects = SoundTable + Origin.SoundCount = SoundCount + -- Ensuring that the sounds can't stick around if the server doesn't properly ask for them to be destroyed + Origin:CallOnRemove("ACF_ForceStopMultipleAdjustableSounds", function(Entity) + Sounds.DeleteMultipleAdjustableSounds(Entity, true) + end) + end + + local IsValid = IsValid + --- Stops all the existing sounds from the entity + --- @param Origin table The entity to stop all the sounds from + function Sounds.DeleteMultipleAdjustableSounds(Origin, _) + if not IsValid(Origin) then return end + if not Origin.SoundObjects then return end + + for idx, snd in ipairs(Origin.SoundObjects) do + snd.Sound:Stop() + Origin.SoundObjects[idx] = nil + end + Origin.Sound = nil -- Just in case + Origin.SoundCount = 0 + end + + -- For multiple sounds creation + net.Receive("ACF_Sounds_AdjustableCreate_Multi", function() + --print("Received " .. len .. " bits from \"ACF_Sounds_AdjustableCreate_Multi\" for sound creation!") -- Debug print + local Origin = net.ReadEntity() + + local SoundTable = {} + local Count = net.ReadUInt(4) + + local I = 0 + + while (I < Count) do + local RPM = net.ReadUInt(14) + local StringPath = net.ReadString() + local Pitch = net.ReadUInt(8) + local Volume = net.ReadUInt(8) + local Width = net.ReadUInt(4) + + Volume = Volume * 0.01 -- Reduce the received value down to a float + table.insert(SoundTable, { RPM = RPM, + Path = StringPath, + Pitch = Pitch or 100, + Volume = Volume or 1, + Width = Width or 0, + Sound = nil }) -- Fuck it we ball + I = I + 1 + end + + if not IsValid(Origin) then return end + Sounds.CreateMultipleAdjustableSounds(Origin, SoundTable) + end) + + -- For updates on multiple sounds + net.Receive("ACF_Sounds_Adjustable_Multi", function() + --print("Received " .. len .. " bits from \"ACF_Sounds_Adjustable_Multi\" for sound updates!") -- Debug print + local Origin = net.ReadEntity() + local ShouldStop = net.ReadBool() + + if not IsValid(Origin) then return end + + -- Do we really need to remove every existing sound when the engine just turns off? + if ShouldStop then + Sounds.DeleteMultipleAdjustableSounds(Origin) + else + local Throttle = net.ReadUInt(7) + local RPM = net.ReadUInt(14) + + DoPitchVolumeAtRPM(Origin, Throttle, RPM) + end + end) +end --- Returns a table of sound infomation depending on what the trace hit. --- @param Data table The effect data relating to the projectile --- @param Trace table The trace data relating to the projectile diff --git a/lua/acf/core/utilities/sounds/sounds_sv.lua b/lua/acf/core/utilities/sounds/sounds_sv.lua index 830504d57..49d5414cc 100644 --- a/lua/acf/core/utilities/sounds/sounds_sv.lua +++ b/lua/acf/core/utilities/sounds/sounds_sv.lua @@ -3,13 +3,15 @@ local Sounds = ACF.Utilities.Sounds util.AddNetworkString("ACF_Sounds") util.AddNetworkString("ACF_Sounds_Adjustable") util.AddNetworkString("ACF_Sounds_AdjustableCreate") +util.AddNetworkString("ACF_Sounds_Adjustable_Multi") +util.AddNetworkString("ACF_Sounds_AdjustableCreate_Multi") ---- Sends a single, non-looping sound to all clients in the PAS. ---- @param Origin table | vector The source to play the sound from ---- @param Path string The path to the sound to be played local to the game's sound folder ---- @param Level? integer The sound's level/attenuation from 0-127 ---- @param Pitch? integer The sound's pitch from 0-255 ---- @param Volume number A float representing the sound's volume. This is internally converted into an integer from 0-255 for network optimization + --- Sends a single, non-looping sound to all clients in the PAS. + --- @param Origin table | vector The source to play the sound from + --- @param Path string The path to the sound to be played local to the game's sound folder + --- @param Level? integer The sound's level/attenuation from 0-127 + --- @param Pitch? integer The sound's pitch from 0-255 + --- @param Volume number A float representing the sound's volume. This is internally converted into an integer from 0-255 for network optimization function Sounds.SendSound(Origin, Path, Level, Pitch, Volume) if not IsValid(Origin) then return end @@ -36,13 +38,13 @@ function Sounds.SendSound(Origin, Path, Level, Pitch, Volume) net.SendPAS(Pos) end ---- Creates a sound patch on all clients in the PAS. ---- This is intended to be used for self-looping sounds played on an entity that can be adjusted easily later. ---- This allows us to modify the pitch/volume of a looping sound (ex. engines) with minimal network usage. ---- @param Origin table The entity to play the sound from ---- @param Path string The path to the sound to be played local to the game's sound folder ---- @param Pitch integer The sound's pitch from 0-255 ---- @param Volume number A float representing the sound's volume + --- Creates a sound patch on all clients in the PAS. + --- This is intended to be used for self-looping sounds played on an entity that can be adjusted easily later. + --- This allows us to modify the pitch/volume of a looping sound (ex. engines) with minimal network usage. + --- @param Origin table The entity to play the sound from + --- @param Path string The path to the sound to be played local to the game's sound folder + --- @param Pitch integer The sound's pitch from 0-255 + --- @param Volume number A float representing the sound's volume function Sounds.CreateAdjustableSound(Origin, Path, Pitch, Volume) if not IsValid(Origin) then return end @@ -54,17 +56,19 @@ function Sounds.CreateAdjustableSound(Origin, Path, Pitch, Volume) net.SendPAS(Origin:GetPos()) end ---- Sends an update to an adjustable sound to all clients in the PAS. ---- If the adjustable sound was stopped on the client, it will begin playing again on the origin with the given parameters. ---- This function is ratelimited to reduce network consumption, and subsequent updates will be smoothed on the client with an equivalent delta time. ---- @param Origin table The entity to update the sound on ---- @param ShouldStop? boolean Whether the sound should be destroyed; defaults to false ---- @param Pitch integer The sound's pitch from 0-255 ---- @param Volume number A float representing the sound's volume. This is internally converted into an integer from 0-255 for network optimization + --- Sends an update to an adjustable sound to all clients in the PAS. + --- If the adjustable sound was stopped on the client, it will begin playing again on the origin with the given parameters. + --- This function is ratelimited to reduce network consumption, and subsequent updates will be smoothed on the client with an equivalent delta time. + --- @param Origin table The entity to update the sound on + --- @param ShouldStop? boolean Whether the sound should be destroyed; defaults to false + --- @param Pitch integer The sound's pitch from 0-255 + --- @param Volume number A float representing the sound's volume. This is internally converted into an integer from 0-255 for network optimization function Sounds.SendAdjustableSound(Origin, ShouldStop, Pitch, Volume) ShouldStop = ShouldStop or false + local Time = CurTime() local OriginTbl = Origin.ACF + if not OriginTbl then OriginTbl = {} Origin.ACF = OriginTbl @@ -85,4 +89,72 @@ function Sounds.SendAdjustableSound(Origin, ShouldStop, Pitch, Volume) net.SendPAS(Origin:GetPos()) OriginTbl.SoundTimer = Time + 0.05 end -end \ No newline at end of file +end + + --- Creates a sound table to be broadcasted to all players within PAS. + --- This allows us to then create multiple sounds attached to a single entity, and be played fully clientside. + --- For creating 13 sounds, the data being sent can ballon up to 1.537kb's of data at once. + --- @param Origin table The entity to play the sound from + --- @param SoundTable table The table whose keys are arbitrary RPM's and values containing a table with a sound path, pitch and volume, to be played at a defined RPM(Its keys). +function Sounds.CreateMultipleAdjustableSounds(Origin, SoundTable, SoundCount) + if not IsValid(Origin) then return end + if not istable(SoundTable) then return end + + -- Separate our table in chunks to be sent instead of all at once + -- This saves about 40% in data size vs. sending the whole table + net.Start("ACF_Sounds_AdjustableCreate_Multi") + net.WriteEntity(Origin) + net.WriteUInt(SoundCount, 4) + + for _, v in ipairs(SoundTable) do + local rpm = v.RPM + local stringPath = v.Path + local pitch = v.Pitch + local volume = v.Volume + local width = v.Width + + net.WriteUInt(rpm, 14) + net.WriteString(stringPath) + net.WriteUInt(pitch, 8) + + volume = volume * 100 -- Sending the approximate volume as an int to reduce message size + net.WriteUInt(volume, 8) + net.WriteUInt(width, 4) + end + net.SendPAS(Origin:GetPos()) +end + + --- Sends an update to the client regarding Throttle, RPM and if it should stop the sound, from an engine. + --- This also allows us to modify the pitch/volume of multiple looping sounds (for an engine) with minimal network usage. + --- The sound calculations are performed entirely clientside and require net unreliable for better sound composition. + --- This function is also rate limited to reduce network consumption, and subsequent updates will be smoothed on the client with an equivalent delta time. + --- @param Origin table The entity to update the sound from + --- @param ShouldStop? boolean Whether the sound should be destroyed; defaults to false + --- @param Throttle int The entity's throttle + --- @param RPM int The entity's RPM +function Sounds.SendMultipleAdjustableSounds(Origin, ShouldStop, Throttle, RPM) + if not IsValid(Origin) then return end + ShouldStop = ShouldStop or false + + local Time = CurTime() + local OriginTbl = Origin.ACF + + if not OriginTbl then + OriginTbl = {} + Origin.ACF = OriginTbl + end + OriginTbl.SoundTimer = OriginTbl.SoundTimer or Time + + -- Slowing down the rate of sending a bit + if OriginTbl.SoundTimer <= Time or ShouldStop then + net.Start("ACF_Sounds_Adjustable_Multi", true) + net.WriteEntity(Origin) + net.WriteBool(ShouldStop) + if not ShouldStop then + net.WriteUInt(Throttle or 0, 7) + net.WriteUInt(RPM or 0, 14) -- Theorically there are engines capable of reaching more than 16K RPM. If you do so, you can go off yourself... + end + net.SendPAS(Origin:GetPos()) + OriginTbl.SoundTimer = Time + 0.05 + end +end diff --git a/lua/acf/core/utilities/sounds/tool_support_sh.lua b/lua/acf/core/utilities/sounds/tool_support_sh.lua index 074f84757..359a5d140 100644 --- a/lua/acf/core/utilities/sounds/tool_support_sh.lua +++ b/lua/acf/core/utilities/sounds/tool_support_sh.lua @@ -53,6 +53,21 @@ Sounds.acf_engine = { Ent.SoundVolume = 1 Ent:UpdateSound() + end, + GetSoundBank = function(Ent) + return { + SoundBank = Ent.SoundBank + } + end, + SetSoundBank = function(Ent, SoundBankData) + Ent.SoundBank = SoundBankData + + Ent:UpdateSoundBank() + end, + ResetSoundBank = function(Ent) + Ent.SoundBank = {} + + Ent:UpdateSoundBank() end } diff --git a/lua/acf/entities/engines/special.lua b/lua/acf/entities/engines/special.lua index f6ecf8b0b..4bae17116 100644 --- a/lua/acf/entities/engines/special.lua +++ b/lua/acf/entities/engines/special.lua @@ -71,7 +71,42 @@ do -- Special I4 Engines FOV = 120, }, }) - + -- Test engine, remove before PR! + Engines.RegisterItem("2.0L-I4", "SP", { + Name = "2.0L 4B1 I4 Petrol", + Description = "The Mitsubishi 4B1 engine is a range of all-alloy straight-4 piston engines built at Mitsubishi's Japanese" .. + "\"World Engine\" powertrain plant in Shiga on the basis of the Global Engine Manufacturing Alliance (GEMA).", + Model = "models/engines/inline4s.mdl", + Sound = "acf_extra/vehiclefx/engines/l4/mini_onhigh.wav", + SoundBank = { + {RPM = 714, Path = "acf_forza6apex/mitsubishi/mitsubishilancerevoxgsr/engine_00714.wav", Pitch = 100, Volume = 1, Width = 0}, + {RPM = 967, Path = "acf_forza6apex/mitsubishi/mitsubishilancerevoxgsr/engine_00967.wav", Pitch = 100, Volume = 1, Width = 0}, + {RPM = 1538, Path = "acf_forza6apex/mitsubishi/mitsubishilancerevoxgsr/engine_01538.wav", Pitch = 100, Volume = 1, Width = 0}, + {RPM = 1978, Path = "acf_forza6apex/mitsubishi/mitsubishilancerevoxgsr/engine_01978.wav", Pitch = 100, Volume = 1, Width = 0}, + {RPM = 2571, Path = "acf_forza6apex/mitsubishi/mitsubishilancerevoxgsr/engine_02571.wav", Pitch = 100, Volume = 1, Width = 0}, + {RPM = 3450, Path = "acf_forza6apex/mitsubishi/mitsubishilancerevoxgsr/engine_03450.wav", Pitch = 100, Volume = 1, Width = 0}, + {RPM = 3889, Path = "acf_forza6apex/mitsubishi/mitsubishilancerevoxgsr/engine_03889.wav", Pitch = 100, Volume = 1, Width = 0}, + {RPM = 4482, Path = "acf_forza6apex/mitsubishi/mitsubishilancerevoxgsr/engine_04482.wav", Pitch = 100, Volume = 1, Width = 0}, + {RPM = 4922, Path = "acf_forza6apex/mitsubishi/mitsubishilancerevoxgsr/engine_04922.wav", Pitch = 100, Volume = 1, Width = 0}, + {RPM = 5295, Path = "acf_forza6apex/mitsubishi/mitsubishilancerevoxgsr/engine_05295.wav", Pitch = 100, Volume = 1, Width = 0}, + {RPM = 5823, Path = "acf_forza6apex/mitsubishi/mitsubishilancerevoxgsr/engine_05823.wav", Pitch = 100, Volume = 1, Width = 0}, + {RPM = 6350, Path = "acf_forza6apex/mitsubishi/mitsubishilancerevoxgsr/engine_06350.wav", Pitch = 100, Volume = 1, Width = 0}, + {RPM = 6833, Path = "acf_forza6apex/mitsubishi/mitsubishilancerevoxgsr/engine_06833.wav", Pitch = 100, Volume = 1, Width = 0} + }, + Fuel = { Petrol = true }, + Type = "GenericPetrol", + Mass = 138, + Torque = 199, + FlywheelMass = 0.083, + Pitch = 1, + RPM = { + Idle = 700, + Limit = 7500, + }, + Preview = { + FOV = 120, + }, + }) Engines.RegisterItem("1.9L-I4", "SP", { Name = "1.9L I4 Petrol", Description = "#acf.descs.engines.sp.1_9", diff --git a/lua/acf/menu/items_cl/sound_replacer.lua b/lua/acf/menu/items_cl/sound_replacer.lua index 97aa1f0a1..1af303777 100644 --- a/lua/acf/menu/items_cl/sound_replacer.lua +++ b/lua/acf/menu/items_cl/sound_replacer.lua @@ -1,65 +1,650 @@ +local ACF = ACF +local Sounds = ACF.Utilities.Sounds +local GetClientData, SetClientData = ACF.GetClientData, ACF.SetClientData +local GetClientNumber, GetClientString = ACF.GetClientNumber, ACF.GetClientString + +local _MAXSOUNDS = 16 -- Maximum amount of sounds we're willing to send and have. TODO(TMF): Make this a global! +local Current = {Panels = {}, -- Contains the panel objects + Count = 0, -- Keeps count of them + Graph = { -- This only relates to the graph + Idle = 0, + Redline = 1, + RPMSlider = 2}, + Colors = (function() -- This IIFE returns a table with all the randomized colors and if the text should be dark or light colored + local ColorTable = {} + + for I = 1, _MAXSOUNDS do + local freq = 360 / _MAXSOUNDS + local color = HSVToColor(I * freq % 360, 1, 1) + -- Calculate luminance to determine text color (0.2126*R + 0.7152*G + 0.0722*B) + local luminance = (0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b) / 255 + local textColor = luminance > 0.5 and color_black or color_white + ColorTable[I] = {color, textColor} + end + + return ColorTable + end)() + } --- Generates the menu used in the Sound Replacer tool. --- @param Panel panel The base panel to build the menu off of. function ACF.CreateSoundMenu(Panel) - local Menu = ACF.InitMenuBase(Panel, "SoundMenu", "acf_reload_sound_menu") - local Wide = Menu:GetWide() - local ButtonHeight = 20 - Menu:AddLabel("#tool.acfsound.help") + local AddValue, ListSlider, ListPanel, SoundGraph -- Glocals + -- The graphing function, this is a mirror of the function found in sounds_cl.lua and is redundant + -- The plotting function is wrong, its plotting as if it was a function of pitch when the fade is calculated for volume instead + -- TODO(TMF): This should be a single function pulled from ACF.Sounds object + local function UpdateGraph(Panel) + local Count = Current.Count + if not Count then return end + + local clamp = math.Clamp + local fade = Sounds.Fade - local SoundNameText = Menu:AddPanel("DTextEntry") - SoundNameText:SetText("") - SoundNameText:SetWide(Wide - 20) - SoundNameText:SetTall(ButtonHeight) - SoundNameText:SetMultiline(false) - SoundNameText:SetConVar("wire_soundemitter_sound") - - local SoundBrowserButton = Menu:AddButton("#tool.acfsound.open_browser", "wire_sound_browser_open", SoundNameText:GetValue(), "1") - SoundBrowserButton:SetWide(Wide) - SoundBrowserButton:SetTall(ButtonHeight) - SoundBrowserButton:SetIcon("icon16/application_view_list.png") - - local SoundPre = Menu:AddPanel("ACF_Panel") - SoundPre:SetWide(Wide) - SoundPre:SetTall(ButtonHeight) - - local SoundPrePlay = SoundPre:AddButton("#tool.acfsound.play") - SoundPrePlay:SetIcon("icon16/sound.png") - SoundPrePlay.DoClick = function() - RunConsoleCommand("play", SoundNameText:GetValue()) + Panel:Clear() + + for I = 1, Count do + local addCurveWidth = GetClientNumber("Width " .. I, 0) + local volume = GetClientNumber("Volume " .. I, 0) * 100 + local min = I == 1 and 0 or GetClientNumber("RPM " .. clamp(I - 1 - addCurveWidth, 1, _MAXSOUNDS)) + local mid = GetClientNumber("RPM " .. I, 0) + local max = I == Count and 1000000 or GetClientNumber("RPM " .. clamp(I + 1 + addCurveWidth, 1, _MAXSOUNDS)) + + -- The 1000 extra is so it can see til the graph X limit and not cutoff + Panel:PlotLimitFunction("Sound " .. I, 0, 16383 + 1000, Current.Colors[I][1], function(X) + return (fade(X, min, mid, max)) * volume + end) + end end + -- The function that adds the panels to the menu + local function AddValuePanel(Menu) + local ID = #Current.Panels == 0 and 1 or #Current.Panels + 1 -- Ensure it always begins from 1 and increments from there on + local BGColor = Current.Colors[ID][1] or color_white + local TextColor = Current.Colors[ID][2] + + -- Defaults + local DefaultRPM = 1000 * ID + local DefaultPath = "" + local DefaultPitch = 100 + local DefaultVolume = 1 + local DefaultWidth = 0 + + -- VGUI panels + local _, MPanel = Menu:AddCollapsible() + local Base = Menu:AddPanel("DPanel") + _ = Base -- Override ACF's basic Base with this + local TopDiv = Menu:AddPanel("ACF_Panel") -- This is equivalent to a HTML Div, generic panel to parent other children to. + local BotDiv = Menu:AddPanel("ACF_Panel") -- Same as above. + -- TODO(TMF): The max value below is hardcoded, this should be a global! + local RPMWang, RPMLabel = Menu:AddNumberWang("RPM:", 0, 16383, 0) + local _, PathLabel, PathText = Menu:AddTextEntry("Path:") + local ParseIcon = Menu:AddPanel("DImage") + local SearchButton = Menu:AddPanel("DImageButton") + local RemoveButton = Menu:AddPanel("DImageButton") + local PitchWang, PitchLabel = Menu:AddNumberWang("Pitch:", 0, 255, 0) + local VolumeWang, VolumeLabel = Menu:AddNumberWang("Volume:", 0, 1, 2) + local WidthWang, WidthLabel = Menu:AddNumberWang("Width:", 0, 15, 0) + + MPanel:DockMargin(0, 0, 0, 0) + MPanel:SetLabel("Value " .. ID) + + Base:SetParent(MPanel) + Base:SetTall(72) + Base:DockPadding(4, 6, 4, 0) + Base:DockMargin(0, 0, 0, 0) + Base:SetBackgroundColor(BGColor) + + TopDiv:SetParent(Base) + TopDiv:Dock(TOP) + + BotDiv:SetParent(Base) + BotDiv:Dock(BOTTOM) + + RPMLabel:SetParent(TopDiv) + RPMLabel:DockMargin(0, 0, 0, 0) + RPMLabel:Dock(LEFT) + RPMLabel:SetTextColor(TextColor) + + RPMWang:SetParent(TopDiv) + RPMWang:SetWide(48) -- Equivalent to 00000 + up/down buttons at font size = 16 + padding + RPMWang:DockMargin(-30, 0, 0, 0) + RPMWang:Dock(LEFT) + RPMWang:SetValue(GetClientNumber("RPM " .. ID, DefaultRPM)) + RPMWang:SetClientData("RPM " .. ID, "OnValueChanged") + RPMWang:DefineSetter(function(Panel, _, _, Value) + -- TODO(TMF): The max value below is hardcoded, this should be a global! + local min = ID == 1 and 0 or GetClientNumber("RPM " .. ID - 1) + local max = ID == #Current.Panels and 16383 or GetClientNumber("RPM " .. ID + 1) + + Panel:SetMinMax(min, max) -- YEA, I MINMAX MY NUMBERS, SO What!? + Panel:SetValue(Value) + + return Value, Panel + end) + + PathLabel:SetParent(TopDiv) + PathLabel:Dock(LEFT) + PathLabel:SetTextColor(TextColor) + + PathText:SetParent(TopDiv) + PathText:Dock(FILL) + PathText:DockMargin(-25, 0, 0, 0) + PathText:SetTall(Menu.ButtonHeight) + PathText:SetValue(GetClientString("Path " .. ID, DefaultPath)) + PathText:SetClientData("Path " .. ID, "OnValueChange") + PathText:DefineSetter(function(Panel, _, _, Value) + local isValid = Sounds.IsValidSound + + if isValid(Value) then + ParseIcon:SetTooltip() + ParseIcon:SetImage("icon16/accept.png") + + SetClientData("Path " .. ID, Value) + else + ParseIcon:SetTooltip("Invalid sound: File does not exist") + ParseIcon:SetImage("icon16/cancel.png") - -- Playing a silent sound will mute the preview but not the sound emitters. - local SoundPreStop = SoundPre:AddButton("#tool.acfsound.stop", "play", "common/null.wav") - SoundPreStop:SetIcon("icon16/sound_mute.png") - - -- Set the Play/Stop button positions here - SoundPre:InvalidateLayout(true) - SoundPre.PerformLayout = function() - local HWide = SoundPre:GetWide() / 2 - SoundPrePlay:SetSize(HWide, ButtonHeight) - SoundPrePlay:Dock(LEFT) - SoundPreStop:Dock(FILL) -- FILL will cover the remaining space which the previous button didn't. + SetClientData("Path " .. ID, "") + end + return Value, Panel + end) + + ParseIcon:SetParent(PathText) + ParseIcon:Dock(RIGHT) + ParseIcon:DockMargin(3, 3, 3, 3) + ParseIcon:SetImage("icon16/accept.png") + ParseIcon:SetSize(16, 16) + + RemoveButton:SetParent(TopDiv) + RemoveButton:Dock(RIGHT) + RemoveButton:DockMargin(3, 3, 3, 3) + RemoveButton:SetImage("icon16/delete.png") + RemoveButton:SetTooltip("Remove this sound.") + RemoveButton:SetStretchToFit(false) + RemoveButton:SetSize(16, 16) + RemoveButton.DoClick = function() + -- Don't remove the last panel + if Current.Count == 1 then + RemoveButton.DoClick = function() end + return + end + + for i = ID, Current.Count + 1 do + if not Current.Panels[i] and IsValid(Current.Panels[i]) then continue end + -- Remove the last panel and reset its values + if i == Current.Count then + SetClientData("RPM " .. i, 1000 * i) --DefaultRPM + SetClientData("Path " .. i, DefaultPath) + SetClientData("Pitch " .. i, DefaultPitch) + SetClientData("Volume " .. i, DefaultVolume) + SetClientData("Width " .. i, DefaultWidth) + + SetClientData("ListSlider", GetClientNumber("ListSlider") - 1) + ListSlider:SetValue(GetClientData("ListSlider")) + table.remove(Current.Panels, i) + Current.Count = #Current.Panels + break + end + -- Move the datavars values one step back(or forward) to compensate + Current.Panels[i] = Current.Panels[i + 1] + SetClientData("RPM " .. i, GetClientNumber("RPM " .. i + 1)) + SetClientData("Path " .. i, GetClientNumber("Path " .. i + 1)) + SetClientData("Pitch " .. i, GetClientNumber("Pitch " .. i + 1)) + SetClientData("Volume " .. i, GetClientNumber("Volume " .. i + 1)) + SetClientData("Width " .. i, GetClientNumber("Width " .. i + 1)) + end + end + + SearchButton:SetParent(TopDiv) + SearchButton:Center() + SearchButton:Dock(RIGHT) + SearchButton:DockMargin(3, 3, 3, 3) + SearchButton:SetImage("icon16/application_view_list.png") + SearchButton:SetTooltip("Open sound browser.") + SearchButton:SetStretchToFit(false) + SearchButton:SetSize(16, 16) + SearchButton.DoClick = function() + RunConsoleCommand("wire_sound_browser_open") + end + + PitchLabel:SetParent(BotDiv) + PitchLabel:Dock(LEFT) + PitchLabel:SetTextColor(TextColor) + + PitchWang:SetParent(BotDiv) + PitchWang:SetWide(40) -- Equivalent to 000 + up/down buttons at font size = 16 + padding + PitchWang:DockMargin(-30, 0, 4, 0) + PitchWang:Dock(LEFT) + PitchWang:SetValue(GetClientNumber("Pitch " .. ID, DefaultPitch)) + PitchWang:SetClientData("Pitch " .. ID, "OnValueChanged") + PitchWang:DefineSetter(function(_, _, _, Value) + SetClientData("Pitch " .. ID, Value) + end) + + VolumeLabel:SetParent(BotDiv) + VolumeLabel:Dock(LEFT) + VolumeLabel:SetTextColor(TextColor) + + VolumeWang:SetParent(BotDiv) + VolumeWang:SetWide(40) -- Equivalent to 0.00 + up/down buttons at font size = 16 + padding + VolumeWang:DockMargin(-16, 0, 4, 0) + VolumeWang:Dock(LEFT) + VolumeWang:SetValue(GetClientNumber("Volume " .. ID, DefaultVolume)) + VolumeWang:SetClientData("Volume " .. ID, "OnValueChanged") + VolumeWang:DefineSetter(function(_, _, _, Value) + SetClientData("Volume " .. ID, Value) + end) + + WidthLabel:SetParent(BotDiv) + WidthLabel:Dock(LEFT) + WidthLabel:SetTextColor(TextColor) + + WidthWang:SetParent(BotDiv) + WidthWang:SetWide(32) -- Equivalent to 00 + up/down buttons at font size = 16 + padding + WidthWang:DockMargin(-24, 0, 4, 0) + WidthWang:Dock(LEFT) + WidthWang:SetValue(GetClientNumber("Width " .. ID, DefaultWidth)) + WidthWang:SetClientData("Width " .. ID, "OnValueChanged") + WidthWang:DefineSetter(function(_, _, _, Value) + SetClientData("Width " .. ID, Value) + end) + + table.insert(Current.Panels, MPanel) -- Insert this panel to keep count of them panels + return MPanel end + -- Actual menu stuff + local Menu = ACF.InitMenuBase(Panel, "SoundMenu", "acf_reload_sound_menu") + Menu.ButtonHeight = 20 + Menu.Wide = Menu:GetWide() + Menu:AddLabel("#tool.acfsound.help") - local CopyButton = Menu:AddButton("#tool.acfsound.copy") - CopyButton:SetWide(Wide) - CopyButton:SetTall(ButtonHeight) - CopyButton:SetIcon("icon16/page_copy.png") - CopyButton.DoClick = function() - SetClipboardText(SoundNameText:GetValue()) + local OptionSelectionBox = Menu:AddComboBox() + OptionSelectionBox:SetText("Select an Option...") + OptionSelectionBox:Dock(TOP) + OptionSelectionBox:SetTall(Menu.ButtonHeight) + OptionSelectionBox:AddChoice("Generic - One sound. ", 1) + --OptionSelectionBox:AddChoice("Weapons - Start/Loop/Stop. ", 2) + --OptionSelectionBox:AddChoice("Engines - Simple interpolated. ", 3) + OptionSelectionBox:AddChoice("Engines - Multiple interpolated. ", 4) + OptionSelectionBox.OnSelect = function(_, Index, _) + Menu:StartTemporal(Panel) + Menu:ClearTemporal(Panel) + Menu:CreateSubMenu(Index) -- Build the sub menu + Menu:EndTemporal(Panel) end - local ClearButton = Menu:AddButton("#tool.acfsound.clear") - ClearButton:SetWide(Wide) - ClearButton:SetTall(ButtonHeight) - ClearButton:SetIcon("icon16/cancel.png") - ClearButton.DoClick = function() - SoundNameText:SetValue("") - RunConsoleCommand("wire_soundemitter_sound", "") + --- Build the rest of the menu according to our selection + --- @param Num int The sub menu selected at the index + function Menu:CreateSubMenu(Num) + local Case = { + -- I explictly gave these their numeric keys so its easier to infer which panel we're working with + -- First panel, Generic - One sound. Old menu with text entry for a single sound + [1] = function () + self:AddLabel("Play a single sound for all the supported ACF entities, including engines.") + + local SoundNameText = self:AddPanel("DTextEntry") + SoundNameText:SetText("") + SoundNameText:SetWide(Menu.Wide - 20) + SoundNameText:SetTall(Menu.ButtonHeight) + SoundNameText:SetMultiline(false) + SoundNameText:SetConVar("wire_soundemitter_sound") + + local SoundBrowserButton = self:AddButton("#tool.acfsound.open_browser", "wire_sound_browser_open", SoundNameText:GetValue(), "1") + SoundBrowserButton:SetWide(Menu.Wide) + SoundBrowserButton:SetTall(Menu.ButtonHeight) + SoundBrowserButton:SetIcon("icon16/application_view_list.png") + + local SoundPre = self:AddPanel("ACF_Panel") + SoundPre:SetWide(Menu.Wide) + SoundPre:SetTall(Menu.ButtonHeight) + + local SoundPrePlay = SoundPre:AddButton("#tool.acfsound.play") + SoundPrePlay:SetIcon("icon16/sound.png") + SoundPrePlay.DoClick = function() + RunConsoleCommand("play", SoundNameText:GetValue()) + end + + -- Playing a silent sound will mute the preview but not the sound emitters. + local SoundPreStop = SoundPre:AddButton("#tool.acfsound.stop", "play", "common/null.wav") + SoundPreStop:SetIcon("icon16/sound_mute.png") + + -- Set the Play/Stop button positions here + SoundPre:InvalidateLayout(true) + SoundPre.PerformLayout = function() + local HWide = SoundPre:GetWide() / 2 + SoundPrePlay:SetSize(HWide, Menu.ButtonHeight) + SoundPrePlay:Dock(LEFT) + SoundPreStop:Dock(FILL) -- FILL will cover the remaining space which the previous button didn't + end + + local CopyButton = self:AddButton("#tool.acfsound.copy") + CopyButton:SetWide(Menu.Wide) + CopyButton:SetTall(Menu.ButtonHeight) + CopyButton:SetIcon("icon16/page_copy.png") + CopyButton.DoClick = function() + SetClipboardText(SoundNameText:GetValue()) + end + + local ClearButton = self:AddButton("#tool.acfsound.clear") + ClearButton:SetWide(Menu.Wide) + ClearButton:SetTall(Menu.ButtonHeight) + ClearButton:SetIcon("icon16/cancel.png") + ClearButton.DoClick = function() + SoundNameText:SetValue("") + RunConsoleCommand("wire_soundemitter_sound", "") + end + + local VolumeSlider = self:AddSlider("#tool.acfsound.volume", 0.1, 1, 2) + VolumeSlider:SetConVar("acfsound_volume") + local PitchSlider = self:AddSlider("#tool.acfsound.pitch", 0.1, 2, 2) + PitchSlider:SetConVar("acfsound_pitch") + end, + -- Second panel, Weapons - Start/Loop/Stop. New menu with three text entries labeled as "Start", "Loop", "End" respectively, to put the sound paths + -- Layout is similar to the first option + --[[[2] = function() + self:AddLabel("This is the second panel, I don't know what to add here yet but you can imagine its gonna be something nice, so stay tuned!") + + end, + -- Third panel, Engines - Simple interpolated. New menu with a Slider that creates N amount of text entries to put the sound paths + -- Layout is similar to the first option + [3] = function() + self:AddLabel("This is the third panel, I don't know what to add here yet but you can imagine its gonna be something fantastic, so stay tuned!") + + end,]]-- + -- Fourth panel, Engines - Custom interpolated. New menu with a button to add up to 16 sound paths, with configurable pitch, volume and width for each sound + -- Has a graph at the top of the list to better visualise how they play at a determined engine RPM + [2] = function() + self:AddLabel("Play multiple interpolated sounds exclusively for ACF engines.") + -- Contact panel + local Contact = self:AddCollapsible("Contact", true, "icon16/bug_link.png") + local Help = self:AddHelp("This panel is a Work In Progress. Expect bugs to arise and things to not work! \n \ + If you have any errors to report and/or suggestions to make, please contact us on our official discord server.") + local ContactBtn = self:AddButton("Discord link") + function ContactBtn:DoClick() gui.OpenURL("https://discord.gg/jf4cwarPUG") end + + Help:SetParent(Contact) + ContactBtn:SetParent(Contact) + + -- Reset them panels + Current.Panels = nil + Current.Panels = {} + Current.Count = 0 + -- The menu is divided in two groups + -- The top group where the graph lies + local GraphGroup = self:AddCollapsible("Graph", nil, "icon16/chart_curve_edit.png") + local GraphPanel = self:AddPanel("DPanel") + local LabelTop = self:AddLabel("This graph shows how your engine sound/s will be heard as a function of RPM.\ + Beware this panel can be resource intensive if you add too many sounds!") + local RefreshBtn = self:AddPanel("DImageButton") + SoundGraph = self:AddGraph() -- A Glocal so other functions can call this + local PanelBottom = self:AddPanel("ACF_Panel") + local IdleLabel = self:AddLabel("Idle:") + local IdleWang = self:AddPanel("DNumberWang", 0, 2000) + local RedlineLabel = self:AddLabel("Redline:") + -- TODO(TMF): The max values below are hardcoded, this should be a global! + local RedlineWang = self:AddPanel("DNumberWang", 0, 16383) + local RPMSlider = self:AddSlider("RPM", 0, 16383) + local SoundPre = self:AddPanel("ACF_Panel") + local SoundPrePlay = SoundPre:AddButton("#tool.acfsound.play") + local SoundPreStop = SoundPre:AddButton("#tool.acfsound.stop", "play", "common/null.wav") -- Playing a silent sound will mute the preview but not the sound emitters + local VolumeSlider = self:AddSlider("#tool.acfsound.volume", 0.1, 1, 2) + + -- Set defaults + local DefaultIdle = GetClientData("Idle", 800) + local DefaultRedline = GetClientData("Redline", 8000) + SetClientData("Idle", DefaultIdle, true) + SetClientData("Redline", DefaultRedline, true) + SetClientData("RPMSlider", (DefaultIdle + DefaultRedline) / 2, true) + + -- The properties + GraphGroup:DockMargin(0, 0, 0, 0) + + GraphPanel:SetParent(GraphGroup) + GraphPanel:DockPadding(4, 4, 4, 8) + GraphPanel:Dock(TOP) + GraphPanel:SetTall(436) -- Why can't this grow dynamically + + LabelTop:SetParent(GraphPanel) + LabelTop:Dock(TOP) + LabelTop:DockMargin(0, 2, 0, 2) + + RefreshBtn:SetParent(LabelTop) + RefreshBtn:Dock(RIGHT) + RefreshBtn:SetImage("icon16/arrow_refresh_small.png") + RefreshBtn:SetTooltip("Refresh this graph.") + RefreshBtn:SetStretchToFit(false) + RefreshBtn:SetSize(16, 16) + RefreshBtn.DoClick = function() + UpdateGraph(SoundGraph) + end + + SoundGraph:SetParent(GraphPanel) + SoundGraph:Dock(TOP) + SoundGraph:SetTall(192) + SoundGraph:SetXLabel("RPM") + SoundGraph:SetYLabel("Volume") + SoundGraph:SetYRange(0, 200) + SoundGraph:SetFidelity(1) + SoundGraph:SetXSpacing(1000) + SoundGraph:SetYSpacing(100) + + PanelBottom:SetParent(GraphPanel) + PanelBottom:Dock(TOP) + PanelBottom:DockPadding(0, 4, 4, -4) + PanelBottom:SetTall(34) + + IdleLabel:SetParent(PanelBottom) + IdleLabel:Dock(LEFT) + + IdleWang:SetParent(PanelBottom) + IdleWang:Dock(LEFT) + IdleWang:SetValue(DefaultIdle) -- I shouldn't need to do this but oh well, here we go... + IdleWang:SetClientData("Idle", "OnValueChanged") + IdleWang:DefineSetter(function(Panel, _, _, Value) + Panel:SetMinMax(0, 2000) -- I shouldn't even need to do this! + Panel:SetValue(Value) + RedlineWang:SetMin(Value or 1) + Current.Graph["Idle"] = Value + + return Value + end) + + RedlineLabel:SetParent(PanelBottom) + RedlineLabel:Dock(LEFT) + RedlineLabel:DockMargin(8, 4, 0, 0) -- Fucking retarded + + RedlineWang:SetParent(PanelBottom) + RedlineWang:Dock(LEFT) + RedlineWang:SetValue(DefaultRedline) + RedlineWang:SetMinMax(Current.Graph["Idle"], 16383) + RedlineWang:SetClientData("Redline", "OnValueChanged") + RedlineWang:DefineSetter(function(Panel, _, _, Value) + -- TODO(TMF): The max value below is hardcoded, this should be a global! + Panel:SetValue(Value) + Current.Graph["Redline"] = Value + + SoundGraph:SetXRange(0, Value + 1000) + SoundGraph:SetXSpacing(Value < 1000 and 100 or 1000) + return Value + end) + + RPMSlider:SetParent(GraphPanel) + RPMSlider:Dock(TOP) + RPMSlider:SetWide(Menu.Wide) + RPMSlider:SetValue(GetClientNumber("RPMSlider", 4400)) + RPMSlider:SetClientData("RPMSlider", "OnValueChanged") + RPMSlider:DefineSetter(function(Panel, _, _, Value) + -- TODO(TMF): The max value below is hardcoded, this should be a global! + local Min = Current.Graph["Idle"] or 0 + local Max = Current.Graph["Redline"] or 16383 + + Panel:SetMinMax(Min, Max) + Panel:SetValue(Value) + Current.Graph["RPM"] = Value + + SoundGraph:PlotLimitLine("RPM", false, Value, color_black) + return Value + end) + + VolumeSlider:SetConVar("acfsound_volume") + VolumeSlider:SetParent(GraphPanel) + VolumeSlider:Dock(TOP) + + SoundPre:SetParent(GraphPanel) + SoundPre:SetWide(Menu.Wide) + SoundPre:SetTall(Menu.ButtonHeight) + + SoundPrePlay:SetIcon("icon16/sound.png") + SoundPrePlay:SetTooltip("Unimplemented!") + SoundPrePlay:SetEnabled(false) + SoundPrePlay.DoClick = function() + -- Do something here to play them sounds! + end + + SoundPreStop:SetIcon("icon16/sound_mute.png") + SoundPreStop:SetTooltip("Unimplemented!") + SoundPreStop:SetEnabled(false) + + -- Set the Play/Stop button positions here + SoundPre:InvalidateLayout(true) + SoundPre.PerformLayout = function() + local HWide = SoundPre:GetWide() / 2 + SoundPrePlay:SetSize(HWide, Menu.ButtonHeight) + SoundPrePlay:Dock(LEFT) + SoundPreStop:Dock(FILL) -- FILL will cover the remaining space which the previous button didn't + end + + -- The bottom group where the panels are added and removed dynamically + local ValuesGroup = self:AddCollapsible("Values", nil, "icon16/application_double.png") + ValuesGroup:DockMargin(0, 4, 0, 4) + ListSlider = self:AddSlider("Values", 1, _MAXSOUNDS, 0) + ListPanel = self:AddPanel("DListLayout") + AddValue = self:AddPanel("DImageButton") + + local LastValueAmount = 0 + ListSlider:SetParent(ValuesGroup) + ListSlider:Dock(TOP) + ListSlider:SetValue(GetClientData("ListSlider")) + ListSlider:SetClientData("ListSlider", "OnValueChanged") + ListSlider:DefineSetter(function(Panel, _, _, Value) + local ValueAmount = math.Clamp(math.floor(tonumber(Value)), 1, _MAXSOUNDS) + if ValueAmount ~= LastValueAmount then + if ValueAmount > LastValueAmount then + for _ = LastValueAmount + 1, ValueAmount do + ListPanel:Add(AddValuePanel(self)) + end + elseif ValueAmount < LastValueAmount then + for I = ValueAmount + 1, LastValueAmount do + if IsValid(Current.Panels[I]) then + Current.Panels[I]:Remove() + Current.Panels[I] = nil + end + end + end + end + LastValueAmount = ValueAmount + Panel:SetClientData("ListSlider", ValueAmount) + end) + -- I don't know if this makes sense, but somehow it gives me less trouble to later remove any arbitrary panels + self:StartTemporal(ValuesGroup) + self:ClearTemporal(ValuesGroup) + + ListPanel:SetParent(ValuesGroup) + ListPanel:Dock(TOP) + ListPanel.OnChildAdded = function() + Current.Count = #Current.Panels + UpdateGraph(SoundGraph) -- Update our graph + + -- Disable the button if enough panels exist already + if #Current.Panels >= _MAXSOUNDS then AddValue:SetEnabled(false) return end + end + ListPanel.OnChildRemoved = function() + AddValue:SetEnabled(true) -- Re-enable our add button + + Current.Count = #Current.Panels + UpdateGraph(SoundGraph) -- Same here + end + + self:EndTemporal(ValuesGroup) + + AddValue:SetParent(ValuesGroup) + AddValue:Dock(TOP) + AddValue:SetImage("icon16/add.png") + AddValue:SetTooltip("Add a new sound.") + AddValue:SetStretchToFit(false) + AddValue:SetSize(16, 16) + AddValue.DoClick = function() + local Value = GetClientData("ListSlider") + ListSlider:SetValue(Value + 1) + end + end + } + local Switch = Case[Num] + Switch() end - local VolumeSlider = Menu:AddSlider("#tool.acfsound.volume", 0.1, 1, 2) - VolumeSlider:SetConVar("acfsound_volume") - local PitchSlider = Menu:AddSlider("#tool.acfsound.pitch", 0.1, 2, 2) - PitchSlider:SetConVar("acfsound_pitch") + do -- SoundBank entity data reception and menu population + local function PopulateMenu(Count) + -- We set it to option 2 since that's where the values are located at + OptionSelectionBox:ChooseOption(OptionSelectionBox:GetOptionText(2), 2) + + -- Wipe the clients values list + Menu:ClearTemporal(ListPanel) + + -- Reset them panels once again, but initialized count at 1 + Current.Panels = nil + Current.Panels = {} + Current.Count = 1 + + -- Set the slider to whatever count is + ListSlider:SetValue(Count) + end + -- Receives data to populate the menu or to send back to server the client's datavars + net.Receive("ACF_SoundMenu_Get_Multi", function() + --print("Received " .. len .. " bits for call: \"ACF_SoundMenu_Get_Multi\"") -- Debug print + + local Origin = net.ReadEntity() + if not Origin then return end + + local Feedback = net.ReadBool() + if not Feedback then -- Get the data from the entity and populate menu + local Count = net.ReadUInt(4) + + for I = 1, Count do + local RPM = net.ReadUInt(14) + local StringPath = net.ReadString() + local Pitch = net.ReadUInt(8) + local Volume = net.ReadUInt(8) + local Width = net.ReadUInt(4) + + Volume = Volume * 0.01 -- Reduce the received value down to a float + + SetClientData("RPM " .. I, RPM) + SetClientData("Path " .. I, StringPath) + SetClientData("Pitch " .. I, Pitch) + SetClientData("Volume " .. I, Volume) + SetClientData("Width " .. I, Width) + end + PopulateMenu(Count) + else -- Gets any datavars and networks them back to the server + net.Start("ACF_SoundMenu_Set_Multi") + net.WriteEntity(Origin) + net.WriteUInt(Current.Count, 4) + for I = 1, Current.Count do + local RPM = GetClientNumber("RPM " .. I) + local Path = GetClientString("Path " .. I) + local Pitch = GetClientNumber("Pitch " .. I) + local Volume = GetClientNumber("Volume " .. I) + local Width = GetClientNumber("Width " .. I) + + Volume = Volume * 100 -- Increase the value up to an int + net.WriteUInt(RPM, 14) + net.WriteString(Path) + net.WriteUInt(Pitch, 8) + net.WriteUInt(Volume, 8) + net.WriteUInt(Width, 4) + end + -- We're making the supposition here that the values being sent are already sorted + net.SendToServer() + end + end) + end end \ No newline at end of file diff --git a/lua/entities/acf_engine/init.lua b/lua/entities/acf_engine/init.lua index 3402915d3..6b1f88f39 100644 --- a/lua/entities/acf_engine/init.lua +++ b/lua/entities/acf_engine/init.lua @@ -122,6 +122,17 @@ local TimerCreate = timer.Create local TimerRemove = timer.Remove local TickInterval = engine.TickInterval +-- Count all the existing sounds in a SoundBank +local function GetSoundCount(Engine) + if not Engine.SoundBank then return 1 end + + local SoundCount = 0 + for _ in pairs(Engine.SoundBank) do + SoundCount = SoundCount + 1 + end + return math.max(SoundCount, 1) +end + local function GetPitchVolume(Engine) local RPM = Engine.FlyRPM local Pitch = Clamp(20 + (RPM * Engine.SoundPitch) * 0.02, 1, 255) @@ -182,7 +193,6 @@ end local function SetActive(Entity, Value, EntTbl) EntTbl = EntTbl or Entity:GetTable() - local ActBool = tobool(Value) if EntTbl.Active == ActBool then return end -- Already in the desired state @@ -197,7 +207,11 @@ local function SetActive(Entity, Value, EntTbl) EntTbl.Torque = EntTbl.PeakTorque EntTbl.FlyRPM = EntTbl.IdleRPM * 1.5 - Entity:UpdateSound(EntTbl) + if Entity.SoundCount > 1 then + Entity:UpdateSoundBank(EntTbl) + else + Entity:UpdateSound(EntTbl) + end Entity:NextThink(Clock.CurTime + TickInterval()) @@ -210,11 +224,11 @@ local function SetActive(Entity, Value, EntTbl) Entity:CalcMassRatio(EntTbl) end) else -- Was on, turn off - EntTbl.Active = false - EntTbl.FlyRPM = 0 - EntTbl.Torque = 0 + EntTbl.Active = false + EntTbl.FlyRPM = 0 + EntTbl.Torque = 0 - Entity:DestroySound() + Entity:DestroyAllSounds() TimerRemove("ACF Engine Clock " .. Entity:EntIndex()) end @@ -284,6 +298,7 @@ do -- Spawn and Update functions end end + -- Engine update function local function UpdateEngine(Entity, Data, Class, Engine, Type) local Mass = Engine.Mass @@ -304,6 +319,9 @@ do -- Spawn and Update functions Entity.EntType = Class.Name Entity.ClassData = Class Entity.DefaultSound = Engine.Sound + Entity.DefaultSoundBank = Engine.SoundBank + Entity.SoundBank = Engine.SoundBank + Entity.SoundCount = GetSoundCount(Engine) Entity.SoundPitch = Engine.Pitch or 1 Entity.SoundVolume = Engine.SoundVolume or 1 Entity.TorqueCurve = Engine.TorqueCurve @@ -333,7 +351,7 @@ do -- Spawn and Update functions if Engine.IsTrans then Entity.Out = ACF.LocalPlane(vector_origin, Vector(0, 1, 0)) end - Entity.IsSpecial = Engines.IsSpecial(Engines.GetItem(Class.ID, Data.Engine)) + Entity.IsSpecial = Engines.IsSpecial(Engines.GetItem(Class.ID, Data.Engine)) WireIO.SetupInputs(Entity, Inputs, Data, Class, Engine, Type) WireIO.SetupOutputs(Entity, Outputs, Data, Class, Engine, Type) @@ -352,6 +370,7 @@ do -- Spawn and Update functions Contraption.SetMass(Entity, Mass) end + -- Engine creation function function ACF.MakeEngine(Player, Pos, Angle, Data) VerifyData(Data) @@ -377,23 +396,25 @@ do -- Spawn and Update functions Player:AddCleanup("acf_engine", Entity) Player:AddCount(Limit, Entity) - Entity.Active = false - Entity.Gearboxes = {} - Entity.FuelTanks = {} - Entity.LastThink = 0 - Entity.MassRatio = 1 - Entity.FuelUsage = 0 - Entity.Throttle = 0 - Entity.FlyRPM = 0 - Entity.SoundPath = Engine.Sound - Entity.LastPitch = 0 - Entity.LastTorque = 0 - Entity.LastFuelUsage = 0 - Entity.LastPower = 0 - Entity.LastRPM = 0 - Entity.LastTotalMass = 0 - Entity.LastPhysMass = 0 - Entity.DataStore = Entities.GetArguments("acf_engine") + Entity.Active = false + Entity.Gearboxes = {} + Entity.FuelTanks = {} + Entity.LastThink = 0 + Entity.MassRatio = 1 + Entity.FuelUsage = 0 + Entity.Throttle = 0 + Entity.FlyRPM = 0 + Entity.SoundPath = Engine.Sound + Entity.SoundBank = Engine.SoundBank + Entity.SoundCount = 0 + Entity.LastPitch = 0 + Entity.LastTorque = 0 + Entity.LastFuelUsage = 0 + Entity.LastPower = 0 + Entity.LastRPM = 0 + Entity.LastTotalMass = 0 + Entity.LastPhysMass = 0 + Entity.DataStore = Entities.GetArguments("acf_engine") Entity.revLimiterEnabled = true duplicator.ClearEntityModifier(Entity, "mass") @@ -607,6 +628,29 @@ function ENT:ACF_OnDamage(DmgResult, DmgInfo) return HitRes end +function ENT:UpdateSoundBank(SelfTbl) + SelfTbl = SelfTbl or self:GetTable() + + local SoundBank = SelfTbl.SoundBank + local SoundCount = GetSoundCount(self) + + if SelfTbl.Sound then + local Throttle = Round(SelfTbl.Throttle, 2) * 100 + local RPM = Round(SelfTbl.FlyRPM) + + Sounds.SendMultipleAdjustableSounds(self, false, Throttle, RPM) + else + if table.IsEmpty(SoundBank) then + SelfTbl.SoundBank = SelfTbl.DefaultSoundBank or {} + else + Sounds.CreateMultipleAdjustableSounds(self, SoundBank, SoundCount) + SelfTbl.Sound = true + end + + SelfTbl.SoundCount = GetSoundCount(self) + end +end + function ENT:UpdateSound(SelfTbl) SelfTbl = SelfTbl or self:GetTable() @@ -614,7 +658,7 @@ function ENT:UpdateSound(SelfTbl) local LastSound = SelfTbl.LastSound if Path ~= LastSound and LastSound ~= nil then - self:DestroySound() + self:DestroyAllSounds() SelfTbl.LastSound = Path end @@ -636,8 +680,12 @@ function ENT:UpdateSound(SelfTbl) end end -function ENT:DestroySound() - Sounds.SendAdjustableSound(self, true) +function ENT:DestroyAllSounds() + if self.SoundCount > 1 then + Sounds.SendMultipleAdjustableSounds(self, true, _, _) + else + Sounds.SendAdjustableSound(self, true, _, _) + end self.LastSound = nil self.LastPitch = 0 @@ -854,7 +902,12 @@ function ENT:CalcRPM(SelfTbl) SelfTbl.FlyRPM = FlyRPM - min(TorqueDiff, TotalReqTq) / Inertia SelfTbl.LastThink = ClockTime - self:UpdateSound(SelfTbl) + if self.SoundCount > 1 then + self:UpdateSoundBank(SelfTbl) + else + self:UpdateSound(SelfTbl) + end + self:UpdateOutputs(SelfTbl) end @@ -944,7 +997,7 @@ function ENT:OnRemove() hook.Run("ACF_OnEntityLast", "acf_engine", self, Class) - self:DestroySound() + self:DestroyAllSounds() for Gearbox in pairs(self.Gearboxes) do self:Unlink(Gearbox) diff --git a/lua/weapons/gmod_tool/stools/acfsound.lua b/lua/weapons/gmod_tool/stools/acfsound.lua index fc02fa288..1470ce96f 100644 --- a/lua/weapons/gmod_tool/stools/acfsound.lua +++ b/lua/weapons/gmod_tool/stools/acfsound.lua @@ -12,8 +12,54 @@ TOOL.Information = { { name = "info" } } +-- NOTE(TMF): I would have used concommands just to set clients data, however i didn't feel like using them here since i don't know how to use them lol +-- So instead i went the dumb, hard and convoluted way and network the data needed back and forth +if SERVER then + util.AddNetworkString("ACF_SoundMenu_Get_Multi") -- Networks data from Entity(Server) to Client + util.AddNetworkString("ACF_SoundMenu_Set_Multi") -- Networks data from Client to Entity(Server) +end + local Sounds = ACF.SoundToolSupport + --- This function acts like a getter/setter where we network an entity soundbank data back and forth between the client and the server + --- This allows the client to populate a menu with the data received from the server's entity(engine) or... + --- Sends any datavars that the client has back to the server to update an entity's soundbank table with the datavars that the client had, if any. + --- @param Player player The player who clicked on the Entity + --- @param Entity entity The entity, which has to be an engine(for now) + --- @param Data table? The soundbank table to set soundbank Data to the Entity or not + --- @param Loopback bool? False to just populate a client menu and its datavars or True to GET the datavars from client and send them back +local function DoSoundBankData(Player, Entity, Data, Loopback) + net.Start("ACF_SoundMenu_Get_Multi") + net.WriteEntity(Entity) + if not Loopback then -- Send the sound table to populate the client's sound replacer menu + local soundTable = Data + local count = #soundTable + + net.WriteBool(false) -- Just in case + net.WriteUInt(count, 4) + + for _, v in ipairs(soundTable) do + local rpm = v.RPM + local stringPath = v.Path + local pitch = v.Pitch + local volume = v.Volume + local width = v.Width + + net.WriteUInt(rpm, 14) + net.WriteString(stringPath) + net.WriteUInt(pitch, 8) + + volume = volume * 100 -- Sending the approximate volume as an int to reduce message size + net.WriteUInt(volume, 8) + net.WriteUInt(width, 4) + end + else -- Otherwise we get from the client's data vars to create and replace the entity's soundbank + net.WriteBool(true) + end + net.Send(Player) +end + +-- Sets the sound, pitch and volume to a valid ACF entity local function ReplaceSound(_, Entity, Data) if not IsValid(Entity) then return end @@ -33,6 +79,21 @@ end duplicator.RegisterEntityModifier("acf_replacesound", ReplaceSound) +-- Just like the above function, except for soundbanks +local function ReplaceSounds(_, Entity, Data) + if not IsValid(Entity) then return end + + local Support = Sounds[Entity:GetClass()] + if not Support then return end + + Support.SetSoundBank(Entity, Data) + + duplicator.StoreEntityModifier(Entity, "acf_replacesoundbank", Data) +end + +duplicator.RegisterEntityModifier("acf_replacesoundbank", ReplaceSounds) + +-- An improved IsValid function, just to check if an entity is ACF class and if it has support from this tool local function IsReallyValid(trace, ply) if not trace.Entity:IsValid() then return false end if trace.Entity:IsPlayer() then return false end @@ -64,6 +125,33 @@ function TOOL:LeftClick(trace) ReplaceSound(owner, trace.Entity, { sound, pitch, volume }) + -- Simple call just to get the client's sound menu data + DoSoundBankData(owner, trace.Entity, _, true) + do -- Receives any datavars from the client, which matches what's seen regarding any values on the menu + net.Receive("ACF_SoundMenu_Set_Multi", function (_, ply) + local SoundTable = {} + local Origin = net.ReadEntity() + local Count = net.ReadUInt(4) + + if not Origin then return end + for _ = 1, Count do + local RPM = net.ReadUInt(14) + local StringPath = net.ReadString() + local Pitch = net.ReadUInt(8) + local Volume = net.ReadUInt(8) + local Width = net.ReadUInt(4) + + Volume = Volume * 0.01 -- Reduce the received value down to a float + table.insert(SoundTable, { RPM = RPM, + Path = StringPath, + Pitch = Pitch or 100, + Volume = Volume or 1, + Width = Width or 0}) + end + ReplaceSounds(ply, Origin, SoundTable) + end) + end + return true end @@ -75,8 +163,11 @@ function TOOL:RightClick(trace) local class = trace.Entity:GetClass() local support = ACF.SoundToolSupport[class] + if not support then return false end + local soundData = support.GetSound(trace.Entity) + owner:ConCommand("wire_soundemitter_sound " .. soundData.Sound) if soundData.Pitch then @@ -87,6 +178,15 @@ function TOOL:RightClick(trace) owner:ConCommand("acfsound_volume " .. soundData.Volume) end + -- Soundbank stuff, if it gets found, we switch to that instead + if not trace.Entity.SoundBank then return true end + local soundTable = support.GetSoundBank(trace.Entity).SoundBank + + -- Send the found soundbank table from the entity to the client for sound menu population + if soundTable then + DoSoundBankData(owner, trace.Entity, soundTable, false) + end + return true end @@ -99,6 +199,10 @@ function TOOL:Reload(trace) if not support then return false end support.ResetSound(trace.Entity) + -- If it has a soundbank set, we also reset that + if not trace.Entity.SoundBank then return true end + support.ResetSoundBank(trace.Entity) + return true end