function widget:GetInfo() return { name = "Attack Warning", desc = "Warns if stuff gets attacked", author = "knorke, Birdulon", date = "Oct 2011, June 2020", license = "GNU GPL, v2 or later", layer = 0, enabled = true, } end 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 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") end 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 -- 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) end function widget:Shutdown() WG.ShutdownTranslation(GetInfo().name) end --changing teams, rejoin, becoming spec etc function widget:PlayerChanged(playerID) if spGetSpectatingState() then --Spring.Echo(": Spectator mode. Widget removed.") widgetHandler:RemoveWidget() end localTeamID = Spring.GetLocalTeamID() end