Skip to content

Commit 190ee3d

Browse files
feat(gui_juno_impact_range): add Juno Impact Range widget (#7)
1 parent fd228d7 commit 190ee3d

4 files changed

Lines changed: 403 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Juno Impact Range
2+
3+
This widget draws the small AEO circle for the duration of juno (~27 seconds) and displays a countdown timer.
4+
2.99 MB
Loading
Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
local widget = widget ---@type Widget
2+
3+
function widget:GetInfo()
4+
return {
5+
name = "Juno Shot Ranges",
6+
desc = "Draws an AOE circle while targeting Juno, and after impact",
7+
author = "Rysica",
8+
date = "2026-07",
9+
license = "GNU GPL, v2 or later",
10+
layer = 5,
11+
enabled = true,
12+
}
13+
end
14+
15+
--------------------------------------------------------------------------------
16+
-- Localize
17+
--------------------------------------------------------------------------------
18+
local spGetProjectilesInRectangle = Spring.GetProjectilesInRectangle
19+
local spGetProjectileDefID = Spring.GetProjectileDefID
20+
local spGetProjectileTarget = Spring.GetProjectileTarget
21+
local spGetProjectilePosition = Spring.GetProjectilePosition
22+
local spGetProjectileTeamID = Spring.GetProjectileTeamID
23+
local spGetGroundHeight = Spring.GetGroundHeight
24+
local spGetMyAllyTeamID = Spring.GetMyAllyTeamID
25+
local spGetTeamInfo = Spring.GetTeamInfo
26+
local spGetUnitPosition = Spring.GetUnitPosition
27+
local spIsGUIHidden = Spring.IsGUIHidden
28+
local spIsSphereInView = Spring.IsSphereInView
29+
local spGetGameFrame = Spring.GetGameFrame
30+
local spGetActiveCommand = Spring.GetActiveCommand
31+
local spGetMouseState = Spring.GetMouseState
32+
local spTraceScreenRay = Spring.TraceScreenRay
33+
local spGetSelectedUnitsSorted = Spring.GetSelectedUnitsSorted
34+
local spIsAboveMiniMap = Spring.IsAboveMiniMap
35+
local spWorldToScreenCoords = Spring.WorldToScreenCoords
36+
37+
local mapSizeX = Game.mapSizeX
38+
local mapSizeZ = Game.mapSizeZ
39+
40+
local glColor = gl.Color
41+
local glLineWidth = gl.LineWidth
42+
local glDepthTest = gl.DepthTest
43+
local glDrawGroundCircle = gl.DrawGroundCircle
44+
45+
local math_ceil = math.ceil
46+
47+
local CMD_ATTACK = CMD.ATTACK
48+
local CMD_MANUALFIRE = CMD.MANUALFIRE
49+
local CMD_MANUAL_LAUNCH = GameCMD and GameCMD.MANUAL_LAUNCH
50+
51+
--------------------------------------------------------------------------------
52+
-- Config
53+
--------------------------------------------------------------------------------
54+
local CIRCLE_DIVS = 96
55+
local UPDATE_INTERVAL = 0.15 -- seconds
56+
-- Persist circle for 27s after impact (gameframes at 30fps), then fade in last 0.5s.
57+
local JUNO_EFFECT_TTL_FRAMES = 810 -- 27s
58+
local JUNO_EFFECT_DECAY_FRAMES = 15 -- 0.5s at 30fps
59+
local LINE_WIDTH = 2.2
60+
local HEIGHT_OFFSET = 3
61+
local TIMER_FONT_SIZE = 14
62+
63+
-- Green (matches BAR projectile-AOE juno color)
64+
local ALLY_COLOR = { 0.25, 1.0, 0.35, 0.70 }
65+
local ENEMY_COLOR = { 0.45, 1.0, 0.20, 0.75 }
66+
local RETICLE_COLOR = { 0.87, 0.94, 0.40, 0.85 } -- match Attack AoE juno color
67+
local AOE_DRAW_SCALE = 0.65 -- draw circle at 65% of true damage AOE
68+
69+
--------------------------------------------------------------------------------
70+
-- State
71+
--------------------------------------------------------------------------------
72+
local junoWeapons = {} -- weaponDefID -> { aoe = number }
73+
local junoUnitAoe = {} -- unitDefID -> aoe of its juno weapon
74+
local inFlight = {} -- proID -> pending impact data (not drawn)
75+
local impacts = {} -- active ground circles after detonation
76+
local updateAccum = 0
77+
local myAllyTeamID = 0
78+
local font
79+
80+
-- Targeting reticle (updated each draw)
81+
local aimTx, aimTy, aimTz, aimAoe
82+
83+
--------------------------------------------------------------------------------
84+
-- Helpers
85+
--------------------------------------------------------------------------------
86+
local function IsJunoWeapon(wd)
87+
if not wd then
88+
return false
89+
end
90+
local name = (wd.name or ""):lower()
91+
if name:find("juno", 1, true) then
92+
return true
93+
end
94+
local cp = wd.customParams or wd.customparams
95+
if cp and (cp.junotype or cp.junoType) then
96+
return true
97+
end
98+
return false
99+
end
100+
101+
local function BuildWeaponCache()
102+
junoWeapons = {}
103+
junoUnitAoe = {}
104+
105+
for wdid, wd in pairs(WeaponDefs) do
106+
if IsJunoWeapon(wd) then
107+
local aoe = wd.damageAreaOfEffect or wd.areaofeffect or 0
108+
if aoe > 0 then
109+
junoWeapons[wdid] = { aoe = aoe }
110+
end
111+
end
112+
end
113+
114+
for udid, ud in pairs(UnitDefs) do
115+
local weapons = ud.weapons
116+
if weapons then
117+
for i = 1, #weapons do
118+
local wdid = weapons[i].weaponDef
119+
local info = wdid and junoWeapons[wdid]
120+
if info then
121+
junoUnitAoe[udid] = info.aoe
122+
break
123+
end
124+
end
125+
end
126+
end
127+
end
128+
129+
local function GetProjectileTargetPos(proID)
130+
local targetType, targetData = spGetProjectileTarget(proID)
131+
if not targetType then
132+
return nil
133+
end
134+
135+
-- Ground
136+
if targetType == 103 then -- 'g'
137+
if type(targetData) == "table" then
138+
return targetData[1], targetData[2], targetData[3]
139+
end
140+
-- Unit
141+
elseif targetType == 117 then -- 'u'
142+
local ux, uy, uz = spGetUnitPosition(targetData)
143+
if ux then
144+
return ux, uy, uz
145+
end
146+
-- Projectile (interceptor)
147+
elseif targetType == 112 then -- 'p'
148+
local px, py, pz = spGetProjectilePosition(targetData)
149+
if px then
150+
return px, py, pz
151+
end
152+
end
153+
return nil
154+
end
155+
156+
local function ColorForTeam(teamID)
157+
local _, _, _, _, _, allyTeamID = spGetTeamInfo(teamID)
158+
if allyTeamID == myAllyTeamID then
159+
return ALLY_COLOR
160+
end
161+
return ENEMY_COLOR
162+
end
163+
164+
local function ImpactAlpha(ageFrames, baseAlpha)
165+
if ageFrames >= JUNO_EFFECT_TTL_FRAMES then
166+
return 0
167+
end
168+
local remaining = JUNO_EFFECT_TTL_FRAMES - ageFrames
169+
if remaining < JUNO_EFFECT_DECAY_FRAMES then
170+
return baseAlpha * (remaining / JUNO_EFFECT_DECAY_FRAMES)
171+
end
172+
return baseAlpha
173+
end
174+
175+
local function IsJunoAimCommand(cmd)
176+
if not cmd then
177+
return false
178+
end
179+
if cmd == CMD_MANUALFIRE or cmd == CMD_ATTACK then
180+
return true
181+
end
182+
if CMD_MANUAL_LAUNCH and cmd == CMD_MANUAL_LAUNCH then
183+
return true
184+
end
185+
return false
186+
end
187+
188+
local function GetSelectedJunoAoe()
189+
local sel = spGetSelectedUnitsSorted()
190+
if not sel then
191+
return nil
192+
end
193+
local bestAoe
194+
for unitDefID, _ in pairs(sel) do
195+
local aoe = junoUnitAoe[unitDefID]
196+
if aoe and (not bestAoe or aoe > bestAoe) then
197+
bestAoe = aoe
198+
end
199+
end
200+
return bestAoe
201+
end
202+
203+
local function UpdateAimReticle()
204+
aimTx, aimTy, aimTz, aimAoe = nil, nil, nil, nil
205+
206+
local _, cmd = spGetActiveCommand()
207+
if not IsJunoAimCommand(cmd) then
208+
return
209+
end
210+
211+
local aoe = GetSelectedJunoAoe()
212+
if not aoe then
213+
return
214+
end
215+
216+
local mx, my = spGetMouseState()
217+
if not mx or spIsAboveMiniMap(mx, my) then
218+
return
219+
end
220+
221+
local _, pos = spTraceScreenRay(mx, my, true)
222+
if not pos then
223+
return
224+
end
225+
226+
aimTx = pos[1]
227+
aimTy = (pos[2] or 0) + HEIGHT_OFFSET
228+
aimTz = pos[3]
229+
aimAoe = aoe
230+
end
231+
232+
--------------------------------------------------------------------------------
233+
-- Tracking (silent until impact)
234+
--------------------------------------------------------------------------------
235+
local function UpdateTrackedProjectiles()
236+
local nowGF = spGetGameFrame()
237+
local seen = {}
238+
local all = spGetProjectilesInRectangle(0, 0, mapSizeX, mapSizeZ)
239+
240+
if all then
241+
for i = 1, #all do
242+
local proID = all[i]
243+
local defID = spGetProjectileDefID(proID)
244+
local info = defID and junoWeapons[defID]
245+
if info then
246+
seen[proID] = true
247+
if not inFlight[proID] then
248+
local tx, ty, tz = GetProjectileTargetPos(proID)
249+
if tx then
250+
local groundY = spGetGroundHeight(tx, tz)
251+
if groundY and groundY > ty then
252+
ty = groundY
253+
end
254+
inFlight[proID] = {
255+
tx = tx,
256+
ty = ty + HEIGHT_OFFSET,
257+
tz = tz,
258+
aoe = info.aoe,
259+
color = ColorForTeam(spGetProjectileTeamID(proID) or -1),
260+
}
261+
end
262+
end
263+
end
264+
end
265+
end
266+
267+
-- Projectile gone → assume impact; start ground circle
268+
for proID, data in pairs(inFlight) do
269+
if not seen[proID] then
270+
impacts[#impacts + 1] = {
271+
tx = data.tx,
272+
ty = data.ty,
273+
tz = data.tz,
274+
aoe = data.aoe,
275+
color = data.color,
276+
impactGF = nowGF,
277+
}
278+
inFlight[proID] = nil
279+
end
280+
end
281+
282+
local i = 1
283+
while i <= #impacts do
284+
if (nowGF - impacts[i].impactGF) >= JUNO_EFFECT_TTL_FRAMES then
285+
impacts[i] = impacts[#impacts]
286+
impacts[#impacts] = nil
287+
else
288+
i = i + 1
289+
end
290+
end
291+
end
292+
293+
--------------------------------------------------------------------------------
294+
-- Drawing
295+
--------------------------------------------------------------------------------
296+
function widget:DrawWorld()
297+
if spIsGUIHidden() then
298+
return
299+
end
300+
301+
UpdateAimReticle()
302+
303+
if not aimTx and #impacts == 0 then
304+
return
305+
end
306+
307+
glDepthTest(false)
308+
glLineWidth(LINE_WIDTH)
309+
310+
-- Targeting reticle (0.65× AOE under cursor while aiming Juno)
311+
if aimTx then
312+
local radius = aimAoe * AOE_DRAW_SCALE
313+
if spIsSphereInView(aimTx, aimTy, aimTz, radius) then
314+
glColor(RETICLE_COLOR[1], RETICLE_COLOR[2], RETICLE_COLOR[3], RETICLE_COLOR[4])
315+
glDrawGroundCircle(aimTx, aimTy, aimTz, radius, CIRCLE_DIVS)
316+
end
317+
end
318+
319+
-- Post-impact circles
320+
local nowGF = spGetGameFrame()
321+
for i = 1, #impacts do
322+
local data = impacts[i]
323+
local alpha = ImpactAlpha(nowGF - data.impactGF, data.color[4])
324+
local radius = data.aoe * AOE_DRAW_SCALE
325+
if alpha > 0.01 and spIsSphereInView(data.tx, data.ty, data.tz, radius) then
326+
glColor(data.color[1], data.color[2], data.color[3], alpha)
327+
glDrawGroundCircle(data.tx, data.ty, data.tz, radius, CIRCLE_DIVS)
328+
end
329+
end
330+
331+
glLineWidth(1)
332+
glColor(1, 1, 1, 1)
333+
glDepthTest(true)
334+
end
335+
336+
function widget:DrawScreen()
337+
if spIsGUIHidden() or #impacts == 0 or not font then
338+
return
339+
end
340+
341+
local nowGF = spGetGameFrame()
342+
font:Begin()
343+
for i = 1, #impacts do
344+
local data = impacts[i]
345+
local remaining = JUNO_EFFECT_TTL_FRAMES - (nowGF - data.impactGF)
346+
if remaining > 0 then
347+
local radius = data.aoe * AOE_DRAW_SCALE
348+
if spIsSphereInView(data.tx, data.ty, data.tz, radius) then
349+
local sx, sy = spWorldToScreenCoords(data.tx, data.ty, data.tz)
350+
if sx and sy then
351+
local alpha = ImpactAlpha(nowGF - data.impactGF, 1)
352+
local secs = math_ceil(remaining / 30)
353+
font:SetTextColor(data.color[1], data.color[2], data.color[3], alpha)
354+
font:SetOutlineColor(0, 0, 0, alpha * 0.85)
355+
font:Print(secs .. "s", sx, sy, TIMER_FONT_SIZE, "oc")
356+
end
357+
end
358+
end
359+
end
360+
font:End()
361+
end
362+
363+
--------------------------------------------------------------------------------
364+
-- Callins
365+
--------------------------------------------------------------------------------
366+
function widget:ViewResize()
367+
if WG and WG["fonts"] then
368+
font = WG["fonts"].getFont(nil, 1.2, 0.2, 18)
369+
end
370+
end
371+
372+
function widget:Initialize()
373+
myAllyTeamID = spGetMyAllyTeamID()
374+
BuildWeaponCache()
375+
widget:ViewResize()
376+
end
377+
378+
function widget:PlayerChanged()
379+
myAllyTeamID = spGetMyAllyTeamID()
380+
inFlight = {}
381+
impacts = {}
382+
end
383+
384+
function widget:Update(dt)
385+
updateAccum = updateAccum + dt
386+
if updateAccum >= UPDATE_INTERVAL then
387+
updateAccum = 0
388+
UpdateTrackedProjectiles()
389+
end
390+
end

0 commit comments

Comments
 (0)