diff --git a/unit_attack_warning.lua b/unit_attack_warning.lua index 49041dc..cb49dad 100644 --- a/unit_attack_warning.lua +++ b/unit_attack_warning.lua @@ -2,59 +2,286 @@ function widget:GetInfo() return { name = "Attack Warning", desc = "Warns if stuff gets attacked", - author = "knorke", - date = "Oct 2011", + author = "knorke, Birdulon", + date = "Oct 2011, June 2020", license = "GNU GPL, v2 or later", layer = 0, enabled = true, } end -local spGetSpectatingState = Spring.GetSpectatingState +options_path = "Settings/Alerts" +options_order = { + 'voiceSelfVolume', 'voiceAllyVolume', 'voiceSelfCooldown', 'voiceAllyCooldown', + 'markersEnable', 'markersSelfDuration', 'markersAllyDuration', 'markersSelfRadius', 'markersAllyRadius', +} +options = { + markersEnable = { + name = "Enable attack alert markers", + type = "bool", + value = true, + desc = "Place temporary map markers to increase visibility of off-screen attacks", + noHotkey = true, + }, + markersSelfDuration = { + name = "Attack marker duration (s) - self", + type = "number", + value = 5.0, + min = 0.5, + max = 15.0, + step = 0.5, + desc = "Attack markers for your own forces will be removed after this many seconds", + noHotkey = true, + }, + markersSelfRadius = { + name = "Attack marker spacing (elmo) - self", + type = "number", + value = 750, + min = 250, + max = 2000, + step = 250, + desc = "If your forces are attacked within this distance of an existing attack marker, suppress the alert", + noHotkey = true, + }, + markersAllyDuration = { + name = "Attack marker duration (s) - ally", + type = "number", + value = 2.5, + min = 0.5, + max = 15.0, + step = 0.5, + desc = "Attack markers for allied forces will be removed after this many seconds. You might want them to expire quicker than for your own forces.", + noHotkey = true, + }, + markersAllyRadius = { + name = "Attack marker spacing (elmo) - ally", + type = "number", + value = 1500, + min = 250, + max = 3000, + step = 250, + desc = "If allied forces are attacked within this distance of an existing attack marker, suppress the alert. You might want them spaced out further than attacks on your forces.", + noHotkey = true, + }, + voiceSelfVolume = { + name = "Attack alert volume - self", + type = "number", + value = 1.0, + min = 0.0, + max = 1.0, + step = 0.05, + desc = "Volume for attack alerts for your forces", + noHotkey = true, + }, + voiceSelfCooldown = { + name = "Attack alert cooldown (s) - self", + type = "number", + value = 6, + min = 1, + max = 30, + step = 1, + desc = "Attack alerts for your forces will not be sounded if the last alert sound was within this period", + noHotkey = true, + }, + voiceAllyVolume = { + name = "Attack alert volume - ally", + type = "number", + value = 1.0, + min = 0.0, + max = 1.0, + step = 0.05, + desc = "Volume for attack alerts for your forces", + noHotkey = true, + }, + voiceAllyCooldown = { + name = "Attack alert cooldown (s) - ally", + type = "number", + value = 6, + min = 1, + max = 30, + step = 1, + desc = "Attack alerts for your forces will not be sounded if the last alert sound was within this period", + noHotkey = true, + }, +} -local warningDelay = 30 * 5 --in frames -local lastWarning = 0 --in frames -local localTeamID = Spring.GetLocalTeamID () +local voiceFilenameSelfBaseAttacked = "sounds/alerts/SelfBaseAttacked.ogg" +local voiceFilenameSelfUnitAttacked = "sounds/alerts/SelfUnitAttacked.ogg" +local voiceFilenameAllyBaseAttacked = "sounds/alerts/AllyBaseAttacked.ogg" +local voiceFilenameAllyUnitAttacked = "sounds/alerts/AllyUnitAttacked.ogg" +local voiceFilenames = { -- [bSelf][isBuilding] + [false]={ + [false]=voiceFilenameAllyUnitAttacked, + [true]=voiceFilenameAllyBaseAttacked, + }, + [true]={ + [false]=voiceFilenameSelfUnitAttacked, + [true]=voiceFilenameSelfBaseAttacked, + }, +} + +local osClock = os.clock +local spPlaySoundFile = Spring.PlaySoundFile -- (filename, volume, posx, posy, posz, speedx, speedy, speedz, channel) +local spMarkerAddPoint = Spring.MarkerAddPoint -- (x, y, z, text, localonly) +local spMarkerErasePosition = Spring.MarkerErasePosition -- (x, y, z) Sadly you can't keep handles of markers +local spGetUnitPosition = Spring.GetUnitPosition +local spAreTeamsAllied = Spring.AreTeamsAllied +local spGetGameFrame = Spring.GetGameFrame +local spGetSpectatingState = Spring.GetSpectatingState +local spEcho = Spring.Echo +local spSetLastMessagePosition = Spring.SetLastMessagePosition + +local warningDelay = 30 * 5 --in frames +local lastWarning = 0 --in frames +local localTeamID = Spring.GetLocalTeamID() local under_attack_translation -local function languageChanged () - under_attack_translation = WG.Translate ("interface", "unit_under_attack") +local function languageChanged() + under_attack_translation = WG.Translate("interface", "unit_under_attack") end -function widget:UnitDamaged (unitID, unitDefID, unitTeam, damage, paralyzer, weaponID, attackerID, attackerDefID, attackerTeam) - if damage <= 0 then return end - local currentFrame = Spring.GetGameFrame () - if (lastWarning+warningDelay > currentFrame) then - return +local function dist2(x1, z1, x2, z2) + -- Distance squared between two points + -- Coords are X and Z because Spring uses Y as the vertical axis, and we only care about the planar position. + local dx, dz = x2-x1, z2-z1 + return dx*dx + dz*dz +end + +function TextAlert(unitDefID, x, y, z) + local currentFrame = spGetGameFrame() + if (lastWarning+warningDelay > currentFrame) then return end + lastWarning = currentFrame + spEcho("game_message: " .. Spring.Utilities.GetHumanName(UnitDefs[unitDefID]) .. " " .. under_attack_translation) + spSetLastMessagePosition(x, y, z) +end + +local attackMarkers = {} +local tLastVoice = 0 +local markerOffset = 2 -- Offset the marker Y coord by this much to avoid potential conflict with user-placed markers + +local data = { -- [bSelf] + [false]={ + voiceCooldown=options.voiceAllyCooldown.value, + voiceVolume=options.voiceAllyVolume.value, + markerD2=options.markersAllyRadius.value*options.markersAllyRadius.value, + markerPrefix={[false]="Allied Unit", [true]="Allied Base"}, + markerDuration=options.markersAllyDuration.value, + }, + [true]={ + voiceCooldown=options.voiceSelfCooldown.value, + voiceVolume=options.voiceSelfVolume.value, + markerD2=options.markersSelfRadius.value*options.markersSelfRadius.value, + markerPrefix={[false]="Unit", [true]="Base"}, + markerDuration=options.markersSelfDuration.value, + }, +} +function UpdateOptions() + data = { -- [bSelf] + [false]={ + voiceCooldown=options.voiceAllyCooldown.value, + voiceVolume=options.voiceAllyVolume.value, + markerD2=options.markersAllyRadius.value*options.markersAllyRadius.value, + markerPrefix={[false]="Allied Unit", [true]="Allied Base"}, + markerDuration=options.markersAllyDuration.value, + }, + [true]={ + voiceCooldown=options.voiceSelfCooldown.value, + voiceVolume=options.voiceSelfVolume.value, + markerD2=options.markersSelfRadius.value*options.markersSelfRadius.value, + markerPrefix={[false]="Unit", [true]="Base"}, + markerDuration=options.markersSelfDuration.value, + }, + } +end + + +function MarkerAlert(unitTeam, unitDefID, x, y, z) + -- Assumptions for calling this function: + -- - Unit is allied to the player + -- - Unit is not on-screen for the player + y = y + markerOffset + local t = osClock() + local isBuilding = UnitDefs[unitDefID].isBuilding + local bSelf = (unitTeam == localTeamID) + + -- Do voice alert if appropriate + if t > (tLastVoice + data[bSelf].voiceCooldown) then + spPlaySoundFile(voiceFilenames[bSelf][isBuilding], data[bSelf].voiceVolume, nil, nil, nil, nil, nil, nil, "userinterface") + spSetLastMessagePosition(x, y, z) + tLastVoice = t end - if (localTeamID==unitTeam and not Spring.IsUnitInView (unitID)) then - lastWarning = currentFrame - Spring.Echo ("game_message: " .. Spring.Utilities.GetHumanName(UnitDefs[unitDefID]) .. " " .. under_attack_translation) - --Spring.PlaySoundFile (blabla attack.wav, ... "userinterface") - local x,y,z = Spring.GetUnitPosition (unitID) - if (x and y and z) then - Spring.SetLastMessagePosition (x,y,z) + + -- Check if the new marker would be too close to any existing markers + for i=1,#attackMarkers do + local marker = attackMarkers[i] + local d2 = dist2(x, z, marker.x, marker.z) + if d2 < data[bSelf].markerD2 then return end + end + -- Place a marker + attackMarkers[#attackMarkers+1] = {x=x, y=y, z=z, tDeath=t+data[bSelf].markerDuration} + spMarkerAddPoint(x, y, z, data[bSelf].markerPrefix[isBuilding] .. " attacked", true) + spSetLastMessagePosition(x, y, z) +end + + +function CleanMarkers() + local t = osClock() + for i=#attackMarkers,1,-1 do + local m = attackMarkers[i] + if m.tDeath < t then + spMarkerErasePosition(m.x, m.y, m.z) + table.remove(attackMarkers, i) end end end + +local UPDATE_PERIOD = 0.25 -- Check to remove markers every 250ms +local timeframetimer = 0.0 +function widget:Update(dt) + timeframetimer = timeframetimer + dt + if (timeframetimer > UPDATE_PERIOD) then + UpdateOptions() -- Couldn't find a suitable call-in for options being changed + CleanMarkers() + timeframetimer = 0 + end +end + + +function widget:UnitDamaged(unitID, unitDefID, unitTeam, damage, paralyzer, weaponID, attackerID, attackerDefID, attackerTeam) + if damage <= 0 then return end + --spEcho("game_message: Processing damage event! " .. tostring(unitID) .. ", " .. tostring(unitDefID) .. ", " .. tostring(unitTeam) .. ", " .. tostring(attackerTeam) .. ", " .. tostring(attackerID)); + if Spring.IsUnitInView(unitID) then return end + if not spAreTeamsAllied(localTeamID, unitTeam) then return end + if not unitTeam then return end -- I don't even? + if not unitDefID then return end -- Haven't encountered this being nil but I'm not risking it + if attackerTeam and spAreTeamsAllied(unitTeam, attackerTeam) then return end -- Get a lot of nil for attacker stats, probably from things out of vision/radar. + local x,y,z = spGetUnitPosition(unitID) + if not (x and y and z) then return end + TextAlert(unitDefID, x, y, z) + MarkerAlert(unitTeam, unitDefID, x, y, z) +end + + function widget:Initialize() if spGetSpectatingState() then widgetHandler:RemoveWidget() end - WG.InitializeTranslation (languageChanged, GetInfo().name) + WG.InitializeTranslation(languageChanged, GetInfo().name) end + function widget:Shutdown() WG.ShutdownTranslation(GetInfo().name) end + --changing teams, rejoin, becoming spec etc -function widget:PlayerChanged (playerID) +function widget:PlayerChanged(playerID) if spGetSpectatingState() then --Spring.Echo(": Spectator mode. Widget removed.") widgetHandler:RemoveWidget() end - localTeamID = Spring.GetLocalTeamID () + localTeamID = Spring.GetLocalTeamID() end