diff --git a/Moose Development/Moose/Ops/CTLD.lua b/Moose Development/Moose/Ops/CTLD.lua index 9f43c8ef1..ca75e77db 100644 --- a/Moose Development/Moose/Ops/CTLD.lua +++ b/Moose Development/Moose/Ops/CTLD.lua @@ -40,6 +40,7 @@ do -- @field #string ClassName Class name. -- @field #number ID ID of this cargo. -- @field #string Name Name for menu. +-- @field #string DisplayName Display name for menu/messages. -- @field #table Templates Table of #POSITIONABLE objects. -- @field #string CargoType Enumerator of Type. -- @field #boolean HasBeenMoved Flag for moving. @@ -66,6 +67,7 @@ CTLD_CARGO = { ClassName = "CTLD_CARGO", ID = 0, Name = "none", + DisplayName = "none", Templates = {}, CargoType = "none", HasBeenMoved = false, @@ -125,6 +127,7 @@ CTLD_CARGO = { self:T({ID, Name, Templates, Sorte, HasBeenMoved, LoadDirectly, CratesNeeded, Positionable, Dropped}) self.ID = ID or math.random(100000,1000000) self.Name = Name or "none" -- #string + self.DisplayName = Name or "none" -- #string self.Templates = Templates or {} -- #table self.CargoType = Sorte or "type" -- #CTLD_CARGO.Enum self.HasBeenMoved = HasBeenMoved or false -- #boolean @@ -253,6 +256,26 @@ CTLD_CARGO = { function CTLD_CARGO:GetName() return self.Name end + + --- Set display name. + -- @param #CTLD_CARGO self + -- @param #string DisplayName Display label used in menus/messages (optional). + -- @return #CTLD_CARGO self + function CTLD_CARGO:SetDisplayName(DisplayName) + if type(DisplayName) == "string" and DisplayName ~= "" then + self.DisplayName = DisplayName + else + self.DisplayName = self.Name + end + return self + end + + --- Query display name. + -- @param #CTLD_CARGO self + -- @return #string Display name, or Name if not set + function CTLD_CARGO:GetDisplayName() + return self.DisplayName or self.Name + end --- Query Templates. -- @param #CTLD_CARGO self @@ -1491,7 +1514,7 @@ CTLD.UnitTypeCapabilities = { ["C-130J-30"] = {type="C-130J-30", crates=true, troops=true, cratelimit = 7, trooplimit = 64, length = 35, cargoweightlimit = 21500}, -- 19t cargo, 64 paratroopers. --Actually it's longer, but the center coord is off-center of the model. ["UH-60L"] = {type="UH-60L", crates=true, troops=true, cratelimit = 2, trooplimit = 20, length = 16, cargoweightlimit = 3500}, -- 4t cargo, 20 (unsec) seats - ["UH-60L_DAP"] = {type="UH-60L_DAP", crates=false, troops=true, cratelimit = 0, trooplimit = 2, length = 16, cargoweightlimit = 500}, -- UH-60L DAP is an attack helo but can do limited CSAR and CTLD + ["UH-60L_DAP"] = {type="UH-60L_DAP", crates=false, troops=true, cratelimit = 2, trooplimit = 2, length = 16, cargoweightlimit = 3000}, -- UH-60L DAP is an attack helo but can do limited CSAR and CTLD ["MH-60R"] = {type="MH-60R", crates=true, troops=true, cratelimit = 2, trooplimit = 20, length = 16, cargoweightlimit = 3500}, -- 4t cargo, 20 (unsec) seats ["SH-60B"] = {type="SH-60B", crates=true, troops=true, cratelimit = 2, trooplimit = 20, length = 16, cargoweightlimit = 3500}, -- 4t cargo, 20 (unsec) seats ["AH-64D_BLK_II"] = {type="AH-64D_BLK_II", crates=false, troops=true, cratelimit = 0, trooplimit = 2, length = 17, cargoweightlimit = 200}, -- 2 ppl **outside** the helo @@ -1624,6 +1647,9 @@ function CTLD:New(Coalition, Prefixes, Alias) self.Cargo_Crates = {} self.Cargo_Troops = {} self.Cargo_Statics = {} + self._troopsByName = {} + self._crateOrStaticByName = {} + self._cargoByTemplate = {} self.Loaded_Cargo = {} self.Spawned_Crates = {} self.Spawned_Cargo = {} @@ -2137,6 +2163,35 @@ function CTLD:_GetUnitCapabilities(Unit) return capabilities end +--- (Internal) Resolve cargo label for menus/messages. +-- @param #CTLD self +-- @param #CTLD_CARGO Cargo Cargo object or cargo name. +-- @return #string Cargo label +function CTLD:_GetCargoDisplayName(Cargo) + if type(Cargo) == "table" then + if Cargo.GetDisplayName then + local dname = Cargo:GetDisplayName() + if type(dname) == "string" and dname ~= "" then + return dname + end + end + if Cargo.GetName then + local name = Cargo:GetName() + if type(name) == "string" and name ~= "" then + return name + end + end + if type(Cargo.Name) == "string" and Cargo.Name ~= "" then + return Cargo.Name + end + return "Unknown" + end + if type(Cargo) == "string" and Cargo ~= "" then + return Cargo + end + return "Unknown" +end + --- (User) Function to allow transport via Combined Arms Trucks. -- @param #CTLD self -- @param #boolean OnOff Switch on (true) or off (false). @@ -2367,9 +2422,9 @@ end --- (Internal) Function to check if a unit is a C-130J -- @param #CTLD self -function CTLD:IsC130J(Unit) +function CTLD:IsC130J(Unit, IgnoreUseC130Flag) if not Unit then return false end - if not self.UseC130LoadAndUnload then return false end + if not IgnoreUseC130Flag and not self.UseC130LoadAndUnload then return false end self.C130JUnits = self.C130JUnits or {} local unitname = Unit:GetName() or "none" return self.C130JUnits[unitname] == true @@ -2395,10 +2450,16 @@ end -- @return #CTLD_CARGO Cargo object, nil if it cannot be found function CTLD:_FindTroopsCargoObject(Name) self:T(self.lid .. " _FindTroopsCargoObject") + self._troopsByName = self._troopsByName or {} + local cached = self._troopsByName[Name] + if cached then + return cached + end local cargo = nil for _,_cargo in pairs(self.Cargo_Troops)do local cargo = _cargo -- #CTLD_CARGO if cargo.Name == Name then + self._troopsByName[Name] = cargo return cargo end end @@ -2411,16 +2472,23 @@ end -- @return #CTLD_CARGO Cargo object, nil if it cannot be found function CTLD:_FindCratesCargoObject(Name) self:T(self.lid .. " _FindCratesCargoObject") + self._crateOrStaticByName = self._crateOrStaticByName or {} + local cached = self._crateOrStaticByName[Name] + if cached then + return cached + end local cargo = nil for _,_cargo in pairs(self.Cargo_Crates)do local cargo = _cargo -- #CTLD_CARGO if cargo.Name == Name then + self._crateOrStaticByName[Name] = cargo return cargo end end for _,_cargo in pairs(self.Cargo_Statics)do local cargo = _cargo -- #CTLD_CARGO if cargo.Name == Name then + self._crateOrStaticByName[Name] = cargo return cargo end end @@ -2546,6 +2614,20 @@ function CTLD:PreloadCrates(Unit,Cratesname,NumberOfCrates) return self end +--- (User) Hook to allow mission-specific troop restrictions. +-- Override this in your mission to perform custom checks (e.g. warehouse stock, role limits) before troops are loaded. +-- Return true to allow the request, or false to block it. When blocked, _LoadTroops and _LoadTroopsQuantity exit silently. +-- @param #CTLD self +-- @param Wrapper.Group#GROUP Group Requesting player group. +-- @param Wrapper.Unit#UNIT Unit Requesting unit. +-- @param #CTLD_CARGO Cargo Troop cargo type being requested. +-- @param #number quantity Number of troop sets requested. +-- @param #boolean Inject If true, this call originates from an inject/preload path. +-- @return #boolean Allow troop loading. +function CTLD:CanGetTroops(Group, Unit, Cargo, quantity, Inject) + return true +end + --- (Internal) Function to load troops into a heli. -- @param #CTLD self -- @param Wrapper.Group#GROUP Group @@ -2616,6 +2698,9 @@ function CTLD:_LoadTroops(Group, Unit, Cargotype, Inject) self:_SendMessage("Sorry, that\'s too heavy to load!", 10, false, Group) return else + if not self:CanGetTroops(Group, Unit, Cargotype, 1, Inject) then + return self + end self.CargoCounter = self.CargoCounter + 1 local loadcargotype = CTLD_CARGO:New(self.CargoCounter, Cargotype.Name, Cargotype.Templates, cgotype, true, true, Cargotype.CratesNeeded,nil,nil,Cargotype.PerCrateMass) self:T({cargotype=loadcargotype}) @@ -2935,6 +3020,8 @@ function CTLD:_LoadTroopsQuantity(Group, Unit, Cargo, quantity) if not self.debug then return self end end + if not self:CanGetTroops(Group, Unit, Cargo, n, false) then return self end + local prevSuppress = self.suppressmessages self.suppressmessages = true for i = 1, n do @@ -3089,26 +3176,40 @@ function CTLD:_AddCrateQuantityMenus(Group, Unit, parentMenu, cargoObj, stockSum maxQuantity = maxMassSets end end - if type(maxload)=="number"and maxload>0 and perCrateMass>0 then - maxMassCrates=math.floor(maxload/perCrateMass) + if type(maxload) == "number" and maxload > 0 and perCrateMass > 0 then + maxMassCrates = math.floor(maxload / perCrateMass) end end - self:T("_AddCrateQuantityMenus maxQuantity "..maxQuantity.." allowLoad "..tostring(allowLoad)) + self:T("_AddCrateQuantityMenus maxQuantity "..maxQuantity.." allowLoad "..tostring(allowLoad)) if maxQuantity < 1 then return self end if maxQuantity == 1 then self:T("_AddCrateQuantityMenus maxQuantity "..maxQuantity.." Menu for MaxQ=1 ".."parentMenu.MenuText = "..parentMenu.MenuText) - --parentMenu.MenuText - MENU_GROUP_COMMAND:New(Group, "Get", parentMenu, self._GetCrateQuantity, self, Group, Unit, cargoObj, 1) local canLoad = (allowLoad and (not capacitySets or capacitySets >= 1) and (not maxMassSets or maxMassSets >= 1)) local isHerc = self:IsC130J(Unit) - local isHook = self:IsHook(Unit) local cgotype = cargoObj:GetType() or nil - local suppressGetAndLoad = (self.enableChinookGCLoading == true) and isHook and (cgotype == CTLD_CARGO.Enum.STATIC) - local canPartiallyLoad=((not capacityCrates or capacityCrates>=1)and(not maxMassCrates or maxMassCrates>=1)) + local suppressGetAndLoad = (self.enableChinookGCLoading == true) and (cgotype == CTLD_CARGO.Enum.STATIC) + local canPartiallyLoad = ((not capacityCrates or capacityCrates >= 1) and (not maxMassCrates or maxMassCrates >= 1)) + + if suppressGetAndLoad or isHerc then + if canLoad then + MENU_GROUP_COMMAND:New(Group, "1", parentMenu, self._GetCrateQuantity, self, Group, Unit, cargoObj, 1) + else + local msg + if maxMassSets and (not capacitySets or capacitySets >= 1) and maxMassSets < 1 then + msg = "Weight limit reached" + else + msg = "Crate limit reached" + end + MENU_GROUP_COMMAND:New(Group, msg, parentMenu, self._SendMessage, self, msg, 10, false, Group) + end + return self + end + if canLoad and not isHerc and not suppressGetAndLoad then + MENU_GROUP_COMMAND:New(Group, "Get", parentMenu, self._GetCrateQuantity, self, Group, Unit, cargoObj, 1) MENU_GROUP_COMMAND:New(Group, "Get and Load", parentMenu, self._GetAndLoad, self, Group, Unit, cargoObj, 1) else local msg @@ -3119,13 +3220,14 @@ function CTLD:_AddCrateQuantityMenus(Group, Unit, parentMenu, cargoObj, stockSum msg = "Crate limit reached" end MENU_GROUP_COMMAND:New(Group, msg, parentMenu, self._SendMessage, self, msg, 10, false, Group) - if canPartiallyLoad and (cgotype ~= CTLD_CARGO.Enum.STATIC) and (not suppressGetAndLoad) then - MENU_GROUP_COMMAND:New(Group, "Partially load", parentMenu, self._GetAndLoad, self, Group, Unit, cargoObj, 1,true) + MENU_GROUP_COMMAND:New(Group, "Get anyway", parentMenu, self._GetCrateQuantity, self, Group, Unit, cargoObj, 1) + + MENU_GROUP_COMMAND:New(Group, "Partially load", parentMenu, self._GetAndLoad, self, Group, Unit, cargoObj, 1, true) end end end - + return self end @@ -3133,29 +3235,17 @@ function CTLD:_AddCrateQuantityMenus(Group, Unit, parentMenu, cargoObj, stockSum self:T("_AddCrateQuantityMenus maxQuantity "..maxQuantity.." Menu for MaxQ>1") local label = tostring(quantity) self:T("_AddCrateQuantityMenus Label "..label) - local qMenu = MENU_GROUP:New(Group, label, parentMenu) - MENU_GROUP_COMMAND:New(Group, "Get", qMenu, self._GetCrateQuantity, self, Group, Unit, cargoObj, quantity) local canLoad = (allowLoad and (not capacitySets or capacitySets >= quantity) and (not maxMassSets or maxMassSets >= quantity)) local isHerc = self:IsC130J(Unit) - local isHook = self:IsHook(Unit) local cgotype = cargoObj:GetType() or nil - local suppressGetAndLoad = (self.enableChinookGCLoading == true) and isHook and (cgotype == CTLD_CARGO.Enum.STATIC) - local canPartiallyLoad=((not capacityCrates or capacityCrates>=1)and(not maxMassCrates or maxMassCrates>=1)) - if canLoad and not isHerc and not suppressGetAndLoad then + local suppressGetAndLoad = (self.enableChinookGCLoading == true) and (cgotype == CTLD_CARGO.Enum.STATIC) + + if canLoad and not isHerc and not suppressGetAndLoad then + local qMenu = MENU_GROUP:New(Group, label, parentMenu) + MENU_GROUP_COMMAND:New(Group, "Get", qMenu, self._GetCrateQuantity, self, Group, Unit, cargoObj, quantity) MENU_GROUP_COMMAND:New(Group, "Get and Load", qMenu, self._GetAndLoad, self, Group, Unit, cargoObj, quantity) else - local msg - if not isHerc and not suppressGetAndLoad then - if maxMassSets and (not capacitySets or capacitySets >= quantity) and maxMassSets < quantity then - msg = "Weight limit reached" - else - msg = "Crate limit reached" - end - MENU_GROUP_COMMAND:New(Group, msg, qMenu, self._SendMessage, self, msg, 10, false, Group) - if canPartiallyLoad and (cgotype ~= CTLD_CARGO.Enum.STATIC) and (not suppressGetAndLoad) then - MENU_GROUP_COMMAND:New(Group, "Partially load", qMenu, self._GetAndLoad, self, Group, Unit, cargoObj, quantity, true) - end - end + MENU_GROUP_COMMAND:New(Group, label, parentMenu, self._GetCrateQuantity, self, Group, Unit, cargoObj, quantity) end end return self @@ -3322,7 +3412,6 @@ end function CTLD:_GetCrates(Group, Unit, Cargo, number, drop, pack, quiet, suppressGetEvent) self:T(self.lid .. " _GetCrates") - -- check if we have stock local perSet = Cargo:GetCratesNeeded() or 1 if perSet < 1 then perSet = 1 end local requestNumber = tonumber(number) @@ -3338,36 +3427,34 @@ function CTLD:_GetCrates(Group, Unit, Cargo, number, drop, pack, quiet, suppress local cgoname = Cargo:GetName() local instock = Cargo:GetStock() if type(instock) == "number" and tonumber(instock) <= 0 and tonumber(instock) ~= -1 then - -- nothing left over self:_SendMessage(string.format("Sorry, we ran out of %s", cgoname), 10, false, Group) return false end end -- check if we are in LOAD zone - local inzone = false + local inzone = false local drop = drop or false local suppressGetEvent = suppressGetEvent or false local ship = nil local width = 20 local distance = nil local zone = nil - if not drop and not pack then - inzone = self:IsUnitInZone(Unit,CTLD.CargoZoneType.LOAD) + if not drop and not pack then + inzone = self:IsUnitInZone(Unit, CTLD.CargoZoneType.LOAD) if not inzone then ----@diagnostic disable-next-line: cast-local-type - inzone, ship, zone, distance, width = self:IsUnitInZone(Unit,CTLD.CargoZoneType.SHIP) + inzone, ship, zone, distance, width = self:IsUnitInZone(Unit, CTLD.CargoZoneType.SHIP) end elseif drop and not pack then if self.dropcratesanywhere then -- #1570 inzone = true else - inzone = self:IsUnitInZone(Unit,CTLD.CargoZoneType.DROP) + inzone = self:IsUnitInZone(Unit, CTLD.CargoZoneType.DROP) end elseif pack and not drop then inzone = true end - + if not inzone then self:_SendMessage("You are not close enough to a logistics zone!", 10, false, Group) if not self.debug then return self end @@ -3390,23 +3477,25 @@ function CTLD:_GetCrates(Group, Unit, Cargo, number, drop, pack, quiet, suppress local capabilities = self:_GetUnitCapabilities(Unit) -- #CTLD.UnitTypeCapabilities local canloadcratesno = capabilities.cratelimit local loaddist = self.CrateDistance or 35 - local nearcrates, numbernearby = self:_FindCratesNearby(Group,Unit,loaddist,true,true,true) -- to ignore what's inside + local nearcrates, numbernearby = self:_FindCratesNearby(Group, Unit, loaddist, true, true, true) if numbernearby >= canloadcratesno and not drop then self:_SendMessage("There are enough crates nearby already! Take care of those first!", 10, false, Group) return false end - if not self:CanGetCrates(Group, Unit, Cargo, requestNumber, drop, pack, quiet, suppressGetEvent) then + if not drop and not self:CanGetCrates(Group, Unit, Cargo, requestNumber, drop, pack, quiet, suppressGetEvent) then return false end -- spawn crates in front of helicopter local IsHerc = self:IsFixedWing(Unit) -- Herc, Bronco and Hook load from behind local IsHook = self:IsHook(Unit) -- Herc, Bronco and Hook load from behind + local IsHelo = Unit and Unit.IsHelicopter and Unit:IsHelicopter() or false local IsTruck = Unit:IsGround() local cargotype = Cargo -- Ops.CTLD#CTLD_CARGO local number = requestNumber --#number local cratesneeded = cargotype:GetCratesNeeded() --#number local cratename = cargotype:GetName() + local cratedisplayname = self:_GetCargoDisplayName(cargotype) local cratetemplate = "Container"-- #string local cgotype = cargotype:GetType() local cgomass = cargotype:GetMass() @@ -3430,89 +3519,204 @@ function CTLD:_GetCrates(Group, Unit, Cargo, number, drop, pack, quiet, suppress -- spawn behind the Herc addon = 180 end - heading = (heading+addon)%360 + heading = (heading + addon) % 360 local row = 1 local column = 1 - local initialdist = IsHerc and 16 or (capabilities.length+2) -- initial spacing of the first crates - local startpos = position:Translate(initialdist,heading) + local initialdist = IsHerc and 16 or (capabilities.length + 2) + local startpos = position:Translate(initialdist, heading) if self.placeCratesAhead == true then cratedistance = initialdist end - -- loop crates needed - local cratecoord = nil -- Core.Point#COORDINATE - for i=1,number do - local cratealias = string.format("%s-%s-%d", cratename, cratetemplate, math.random(1,100000)) - if not self.placeCratesAhead or drop == true then - cratedistance = (i-1)*2.5 + capabilities.length - if cratedistance > self.CrateDistance then cratedistance = self.CrateDistance end - -- altered heading logic - -- DONE: right standard deviation? - if self:IsUnitInAir(Unit) and self:IsFixedWing(Unit) then - rheading = math.random(20,60) - else - rheading = UTILS.RandomGaussian(0, 30, -90, 90, 100) - end - rheading=math.fmod((heading+rheading),360) - cratecoord = position:Translate(cratedistance,rheading) - else - cratedistance = (row-1)*6 - rheading = 90 - row = row+1 - cratecoord = startpos:Translate(cratedistance,rheading) - if row > 4 then - row = 1 - startpos:Translate(6,heading,nil,true) - end + local cratecoord = nil + local shipdist = nil + local shipoffset = nil + + local FW_STEP_BY_TYPE = { + cds_crate = 2.0, + cds_barrels = 2.0, + ammo_cargo = 1.0, + iso_container_small = 3.5, + iso_container = 3.5, + uh1h_cargo = 2.0, + container_cargo = 2.5, + } + local FW_WIDTH_BY_TYPE = { -- spacing between crates in the set. + cds_crate = 0.8, + cds_barrels = 0.8, + ammo_cargo = 0.4, + iso_container_small = 2.0, + iso_container = 2.0, + uh1h_cargo = 0.6, + container_cargo = 1.3, + } + local FW_ROW_GAP = 0.6 + local FW_BATCH_ANGLE_PATTERN = { 0, -20, 20, -40, 40, -60, 60, -80, 80 } + local fwBatchState = nil + local fwBatchIndex = 0 + local fwBatchKey = nil + if IsHerc or IsHelo then + self._fwBatchState = self._fwBatchState or {} + fwBatchKey = (Unit and Unit.GetName and Unit:GetName()) or "FW" + fwBatchState = self._fwBatchState[fwBatchKey] or { batch = 0 } + if (tonumber(numbernearby) or 0) <= 0 then + fwBatchState.batch = 0 end - - --local cratevec2 = cratecoord:GetVec2() - self.CrateCounter = self.CrateCounter + 1 + fwBatchIndex = fwBatchState.batch or 0 + end + + local fwZeroAngleSetHeading = nil + local fwNonZeroAngleSetHeading = nil + + for i = 1, number do + local currentAngleOffset = 0 + local cratealias = string.format("%s-%d", cratename, math.random(1, 100000)) local CCat, CType, CShape = Cargo:GetStaticTypeAndShape() local basetype = CType or self.basetype or "container_cargo" CCat = CCat or "Cargos" - if not isstatic and self:IsC130J(Unit) then + + if not isstatic and self:IsC130J(Unit, true) then if Cargo.C130TypeName then basetype = Cargo.C130TypeName elseif self.C130basetype and (not CType or CType == self.basetype) then basetype = self.C130basetype end end + + if not self.placeCratesAhead or drop == true then + local step = (IsHerc or IsHelo) and (FW_STEP_BY_TYPE[basetype] or 2.6) or 1.6 + if (IsHerc or IsHelo) and not drop then + local safeDistance = capabilities.length * 0.9 + local maxDist = self.CrateDistance or 35 + if safeDistance > maxDist then safeDistance = maxDist end + + local angleIndex = (fwBatchIndex % #FW_BATCH_ANGLE_PATTERN) + 1 + currentAngleOffset = FW_BATCH_ANGLE_PATTERN[angleIndex] or 0 + local centerHeading = math.fmod((heading + currentAngleOffset), 360) + if math.abs(currentAngleOffset) >= 0.01 and not fwNonZeroAngleSetHeading then + fwNonZeroAngleSetHeading = centerHeading + end + + local baseDistance = safeDistance + local crateWidth = FW_WIDTH_BY_TYPE[basetype] or step + local zeroAngle = math.abs(currentAngleOffset) < 0.01 + local lateral = nil + local lateralHeading = nil + if zeroAngle then + local totalRowWidth = (number * crateWidth) + (math.max(0, number - 1) * FW_ROW_GAP) + local leftEdge = -(totalRowWidth / 2) + lateral = leftEdge + ((i - 1) * (crateWidth + FW_ROW_GAP)) + (crateWidth / 2) + if lateral >= 0 then + lateralHeading = math.fmod(centerHeading + 90, 360) + else + lateral = -lateral + lateralHeading = math.fmod(centerHeading + 270, 360) + end + else + lateral = (i - 1) * (crateWidth + FW_ROW_GAP) + if currentAngleOffset < 0 then + lateralHeading = math.fmod(centerHeading + 270, 360) + else + lateralHeading = math.fmod(centerHeading + 90, 360) + end + end + + local maxLateralSq = (maxDist * maxDist) - (baseDistance * baseDistance) + if maxLateralSq < 0 then maxLateralSq = 0 end + local maxLateral = math.sqrt(maxLateralSq) + if lateral > maxLateral then + lateral = maxLateral + end + + local baseCoord = position:Translate(baseDistance, centerHeading) + cratecoord = baseCoord:Translate(lateral, lateralHeading) + cratedistance = baseDistance + rheading = centerHeading + else + cratedistance = (i - 1) * step + capabilities.length + if cratedistance > self.CrateDistance then cratedistance = self.CrateDistance end + rheading = UTILS.RandomGaussian(0, 18, -55, 55, 100) + rheading = math.fmod((heading + rheading), 360) + cratecoord = position:Translate(cratedistance, rheading) + end + else + cratedistance = (row - 1) * 6 + rheading = 90 + row = row + 1 + cratecoord = startpos:Translate(cratedistance, rheading) + if row > 4 then + row = 1 + startpos:Translate(6, heading, nil, true) + end + end + + self.CrateCounter = self.CrateCounter + 1 + local crateSpawnHeading = 270 + if (IsHerc or IsHelo) and not drop and cratecoord and type(ship) ~= "string" then + if math.abs(currentAngleOffset) < 0.01 then + if not fwZeroAngleSetHeading then + fwZeroAngleSetHeading = heading + end + crateSpawnHeading = fwZeroAngleSetHeading + else + crateSpawnHeading = fwNonZeroAngleSetHeading + end + end + if type(ship) == "string" then self:T("Spawning on ship "..ship) local Ship = UNIT:FindByName(ship) local shipcoord = Ship:GetCoordinate() local unitcoord = Unit:GetCoordinate() local dist = shipcoord:Get2DDistance(unitcoord) - dist = dist - (20 + math.random(1,10)) - local width = width / 2 - local Offy = math.random(-width,width) - local spawnstatic = SPAWNSTATIC:NewFromType(basetype,CCat,self.cratecountry) - :InitCargoMass(cgomass) - :InitCargo(self.enableslingload) - :InitLinkToUnit(Ship,dist,Offy,0) + dist = dist - (20 + math.random(1, 10)) + local halfwidth = (width or 20) / 2 + local Offy = nil + if i == 1 or shipdist == nil or shipoffset == nil then + Offy = math.random(-halfwidth, halfwidth) + shipoffset = Offy + shipdist = dist + else + dist = shipdist + local step = math.max(1, math.min(3, halfwidth * 0.2)) + local slot = i - 1 + local ring = math.floor((slot + 1) / 2) + local sign = (slot % 2 == 1) and 1 or -1 + Offy = shipoffset + (sign * ring * step) + if Offy > halfwidth then + Offy = halfwidth + elseif Offy < -halfwidth then + Offy = -halfwidth + end + end + + local spawnstatic = SPAWNSTATIC:NewFromType(basetype, CCat, self.cratecountry) + :InitCargoMass(cgomass) + :InitCargo(self.enableslingload) + :InitLinkToUnit(Ship, dist, Offy, 0) if CShape then spawnstatic:InitShape(CShape) - end + end if isstatic then - local map=cargotype:GetStaticResourceMap() + local map = cargotype:GetStaticResourceMap() spawnstatic.TemplateStaticUnit.resourcePayload = map end - self.Spawned_Crates[self.CrateCounter] = spawnstatic:Spawn(270,cratealias) - else - local spawnstatic = SPAWNSTATIC:NewFromType(basetype,CCat,self.cratecountry) + self.Spawned_Crates[self.CrateCounter] = spawnstatic:Spawn(crateSpawnHeading, cratealias) + else + local spawnstatic = SPAWNSTATIC:NewFromType(basetype, CCat, self.cratecountry) :InitCoordinate(cratecoord) :InitCargoMass(cgomass) :InitCargo(self.enableslingload) if CShape then spawnstatic:InitShape(CShape) - end + end if isstatic then - local map=cargotype:GetStaticResourceMap() + local map = cargotype:GetStaticResourceMap() spawnstatic.TemplateStaticUnit.resourcePayload = map end - self.Spawned_Crates[self.CrateCounter] = spawnstatic:Spawn(270,cratealias) + self.Spawned_Crates[self.CrateCounter] = spawnstatic:Spawn(crateSpawnHeading, cratealias) end + local templ = cargotype:GetTemplates() local sorte = cargotype:GetType() local subcat = cargotype.Subcategory @@ -3520,41 +3724,50 @@ function CTLD:_GetCrates(Group, Unit, Cargo, number, drop, pack, quiet, suppress local realcargo = nil if drop then --CTLD_CARGO:New(ID, Name, Templates, Sorte, HasBeenMoved, LoadDirectly, CratesNeeded, Positionable, Dropped, PerCrateMass, Stock, Subcategory) - realcargo = CTLD_CARGO:New(self.CargoCounter,cratename,templ,sorte,true,false,cratesneeded,self.Spawned_Crates[self.CrateCounter],true,cargotype.PerCrateMass,nil,subcat) -- #CTLD_CARGO - local map=cargotype:GetStaticResourceMap() + realcargo = CTLD_CARGO:New(self.CargoCounter, cratename, templ, sorte, true, false, cratesneeded, self.Spawned_Crates[self.CrateCounter], true, cargotype.PerCrateMass, nil, subcat) + realcargo:SetDisplayName(cargotype:GetDisplayName()) + local map = cargotype:GetStaticResourceMap() realcargo:SetStaticResourceMap(map) local CCat3, CType3, CShape3 = cargotype:GetStaticTypeAndShape() - realcargo:SetStaticTypeAndShape(CCat3,CType3,CShape3) + realcargo:SetStaticTypeAndShape(CCat3, CType3, CShape3) if cargotype.TypeNames then realcargo.TypeNames = UTILS.DeepCopy(cargotype.TypeNames) end - table.insert(droppedcargo,realcargo) + table.insert(droppedcargo, realcargo) else - realcargo = CTLD_CARGO:New(self.CargoCounter,cratename,templ,sorte,false,false,cratesneeded,self.Spawned_Crates[self.CrateCounter],false,cargotype.PerCrateMass,nil,subcat) - local map=cargotype:GetStaticResourceMap() - realcargo:SetStaticResourceMap(map) + realcargo = CTLD_CARGO:New(self.CargoCounter, cratename, templ, sorte, false, false, cratesneeded, self.Spawned_Crates[self.CrateCounter], false, cargotype.PerCrateMass, nil, subcat) + realcargo:SetDisplayName(cargotype:GetDisplayName()) + local map = cargotype:GetStaticResourceMap() + realcargo:SetStaticResourceMap(map) if cargotype.TypeNames then realcargo.TypeNames = UTILS.DeepCopy(cargotype.TypeNames) end if self.UseC130LoadAndUnload and self:IsC130J(Unit) then - realcargo:SetWasDropped(true,true) -- we mark here that the crates was dropped even though we just got them because of the herc. + realcargo:SetWasDropped(true, true) end end + if not drop and not pack then table.insert(obtainedcargo, realcargo) end local CCat4, CType4, CShape4 = cargotype:GetStaticTypeAndShape() - realcargo:SetStaticTypeAndShape(CCat4,CType4,CShape4) + realcargo:SetStaticTypeAndShape(CCat4, CType4, CShape4) table.insert(self.Spawned_Cargo, realcargo) end + if (IsHerc or IsHelo) and fwBatchState and fwBatchKey and not drop then + local maxBatches = #FW_BATCH_ANGLE_PATTERN + fwBatchState.batch = (fwBatchIndex + 1) % maxBatches + self._fwBatchState[fwBatchKey] = fwBatchState + end + if not (drop or pack) then Cargo:RemoveStock(requestedSets) self:_RefreshCrateQuantityMenus(Group, Unit, Cargo) end - local text = string.format("%d crates for %s have been positioned near you!",number,cratename) + local text = string.format("%d crates for %s have been positioned near you!", number, cratedisplayname) if drop then - text = string.format("%d crates for %s have been dropped!",number,cratename) + text = string.format("%d crates for %s have been dropped!", number, cratedisplayname) self:__CratesDropped(1, Group, Unit, droppedcargo) else if not quiet then @@ -3875,11 +4088,11 @@ function CTLD:_FindCratesNearby( _group, _unit, _dist, _ignoreweight, ignoretype end end self:T(self.lid..string.format("Dist %dm/%dm | weight %dkg | maxloadable %dkg",distance,finddist,weight,maxloadable)) - if distance<=finddist and(weight<=maxloadable or _ignoreweight)and restricted==false and cando==true and not hercInnerBlocked then - index = index + 1 - table.insert(found, staticid, cargo) - maxloadable = maxloadable - weight - end + if distance<=finddist and(weight<=maxloadable or _ignoreweight)and restricted==false and cando==true and not hercInnerBlocked then + index = index + 1 + found[#found+1] = cargo + maxloadable = maxloadable - weight + end end end @@ -4026,22 +4239,21 @@ end function CTLD:_CleanupTrackedCrates(crateIdsToRemove) local existingcrates = self.Spawned_Cargo -- #table local newexcrates = {} + local remove = {} + for _,_ID in pairs(crateIdsToRemove or {}) do + remove[_ID] = true + end for _,_crate in pairs(existingcrates) do local excrate = _crate -- #CTLD_CARGO local ID = excrate:GetID() - local keep = true - for _,_ID in pairs(crateIdsToRemove) do - if ID == _ID then - keep = false - end - end + local keep = not remove[ID] -- remove destroyed crates here too local static = _crate:GetPositionable() -- Wrapper.Static#STATIC -- crates if not static or not static:IsAlive() then keep = false end if keep then - table.insert(newexcrates,_crate) + newexcrates[#newexcrates+1] = _crate end end self.Spawned_Cargo = nil @@ -5592,7 +5804,7 @@ function CTLD:_RefreshF10Menus() end for _, cargoObj in pairs(self.Cargo_Troops) do if not cargoObj.DontShowInMenu then - local menutext = cargoObj.Name + local menutext = self:_GetCargoDisplayName(cargoObj) local parent = troopsmenu if useTroopSubcats and cargoObj.Subcategory and subcatmenus[cargoObj.Subcategory] then parent = subcatmenus[cargoObj.Subcategory] @@ -5605,7 +5817,7 @@ function CTLD:_RefreshF10Menus() else for _, cargoObj in pairs(self.Cargo_Troops) do if not cargoObj.DontShowInMenu then - local menutext = cargoObj.Name + local menutext = self:_GetCargoDisplayName(cargoObj) local mSet = MENU_GROUP:New(_group, menutext, troopsmenu) _group.CTLD_TroopMenus[cargoObj.Name] = mSet self:_AddTroopQuantityMenus(_group,_unit,mSet,cargoObj) @@ -5620,7 +5832,7 @@ function CTLD:_RefreshF10Menus() if loadedData and loadedData.Cargo then for i,cargoObj in ipairs(loadedData.Cargo) do if cargoObj and (cargoObj:GetType()==CTLD_CARGO.Enum.TROOPS or cargoObj:GetType()==CTLD_CARGO.Enum.ENGINEERS) and not cargoObj:WasDropped() then - local name=cargoObj:GetName() or "Unknown" + local name=self:_GetCargoDisplayName(cargoObj) local needed=cargoObj:GetCratesNeeded() or 1 local cID=cargoObj:GetID() local line=string.format("Drop: %s",name,needed,cID) @@ -5673,10 +5885,11 @@ function CTLD:_RefreshF10Menus() local needed = cargoObj:GetCratesNeeded() or 1 local txt + local cargoLabel = self:_GetCargoDisplayName(cargoObj) if needed > 1 then - txt = string.format("%d crate%s %s (%dkg)",needed,needed==1 and "" or "s",cargoObj.Name,cargoObj.PerCrateMass or 0) + txt = string.format("%d crate%s %s (%dkg)",needed,needed==1 and "" or "s",cargoLabel,cargoObj.PerCrateMass or 0) else - txt = string.format("%s (%dkg)",cargoObj.Name,cargoObj.PerCrateMass or 0) + txt = string.format("%s (%dkg)",cargoLabel,cargoObj.PerCrateMass or 0) end if cargoObj.Location then txt = txt.."[R]" end if self.showstockinmenuitems then @@ -5721,10 +5934,11 @@ function CTLD:_RefreshF10Menus() if not cargoObj.DontShowInMenu then local needed = cargoObj:GetCratesNeeded() or 1 local txt + local cargoLabel = self:_GetCargoDisplayName(cargoObj) if needed > 1 then - txt = string.format("%d crate%s %s (%dkg)",needed,needed==1 and "" or "s",cargoObj.Name,cargoObj.PerCrateMass or 0) + txt = string.format("%d crate%s %s (%dkg)",needed,needed==1 and "" or "s",cargoLabel,cargoObj.PerCrateMass or 0) else - txt = string.format("%s (%dkg)",cargoObj.Name,cargoObj.PerCrateMass or 0) + txt = string.format("%s (%dkg)",cargoLabel,cargoObj.PerCrateMass or 0) end if cargoObj.Location then txt = txt.."[R]" end local stock = cargoObj:GetStock() @@ -5736,10 +5950,11 @@ function CTLD:_RefreshF10Menus() if (not cargoObj.DontShowInMenu) and (not cargoObj.UnitCanCarry or cargoObj:UnitCanCarry(_unit)) then local needed = cargoObj:GetCratesNeeded() or 1 local txt + local cargoLabel = self:_GetCargoDisplayName(cargoObj) if needed > 1 then - txt = string.format("%d crate%s %s (%dkg)",needed,needed==1 and "" or "s",cargoObj.Name,cargoObj.PerCrateMass or 0) + txt = string.format("%d crate%s %s (%dkg)",needed,needed==1 and "" or "s",cargoLabel,cargoObj.PerCrateMass or 0) else - txt = string.format("%s (%dkg)",cargoObj.Name,cargoObj.PerCrateMass or 0) + txt = string.format("%s (%dkg)",cargoLabel,cargoObj.PerCrateMass or 0) end if cargoObj.Location then txt = txt.."[R]" end local stock = cargoObj:GetStock() @@ -5752,10 +5967,11 @@ function CTLD:_RefreshF10Menus() if not cargoObj.DontShowInMenu then local needed = cargoObj:GetCratesNeeded() or 1 local txt + local cargoLabel = self:_GetCargoDisplayName(cargoObj) if needed > 1 then - txt = string.format("%d crate%s %s (%dkg)",needed,needed==1 and "" or "s",cargoObj.Name,cargoObj.PerCrateMass or 0) + txt = string.format("%d crate%s %s (%dkg)",needed,needed==1 and "" or "s",cargoLabel,cargoObj.PerCrateMass or 0) else - txt = string.format("%s (%dkg)",cargoObj.Name,cargoObj.PerCrateMass or 0) + txt = string.format("%s (%dkg)",cargoLabel,cargoObj.PerCrateMass or 0) end if cargoObj.Location then txt = txt.."[R]" end local stock = cargoObj:GetStock() @@ -5767,10 +5983,11 @@ function CTLD:_RefreshF10Menus() if (not cargoObj.DontShowInMenu) and (not cargoObj.UnitCanCarry or cargoObj:UnitCanCarry(_unit)) then local needed = cargoObj:GetCratesNeeded() or 1 local txt + local cargoLabel = self:_GetCargoDisplayName(cargoObj) if needed > 1 then - txt = string.format("%d crate%s %s (%dkg)",needed,needed==1 and "" or "s",cargoObj.Name,cargoObj.PerCrateMass or 0) + txt = string.format("%d crate%s %s (%dkg)",needed,needed==1 and "" or "s",cargoLabel,cargoObj.PerCrateMass or 0) else - txt = string.format("%s (%dkg)",cargoObj.Name,cargoObj.PerCrateMass or 0) + txt = string.format("%s (%dkg)",cargoLabel,cargoObj.PerCrateMass or 0) end if cargoObj.Location then txt = txt.."[R]" end local stock = cargoObj:GetStock() @@ -5816,12 +6033,13 @@ function CTLD:_RefreshF10Menus() if cgo and (not cgo:WasDropped()) then local cname = cgo:GetName() local cneeded = cgo:GetCratesNeeded() - cargoByName[cname] = cargoByName[cname] or { count=0, needed=cneeded } + local cdisplay = self:_GetCargoDisplayName(cgo) + cargoByName[cname] = cargoByName[cname] or { count=0, needed=cneeded, display=cdisplay } cargoByName[cname].count = cargoByName[cname].count + 1 end end for name, info in pairs(cargoByName) do - local line = string.format("Drop %s (%d/%d)", name, info.count, info.needed) + local line = string.format("Drop %s (%d/%d)", info.display or name, info.count, info.needed) MENU_GROUP_COMMAND:New(_group, line, dropCratesMenu, self._UnloadSingleCrateSet, self, _group, _unit, name) end end @@ -5857,7 +6075,7 @@ function CTLD:_RefreshF10Menus() end parent = sub end - local menutext = cargoObj.Name + local menutext = self:_GetCargoDisplayName(cargoObj) if type(cargoObj.Stock) == "number" and cargoObj.Stock >= 0 and self.showstockinmenuitems then menutext = menutext.."["..cargoObj.Stock.."]" end @@ -6758,6 +6976,16 @@ function CTLD:AddTroopsCargo(Name,Templates,Type,NoTroops,PerTroopMass,Stock,Sub -- Troops are directly loadable local cargo = CTLD_CARGO:New(self.CargoCounter,Name,Templates,Type,false,true,NoTroops,nil,nil,PerTroopMass,Stock, SubCategory) table.insert(self.Cargo_Troops,cargo) + self._troopsByName = self._troopsByName or {} + self._troopsByName[cargo.Name] = cargo + self._cargoByTemplate = self._cargoByTemplate or {} + local _template = cargo.Templates + if type(_template) == "table" then + _template = _template[1] + end + if type(_template) == "string" and _template ~= "" then + self._cargoByTemplate[_template] = cargo + end if SubCategory and self.usesubcats ~= true then self.usesubcats=true end return self end @@ -6855,6 +7083,16 @@ function CTLD:AddCratesCargo(Name,Templates,Type,NoCrates,PerCrateMass,Stock,Sub end cargo.C130TypeName = C130TypeName table.insert(self.Cargo_Crates,cargo) + self._crateOrStaticByName = self._crateOrStaticByName or {} + self._crateOrStaticByName[cargo.Name] = cargo + self._cargoByTemplate = self._cargoByTemplate or {} + local _template = cargo.Templates + if type(_template) == "table" then + _template = _template[1] + end + if type(_template) == "string" and _template ~= "" then + self._cargoByTemplate[_template] = cargo + end if SubCategory and self.usesubcats ~= true then self.usesubcats=true end return self end @@ -6895,6 +7133,16 @@ function CTLD:AddCratesCargoNoMove(Name,Templates,Type,NoCrates,PerCrateMass,Sto end cargo.C130TypeName = C130TypeName table.insert(self.Cargo_Crates,cargo) + self._crateOrStaticByName = self._crateOrStaticByName or {} + self._crateOrStaticByName[cargo.Name] = cargo + self._cargoByTemplate = self._cargoByTemplate or {} + local _template = cargo.Templates + if type(_template) == "table" then + _template = _template[1] + end + if type(_template) == "string" and _template ~= "" then + self._cargoByTemplate[_template] = cargo + end self.templateToCargoName = self.templateToCargoName or {} if type(Templates)=="table" then for _,t in pairs(Templates) do self.templateToCargoName[t] = Name end @@ -6915,11 +7163,13 @@ end -- @param #string SubCategory Name of sub-category (optional). -- @param #boolean DontShowInMenu (optional) If set to "true" this won't show up in the menu. -- @param Core.Zone#ZONE Location (optional) If set, the cargo item is **only** available here. Can be a #ZONE object or the name of a zone as #string. +-- @param #string UnitTypes Unit type names (optional). If set, only these unit types can pick up the cargo, e.g. "UH-1H" or {"UH-1H","OH58D"}. +-- @param #string DisplayName Display name shown in menus/messages (optional). Falls back to `Name`. -- @return #CTLD_CARGO CargoObject -function CTLD:AddStaticsCargo(Name,Mass,Stock,SubCategory,DontShowInMenu,Location,UnitTypes) +function CTLD:AddStaticsCargo(Name,Mass,Stock,SubCategory,DontShowInMenu,Location,UnitTypes,DisplayName) self:T(self.lid .. " AddStaticsCargo") self.CargoCounter = self.CargoCounter + 1 - local type = CTLD_CARGO.Enum.STATIC + local cargotype = CTLD_CARGO.Enum.STATIC local template = STATIC:FindByName(Name,true):GetTypeName() local unittemplate = _DATABASE:GetStaticUnitTemplate(Name) local ResourceMap = nil @@ -6927,12 +7177,69 @@ function CTLD:AddStaticsCargo(Name,Mass,Stock,SubCategory,DontShowInMenu,Locatio ResourceMap = UTILS.DeepCopy(unittemplate.resourcePayload) end -- Crates are not directly loadable - local cargo = CTLD_CARGO:New(self.CargoCounter,Name,template,type,false,false,1,nil,nil,Mass,Stock,SubCategory,DontShowInMenu,Location) + local cargo = CTLD_CARGO:New(self.CargoCounter,Name,template,cargotype,false,false,1,nil,nil,Mass,Stock,SubCategory,DontShowInMenu,Location) if UnitTypes then cargo:AddUnitTypeName(UnitTypes) end + cargo:SetDisplayName(DisplayName or Name) cargo:SetStaticResourceMap(ResourceMap) table.insert(self.Cargo_Statics,cargo) + self._crateOrStaticByName = self._crateOrStaticByName or {} + self._crateOrStaticByName[cargo.Name] = cargo + self._cargoByTemplate = self._cargoByTemplate or {} + local _template = cargo.Templates + if type(_template) == "table" then + _template = _template[1] + end + if type(_template) == "string" and _template ~= "" then + self._cargoByTemplate[_template] = cargo + end + if SubCategory and self.usesubcats ~= true then self.usesubcats=true end + return cargo +end + +--- User function - Add *generic* static-type loadable as cargo from a static **type name**. +-- This variant does **not** require a matching mission-editor static template. +-- @param #CTLD self +-- @param #string Name Unique cargo identifier for this type. +-- @param #string TypeName Static type name to spawn, e.g. "ammo_cargo", "iso_container_small". +-- @param #number Mass Mass in kg of each static in kg, e.g. 100. +-- @param #number Stock Number of groups in stock. Nil for unlimited. +-- @param #string SubCategory Name of sub-category (optional). +-- @param #boolean DontShowInMenu (optional) If set to "true" this won't show up in the menu. +-- @param Core.Zone#ZONE Location (optional) If set, the cargo item is **only** available here. Can be a #ZONE object or the name of a zone as #string. +-- @param #string UnitTypes Unit type names (optional). If set, only these unit types can pick up the cargo, e.g. "UH-1H" or {"UH-1H","OH58D"}. +-- @param #string Category Static category name (optional). Default is "Cargos". +-- @param #string ShapeName Static shape name (optional). If set, force a specific shape, e.g. "iso_container_small_cargo". +-- @param #table ResourceMap Resource map payload (optional) for static cargo. +-- @param #string DisplayName Display name shown in menus/messages (optional). Falls back to `Name`. +-- @return #CTLD_CARGO CargoObject +function CTLD:AddStaticsCargoFromType(Name,TypeName,Mass,Stock,SubCategory,DontShowInMenu,Location,UnitTypes,Category,ShapeName,ResourceMap,DisplayName) + self:T(self.lid .. " AddStaticsCargoFromType") + self.CargoCounter = self.CargoCounter + 1 + local cargotype = CTLD_CARGO.Enum.STATIC + local template = TypeName or self.basetype or "container_cargo" + local cargo = CTLD_CARGO:New(self.CargoCounter,Name,template,cargotype,false,false,1,nil,nil,Mass,Stock,SubCategory,DontShowInMenu,Location) + if UnitTypes then + cargo:AddUnitTypeName(UnitTypes) + end + cargo:SetStaticTypeAndShape(Category or "Cargos", template, ShapeName) + cargo:SetDisplayName(DisplayName or Name) + if ResourceMap then + ResourceMap = UTILS.DeepCopy(ResourceMap) + end + cargo:SetStaticResourceMap(ResourceMap) + table.insert(self.Cargo_Statics,cargo) + self._crateOrStaticByName = self._crateOrStaticByName or {} + self._crateOrStaticByName[cargo.Name] = cargo + self._cargoByTemplate = self._cargoByTemplate or {} + local _template = cargo.Templates + if type(_template) == "table" then + _template = _template[1] + end + if type(_template) == "string" and _template ~= "" then + self._cargoByTemplate[_template] = cargo + end if SubCategory and self.usesubcats ~= true then self.usesubcats=true end return cargo end @@ -6941,11 +7248,12 @@ end -- @param #CTLD self -- @param #string Name Unique Unit(!) name of this type of cargo as set in the mission editor (not: GROUP name!), e.g. "Ammunition-1". -- @param #number Mass Mass in kg of each static in kg, e.g. 100. +-- @param #string DisplayName Display name shown in menus/messages (optional). Falls back to `Name`. -- @return #CTLD_CARGO Cargo object -function CTLD:GetStaticsCargoFromTemplate(Name,Mass) +function CTLD:GetStaticsCargoFromTemplate(Name,Mass,DisplayName) self:T(self.lid .. " GetStaticsCargoFromTemplate") self.CargoCounter = self.CargoCounter + 1 - local type = CTLD_CARGO.Enum.STATIC + local cargotype = CTLD_CARGO.Enum.STATIC local template = STATIC:FindByName(Name,true):GetTypeName() local unittemplate = _DATABASE:GetStaticUnitTemplate(Name) local ResourceMap = nil @@ -6953,12 +7261,38 @@ function CTLD:GetStaticsCargoFromTemplate(Name,Mass) ResourceMap = UTILS.DeepCopy(unittemplate.resourcePayload) end -- Crates are not directly loadable - local cargo = CTLD_CARGO:New(self.CargoCounter,Name,template,type,false,false,1,nil,nil,Mass,1) + local cargo = CTLD_CARGO:New(self.CargoCounter,Name,template,cargotype,false,false,1,nil,nil,Mass,1) + cargo:SetDisplayName(DisplayName or Name) cargo:SetStaticResourceMap(ResourceMap) --table.insert(self.Cargo_Statics,cargo) return cargo end +--- User function - Get a *generic* static-type loadable cargo from a static **type name**. +-- @param #CTLD self +-- @param #string Name Unique name of this type of cargo. +-- @param #string TypeName Static type name, e.g. "ammo_cargo", "iso_container_small". +-- @param #number Mass Mass in kg of each static in kg, e.g. 100. +-- @param #string Category Static category name (optional). Default is "Cargos". +-- @param #string ShapeName Static shape name (optional). +-- @param #table ResourceMap Resource map payload (optional) for static cargo. +-- @param #string DisplayName Display name shown in menus/messages (optional). Falls back to `Name`. +-- @return #CTLD_CARGO Cargo object +function CTLD:GetStaticsCargoFromType(Name,TypeName,Mass,Category,ShapeName,ResourceMap,DisplayName) + self:T(self.lid .. " GetStaticsCargoFromType") + self.CargoCounter = self.CargoCounter + 1 + local cargotype = CTLD_CARGO.Enum.STATIC + local template = TypeName or self.basetype or "container_cargo" + local cargo = CTLD_CARGO:New(self.CargoCounter,Name,template,cargotype,false,false,1,nil,nil,Mass,1) + cargo:SetStaticTypeAndShape(Category or "Cargos", template, ShapeName) + cargo:SetDisplayName(DisplayName or Name) + if ResourceMap then + ResourceMap = UTILS.DeepCopy(ResourceMap) + end + cargo:SetStaticResourceMap(ResourceMap) + return cargo +end + --- User function - Add *generic* repair crates loadable as cargo. This type will create crates that need to be loaded, moved, dropped and built. -- @param #CTLD self -- @param #string Name Unique name of this type of cargo. E.g. "Humvee". @@ -6992,6 +7326,16 @@ function CTLD:AddCratesRepair(Name,Template,Type,NoCrates, PerCrateMass,Stock,Su cargo:SetStaticTypeAndShape(Category,TypeName,ShapeName) end table.insert(self.Cargo_Crates,cargo) + self._crateOrStaticByName = self._crateOrStaticByName or {} + self._crateOrStaticByName[cargo.Name] = cargo + self._cargoByTemplate = self._cargoByTemplate or {} + local _template = cargo.Templates + if type(_template) == "table" then + _template = _template[1] + end + if type(_template) == "string" and _template ~= "" then + self._cargoByTemplate[_template] = cargo + end return self end @@ -8429,11 +8773,17 @@ end template = string.gsub(GroupName,"#(%d+)$","") end template = string.gsub(template,"-(%d+)$","") + self._cargoByTemplate = self._cargoByTemplate or {} + local cached = self._cargoByTemplate[template] + if cached and cached.CargoType ~= CTLD_CARGO.Enum.REPAIR then + return cached + end for k,v in pairs(self.Cargo_Troops) do local comparison = "" if type(v.Templates) == "string" then comparison = v.Templates else comparison = v.Templates[1] end if comparison == template then Cargotype = v + self._cargoByTemplate[template] = v break end end @@ -8443,6 +8793,7 @@ end if type(v.Templates) == "string" then comparison = v.Templates else comparison = v.Templates[1] end if comparison == template and v.CargoType ~= CTLD_CARGO.Enum.REPAIR then Cargotype = v + self._cargoByTemplate[template] = v break end end @@ -8867,23 +9218,20 @@ end -- @return #CTLD self function CTLD:onafterStatus(From, Event, To) self:T({From, Event, To}) - -- gather some stats - -- pilots - local pilots = 0 - for _,_pilot in pairs (self.CtldUnits) do - pilots = pilots + 1 - end - - -- spawned cargo boxes curr in field - local boxes = 0 - for _,_pilot in pairs (self.Spawned_Cargo) do - boxes = boxes + 1 - end - - local cc = self.CargoCounter - local tc = self.TroopCounter - if self.debug or self.verbose > 0 then + -- gather stats only when logging is enabled + local pilots = 0 + for _,_pilot in pairs (self.CtldUnits) do + pilots = pilots + 1 + end + + local boxes = 0 + for _,_pilot in pairs (self.Spawned_Cargo) do + boxes = boxes + 1 + end + + local cc = self.CargoCounter + local tc = self.TroopCounter local text = string.format("%s Pilots %d | Live Crates %d |\nCargo Counter %d | Troop Counter %d", self.lid, pilots, boxes, cc, tc) local m = MESSAGE:New(text,10,"CTLD"):ToAll() if self.verbose > 0 then diff --git a/Moose Development/Moose/Sound/SRS.lua b/Moose Development/Moose/Sound/SRS.lua index 3432e0f0c..ff1aeb491 100644 --- a/Moose Development/Moose/Sound/SRS.lua +++ b/Moose Development/Moose/Sound/SRS.lua @@ -50,8 +50,8 @@ -- @field #string ConfigFilePath Path to the standard config file. -- @field #boolean ConfigLoaded If `true` if config file was loaded. -- @field #table poptions Provider options. Each element is a data structure of type `MSRS.ProvierOptions`. --- @field #string provider Provider of TTS (win, gcloud, azure, amazon). --- @field #string backend Backend used as interface to SRS (MSRS.Backend.SRSEXE or MSRS.Backend.GRPC). +-- @field #string provider Provider of TTS (win, gcloud, azure, amazon, ...). +-- @field #string backend Backend used as interface to SRS (MSRS.Backend.SRSEXE, MSRS.Backend.HOUND or MSRS.Backend.GRPC). -- @field #boolean UsePowerShell Use PowerShell to execute the command and not cmd.exe -- @extends Core.Base#BASE @@ -67,7 +67,8 @@ -- -- * This script needs SRS version >= 1.9.6 -- * You need to de-sanitize os, io and lfs in the missionscripting.lua --- * Optional: DCS-gRPC as backend to communicate with SRS (vide infra) +-- * Optional: DCS-gRPC as backend to communicate with SRS (via infra) +-- * Optional: HOUNDTTS as backend to communicate with SRS (via infra) -- -- ## Knwon Issues -- @@ -77,7 +78,7 @@ -- expecially in VR but unavoidable (if you have a solution, please feel free to share!). -- -- NOTE that this is not an issue if the mission is running on a server. --- Also NOTE that using DCS-gRPC as backend will avoid the pop-up window. +-- Also NOTE that using DCS-gRPC and Hound-TTS as backend create no pop-up window. -- -- # Play Sound Files -- @@ -188,14 +189,31 @@ -- The default interface to SRS is via calling the 'DCS-SR-ExternalAudio.exe'. As noted above, this has the unavoidable drawback that a pop-up briefly appears -- and DCS might be put out of focus. -- +-- ## Hound TTS as an alternative to 'DCS-SR-ExternalAudio.exe' for TTS +-- +-- An alternative interface to SRS is [Hound-TTS](https://github.com/uriba107/HoundTTS/releases). This does not call an exe file and therefore avoids the annoying pop-up window. +-- In addition to Windows and Google cloud, it also offers Piper local voice creation and others as providers for TTS. +-- +-- Use @{#MSRS.SetDefaultBackendHound} to enable [Hound-TTS](https://github.com/uriba107/HoundTTS/releases) as an alternate backend. +-- This can be useful if the popup should be avoided or to use Piper or others for TTS. Please note, only text-to-speech is supported and it it cannot be used to transmit audio files. +-- +-- Hound TTS must be installed and configured per the [Hound TTS](https://github.com/uriba107/HoundTTS#installation) prior to use. +-- If a cloud TTS provider is being used, the API key(s) must be set as per the documentation. +-- Hound TTS can be used both with DCS dedicated server and regular DCS installations. +-- +-- To use the default local Windows TTS with Hound TTS, Windows 2019 Server (or newer) or Windows 10/11 are required. Voices for non-local languages and dialects may need to +-- be explicitly installed. +-- +-- To set the MSRS class to use the Hound TTS backend for all future instances, call the function `MSRS.SetDefaultBackendHound()`. +-- -- ## DCS-gRPC as an alternative to 'DCS-SR-ExternalAudio.exe' for TTS -- -- Another interface to SRS is [DCS-gRPC](https://github.com/DCS-gRPC/rust-server). This does not call an exe file and therefore avoids the annoying pop-up window. -- In addition to Windows and Google cloud, it also offers Microsoft Azure and Amazon Web Service as providers for TTS. -- --- Use @{#MSRS.SetDefaultBackendGRPC} to enable [DCS-gRPC](https://github.com/DCS-gRPC/rust-server) as an alternate backend for transmitting text-to-speech over SRS. --- This can be useful if 'DCS-SR-ExternalAudio.exe' cannot be used in the environment or to use Azure or AWS clouds for TTS. Note that DCS-gRPC does not (yet?) support --- all of the features and options available with 'DCS-SR-ExternalAudio.exe'. Of note, only text-to-speech is supported and it it cannot be used to transmit audio files. +-- Use @{#MSRS.SetDefaultBackendGRPC} to enable [DCS-gRPC](https://github.com/DCS-gRPC/rust-server) as an alternate backend. +-- This can be useful if 'DCS-SR-ExternalAudio.exe' cannot be used in the environment or to use Azure or AWS clouds for TTS. Note that DCS-gRPC does not support +-- all of the features and options available with 'DCS-SR-ExternalAudio.exe'. Also note, only text-to-speech is supported and it it cannot be used to transmit audio files. -- -- DCS-gRPC must be installed and configured per the [DCS-gRPC documentation](https://github.com/DCS-gRPC/rust-server) and already running via either the 'autostart' mechanism -- or a Lua call to 'GRPC.load()' prior to use of the alternate DCS-gRPC backend. If a cloud TTS provider is being used, the API key must be set via the 'Config\dcs-grpc.lua' @@ -212,20 +230,20 @@ -- Basic Play Text-To-Speech example using alternate DCS-gRPC backend (DCS-gRPC not previously started): -- -- -- Start DCS-gRPC --- GRPC.load() +-- GRPC.load() -- not needed if you set gRPC to auto-start -- -- Select the alternate DCS-gRPC backend for new MSRS instances -- MSRS.SetDefaultBackendGRPC() -- -- Create a SOUNDTEXT object. -- local text=SOUNDTEXT:New("All Enemies destroyed") -- -- MOOSE SRS --- local msrs=MSRS:New('', 305.0) +-- local msrs=MSRS:New('ExampleInstance', 305.0) -- -- Text-to speech with default voice after 30 seconds. -- msrs:PlaySoundText(text, 30) -- -- Basic example of using another class (ATIS) with SRS and the DCS-gRPC backend (DCS-gRPC not previously started): -- -- -- Start DCS-gRPC --- GRPC.load() +-- GRPC.load() -- not needed if you set gRPC to auto-start -- -- Select the alternate DCS-gRPC backend for new MSRS instances -- MSRS.SetDefaultBackendGRPC() -- -- Create new ATIS as usual @@ -262,7 +280,7 @@ MSRS = { --- MSRS class version. -- @field #string version -MSRS.version="0.3.3" +MSRS.version="0.3.4" --- Voices -- @type MSRS.Voices @@ -699,9 +717,11 @@ MSRS.Voices = { -- @type MSRS.Backend -- @field #string SRSEXE Use `DCS-SR-ExternalAudio.exe`. -- @field #string GRPC Use DCS-gRPC. +-- @field #string GRPC Use Hound-TTS. MSRS.Backend = { SRSEXE = "srsexe", GRPC = "grpc", + HOUND = "hound", } --- Text-to-speech providers. These are compatible with the DCS-gRPC conventions. @@ -710,11 +730,13 @@ MSRS.Backend = { -- @field #string GOOGLE Google (`gcloud`). -- @field #string AZURE Microsoft Azure (`azure`). Only possible with DCS-gRPC backend. -- @field #string AMAZON Amazon Web Service (`aws`). Only possible with DCS-gRPC backend. +-- @field #string PIPER Piper local voice service. Only possible with Hound-TTS backend. MSRS.Provider = { WINDOWS = "win", GOOGLE = "gcloud", AZURE = "azure", AMAZON = "aws", + PIPER = "piper", } --- Function for UUID. @@ -760,6 +782,7 @@ end -- DONE: Add google. -- DONE: Add gRPC google options -- DONE: Add loading default config file +-- DONE: Add Hound TTS ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- Constructor @@ -874,6 +897,16 @@ function MSRS:SetBackendGRPC() return self end +--- Set Hound-TTS as backend to communicate with SRS. +-- @param #MSRS self +-- @return #MSRS self +function MSRS:SetBackendHound() + self:F() + self:SetBackend(MSRS.Backend.HOUND) + + return self +end + --- Set `DCS-SR-ExternalAudio.exe` as backend to communicate with SRS. -- @param #MSRS self -- @return #MSRS self @@ -896,6 +929,12 @@ function MSRS.SetDefaultBackendGRPC() MSRS.backend=MSRS.Backend.GRPC end +--- Set Hound-TTS to be the default backend. +-- @param #MSRS self +function MSRS.SetDefaultBackendHound() + MSRS.backend=MSRS.Backend.HOUND +end + --- Get currently set backend. -- @param #MSRS self -- @return #string Backend. @@ -1147,6 +1186,16 @@ function MSRS:SetVoiceGoogle(Voice) return self end +--- Set to use a specific voice if Piper is used as provider (only Hound-TTS backend). Note that this will override any gender and culture settings. +-- @param #MSRS self +-- @param #string Voice [Piper Voices](https://rhasspy.github.io/piper-samples/). Default `"en_US-ryan-low"`. +-- @return #MSRS self +function MSRS:SetVoicePiper(Voice) + self:F( {Voice=Voice} ) + self:SetVoiceProvider(Voice or "en_US-ryan-low", MSRS.Provider.PIPER) + + return self +end --- Set to use a specific voice if Microsoft Azure is use as provider (only DCS-gRPC backend). Note that this will override any gender and culture settings. -- @param #MSRS self @@ -1241,9 +1290,10 @@ end -- - `MSRS.Provider.WINDOWS`: Microsoft Windows (default) -- - `MSRS.Provider.GOOGLE`: Google Cloud -- - `MSRS.Provider.AZURE`: Microsoft Azure (only with DCS-gRPC backend) --- - `MSRS.Provier.AMAZON`: Amazon Web Service (only with DCS-gRPC backend) +-- - `MSRS.Provider.AMAZON`: Amazon Web Service (only with DCS-gRPC backend) +-- - `MSRS.Provider.PIPER`: Piper Voices (only with Hound-TTS backend) -- --- Note that all providers except Microsoft Windows need as additonal information the credentials of your account. +-- Note that all providers except Microsoft Windows and Piper need as additonal information the credentials of your account. -- -- @param #MSRS self -- @param #string Provider @@ -1390,6 +1440,15 @@ function MSRS:SetTTSProviderAmazon() return self end +--- Use Piper to provide text-to-speech. Only supported if used in combination with Hound-TTS as backend. +-- @param #MSRS self +-- @return #MSRS self +function MSRS:SetTTSProviderPiper() + self:F() + self:SetProvider(MSRS.Provider.PIPER) + return self +end + --- Print SRS help to DCS log file. -- @param #MSRS self @@ -1472,9 +1531,12 @@ function MSRS:PlaySoundText(SoundText, Delay) if Delay and Delay>0 then self:ScheduleOnce(Delay, MSRS.PlaySoundText, self, SoundText, 0) else - + + -- TODO Insert HOUND option if self.backend==MSRS.Backend.GRPC then self:_DCSgRPCtts(SoundText.text, nil, SoundText.gender, SoundText.culture, SoundText.voice, SoundText.volume, SoundText.label, SoundText.coordinate) + elseif self.backend == MSRS.Backend.HOUND then + self:_HoundTextToSpeech(SoundText.text,nil,nil,SoundText.volume,SoundText.label,self.coalition,SoundText.coordinate,SoundText.Speed,SoundText.gender,SoundText.culture,SoundText.voice) else -- Get command. @@ -1498,8 +1560,9 @@ end -- @param #string Text Text message. -- @param #number Delay Delay in seconds, before the message is played. -- @param Core.Point#COORDINATE Coordinate Coordinate. +-- @param #number Speed -- @return #MSRS self -function MSRS:PlayText(Text, Delay, Coordinate) +function MSRS:PlayText(Text, Delay, Coordinate, Speed) self:F( {Text, Delay, Coordinate} ) if Delay and Delay>0 then @@ -1509,8 +1572,10 @@ function MSRS:PlayText(Text, Delay, Coordinate) if self.backend==MSRS.Backend.GRPC then self:T(self.lid.."Transmitting") self:_DCSgRPCtts(Text, nil, nil , nil, nil, nil, nil, Coordinate) + elseif self.backend==MSRS.Backend.HOUND then + self:_HoundTextToSpeech(Text,nil,nil,nil,nil,nil,Coordinate,Speed) else - self:PlayTextExt(Text, Delay, nil, nil, nil, nil, nil, nil, nil, Coordinate) + self:PlayTextExt(Text, Delay, nil, nil, nil, nil, nil, nil, nil, Coordinate, Speed) end end @@ -1530,12 +1595,13 @@ end -- @param #number Volume Volume. -- @param #string Label Label. -- @param Core.Point#COORDINATE Coordinate Coordinate. +-- @param #number Speed Speed. -- @return #MSRS self -function MSRS:PlayTextExt(Text, Delay, Frequencies, Modulations, Gender, Culture, Voice, Volume, Label, Coordinate) - self:T({Text, Delay, Frequencies, Modulations, Gender, Culture, Voice, Volume, Label, Coordinate} ) +function MSRS:PlayTextExt(Text, Delay, Frequencies, Modulations, Gender, Culture, Voice, Volume, Label, Coordinate,Speed) + self:T({Text, Delay, Frequencies, Modulations, Gender, Culture, Voice, Volume, Label, Coordinate, Speed} ) if Delay and Delay>0 then - self:ScheduleOnce(Delay, self.PlayTextExt, self, Text, 0, Frequencies, Modulations, Gender, Culture, Voice, Volume, Label, Coordinate) + self:ScheduleOnce(Delay, self.PlayTextExt, self, Text, 0, Frequencies, Modulations, Gender, Culture, Voice, Volume, Label, Coordinate, Speed) else Frequencies = Frequencies or self:GetFrequencies() @@ -1556,7 +1622,14 @@ function MSRS:PlayTextExt(Text, Delay, Frequencies, Modulations, Gender, Culture --BASE:I("MSRS.Backend.GRPC") self:_DCSgRPCtts(Text, Frequencies, Gender, Culture, Voice, Volume, Label, Coordinate) - + + elseif self.backend==MSRS.Backend.HOUND then + -- BASE:I("MSRS.Backend.HOUND") + + local UseGoogle = (self.provider == MSRS.Provider.GOOGLE) and true or nil + + self:_HoundTextToSpeech(Text,Frequencies,Modulations,Volume,Label,self.coalition,Coordinate,Speed,Gender,Culture,Voice,UseGoogle) + end end @@ -1871,7 +1944,7 @@ end function MSRS:_DCSgRPCtts(Text, Frequencies, Gender, Culture, Voice, Volume, Label, Coordinate) -- Debug info. - self:T("MSRS_BACKEND_DCSGRPC:_DCSgRPCtts()") + self:T("MSRS_BACKEND_DCSGRPC:_DCSgRPCtts") self:T({Text, Frequencies, Gender, Culture, Voice, Volume, Label, Coordinate}) local options = {} -- #MSRS.GRPCOptions @@ -1881,7 +1954,7 @@ function MSRS:_DCSgRPCtts(Text, Frequencies, Gender, Culture, Voice, Volume, Lab -- Get frequenceies. Frequencies = UTILS.EnsureTable(Frequencies, true) or self:GetFrequencies() - -- Plain text (not really used. + -- Plain text - not really used. options.plaintext=Text -- Name shows as sender. @@ -1938,6 +2011,156 @@ function MSRS:_DCSgRPCtts(Text, Frequencies, Gender, Culture, Voice, Volume, Lab end +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +-- Hound-TTS Backend Functions +------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + +--- Hound TTS Function Wrapper +-- @param #MSRS self +-- @param #string Message The text to speak. +-- @param #table Frequencies The table of frequencies to use. +-- @param #table Modulations The table of modulations to use. +-- @param #number Volume The volume to use, defaults to 1.0. +-- @param #string Label The label to use, defaults to "MSRS". +-- @param #number Coalition The coalition to use. +-- @param Core.Point#COORDINATE Point (Optional) The point from which the voice is sent. +-- @param #number Speed (Optional) How fast to speak, defaults to 1.0. +-- @param #string Gender (Optional) Gender to use. +-- @param #string Culture (Optional) Culture to use. +-- @param #string Voice (Optional) Voice to use. +-- @param #boolean UseGoogle (Optional) If to use Google TTS. +-- @return SpeechTime Speech time in seconds. +function MSRS:_HoundTextToSpeech(Message,Frequencies,Modulations,Volume,Label,Coalition,Point,Speed,Gender,Culture,Voice,UseGoogle) + self:I(self.lid.."_HoundTextToSpeech") + + local ffs = {} + for _,_f in pairs(Frequencies or self.frequencies) do + table.insert(ffs,string.format("%.1f",_f)) + end + + local freqs = table.concat(ffs, ",") + local modus = table.concat(Modulations or self.modulations, ",") + + local coal=Coalition or self.coalition + local gender=Gender or self.gender + local voice=Voice or self:GetVoice(self.provider) or self.voice + local culture=Culture or self.culture + local volume=Volume or self.volume or 1.0 + local speed=Speed or self.speed or 1.0 + local label=Label or self.Label or "MSRS" + local coordinate=Point or self.coordinate + local point = (coordinate ~= nil) and coordinate:GetVec3() or nil + local port = self.port or 5002 + + -- Replace modulation + modus=modus:gsub("0", "AM") + modus=modus:gsub("1", "FM") + + self:I({T=Message,F=freqs,M=modus,V=voice,Vx=volume,L=label,C=coal,GGL=tostring(UseGoogle)}) + + if (UseGoogle ~= true) and self.provider == MSRS.Provider.GOOGLE then + UseGoogle = true + end + + local provider = self.provider + provider=provider:gsub("gcloud", "google") + provider=provider:gsub("win", "sapi") + + local TransmissionP = { + freqs = freqs, + modulations = modus, + coalition = coal, + name = label, + point = point, + volume = volume, + port = port, + } + local ProviderP = { + provider = provider, + voice = voice, + speed = speed, + culture = culture, + gender = gender, + } + + local speechtime = HoundTTS.Transmit(Message, TransmissionP, ProviderP) + + return speechtime +end + +--- Hound Transmit Function Wrapper +-- @param #MSRS self +-- @param #string Message The message to speak +-- @param #table Transmission_params Transmission parameter table, see below +-- @param #table Provider_params Provider parameter table, see below +-- @return SpeechTime Speech time in seconds. +-- @usage +-- -- #table Transmission_params +-- | Field | Type | Default | Description | +-- | ----------- | -------- | ------------------- | ---------------------------------------- | +-- | transmitter | string | `"srs"` | Transmitter type. Currently only `"srs"` | +-- | freqs | string | `"251.0"` | Frequency in MHz, comma-separated | +-- | modulations | string | `"AM"` | `AM` or `FM`, comma-separated | +-- | coalition | number | `0` | 0=spectator, 1=red, 2=blue | +-- | name | string | `"HoundTTS"` | Client name shown in SRS | +-- | point | Vec3/nil | `nil` | DCS position for geo-location | +-- | volume | number | `1.0` | 0.0 – 1.0 | +-- | encrypt | boolean | `false` | Enable SRS encryption | +-- | encKey | number | `0` | Encryption key (0–255, must match SRS) | +-- | host | string | `HoundTTS.SRS_HOST` | SRS server IP | +-- | port | number | `HoundTTS.SRS_PORT` | SRS server port | +-- +-- -- #table Provider_params +-- | Field | Type | Default | Description | +-- | -------- | ------ | --------------------------- | ---------------------------------------------------------------------------------- | +-- | provider | string | `HoundTTS.DEFAULT_PROVIDER` | `"piper"` / `"sapi"` / `"azure"` / `"google"` / `"elevenlabs"` | +-- | voice | string | `HoundTTS.DEFAULT_VOICE` | Piper model name, SAPI voice name, Azure/Google voice name, or ElevenLabs voice ID | +-- | culture | string | `HoundTTS.DEFAULT_CULTURE` | BCP-47 locale e.g. `"en-US"`, `"en-GB"` (used by SAPI, Azure, Google) | +-- | gender | string | `HoundTTS.DEFAULT_GENDER` | `"male"` / `"female"` (used by SAPI, Google) | +-- | speed | number | `1.0` | Speech rate (0.5 = half speed, 1.0 = normal, 2.0 = double speed) | +-- +function MSRS:_HoundTransmit(Message, Transmission_params, Provider_params) + self:I(self.lid.."_HoundTransmit") + self:I({Message,Transmission_params,Provider_params}) + local speechtime = HoundTTS.Transmit(Message, Transmission_params, Provider_params) + return speechtime +end + +--- Hound Test Tone function, sends a 2-second 440 Hz sine wave tone directly over SRS, bypassing the TTS engine entirely. +-- Use this to verify the SRS connection is working before debugging TTS issues. +-- @param MSRS self +-- @param #table Frequencies The table of frequencies to use. +-- @param #table Modulations The table of modulations to use. +-- @param #number Coalition The coalition to use. +function MSRS:_HoundTestTone(Frequencies, Modulations, Coalition) + self:I(self.lid.."_HoundTestTone") + + local ffs = {} + for _,_f in pairs(Frequencies or self.frequencies) do + table.insert(ffs,string.format("%.1f",_f)) + end + + local freqs = table.concat(ffs, ",") + local modus = table.concat(Modulations or self.modulations, ",") + -- Replace modulation + modus=modus:gsub("0", "AM") + modus=modus:gsub("1", "FM") + local coal=Coalition or self.coalition + HoundTTS.TestTone(freqs, modus, coal) + return self +end + +--- Hound speech time calculator. Use to determine how long it takes to speak something out. +-- @param MSRS self +-- @param #string Message The message to measure. Can also be handed as string lenght. +-- @param #number Speed The speed to use, defaults to 1.0. +-- @param #boolean UseGoogle If to use google. Default: no. +function MSRS:_HoundSpeechTime(Message,Speed,UseGoogle) + local speed = Speed or 1.0 + local speechtime = HoundTTS.getSpeechTime(Message, speed, UseGoogle) + return speechtime +end + ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -- Config File ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @@ -1957,7 +2180,7 @@ end -- MSRS_Config = { -- Path = "C:\\Program Files\\DCS-SimpleRadio-Standalone\\ExternalAudio", -- Path to SRS install directory. -- Port = 5002, -- Port of SRS server. Default 5002. --- Backend = "srsexe", -- Interface to SRS: "srsexe" or "grpc". +-- Backend = "srsexe", -- Interface to SRS: "srsexe" or "grpc" or "hound". -- Frequency = {127, 243}, -- Default frequences. Must be a table 1..n entries! -- Modulation = {0,0}, -- Default modulations. Must be a table, 1..n entries, one for each frequency! -- Volume = 1.0, -- Default volume [0,1]. @@ -1967,7 +2190,7 @@ end -- Gender = "male", -- Voice = "Microsoft Hazel Desktop", -- Voice that is used if no explicit provider voice is specified. -- Label = "MSRS", --- Provider = "win", --Provider for generating TTS (win, gcloud, azure, aws). +-- Provider = "win", --Provider for generating TTS (win, gcloud, azure, aws, piper). -- -- Windows -- win = { -- voice = "Microsoft Hazel Desktop", @@ -2092,31 +2315,38 @@ end -- @param #boolean isGoogle We're using Google TTS function MSRS.getSpeechTime(length,speed,isGoogle) - local maxRateRatio = 3 - - speed = speed or 1.0 - isGoogle = isGoogle or false - - local speedFactor = 1.0 - if isGoogle then - speedFactor = speed + if MSRS.backend == MSRS.Backend.HOUND then + local speechtime = HoundTTS.getSpeechTime(length, speed, isGoogle) + return speechtime else - if speed ~= 0 then - speedFactor = math.abs( speed ) * (maxRateRatio - 1) / 10 + 1 + + local maxRateRatio = 3 + + speed = speed or 1.0 + isGoogle = isGoogle or false + + local speedFactor = 1.0 + if isGoogle then + speedFactor = speed + else + if speed ~= 0 then + speedFactor = math.abs( speed ) * (maxRateRatio - 1) / 10 + 1 + end + if speed < 0 then + speedFactor = 1 / speedFactor + end end - if speed < 0 then - speedFactor = 1 / speedFactor + + local wpm = math.ceil( 100 * speedFactor ) + local cps = math.floor( (wpm * 5) / 60 ) + + if type( length ) == "string" then + length = string.len( length ) end + + return length/cps --math.ceil(length/cps) + end - - local wpm = math.ceil( 100 * speedFactor ) - local cps = math.floor( (wpm * 5) / 60 ) - - if type( length ) == "string" then - length = string.len( length ) - end - - return length/cps --math.ceil(length/cps) end ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @@ -2168,6 +2398,7 @@ MSRSQUEUE = { -- @field #number volume Volume -- @field #string label Label to be used -- @field Core.Point#COORDINATE coordinate Coordinate for this transmission +-- @field #number speed Speed to be used --- Create a new MSRSQUEUE object for a given radio frequency/modulation. -- @param #MSRSQUEUE self @@ -2252,9 +2483,10 @@ end -- @param #number volume Volume setting -- @param #string label Label to be used -- @param Core.Point#COORDINATE coordinate Coordinate to be used +-- @param #number speed Speed to be used -- @return #MSRSQUEUE.Transmission Radio transmission table. -function MSRSQUEUE:NewTransmission(text, duration, msrs, tstart, interval, subgroups, subtitle, subduration, frequency, modulation, gender, culture, voice, volume, label,coordinate) - self:T({Text=text, Dur=duration, start=tstart, int=interval, sub=subgroups, subt=subtitle, sudb=subduration, F=frequency, M=modulation, G=gender, C=culture, V=voice, Vol=volume, L=label}) +function MSRSQUEUE:NewTransmission(text, duration, msrs, tstart, interval, subgroups, subtitle, subduration, frequency, modulation, gender, culture, voice, volume, label,coordinate,speed) + self:T({Text=text, Dur=duration, start=tstart, int=interval, sub=subgroups, subt=subtitle, sudb=subduration, F=frequency, M=modulation, G=gender, C=culture, V=voice, Vol=volume, L=label, S=speed}) if self.TransmitOnlyWithPlayers then if self.PlayerSet and self.PlayerSet:CountAlive() == 0 then return self @@ -2294,6 +2526,7 @@ function MSRSQUEUE:NewTransmission(text, duration, msrs, tstart, interval, subgr transmission.volume = volume or msrs.volume transmission.label = label or msrs.Label transmission.coordinate = coordinate or msrs.coordinate + transmission.speed = speed or 1.0 -- Add transmission to queue. self:AddTransmission(transmission) @@ -2308,9 +2541,9 @@ function MSRSQUEUE:Broadcast(transmission) self:T(self.lid.."Broadcast") if transmission.frequency then - transmission.msrs:PlayTextExt(transmission.text, nil, transmission.frequency, transmission.modulation, transmission.gender, transmission.culture, transmission.voice, transmission.volume, transmission.label, transmission.coordinate) + transmission.msrs:PlayTextExt(transmission.text, nil, transmission.frequency, transmission.modulation, transmission.gender, transmission.culture, transmission.voice, transmission.volume, transmission.label, transmission.coordinate, transmission.speed) else - transmission.msrs:PlayText(transmission.text,nil,transmission.coordinate) + transmission.msrs:PlayText(transmission.text,nil,transmission.coordinate,transmission.speed) end local function texttogroup(gid) diff --git a/Moose Development/Moose/Sound/SoundOutput.lua b/Moose Development/Moose/Sound/SoundOutput.lua index 638dc9aec..7692b63fa 100644 --- a/Moose Development/Moose/Sound/SoundOutput.lua +++ b/Moose Development/Moose/Sound/SoundOutput.lua @@ -24,7 +24,7 @@ do -- Sound Base - -- @type SOUNDBASE + --- @type SOUNDBASE -- @field #string ClassName Name of the class. -- @extends Core.Base#BASE @@ -100,7 +100,7 @@ end do -- Sound File - -- @type SOUNDFILE + --- @type SOUNDFILE -- @field #string ClassName Name of the class -- @field #string filename Name of the flag. -- @field #string path Directory path, where the sound file is located. This includes the final slash "/". @@ -291,7 +291,8 @@ do -- Sound File end do -- Text-To-Speech - + + --- -- @type SOUNDTEXT -- @field #string ClassName Name of the class -- @field #string text Text to speak. @@ -299,6 +300,7 @@ do -- Text-To-Speech -- @field #string gender Gender: "male", "female". -- @field #string culture Culture, e.g. "en-GB". -- @field #string voice Specific voice to use. Overrules `gender` and `culture` settings. + -- @field #number speed Specific speed to be used. -- @extends Core.Base#BASE @@ -362,7 +364,7 @@ do -- Text-To-Speech self:SetDuration(Duration or MSRS.getSpeechTime(Text)) --self:SetGender() --self:SetCulture() - + self:SetSpeed() -- Debug info: self:T(string.format("New SOUNDTEXT: text=%s, duration=%.1f sec", self.text, self.duration)) @@ -425,4 +427,15 @@ do -- Text-To-Speech return self end + --- Set to use a specific speed. + -- @param #SOUNDTEXT self + -- @param #number Speed + -- @return #SOUNDTEXT self + function SOUNDTEXT:SetSpeed(Speed) + + self.speed = Speed or 1.0 + + return self + end + end \ No newline at end of file diff --git a/Moose Development/Moose/Wrapper/Airbase.lua b/Moose Development/Moose/Wrapper/Airbase.lua index a6c597c93..26f6558e6 100644 --- a/Moose Development/Moose/Wrapper/Airbase.lua +++ b/Moose Development/Moose/Wrapper/Airbase.lua @@ -606,7 +606,7 @@ AIRBASE.Syria = { --- Airbases of the Mariana Islands map -- -- * `AIRBASE.MarianaIslands.Andersen_AFB` Andersen AFB --- * `AIRBASE.MarianaIslands.Antonio_B._Won_Pat_Intl` Antonio B. Won Pat Intl +-- * `AIRBASE.MarianaIslands.Antonio_B_Won_Pat_Intl` Antonio B. Won Pat Intl -- * `AIRBASE.MarianaIslands.North_West_Field` North West Field -- * `AIRBASE.MarianaIslands.Olf_Orote` Olf Orote -- * `AIRBASE.MarianaIslands.Pagan_Airstrip` Pagan Airstrip @@ -617,7 +617,7 @@ AIRBASE.Syria = { -- @field MarianaIslands AIRBASE.MarianaIslands = { ["Andersen_AFB"] = "Andersen AFB", - ["Antonio_B._Won_Pat_Intl"] = "Antonio B. Won Pat Intl", + ["Antonio_B_Won_Pat_Intl"] = "Antonio B. Won Pat Intl", ["North_West_Field"] = "North West Field", ["Olf_Orote"] = "Olf Orote", ["Pagan_Airstrip"] = "Pagan Airstrip", diff --git a/README.md b/README.md index 0a0115d72..32e27d2f7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Moose-Includes](https://github.com/FlightControl-Master/MOOSE/actions/workflows/build-includes.yml/badge.svg?branch=master)](https://github.com/FlightControl-Master/MOOSE/actions/workflows/build-includes.yml) +[![Moose-Includes](https://github.com/FlightControl-Master/MOOSE/actions/workflows/build-includes.yml/badge.svg?branch=master-ng)](https://github.com/FlightControl-Master/MOOSE/actions/workflows/build-includes.yml) MOOSE