Guest User

Untitled

a guest
Nov 5th, 2026
12
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 32.51 KB | None | 0 0
  1. --------------------------------------------------------------------------------
  2. --------------------------------------------------------------------------------
  3.  
  4. function widget:GetInfo()
  5. return {
  6. name = "Auto Engage",
  7. desc = "Combat units act on the Engagement Predictor: they attack-move into fights it rates as won, " ..
  8. "back away from fights it rates as lost, and walk back afterwards. Switch units on with the " ..
  9. "Auto Engage order button. Needs Combat Advisor.",
  10. author = "Thomas",
  11. date = "2026-11-04",
  12. license = "GNU GPL, v2 or later",
  13. layer = 0,
  14. enabled = true,
  15. }
  16. end
  17.  
  18. --------------------------------------------------------------------------------
  19. --------------------------------------------------------------------------------
  20. -- How it works
  21. --
  22. -- Twice a second the managed units are grouped (any two within `LINK` elmos are
  23. -- in the same group) and each group that has enemies nearby is scored with
  24. -- WG.EngagementPredictor.Predict, the same Gather and Score the Combat
  25. -- Advisor's panel and rings use. So the number this widget acts on is the
  26. -- number you would see with that group selected.
  27. --
  28. -- * win chance >= "Attack above": attack-move (FIGHT) at the nearest enemy
  29. -- group, if it is within "Engage range" and inside the leash.
  30. -- * win chance <= "Avoid below": if any enemy could reach the group within a
  31. -- couple of seconds, every unit steps back far enough to be outside that
  32. -- reach, away from the enemy and bent towards your nearest factory. If no
  33. -- enemy is that close yet, units walking in (attack-move) are stopped.
  34. -- * in between: no new orders. That band is the hysteresis.
  35. --
  36. -- Fleeing is immediate; re-engaging after a retreat waits "Re-engage delay"
  37. -- seconds, so a chance that wobbles round the threshold does not dance the
  38. -- army back and forth. When the area is quiet again, units the widget moved
  39. -- walk back to where they started, or pick up the attack-move you had given
  40. -- them before it pulled them out.
  41. --
  42. -- Your own orders win. A unit following a move, attack, guard, build or any
  43. -- other order you gave is left alone until it is idle again (attack-move and
  44. -- patrol excepted: those are what the widget is for, so it may pull such a
  45. -- unit out of a lost fight). Units the gadget is retreating, units in a
  46. -- transport and units on hold position are never sent to attack.
  47. --
  48. -- Orders the widget gave are recognised by their coordinates, as in Auto
  49. -- Retreat Plus: a queue whose first order is not one of ours is the player's.
  50. --------------------------------------------------------------------------------
  51. --------------------------------------------------------------------------------
  52.  
  53. local spGetMyTeamID = Spring.GetMyTeamID
  54. local spGetSpectatingState = Spring.GetSpectatingState
  55. local spGetTeamUnits = Spring.GetTeamUnits
  56. local spGetUnitDefID = Spring.GetUnitDefID
  57. local spGetUnitPosition = Spring.GetUnitPosition
  58. local spGetUnitCommands = Spring.GetUnitCommands
  59. local spGetUnitHealth = Spring.GetUnitHealth
  60. local spGetUnitIsDead = Spring.GetUnitIsDead
  61. local spGetUnitTransporter = Spring.GetUnitTransporter
  62. local spGetUnitRulesParam = Spring.GetUnitRulesParam
  63. local spGetUnitStates = Spring.GetUnitStates
  64. local spGetUnitTeam = Spring.GetUnitTeam
  65. local spGetUnitsInCylinder = Spring.GetUnitsInCylinder
  66. local spGetSelectedUnits = Spring.GetSelectedUnits
  67. local spGiveOrderToUnit = Spring.GiveOrderToUnit
  68. local spGetGroundHeight = Spring.GetGroundHeight
  69. local spGetGaiaTeamID = Spring.GetGaiaTeamID
  70. local spWorldToScreenCoords = Spring.WorldToScreenCoords
  71. local spGetCmdDescIndex = Spring.GetCmdDescIndex
  72. local spGetActiveCmdDesc = Spring.GetActiveCmdDesc
  73. local spEcho = Spring.Echo
  74.  
  75. local floor, max, min, sqrt, huge = math.floor, math.max, math.min, math.sqrt, math.huge
  76.  
  77. local CUSTOM = (Spring.Utilities and Spring.Utilities.CMD) or {}
  78. local CMD_RAW_MOVE = CUSTOM.RAW_MOVE or 31109
  79. local CMD_MOVE = CMD.MOVE
  80. local CMD_FIGHT = CMD.FIGHT
  81. local CMD_PATROL = CMD.PATROL
  82. local CMD_STOP = CMD.STOP
  83. local CMD_AUTO_ENGAGE = 39417 -- unused by ZK's customcmds.lua (checked against v1.14.10.3)
  84.  
  85. local ENEMY_UNITS = Spring.ENEMY_UNITS or -4
  86.  
  87. local MAP_X, MAP_Z = Game.mapSizeX, Game.mapSizeZ
  88. local EDGE = 64
  89.  
  90. local TICK = 15 -- frames between decisions (0.5 s)
  91. local LINK = 450 -- elmos: two managed units this close are one group
  92. local MAX_SCORED = 8 -- groups scored per tick; the rest wait their turn
  93. local MATCH = 8 -- elmos: an order this close to the one we gave is ours
  94. local REISSUE = 200 -- elmos a new target must move before the order is given again
  95. local MIN_STEP = 250 -- shortest backing-off move worth giving
  96. local BLIP_REACH = 500 -- reach assumed for a radar contact of unknown type
  97. local SPEED_TIME = 2 -- s of enemy movement added to its weapon range
  98. local SPEED_CAP = 300 -- elmos, cap on that
  99.  
  100. local ICON = 'LuaUI/Images/commands/Bold/fight.png'
  101.  
  102. --------------------------------------------------------------------------------
  103. -- Options
  104.  
  105. local Forget -- forward declaration, used by the scope option
  106.  
  107. options_path = 'Settings/Unit Behaviour/Auto Engage'
  108. options_order = {'manage', 'newOn',
  109. 'attackAbove', 'avoidBelow', 'engageRange', 'leash', 'safety', 'reengage',
  110. 'returnHome', 'resumeFight', 'overrideOrders', 'countBuildings',
  111. 'commanders', 'gunships', 'showLabels', 'showMarks'}
  112. options = {
  113. manage = {
  114. name = 'Units it controls',
  115. desc = 'Which of your combat units Auto Engage gives orders to. The order button switches the '
  116. .. 'selected units on or off in either mode.',
  117. type = 'radioButton',
  118. value = 'toggled',
  119. items = {
  120. {key = 'toggled', name = 'Units I switch on', desc = 'Off until you press the Auto Engage button.'},
  121. {key = 'all', name = 'All my combat units', desc = 'On unless you switch a unit off.'},
  122. },
  123. OnChange = function()
  124. if Forget then
  125. Forget()
  126. end
  127. end,
  128. },
  129. newOn = {
  130. name = 'New factory units start on',
  131. desc = 'Units leaving a factory are switched on, after they reach the rally point.',
  132. type = 'bool',
  133. value = false,
  134. },
  135. attackAbove = {
  136. name = 'Attack above (win %)',
  137. desc = 'A group the Engagement Predictor gives at least this chance attack-moves at the nearest enemies.',
  138. type = 'number',
  139. min = 50, max = 95, step = 5, value = 65,
  140. },
  141. avoidBelow = {
  142. name = 'Avoid below (win %)',
  143. desc = 'A group given this chance or less backs out of the enemy\'s reach. Between the two '
  144. .. 'thresholds it gets no new orders.',
  145. type = 'number',
  146. min = 5, max = 50, step = 5, value = 35,
  147. },
  148. engageRange = {
  149. name = 'Engage range',
  150. desc = 'Elmos from the edge of a group to the nearest enemy. Farther than this and a winning '
  151. .. 'group does not go looking for that fight.',
  152. type = 'number',
  153. min = 200, max = 3000, step = 50, value = 900,
  154. },
  155. leash = {
  156. name = 'Leash',
  157. desc = 'Never attack a target farther than this from where the unit was when the widget '
  158. .. 'first moved it. 0 = no leash: a winning group keeps rolling.',
  159. type = 'number',
  160. min = 0, max = 6000, step = 100, value = 1800,
  161. },
  162. safety = {
  163. name = 'Safety margin',
  164. desc = 'Elmos kept between a retreating group and the reach of the enemy (weapon range plus '
  165. .. 'two seconds of its movement).',
  166. type = 'number',
  167. min = 0, max = 600, step = 25, value = 150,
  168. },
  169. reengage = {
  170. name = 'Re-engage delay (s)',
  171. desc = 'After backing off, wait this long before attacking or walking back. Fleeing is always immediate.',
  172. type = 'number',
  173. min = 0, max = 15, step = 0.5, value = 3,
  174. },
  175. returnHome = {
  176. name = 'Walk back afterwards',
  177. desc = 'When nothing is left in range, units the widget moved go back to where they were.',
  178. type = 'bool',
  179. value = true,
  180. },
  181. resumeFight = {
  182. name = 'Resume my attack-move',
  183. desc = 'A unit pulled out of an attack-move or patrol you gave picks it up again once the area is quiet.',
  184. type = 'bool',
  185. value = true,
  186. },
  187. overrideOrders = {
  188. name = 'Avoid even during my orders',
  189. desc = 'Also pull out units following your own move/attack/guard orders when the fight is lost, '
  190. .. 'and give the order back afterwards. Off leaves them entirely to you.',
  191. type = 'bool',
  192. value = false,
  193. },
  194. countBuildings = {
  195. name = 'Count turrets and buildings',
  196. desc = 'Score fights with structures on both sides, whatever the Engagement Predictor panel is '
  197. .. 'set to. Off would walk an army into a turret line it never counted.',
  198. type = 'bool',
  199. value = true,
  200. },
  201. commanders = {
  202. name = 'Include commanders',
  203. desc = 'Let it move commanders too.',
  204. type = 'bool',
  205. value = false,
  206. OnChange = function()
  207. if Forget then
  208. Forget()
  209. end
  210. end,
  211. },
  212. gunships = {
  213. name = 'Include gunships',
  214. desc = 'Let it move gunships. Planes (fighters, bombers) are never included.',
  215. type = 'bool',
  216. value = true,
  217. OnChange = function()
  218. if Forget then
  219. Forget()
  220. end
  221. end,
  222. },
  223. showLabels = {
  224. name = 'Show decisions',
  225. desc = 'Write ENGAGE / AVOID / HOLD and the win chance over each group with enemies near.',
  226. type = 'bool',
  227. value = true,
  228. },
  229. showMarks = {
  230. name = 'Mark switched-on units',
  231. desc = 'A ring under the selected units that are switched on.',
  232. type = 'bool',
  233. value = true,
  234. },
  235. }
  236.  
  237. --------------------------------------------------------------------------------
  238. -- Unit definitions
  239.  
  240. -- customparams arrive stringified, so "false" and "0" are off.
  241. local function Flag(cp, key)
  242. local v = cp and cp[key]
  243. return v ~= nil and v ~= false and v ~= "false" and v ~= "0" and v ~= 0
  244. end
  245.  
  246. local armedDef = {} -- defID -> true: mobile, armed, not a plane or drone
  247. local commDef = {}
  248. local gunshipDef = {}
  249. local flyDef = {}
  250. local factoryDef = {}
  251. local skipEnemy = {} -- dontcount (terraform patches and the like): neither threat nor target
  252. local costOf = {}
  253.  
  254. for defID = 1, #UnitDefs do
  255. local ud = UnitDefs[defID]
  256. local cp = ud.customParams or {}
  257. costOf[defID] = ud.metalCost or 0
  258. skipEnemy[defID] = Flag(cp, "dontcount")
  259. factoryDef[defID] = ud.isFactory and true or nil
  260. local fly = ud.canFly and true or false
  261. flyDef[defID] = fly or nil
  262. local hovering = fly and (ud.hoverAttack or ud.isHoveringAirUnit)
  263. gunshipDef[defID] = hovering and true or nil
  264. commDef[defID] = (Flag(cp, "dynamic_comm") or Flag(cp, "commtype")) or nil
  265.  
  266. local armed = false
  267. local weapons = ud.weapons or {}
  268. for i = 1, #weapons do
  269. local wd = weapons[i].weaponDef and WeaponDefs[weapons[i].weaponDef]
  270. if wd and not wd.isShield then
  271. local dmg = tonumber(wd.customParams and wd.customParams.stats_damage)
  272. or (wd.damages and wd.damages[0]) or 0
  273. if dmg > 0 then
  274. armed = true
  275. end
  276. end
  277. end
  278. local mobile = (ud.speed or 0) > 0 and ud.canMove ~= false and not ud.isFactory
  279. local plane = fly and not hovering
  280. if armed and mobile and not plane and not Flag(cp, "is_drone") and not skipEnemy[defID] then
  281. armedDef[defID] = true
  282. end
  283. end
  284.  
  285. -- Category sets, keyed for the reach cache. Same reading of the weapon tables as the Combat
  286. -- Advisor and Auto Retreat Plus: stats_damage, and mount-level onlyTargets keyed by lowercase
  287. -- category, where an empty table hits everything.
  288. local catsCache = {}
  289. local function Cats(defID)
  290. local c = catsCache[defID]
  291. if c then
  292. return c
  293. end
  294. local set, list = {}, {}
  295. for cat in pairs(UnitDefs[defID].modCategories or {}) do
  296. local lc = string.lower(cat)
  297. set[lc] = true
  298. list[#list + 1] = lc
  299. end
  300. table.sort(list)
  301. c = {set = set, key = table.concat(list, " "), fly = flyDef[defID] or false}
  302. catsCache[defID] = c
  303. return c
  304. end
  305.  
  306. local reachCache = {} -- catKey -> enemy defID -> elmos it can shoot that kind of unit from (0 = cannot)
  307. local function Reach(enemyDef, cats)
  308. local byKey = reachCache[cats.key]
  309. if not byKey then
  310. byKey = {}
  311. reachCache[cats.key] = byKey
  312. end
  313. local r = byKey[enemyDef]
  314. if r then
  315. return r
  316. end
  317. r = 0
  318. local ud = UnitDefs[enemyDef]
  319. local weapons = ud.weapons or {}
  320. for i = 1, #weapons do
  321. local wpn = weapons[i]
  322. local wd = wpn.weaponDef and WeaponDefs[wpn.weaponDef]
  323. if wd and not wd.isShield and not (cats.fly and wd.type == "TorpedoLauncher") then
  324. local dmg = tonumber(wd.customParams and wd.customParams.stats_damage)
  325. or (wd.damages and wd.damages[0]) or 0
  326. if dmg > 0 and not (wd.customParams and wd.customParams.interceptor) then
  327. local ot = wpn.onlyTargets
  328. local hits = (not ot) or (next(ot) == nil)
  329. if not hits then
  330. for cat in pairs(ot) do
  331. if cats.set[string.lower(cat)] then
  332. hits = true
  333. break
  334. end
  335. end
  336. end
  337. if hits then
  338. r = max(r, wd.range or 0)
  339. end
  340. end
  341. end
  342. end
  343. byKey[enemyDef] = r
  344. return r
  345. end
  346.  
  347. --------------------------------------------------------------------------------
  348. -- State
  349.  
  350. local myTeam = spGetMyTeamID()
  351. local gaiaTeam = spGetGaiaTeamID()
  352. local spectating = false
  353.  
  354. local mine = {} -- unitID -> defID, my armed mobiles that have finished building
  355. local factories = {} -- unitID -> {x, z}
  356. local S = {} -- unitID -> per-unit record, created on first use
  357. local labels = {} -- what was decided last tick, for drawing: {x, z, p, word}
  358. local selected = {} -- eligible own units in the selection
  359. local rotate = 0 -- which group scores first this tick
  360. local warned = false
  361.  
  362. local function Rec(uid)
  363. local s = S[uid]
  364. if not s then
  365. s = {flipAt = -huge, orderAt = -huge}
  366. S[uid] = s
  367. end
  368. return s
  369. end
  370.  
  371. local function Eligible(defID)
  372. if not armedDef[defID] then
  373. return false
  374. end
  375. if commDef[defID] and not options.commanders.value then
  376. return false
  377. end
  378. if gunshipDef[defID] and not options.gunships.value then
  379. return false
  380. end
  381. return true
  382. end
  383.  
  384. local function IsOn(uid)
  385. local s = S[uid]
  386. if s and s.on ~= nil then
  387. return s.on
  388. end
  389. return options.manage.value == 'all'
  390. end
  391.  
  392. -- Drop what the widget was doing with a unit (one, or all of them), keeping the on/off switch.
  393. -- The unit keeps whatever order it has.
  394. Forget = function(uid)
  395. local function Clear(s)
  396. s.mode, s.oid, s.ox, s.oz, s.anchorX, s.anchorZ, s.resume = nil, nil, nil, nil, nil, nil, nil
  397. end
  398. if uid then
  399. if S[uid] then
  400. Clear(S[uid])
  401. end
  402. else
  403. for _, s in pairs(S) do
  404. Clear(s)
  405. end
  406. end
  407. end
  408.  
  409. local function AddUnit(uid, defID, team)
  410. if team ~= myTeam then
  411. return
  412. end
  413. if armedDef[defID] then
  414. mine[uid] = defID
  415. end
  416. if factoryDef[defID] then
  417. local x, _, z = spGetUnitPosition(uid)
  418. if x then
  419. factories[uid] = {x, z}
  420. end
  421. end
  422. end
  423.  
  424. local function RemoveUnit(uid)
  425. mine[uid] = nil
  426. factories[uid] = nil
  427. S[uid] = nil
  428. end
  429.  
  430. local function Rescan()
  431. mine, factories, S = {}, {}, {}
  432. myTeam = spGetMyTeamID()
  433. spectating = spGetSpectatingState()
  434. if spectating then
  435. return
  436. end
  437. local units = spGetTeamUnits(myTeam) or {}
  438. for i = 1, #units do
  439. local uid = units[i]
  440. local defID = spGetUnitDefID(uid)
  441. local bp = select(5, spGetUnitHealth(uid))
  442. if defID and (bp == nil or bp >= 1) then
  443. AddUnit(uid, defID, myTeam)
  444. end
  445. end
  446. end
  447.  
  448. --------------------------------------------------------------------------------
  449. -- Orders
  450.  
  451. local MOVE_IDS = {[CMD_MOVE] = true, [CMD_RAW_MOVE] = true}
  452.  
  453. local function NearXZ(ax, az, bx, bz, d)
  454. local dx, dz = ax - bx, az - bz
  455. return dx * dx + dz * dz <= d * d
  456. end
  457.  
  458. -- "idle", "ours", "fight" (the player's attack-move or patrol), or "busy" (any other order).
  459. local function QueueState(uid, s)
  460. local q = spGetUnitCommands(uid, 1)
  461. local c = q and q[1]
  462. if not c then
  463. return "idle"
  464. end
  465. local id, p = c.id, c.params
  466. if s.oid and p and p[3] then
  467. local same = (id == s.oid) or (MOVE_IDS[id] and MOVE_IDS[s.oid])
  468. if same and NearXZ(p[1], p[3], s.ox, s.oz, MATCH) then
  469. return "ours"
  470. end
  471. end
  472. if id == CMD_FIGHT or id == CMD_PATROL then
  473. return "fight", c
  474. end
  475. return "busy", c
  476. end
  477.  
  478. local function Clamp(x, z)
  479. return min(max(x, EDGE), MAP_X - EDGE), min(max(z, EDGE), MAP_Z - EDGE)
  480. end
  481.  
  482. local function Give(uid, s, cmdID, x, z, now)
  483. x, z = Clamp(x, z)
  484. local y = spGetGroundHeight(x, z) or 0
  485. spGiveOrderToUnit(uid, cmdID, {x, y, z}, 0)
  486. s.oid, s.ox, s.oz, s.orderAt = cmdID, x, z, now
  487. end
  488.  
  489. local function SetMode(s, mode, now)
  490. if s.mode ~= mode then
  491. s.mode = mode
  492. s.flipAt = now
  493. end
  494. end
  495.  
  496. -- The player's order, kept so it can be given back after the widget pulled the unit out.
  497. local function KeepResume(s, qs, c)
  498. if s.resume or not c then
  499. return
  500. end
  501. if qs == "fight" or (qs == "busy" and options.overrideOrders.value) then
  502. local p = {}
  503. for k = 1, #(c.params or {}) do
  504. p[k] = c.params[k]
  505. end
  506. s.resume = {id = c.id, params = p}
  507. end
  508. end
  509.  
  510. --------------------------------------------------------------------------------
  511. -- Grouping
  512.  
  513. local function Groups(list, n)
  514. local cell = LINK
  515. local grid = {}
  516. local parent = {}
  517. for i = 1, n do
  518. parent[i] = i
  519. end
  520. local function Find(i)
  521. while parent[i] ~= i do
  522. parent[i] = parent[parent[i]]
  523. i = parent[i]
  524. end
  525. return i
  526. end
  527. local STRIDE = 100003
  528. for i = 1, n do
  529. local e = list[i]
  530. local gx, gz = floor(e.x / cell), floor(e.z / cell)
  531. e.gx, e.gz = gx, gz
  532. local key = gx * STRIDE + gz
  533. local b = grid[key]
  534. if b then
  535. b[#b + 1] = i
  536. else
  537. grid[key] = {i}
  538. end
  539. end
  540. local l2 = LINK * LINK
  541. for i = 1, n do
  542. local e = list[i]
  543. for ox = -1, 1 do
  544. for oz = -1, 1 do
  545. local b = grid[(e.gx + ox) * STRIDE + (e.gz + oz)]
  546. if b then
  547. for k = 1, #b do
  548. local j = b[k]
  549. if j > i then
  550. local f = list[j]
  551. local dx, dz = e.x - f.x, e.z - f.z
  552. if dx * dx + dz * dz <= l2 then
  553. local a, c = Find(i), Find(j)
  554. if a ~= c then
  555. parent[a] = c
  556. end
  557. end
  558. end
  559. end
  560. end
  561. end
  562. end
  563. end
  564. local byRoot, groups = {}, {}
  565. for i = 1, n do
  566. local r = Find(i)
  567. local g = byRoot[r]
  568. if not g then
  569. g = {}
  570. byRoot[r] = g
  571. groups[#groups + 1] = g
  572. end
  573. g[#g + 1] = list[i]
  574. end
  575. -- Stable order (lowest unitID first) so the round-robin does not reshuffle every tick.
  576. for i = 1, #groups do
  577. local g = groups[i]
  578. local lo = huge
  579. for k = 1, #g do
  580. if g[k].uid < lo then
  581. lo = g[k].uid
  582. end
  583. end
  584. g.lo = lo
  585. end
  586. table.sort(groups, function(a, b) return a.lo < b.lo end)
  587. return groups
  588. end
  589.  
  590. --------------------------------------------------------------------------------
  591. -- One group
  592.  
  593. local function Home(cx, cz)
  594. local best, bx, bz = huge, nil, nil
  595. for _, f in pairs(factories) do
  596. local dx, dz = f[1] - cx, f[2] - cz
  597. local d = dx * dx + dz * dz
  598. if d < best then
  599. best, bx, bz = d, f[1], f[2]
  600. end
  601. end
  602. if bx then
  603. return bx, bz
  604. end
  605. local x, _, z = Spring.GetTeamStartPosition(myTeam)
  606. if x and x > 0 then
  607. return x, z
  608. end
  609. return nil
  610. end
  611.  
  612. -- What is around a group: the nearest enemy, the enemies that could hit it soon and how far it
  613. -- must step back to be clear of them, and the nearest enemy cluster to attack.
  614. local function Survey(g)
  615. local cx, cz = 0, 0
  616. for k = 1, #g do
  617. cx, cz = cx + g[k].x, cz + g[k].z
  618. end
  619. cx, cz = cx / #g, cz / #g
  620. local r = 0
  621. local catsList, seenKey = {}, {}
  622. for k = 1, #g do
  623. local e = g[k]
  624. local dx, dz = e.x - cx, e.z - cz
  625. r = max(r, sqrt(dx * dx + dz * dz))
  626. local c = Cats(e.defID)
  627. if not seenKey[c.key] then
  628. seenKey[c.key] = true
  629. catsList[#catsList + 1] = c
  630. end
  631. end
  632.  
  633. local engage = options.engageRange.value
  634. local scan = r + max(engage, 1200) + SPEED_CAP
  635. local found = spGetUnitsInCylinder(cx, cz, scan, ENEMY_UNITS) or {}
  636. local sv = {cx = cx, cz = cz, r = r, nearGap = huge, need = 0, threats = 0,
  637. tx = 0, tz = 0, tw = 0, enemies = {}}
  638. local safety = options.safety.value
  639. for i = 1, #found do
  640. local eid = found[i]
  641. if spGetUnitTeam(eid) ~= gaiaTeam then
  642. local defID = spGetUnitDefID(eid)
  643. if not (defID and skipEnemy[defID]) then
  644. local x, _, z = spGetUnitPosition(eid)
  645. if x then
  646. local dx, dz = x - cx, z - cz
  647. local gap = max(0, sqrt(dx * dx + dz * dz) - r)
  648. local en = {x = x, z = z, gap = gap, w = defID and max(costOf[defID], 1) or 100}
  649. sv.enemies[#sv.enemies + 1] = en
  650. if gap < sv.nearGap then
  651. sv.nearGap = gap
  652. end
  653. local reach
  654. if defID then
  655. reach = 0
  656. for k = 1, #catsList do
  657. reach = max(reach, Reach(defID, catsList[k]))
  658. end
  659. if reach > 0 then
  660. reach = reach + min((UnitDefs[defID].speed or 0) * SPEED_TIME, SPEED_CAP)
  661. end
  662. else
  663. reach = BLIP_REACH
  664. end
  665. if reach > 0 and gap < reach + safety then
  666. sv.threats = sv.threats + 1
  667. sv.need = max(sv.need, reach + safety - gap)
  668. sv.tx, sv.tz, sv.tw = sv.tx + x * en.w, sv.tz + z * en.w, sv.tw + en.w
  669. end
  670. end
  671. end
  672. end
  673. end
  674. if sv.tw > 0 then
  675. sv.tx, sv.tz = sv.tx / sv.tw, sv.tz / sv.tw
  676. end
  677.  
  678. -- Attack point: the value-weighted middle of the enemies within 400 of the nearest one's
  679. -- distance, i.e. the nearest enemy cluster rather than the whole field.
  680. if sv.nearGap < huge then
  681. local ax, az, aw = 0, 0, 0
  682. for i = 1, #sv.enemies do
  683. local en = sv.enemies[i]
  684. if en.gap <= sv.nearGap + 400 then
  685. ax, az, aw = ax + en.x * en.w, az + en.z * en.w, aw + en.w
  686. end
  687. end
  688. sv.ax, sv.az = ax / aw, az / aw
  689. end
  690. return sv
  691. end
  692.  
  693. local function Retreat(g, sv, now)
  694. -- Away from the threats, bent towards home unless home is on the far side of them.
  695. local ux, uz = sv.cx - sv.tx, sv.cz - sv.tz
  696. local ul = sqrt(ux * ux + uz * uz)
  697. if ul < 1 then
  698. ux, uz, ul = 0, 1, 1
  699. end
  700. ux, uz = ux / ul, uz / ul
  701. local hx, hz = Home(sv.cx, sv.cz)
  702. if hx then
  703. local vx, vz = hx - sv.cx, hz - sv.cz
  704. local vl = sqrt(vx * vx + vz * vz)
  705. if vl > 300 then
  706. vx, vz = vx / vl, vz / vl
  707. if ux * vx + uz * vz > -0.3 then
  708. ux, uz = ux + vx * 0.8, uz + vz * 0.8
  709. ul = sqrt(ux * ux + uz * uz)
  710. ux, uz = ux / ul, uz / ul
  711. end
  712. end
  713. end
  714. local step = max(sv.need + 100, MIN_STEP)
  715. for k = 1, #g do
  716. local e = g[k]
  717. local s = e.s
  718. local tx, tz = e.x + ux * step, e.z + uz * step
  719. local again = e.qs == "ours" and s.mode == "avoid" and s.oid and MOVE_IDS[s.oid]
  720. and NearXZ(tx, tz, s.ox, s.oz, REISSUE)
  721. if not again then
  722. KeepResume(s, e.qs, e.c)
  723. if not s.anchorX then
  724. s.anchorX, s.anchorZ = e.x, e.z
  725. end
  726. Give(e.uid, s, flyDef[e.defID] and CMD_MOVE or CMD_RAW_MOVE, tx, tz, now)
  727. SetMode(s, "avoid", now)
  728. end
  729. end
  730. end
  731.  
  732. -- Lost, but nothing can reach the group yet: just stop walking in.
  733. local function HoldBack(g, now)
  734. for k = 1, #g do
  735. local e = g[k]
  736. local s = e.s
  737. local walkingIn = e.qs == "fight" or (e.qs == "ours" and s.mode == "attack")
  738. if walkingIn then
  739. KeepResume(s, e.qs, e.c)
  740. spGiveOrderToUnit(e.uid, CMD_STOP, {}, 0)
  741. s.oid, s.orderAt = nil, now
  742. SetMode(s, "avoid", now)
  743. end
  744. end
  745. end
  746.  
  747. local function HoldPosition(uid)
  748. local st = spGetUnitStates(uid)
  749. return type(st) == "table" and st.movestate == 0
  750. end
  751.  
  752. local function Attack(g, sv, now)
  753. local leash = options.leash.value
  754. local wait = options.reengage.value
  755. for k = 1, #g do
  756. local e = g[k]
  757. local s = e.s
  758. local free = e.qs == "idle" or e.qs == "ours"
  759. local cooling = s.mode == "avoid" and now - s.flipAt < wait
  760. if free and not cooling and not HoldPosition(e.uid) then
  761. local ax, az = s.anchorX or e.x, s.anchorZ or e.z
  762. if leash <= 0 or NearXZ(sv.ax, sv.az, ax, az, leash) then
  763. local again = e.qs == "ours" and s.mode == "attack" and s.oid == CMD_FIGHT
  764. and NearXZ(sv.ax, sv.az, s.ox, s.oz, REISSUE)
  765. if not again then
  766. if not s.anchorX then
  767. s.anchorX, s.anchorZ = e.x, e.z
  768. end
  769. Give(e.uid, s, CMD_FIGHT, sv.ax, sv.az, now)
  770. SetMode(s, "attack", now)
  771. end
  772. end
  773. end
  774. end
  775. end
  776.  
  777. -- Nothing to fight or flee: give back the player's order, or walk back, or let go.
  778. local function Settle(g, now)
  779. local wait = options.reengage.value
  780. for k = 1, #g do
  781. local e = g[k]
  782. local s = e.s
  783. if s.mode and e.qs == "idle" and now - s.flipAt >= wait then
  784. if s.mode == "return" then
  785. Forget(e.uid)
  786. elseif s.resume and options.resumeFight.value then
  787. local r = s.resume
  788. spGiveOrderToUnit(e.uid, r.id, r.params, 0)
  789. Forget(e.uid)
  790. elseif options.returnHome.value and s.anchorX
  791. and not NearXZ(e.x, e.z, s.anchorX, s.anchorZ, 120) then
  792. local ax, az = s.anchorX, s.anchorZ
  793. Give(e.uid, s, flyDef[e.defID] and CMD_MOVE or CMD_RAW_MOVE, ax, az, now)
  794. s.mode = "return" -- not SetMode: arriving needs no re-engage wait
  795. s.resume = nil
  796. else
  797. Forget(e.uid)
  798. end
  799. end
  800. end
  801. end
  802.  
  803. --------------------------------------------------------------------------------
  804. -- The tick
  805.  
  806. local function Tick(frame)
  807. local ep = WG.EngagementPredictor
  808. if not (ep and ep.Predict) then
  809. if not warned then
  810. warned = true
  811. spEcho("Auto Engage: waiting for Combat Advisor (its Engagement Predictor scores the fights).")
  812. end
  813. labels = {}
  814. return
  815. end
  816. warned = false
  817. local now = frame / 30
  818.  
  819. local list, n = {}, 0
  820. for uid, defID in pairs(mine) do
  821. if Eligible(defID) and IsOn(uid) and spGetUnitIsDead(uid) == false
  822. and not spGetUnitTransporter(uid) then
  823. local s = Rec(uid)
  824. if spGetUnitRulesParam(uid, "retreat") == 1 then
  825. -- The retreat gadget has it: hands off, and forget what we were doing.
  826. if s.mode then
  827. Forget(uid)
  828. end
  829. else
  830. local qs, c = QueueState(uid, s)
  831. if qs == "busy" and not options.overrideOrders.value then
  832. if s.mode then
  833. Forget(uid) -- the player gave it something else to do
  834. end
  835. else
  836. local x, _, z = spGetUnitPosition(uid)
  837. if x then
  838. n = n + 1
  839. list[n] = {uid = uid, defID = defID, s = s, qs = qs, c = c, x = x, z = z}
  840. end
  841. end
  842. end
  843. end
  844. end
  845.  
  846. local groups = Groups(list, n)
  847. local newLabels = {}
  848. local scored = 0
  849. local count = #groups
  850. local attackAt = options.attackAbove.value / 100
  851. local avoidAt = options.avoidBelow.value / 100
  852. local engage = options.engageRange.value
  853. local skipped = nil -- the first group that needed scoring but was over the cap
  854. for step = 0, count - 1 do
  855. local g = groups[(rotate + step) % count + 1]
  856. local sv = Survey(g)
  857. local near = sv.threats > 0 or sv.nearGap <= engage
  858. if not near then
  859. Settle(g, now)
  860. elseif scored >= MAX_SCORED then
  861. skipped = skipped or step
  862. else
  863. scored = scored + 1
  864. local ids = {}
  865. for k = 1, #g do
  866. ids[k] = g[k].uid
  867. end
  868. local res = ep.Predict(ids, {includeStatic = options.countBuildings.value})
  869. if res and res.p then
  870. local p = res.p
  871. local word
  872. if p >= attackAt then
  873. word = "ENGAGE"
  874. if sv.nearGap <= engage then
  875. Attack(g, sv, now)
  876. end
  877. elseif p <= avoidAt then
  878. word = "AVOID"
  879. if sv.threats > 0 then
  880. Retreat(g, sv, now)
  881. else
  882. HoldBack(g, now)
  883. end
  884. else
  885. word = "HOLD"
  886. end
  887. newLabels[#newLabels + 1] = {x = sv.cx, z = sv.cz, r = sv.r, p = p, word = word}
  888. else
  889. Settle(g, now)
  890. end
  891. end
  892. end
  893. -- Next tick starts at the first group this one had no room for, so none can starve.
  894. if skipped then
  895. rotate = (rotate + skipped) % count
  896. end
  897. labels = newLabels
  898. end
  899.  
  900. --------------------------------------------------------------------------------
  901. -- The order button
  902.  
  903. local function RecountSelection(sel)
  904. selected = {}
  905. if spectating then
  906. return
  907. end
  908. sel = sel or spGetSelectedUnits() or {}
  909. for i = 1, #sel do
  910. local uid = sel[i]
  911. local defID = mine[uid]
  912. if defID and Eligible(defID) then
  913. selected[#selected + 1] = uid
  914. end
  915. end
  916. end
  917.  
  918. local function Toggle()
  919. if #selected == 0 then
  920. return
  921. end
  922. local anyOff = false
  923. for i = 1, #selected do
  924. if not IsOn(selected[i]) then
  925. anyOff = true
  926. break
  927. end
  928. end
  929. for i = 1, #selected do
  930. local uid = selected[i]
  931. local s = Rec(uid)
  932. s.on = anyOff
  933. if not anyOff then
  934. Forget(uid)
  935. end
  936. end
  937. spEcho(("Auto Engage %s for %d unit%s."):format(anyOff and "on" or "off", #selected,
  938. #selected == 1 and "" or "s"))
  939. if Spring.ForceLayoutUpdate then
  940. Spring.ForceLayoutUpdate()
  941. end
  942. end
  943.  
  944. function widget:SelectionChanged(sel)
  945. RecountSelection(sel)
  946. end
  947.  
  948. function widget:CommandsChanged()
  949. if spectating or #selected == 0 then
  950. return
  951. end
  952. local on = 0
  953. for i = 1, #selected do
  954. if IsOn(selected[i]) then
  955. on = on + 1
  956. end
  957. end
  958. local state
  959. if on == 0 then
  960. state = "off"
  961. elseif on == #selected then
  962. state = "ON"
  963. else
  964. state = ("on for %d of %d"):format(on, #selected)
  965. end
  966. -- A widget without `handler = true` gets ZK's restricted widgetHandler, which has no
  967. -- customCommands table (indexing it took the widget down on its first selection).
  968. -- AddLayoutCommand is the supported route, valid only inside CommandsChanged.
  969. local cmd = {
  970. id = CMD_AUTO_ENGAGE,
  971. type = CMDTYPE.ICON,
  972. name = "Auto Engage",
  973. action = "autoengage",
  974. texture = ICON,
  975. params = {},
  976. tooltip = "Auto Engage: " .. state .. "\n"
  977. .. "Switched-on units attack fights the Engagement Predictor rates as won and back away "
  978. .. "from fights it rates as lost. Click to switch the selection "
  979. .. ((on == #selected) and "off." or "on."),
  980. }
  981. if widgetHandler.AddLayoutCommand then
  982. widgetHandler:AddLayoutCommand(cmd)
  983. elseif widgetHandler.customCommands then
  984. local cc = widgetHandler.customCommands
  985. cc[#cc + 1] = cmd
  986. end
  987. end
  988.  
  989. local function IsStateCommand(cmdID)
  990. local idx = spGetCmdDescIndex and spGetCmdDescIndex(cmdID)
  991. local desc = idx and spGetActiveCmdDesc(idx)
  992. return desc and desc.type == CMDTYPE.ICON_MODE
  993. end
  994.  
  995. function widget:CommandNotify(cmdID, params, opts)
  996. if cmdID == CMD_AUTO_ENGAGE then
  997. Toggle()
  998. return true
  999. end
  1000. -- A real order from the player to the selection: it replaces whatever we were doing,
  1001. -- including the order we meant to give back.
  1002. if not IsStateCommand(cmdID) then
  1003. for i = 1, #selected do
  1004. Forget(selected[i])
  1005. end
  1006. end
  1007. return false
  1008. end
  1009.  
  1010. --------------------------------------------------------------------------------
  1011. -- Callins
  1012.  
  1013. function widget:GameFrame(n)
  1014. if spectating or n % TICK ~= 0 then
  1015. return
  1016. end
  1017. Tick(n)
  1018. end
  1019.  
  1020. function widget:UnitFinished(unitID, unitDefID, unitTeam)
  1021. AddUnit(unitID, unitDefID, unitTeam)
  1022. end
  1023.  
  1024. function widget:UnitFromFactory(unitID, unitDefID, unitTeam)
  1025. if unitTeam == myTeam and options.newOn.value and armedDef[unitDefID] then
  1026. Rec(unitID).on = true
  1027. end
  1028. end
  1029.  
  1030. function widget:UnitGiven(unitID, unitDefID, newTeam)
  1031. RemoveUnit(unitID)
  1032. local bp = select(5, spGetUnitHealth(unitID))
  1033. if bp == nil or bp >= 1 then
  1034. AddUnit(unitID, unitDefID, newTeam)
  1035. end
  1036. end
  1037.  
  1038. function widget:UnitTaken(unitID, unitDefID, oldTeam, newTeam)
  1039. RemoveUnit(unitID)
  1040. end
  1041.  
  1042. function widget:UnitDestroyed(unitID)
  1043. RemoveUnit(unitID)
  1044. end
  1045.  
  1046. function widget:PlayerChanged()
  1047. if spGetMyTeamID() ~= myTeam or spGetSpectatingState() ~= spectating then
  1048. Rescan()
  1049. RecountSelection()
  1050. end
  1051. end
  1052.  
  1053. function widget:Initialize()
  1054. Rescan()
  1055. RecountSelection()
  1056. end
  1057.  
  1058. function widget:Shutdown()
  1059. -- Units keep whatever order they have; nothing is cancelled.
  1060. labels = {}
  1061. end
  1062.  
  1063. --------------------------------------------------------------------------------
  1064. -- Drawing
  1065.  
  1066. local COLOUR = {
  1067. ENGAGE = "\255\120\255\120",
  1068. AVOID = "\255\255\100\90",
  1069. HOLD = "\255\220\220\160",
  1070. }
  1071.  
  1072. function widget:DrawScreen()
  1073. if not options.showLabels.value or #labels == 0 then
  1074. return
  1075. end
  1076. for i = 1, #labels do
  1077. local l = labels[i]
  1078. local y = spGetGroundHeight(l.x, l.z) or 0
  1079. local sx, sy, sz = spWorldToScreenCoords(l.x, y + 40, l.z)
  1080. if sx and sz and sz < 1 then
  1081. gl.Text(("%s%s %d%%"):format(COLOUR[l.word] or "", l.word, floor(l.p * 100 + 0.5)),
  1082. sx, sy, 14, "co")
  1083. end
  1084. end
  1085. end
  1086.  
  1087. function widget:DrawWorldPreUnit()
  1088. if not options.showMarks.value or #selected == 0 then
  1089. return
  1090. end
  1091. gl.LineWidth(1.5)
  1092. for i = 1, #selected do
  1093. local uid = selected[i]
  1094. if IsOn(uid) then
  1095. local x, y, z = spGetUnitPosition(uid)
  1096. if x then
  1097. local s = S[uid]
  1098. local mode = s and s.mode
  1099. if mode == "avoid" then
  1100. gl.Color(1, 0.45, 0.35, 0.7)
  1101. elseif mode == "attack" then
  1102. gl.Color(0.5, 1, 0.5, 0.8)
  1103. else
  1104. gl.Color(0.55, 0.85, 1, 0.55)
  1105. end
  1106. gl.DrawGroundCircle(x, y, z, 26, 16)
  1107. end
  1108. end
  1109. end
  1110. gl.Color(1, 1, 1, 1)
  1111. gl.LineWidth(1)
  1112. end
  1113.  
Advertisement
Add Comment
Please, Sign In to add comment