diff --git a/changelog.md b/changelog.md index 1b81ae25c66..bc88a7fba21 100644 --- a/changelog.md +++ b/changelog.md @@ -10,6 +10,7 @@ - [Legion changes] - Perdition doesn't gain extra range from elevation - Martyr damages are added to its team's damage dealt + - Harbinger mines now spawn and detonate on tall targets - EMP damage prevents reactive armor from regenerating - Disable Air Units now removes and refunds drone spawners - Range rings, graphics, blueprints, and other housekeeping diff --git a/language/en/interface.json b/language/en/interface.json index efe9fdada9e..de14242835b 100644 --- a/language/en/interface.json +++ b/language/en/interface.json @@ -24,7 +24,6 @@ "mission": "Mission", "stats": "Statistics", "info": "Info", - "graphs": "Graphs", "save": "Save" }, "quit": { @@ -392,8 +391,17 @@ "graph": { "composition": "Composition", "compositionDesc": "The team's unit value split by kind over the game, as shares of the whole.", + "kindHint": "Click to leave this kind of milestone off the charts, or to put it back.", + "overview": "◀ Graphs overview", + "overviewDesc": "Back to the page of charts this one was opened from.", + "timeline": "Timeline", + "timelineDesc": "A lane per team along the game clock, with every milestone it reached on it: who teched first, who lost a commander, who died. Hover a picture for what it was.", + "profile": "Team profile", + "profileDesc": "Every team's shape as it stands: damage, units, metal, energy, what it has on the field and how fast it plays, each axis measured against the best team on the chart.", "foldHint": "Click to fold this team's players away, and again to show them.", "all": "All", + "you": "You", + "youHint": "Your own team alone: the selection becomes yours, whatever was picked before.", "allHint": "Clears the selection and shows hidden teams again, so every team is drawn alike.", "allMilestones": "Every team's milestones go on the chart.", "teams": "%{count} teams", @@ -430,6 +438,8 @@ "antinuke": "First anti-nuke", "lrpc": "First long-range cannon", "commanderLost": "Commander lost", + "firstKill": "First kill", + "firstLoss": "First loss", "teamDied": "Out of the game" }, "group": { @@ -452,10 +462,16 @@ "perMinuteDesc": "Totals divided by the minutes each team was in the game, so a short game or an early exit compares fairly.", "bars": "Bars", "barsDesc": "A bar behind every number, scaled to the largest in its column.", + "vsMe": "vs. me", + "vsMeDesc": "Every number as the difference from your own: green where a team is doing better than you, red where it is worse. Your own row keeps its numbers, and so do the teams' totals against your team's.", + "trend": "Trend", + "trendDesc": "A small line beside every number: how it moved over the game, every team against the same scale. A running total shows the pace it rose at, so a cell says how it is going, not only where it stands.", "milestones": "Milestones", - "milestonesDesc": "Milestones as unit pictures on the chart, framed in their player's colour: the selected teams', every team's when none is selected. Hover one for whose it was and what.", - "selectedOnly": "Selected only", - "selectedOnlyDesc": "Only the teams selected in the bar above the chart are drawn. Off, every team is drawn and the selected ones stand out in front." + "milestonesDesc": "Milestones as unit pictures on the chart, framed in their player's colour: the selected teams', every team's when none is selected. Each chart only shows the kinds that say something about it - teching on what a team built and spent, a nuke on the damage it dealt. Hover one for whose it was and what.", + "milestoneKinds": "Milestone kinds", + "milestoneKindsDesc": "Which kinds of milestone may go on the charts. Opens a list to pick them from; the count says how many are on. A chart still only shows the kinds that say something about it.", + "perPage": "Graphs per page", + "perPageDesc": "How many graphs the page shows at once, as a grid of the picked group's stats. The wheel scrolls it when the group has more than fit, and a click opens one on its own." }, "damage": "Damage", "damageDealt": "Dealt", @@ -498,6 +514,12 @@ "shortBuildPower": "BP", "buildPowerUse": "Build power in use", "shortBuildPowerUse": "In use", + "buildPowerIdle": "Build power idle", + "shortBuildPowerIdle": "Idle", + "damagePerMetal": "Damage per metal", + "shortDamagePerMetal": "Dmg/M", + "armyShare": "Army share", + "shortArmyShare": "Army %", "value": "Unit value", "unitValue": "Unit value", "shortValue": "Value", @@ -564,6 +586,9 @@ "conversion": "The share of the team's energy conversion capacity converting right now.", "buildPower": "Build power of every builder, nano turret and factory the team has.", "buildPowerUse": "The share of the team's build power at work right now: building, repairing, reclaiming or resurrecting.", + "buildPowerIdle": "Build power that stood idle, added up over the game, in build power minutes: a thousand idle build power standing still for a minute is 1k.", + "damagePerMetal": "Damage dealt for every metal the team spent, so a small army that traded well beats a big one that did not.", + "armyShare": "The share of the team's unit value that is army rather than economy, defence or builders.", "unitValue": "What every unit the team has cost, in metal with energy at a sixtieth.", "valueArmy": "Value of the mobile ground army: every armed ground and hover unit.", "valueAir": "Value of every aircraft that is not a builder.", diff --git a/luarules/gadgets/api_teamstats.lua b/luarules/gadgets/api_teamstats.lua index 9698d82c134..3774aa9bdd7 100644 --- a/luarules/gadgets/api_teamstats.lua +++ b/luarules/gadgets/api_teamstats.lua @@ -52,7 +52,8 @@ local ENERGY_PER_METAL = 60 local BUCKETS = { "army", "air", "sea", "defense", "strategic", "factories", "builders", "economy", "utility" } -- The moments worth remembering, and what marks them. `built` is tested on every unit --- a team finishes and `lost` on every unit it loses; a milestone is kept once per team, +-- a team finishes and `lost` on every unit it loses, and the ones with neither are marked +-- where they happen; a milestone is kept once per team, -- or every time when `every` is set. The unit that reached it is stored with it. local MILESTONES = { { @@ -92,6 +93,8 @@ local MILESTONES = { end, every = true, }, + { key = "firstKill" }, + { key = "firstLoss" }, { key = "teamDied" }, } @@ -114,6 +117,7 @@ local SAMPLED = { "convUse", "buildPower", "buildPowerActive", + "buildPowerIdle", "unitCount", "unitValue", "killedValue", @@ -138,6 +142,7 @@ end -- The keys the tally keeps as running counters; the rest of a sample is read live. local TALLIED = { "unitCount", + "buildPowerIdle", "unitValue", "buildPower", "killedValue", @@ -339,6 +344,12 @@ local function removeUnit(unitID, unitDefID) end end +-- The milestones that are not read off a unit definition, by key. +local MILESTONE_BY_KEY = {} +for i = 1, #MILESTONES do + MILESTONE_BY_KEY[MILESTONES[i].key] = MILESTONES[i] +end + local function markMilestone(teamID, m, unitDefID, unitID) if not m.every then if reached[teamID][m.key] then @@ -390,10 +401,18 @@ end local scratch = {} +-- The minutes one sampling period is worth: idle build power is added up over them. +local SAMPLE_MINUTES = SAMPLE_PERIOD / 1800 + local function sample(frame) for teamID, h in pairs(history) do if not dead[teamID] then readLive(teamID, scratch) + -- What was not building over the period just gone, in build power minutes. + local t = teams[teamID] + local idle = math.max(0, (scratch.buildPower or 0) - (scratch.buildPowerActive or 0)) + t.buildPowerIdle = t.buildPowerIdle + idle * SAMPLE_MINUTES + scratch.buildPowerIdle = t.buildPowerIdle local n = #h.frames + 1 h.frames[n] = frame local values = h.values @@ -448,6 +467,7 @@ function gadget:UnitDestroyed(unitID, unitDefID, unitTeam, attackerID, attackerD local victim = teams[unitTeam] if victim then victim.lostValue = victim.lostValue + value + markMilestone(unitTeam, MILESTONE_BY_KEY.firstLoss, unitDefID, unitID) if defIsCommander[unitDefID] then victim.comLost = victim.comLost + 1 end @@ -468,6 +488,7 @@ function gadget:UnitDestroyed(unitID, unitDefID, unitTeam, attackerID, attackerD return end killer.killedValue = killer.killedValue + value + markMilestone(attackerTeam, MILESTONE_BY_KEY.firstKill, unitDefID, unitID) local split = killedAs[defBucket[unitDefID]] if split then killer[split] = killer[split] + value diff --git a/luarules/gadgets/game_zombies.lua b/luarules/gadgets/game_zombies.lua index db5d506e04f..3f75de08788 100644 --- a/luarules/gadgets/game_zombies.lua +++ b/luarules/gadgets/game_zombies.lua @@ -35,7 +35,6 @@ local ZOMBIE_REZ_FRAME_PARAM = "zombie_rez_frame" local WAS_ZOMBIE_PARAM = "wasZombie" local PUBLIC_RULES_PARAM_ACCESS = { public = true } local WAS_ZOMBIE_TIMEOUT_FRAMES = Game.gameSpeed * 3 -local MIN_CAPTURE_DISTANCE_BOOST = 300 local MIN_ZOMBIE_XP = 0.25 local ZOMBIE_MAX_XP = 1.5 @@ -363,26 +362,6 @@ local function initializeZombieAI(unitID, unitDefID) end end -local function applyZombieBuildRangeBonus(unitID, unitDefID) - local unitDef = unitDefs[unitDefID] - local originalBuildDistance = unitDef and unitDef.buildDistance - if not originalBuildDistance or originalBuildDistance <= 0 then - return - end - local losRadius = unitDef.losRadius or unitDef.sightDistance or 0 - local boostedBuildDistance = math.max(originalBuildDistance, MIN_CAPTURE_DISTANCE_BOOST) - spring.SetUnitBuildParams(unitID, "buildDistance", boostedBuildDistance) -end - -local function restoreOriginalBuildRange(unitID, unitDefID) - local unitDef = unitDefs[unitDefID] - local originalBuildDistance = unitDef and unitDef.buildDistance - if not originalBuildDistance or originalBuildDistance <= 0 then - return - end - spring.SetUnitBuildParams(unitID, "buildDistance", originalBuildDistance) -end - local function rollSpawnCount() return random(currentZombieConfig.countMin, currentZombieConfig.countMax) end @@ -471,7 +450,6 @@ local function spawnZombies(featureID, unitDefID, healthReductionRatio, x, y, z, spring.TransferUnit(unitID, scavTeamID) else initializeZombieAI(unitID, unitDefToCreate) - applyZombieBuildRangeBonus(unitID, unitDefToCreate) end end end @@ -509,9 +487,6 @@ local function setZombie(unitID) spring.SetUnitRulesParam(unitID, "zombie", 1) initializeZombieAI(unitID, unitDefID) - if spring.GetUnitTeam(unitID) == gaiaTeamID then - applyZombieBuildRangeBonus(unitID, unitDefID) - end end function gadget:FeatureBuildStepPost(featureID) @@ -712,9 +687,6 @@ function gadget:AllowUnitCaptureStep(builderID, builderTeam, unitID, unitDefID, end function gadget:UnitGiven(unitID, unitDefID, newTeam, oldTeam) - if oldTeam == gaiaTeamID and newTeam ~= gaiaTeamID and isZombie(unitID) then - restoreOriginalBuildRange(unitID, unitDefID) - end if pendingZombieCaptures[unitID] then pendingZombieCaptures[unitID] = nil if not isZombie(unitID) then diff --git a/luarules/gadgets/unit_explosion_spawner.lua b/luarules/gadgets/unit_explosion_spawner.lua index 315dfd14b43..593df11fb8a 100644 --- a/luarules/gadgets/unit_explosion_spawner.lua +++ b/luarules/gadgets/unit_explosion_spawner.lua @@ -76,7 +76,7 @@ local expireCount = 0 local spawnList = {} -- [index] = {.spawnDef, .teamID, .x, .y, .z, .ownerID}, subtables reused local spawnCount = 0 local spawnNames = {} -local minWaterDepth = -12 --calibrated off of the armpw's (minimum found) maxwaterdepth value +local minWaterDepth = -20 -- see movedefs.lua for weaponDefID = 0, #WeaponDefs do local wdcp = WeaponDefs[weaponDefID].customParams @@ -127,21 +127,19 @@ local function SpawnUnit(spawnData) else -- Early validation checks local x, z = spawnData.x, spawnData.z - if x <= 0 or x >= mapsizeX or z <= 0 or z >= mapsizeZ then - return -- Out of bounds - end local validSurface = false + local unitDetonates = x <= 0 or x >= mapsizeX or z <= 0 or z >= mapsizeZ -- out of bounds? local y = spGetGroundHeight(x, z) if not spawnDef.surface then validSurface = true - elseif spawnData.y < mathMax(y + 32, 32) then + else local surface = spawnDef.surface - if stringFind(surface, "LAND", 1, true) and y > minWaterDepth then - validSurface = true - elseif stringFind(surface, "SEA", 1, true) and y <= 0 then - validSurface = true + validSurface = (surface:find("LAND", 1, true) and y >= minWaterDepth) + or (surface:find("SEA", 1, true) and y <= 0) + if validSurface and spawnData.y >= mathMax(y + 32, 32) then + unitDetonates = true end end @@ -219,6 +217,11 @@ local function SpawnUnit(spawnData) end end + if unitDetonates then + spDestroyUnit(unitID, false, false) -- e.g. mines use explodeas + return + end + if spawnDef.expire then expireCount = expireCount + 1 expireByID[unitID] = expireCount @@ -270,6 +273,18 @@ function gadget:Initialize() end end +local function getProjectileTeam(projectileID, ownerID) + local teamID = spGetProjectileTeamID(projectileID) or (ownerID and spGetUnitTeam(ownerID)) + if teamID then + return teamID + end + + local allyTeamID = Spring.GetProjectileAllyTeamID(projectileID) + if allyTeamID then + return (Spring.GetTeamList(allyTeamID) or {})[1] + end +end + function gadget:Explosion(weaponDefID, x, y, z, ownerID, proID) if noCreate then noCreate = false @@ -278,10 +293,7 @@ function gadget:Explosion(weaponDefID, x, y, z, ownerID, proID) if spawnDefs[weaponDefID] then local spawnDef = spawnDefs[weaponDefID] -- guaranteed not nil by Explosion_GetWantedWeaponDef - local teamID = proID and spGetProjectileTeamID(proID) - if not teamID and ownerID then - teamID = spGetUnitTeam(ownerID) - end + local teamID = proID and getProjectileTeam(proID, ownerID) if not teamID then return end diff --git a/luaui/Include/graph.lua b/luaui/Include/graph.lua index 480fd6ce868..a35a3411413 100644 --- a/luaui/Include/graph.lua +++ b/luaui/Include/graph.lua @@ -18,6 +18,7 @@ -- smooth = true, -- monotone cubic between samples, no overshoot -- markers = { -- unit pictures on the chart, with a text for hover -- { x = 3000, texture = "#143", text = "1:40 First factory (Bot Lab)", series = 1 }, +-- { x = 3000, texture = "#143", text = "...", y = 2.2 }, -- or at a value of its own -- }, -- }) -- chart:draw() -- in DrawScreen @@ -51,6 +52,8 @@ ---@field area table ---@field placed table[] ---@field markerRow table +---@field markerSize number +---@field markerLaneRows integer ---@field yMin number ---@field yMax number ---@field yStep number @@ -121,6 +124,9 @@ local DEFAULTS = { yFormat = nil, -- Stacked: each band named inside it at its right end, where it is thick enough. bandLabels = false, + -- Bands of colour laid across the plot between two values, under everything else: + -- { { from = 0.5, to = 1.5, color = { r, g, b, a } }, ... }. + valueBands = nil, -- The y axis: fixed ends, or found from the data (a line chart always shows zero). yMin = nil, yMax = nil, @@ -147,10 +153,19 @@ local DEFAULTS = { markerZoom = nil, markerCorner = nil, markerFrameWidth = 2, + -- The lane of markers above the plot never takes more than this share of the chart's + -- height: pictures shrink (down to `markerMinSize`) until the rows they need fit, and + -- the rows past that wrap back into the lane, where the hovered one is drawn on top. + markerLaneShare = 0.4, + markerMinSize = 14, + -- How much larger the hovered picture is drawn. + markerHoverScale = 1.2, radar = { rings = 4, axes = nil, fill = true, + -- The axes are named around the wheel; off for a chart too small to read them. + labels = true, }, look = { -- A backdrop under the whole chart, for one drawn straight over the world rather @@ -165,11 +180,10 @@ local DEFAULTS = { crosshair = { 1, 1, 1, 0.22 }, hoverDot = { 1, 1, 1, 0.9 }, -- The other series step back to this share of their colour when one is highlighted. - dimAlpha = 0.28, + dimAlpha = 0.22, areaAlpha = 0.6, fillAlpha = 0.16, markerFrame = { 1, 1, 1, 0.35 }, - markerHover = { 1, 0.78, 0.51, 0.95 }, markerTick = { 1, 1, 1, 0.22 }, radarFillAlpha = 0.22, }, @@ -658,9 +672,24 @@ function Graph:prepareLine() self.sx = function(x) return left + (x - xMin) * xScale end - -- The lane above the plot takes as many rows as the markers need. - local rows = self:markerRows() + -- The lane above the plot takes as many rows as the markers need, up to the share of + -- the chart it may have; past that the pictures shrink, and past the smallest they + -- wrap back into the lane and overlap, the hovered one drawn on top of the rest. local size = cfg.markerSize or mathFloor(fs * 2.6) + -- Never more than a share of the chart itself, so the same marker is small on a small + -- chart and readable on a large one. + size = mathMax(6, mathMin(size, mathFloor((plot.top - bottom) * 0.22), mathFloor((right - left) * 0.18))) + local laneMax = mathMax(0, (plot.top - bottom) * cfg.markerLaneShare) + local minSize = mathMin(size, cfg.markerMinSize) + local rows = self:markerRows(size) + while rows > 0 and rows * size > laneMax and size > minSize do + size = mathMax(minSize, mathFloor(size * 0.8)) + rows = self:markerRows(size) + end + if rows * size > laneMax then + rows = mathMax(1, mathFloor(laneMax / size)) + end + self.markerSize, self.markerLaneRows = size, rows local top = plot.top - (rows > 0 and (rows * size + mathFloor(fs * 0.4)) or 0) self.area = { left = left, right = right, bottom = bottom, top = top } local yScale = (top - bottom) / (yMax - yMin) @@ -676,10 +705,9 @@ end -- The lane above the plot: every marker without a series takes the first row of it in -- which no earlier marker sits too close in x, rows stacking upwards. Returns the rows --- needed; each marker's row is kept for placeMarkers. Needs the x mapping. -function Graph:markerRows() - local cfg = self.cfg - local size = cfg.markerSize or mathFloor(cfg.fontSize * 2.6) +-- needed at this picture size; each marker's row is kept for placeMarkers. Needs the x +-- mapping. +function Graph:markerRows(size) local rows = {} local count = 0 self.markerRow = {} @@ -711,7 +739,7 @@ end -- Where each marker goes: on its series' value at its x, or in its row of the lane. function Graph:placeMarkers() local cfg = self.cfg - local size = cfg.markerSize or mathFloor(cfg.fontSize * 2.6) + local size = self.markerSize or cfg.markerSize or mathFloor(cfg.fontSize * 2.6) local area = self.area self.placed = {} for mi, m in ipairs(self.markers) do @@ -720,18 +748,50 @@ function Graph:placeMarkers() if px >= area.left - size and px <= area.right + size then local py local onSeries = m.series and self.prepared[m.series] - if onSeries then + if m.y then + -- A marker that names its own value sits there, wherever its series runs. + py = self.sy(m.y) + onSeries = true + elseif onSeries then local i = self:nearestIndex(m.x) local v = cfg.kind == "stacked" and self:stackTop(m.series, i) or onSeries.ys[i] py = self.sy(v or self.yMin) else + -- Rows past what the lane holds wrap back into it: those pictures + -- overlap, and hovering one lifts it. local row = self.markerRow[mi] or 1 + local lane = mathMax(1, self.markerLaneRows or 1) + row = (row - 1) % lane + 1 py = area.top + mathFloor(cfg.fontSize * 0.4) + size * 0.5 + (row - 1) * size end local half = size * 0.5 -- The picture stays inside the plot's width, so one at the first sample does -- not sit on the axis labels; its tick still points at the true x. local cx = mathMin(mathMax(px, area.left + half), area.right - half) + -- And out of the way of the ones already placed: a run of milestones close + -- together climbs away from the line instead of piling on one spot. + if onSeries then + local step = size * 0.9 + local tries = 0 + local up = true + local base = py + while tries < 12 do + local clash = false + for _, other in ipairs(self.placed) do + if mathAbs(other.cx - cx) < size and mathAbs(other.py - py) < step * 0.9 then + clash = true + break + end + end + if not clash then + break + end + tries = tries + 1 + py = base + (up and 1 or -1) * mathFloor((tries + 1) / 2) * step + up = not up + end + py = mathMin(mathMax(py, area.bottom + half), area.top - half) + end self.placed[#self.placed + 1] = { marker = m, x1 = mathFloor(cx - half), @@ -894,6 +954,21 @@ function Graph:drawGrid() glVertex(area.left, area.top) end) + -- The bands a chart lays across its plot, under its grid. + for _, band in ipairs(cfg.valueBands or {}) do + local c = band.color + local y1, y2 = self.sy(mathMax(self.yMin, band.from)), self.sy(mathMin(self.yMax, band.to)) + if y2 > y1 then + glColor(c[1], c[2], c[3], c[4] or 0.1) + glBeginEnd(GL_QUADS, function() + glVertex(area.left, y1) + glVertex(area.right, y1) + glVertex(area.right, y2) + glVertex(area.left, y2) + end) + end + end + -- Horizontal lines and their labels. local v = self.yMin local yFormat = self.yFormatter @@ -1183,52 +1258,65 @@ function Graph:markerCut(width) return mathMin(cut, mathFloor(width * 0.5)) end -function Graph:drawMarkers() +-- One marker: its tick, its picture with the corners cut, and its frame. `scale` grows +-- the picture about its middle, for the one under the cursor. +function Graph:drawMarker(m, scale) local look = self.cfg.look - for _, m in ipairs(self.placed) do - local marker = m.marker - local cut = self:markerCut(m.x2 - m.x1) - -- A tick from the picture down to the plot, or to the point it sits on. - glColor(look.markerTick) - glBeginEnd(GL_LINES, function() - glVertex(m.cx + 0.5, m.y1) - glVertex(m.px + 0.5, m.onSeries and m.py or self.area.bottom) + local marker = m.marker + local x1, y1, x2, y2 = m.x1, m.y1, m.x2, m.y2 + if scale and scale ~= 1 then + local gx = mathFloor((x2 - x1) * (scale - 1) * 0.5) + local gy = mathFloor((y2 - y1) * (scale - 1) * 0.5) + x1, y1, x2, y2 = x1 - gx, y1 - gy, x2 + gx, y2 + gy + end + local cut = self:markerCut(x2 - x1) + -- A tick from the picture down to the plot, or to the point it sits on. + glColor(look.markerTick) + glBeginEnd(GL_LINES, function() + glVertex(m.cx + 0.5, y1) + glVertex(m.px + 0.5, m.onSeries and m.py or self.area.bottom) + end) + local corners = chamfered(x1, y1, x2, y2, cut) + if marker.texture then + -- Zoomed in a little, the more the smaller the picture: a unit picture has air + -- around the unit. The engine's textures load flipped, so t runs from 1 down to 0 + -- going up. + local z = marker.zoom or self.cfg.markerZoom or mathMin(0.06, 2.5 / mathMax(1, x2 - x1)) + local w, h = mathMax(1, x2 - x1), mathMax(1, y2 - y1) + glColor(1, 1, 1, 1) + glTexture(marker.texture) + glBeginEnd(GL_TRIANGLE_FAN, function() + for k = 1, 16, 2 do + local x, y = corners[k], corners[k + 1] + glTexCoord(z + (x - x1) / w * (1 - 2 * z), 1 - z - (y - y1) / h * (1 - 2 * z)) + glVertex(x, y) + end end) - local corners = chamfered(m.x1, m.y1, m.x2, m.y2, cut) - if marker.texture then - -- Zoomed in a little, the more the smaller the picture: a unit picture has - -- air around the unit. The engine's textures load flipped, so t runs from 1 - -- down to 0 going up. - local z = marker.zoom or self.cfg.markerZoom or mathMin(0.06, 2.5 / mathMax(1, m.x2 - m.x1)) - local w, h = mathMax(1, m.x2 - m.x1), mathMax(1, m.y2 - m.y1) - glColor(1, 1, 1, 1) - glTexture(marker.texture) - glBeginEnd(GL_TRIANGLE_FAN, function() - for k = 1, 16, 2 do - local x, y = corners[k], corners[k + 1] - glTexCoord(z + (x - m.x1) / w * (1 - 2 * z), 1 - z - (y - m.y1) / h * (1 - 2 * z)) - glVertex(x, y) - end - end) - glTexture(false) - else - local c = marker.color or { 1, 1, 1 } - glColor(c[1], c[2], c[3], 0.9) - glBeginEnd(GL_TRIANGLE_FAN, function() - for k = 1, 16, 2 do - glVertex(corners[k], corners[k + 1]) - end - end) - end - -- The frame, in the marker's own colour when it has one (a team's), following the - -- cut corners. - local frame = marker.frame - if frame then - glColor(frame[1], frame[2], frame[3], frame[4] or 1) - else - glColor(look.markerFrame) - end - chamferRing(m.x1, m.y1, m.x2, m.y2, cut, self.cfg.markerFrameWidth) + glTexture(false) + else + local c = marker.color or { 1, 1, 1 } + glColor(c[1], c[2], c[3], 0.9) + glBeginEnd(GL_TRIANGLE_FAN, function() + for k = 1, 16, 2 do + glVertex(corners[k], corners[k + 1]) + end + end) + end + -- The frame, in the marker's own colour when it has one (a team's), following the cut + -- corners. + local frame = marker.frame + if frame then + glColor(frame[1], frame[2], frame[3], frame[4] or 1) + else + glColor(look.markerFrame) + end + chamferRing(x1, y1, x2, y2, cut, self.cfg.markerFrameWidth) + return x1, y1, x2, y2 +end + +function Graph:drawMarkers() + for _, m in ipairs(self.placed) do + self:drawMarker(m, 1) end end @@ -1282,9 +1370,9 @@ function Graph:prepareRadar() local axes = cfg.radar.axes or {} self.axes = axes local n = #axes - -- Label room around the wheel. + -- Label room around the wheel; a wheel drawn without labels keeps that room. local labelW = 0 - for _, a in ipairs(axes) do + for _, a in ipairs(cfg.radar.labels ~= false and axes or {}) do labelW = mathMax(labelW, self:textWidth(a.label or a.key or "", fs)) end local cx = mathFloor((plot.left + plot.right) * 0.5) @@ -1387,7 +1475,7 @@ function Graph:drawRadar() end -- Labels, past the end of each spoke. - for ai, a in ipairs(self.axes) do + for ai, a in ipairs(cfg.radar.labels ~= false and self.axes or {}) do local x, y = axisPoint(ai, 1) local dx, dy = x - r.cx, y - r.cy local opts = "o" @@ -1511,11 +1599,9 @@ function Graph:drawOverlay() local look = self.cfg.look if hit.kind == "marker" then local m = hit.placed - -- Around the picture's own frame, its corners cut to match: a cut grown by w - -- keeps the diagonal w away. - local w = 2 - glColor(look.markerHover) - chamferRing(m.x1 - w, m.y1 - w, m.x2 + w, m.y2 + w, self:markerCut(m.x2 - m.x1) + w * 0.5857864376, w) + -- Drawn again over the baked ones, larger: pictures that overlap in a crowded lane + -- are read by hovering them. + self:drawMarker(m, self.cfg.markerHoverScale) glColor(1, 1, 1, 1) return end diff --git a/luaui/Include/teamstats_graphs.lua b/luaui/Include/teamstats_graphs.lua index 70af79f4687..094dceb469d 100644 --- a/luaui/Include/teamstats_graphs.lua +++ b/luaui/Include/teamstats_graphs.lua @@ -17,6 +17,7 @@ local mathFloor = math.floor local mathMax = math.max local mathMin = math.min local mathHuge = math.huge +local mathAbs = math.abs local tableSort = table.sort -- The composition bands: one colour per bucket, in the composition view's column order. @@ -33,9 +34,44 @@ local BUCKET_COLORS = { { 0.82, 0.68, 0.76 }, } --- The legend bar's looks: a block is a button, the picked one lit and framed warm. +-- Which kinds of milestone belong on a chart of which column group, so a chart is not +-- littered with moments that say nothing about it. `always` goes on every chart: the end of +-- a team explains the end of all of its lines. +local MILESTONE_GROUPS = { + tech2 = { units = true, value = true, metal = true, energy = true, industry = true }, + tech3 = { units = true, value = true, metal = true, energy = true, industry = true }, + nuke = { damage = true, value = true, traded = true, energy = true }, + antinuke = { damage = true, value = true, energy = true }, + lrpc = { damage = true, value = true, traded = true }, + firstKill = { damage = true, units = true, traded = true }, + firstLoss = { damage = true, units = true, traded = true, value = true }, + commanderLost = { commanders = true, damage = true, units = true, value = true, metal = true, energy = true }, + teamDied = { always = true }, +} + +-- The grids a page can be split into, in the order the setting cycles through them. +local PAGES = { + { n = 9, cols = 3, rows = 3 }, + { n = 12, cols = 4, rows = 3 }, + { n = 16, cols = 4, rows = 4 }, +} + +-- The team profile's axes, in the order they go round the wheel from the top. +local PROFILE_AXES = { + { key = "damageDealt" }, + { key = "unitsProduced" }, + { key = "metalProduced" }, + { key = "energyProduced" }, + { key = "unitValue", gadget = true }, + { key = "actionsPerMinute", gadget = true }, +} + +-- The legend bar's looks: a block is a button, the picked one lit and framed warm. The +-- chart of the grid under the cursor is framed the same way, more faintly. local PLATE = { 1, 1, 1, 0.05 } local PICKED_FRAME = { 1, 0.78, 0.51, 0.85 } +local HOVER_FRAME = { 1, 0.78, 0.51, 0.5 } +local HOVER_FADE = { 1, 0.78, 0.51, 0 } -- A percentage for an axis or a tooltip: whole, a prefix past a million, infinity as -- its sign. @@ -56,7 +92,7 @@ local M = {} function M.new(ctx) ---@type table local page = { - open = false, + open = true, stat = "damageDealt", -- Units right-clicked off the chart, by key. ---@type table @@ -67,11 +103,14 @@ function M.new(ctx) ---@type table selected = {}, selectionSet = false, + -- The axes of the profile wheel, as the last build read them. + ---@type table[] + profileAxes = {}, -- The page's own grouping, kept apart from the table's: a chart of ally teams and -- a table of ally teams are different questions, and picking a single player on the -- chart should not flatten the table. grouped = true, - hover = { stat = 0, legend = 0, block = 0 }, + hover = { stat = 0, legend = 0, block = 0, kind = 0, group = 0 }, -- Per team: the engine's history entries taken at the period, derived, and how -- many of the engine's list they are; the live newest entry is left out so the -- chart changes once a period, not every second. @@ -110,6 +149,32 @@ function M.new(ctx) rects = nil, ---@type table? chartHit = nil, + -- The grid: how many charts a page holds, which row it starts at, the charts on + -- it and the one under the cursor. One chart per page is the single chart view. + perPage = 9, + scroll = 0, + maxScroll = 0, + ---@type table[] + miniCharts = {}, + ---@type table? + miniHit = nil, + -- A chart opened from the grid, by stat key: it takes the whole page until it is + -- closed again. + ---@type string? + zoom = nil, + -- The group the stat list was built for; another one shows its own charts. + ---@type string? + group = nil, + -- Where the card of milestone kinds is anchored: the settings row that opens it. + ---@type table? + kindsAnchor = nil, + -- The kinds of milestone left off the charts, by key, and whether the card that + -- picks them is open. + ---@type table + milestoneOff = {}, + kindsOpen = false, + ---@type table[] + kindRects = {}, empty = true, scale = 1, } @@ -144,11 +209,75 @@ function M.new(ctx) -- The stats of the sidebar's group, the composition chart first in its group. The -- gadget's columns, and the ones only it keeps a history of, only while it is there. + -- The kinds of milestone, in the order the panel names them. + local function milestoneKinds() + return ctx.L.milestoneOrder or {} + end + + -- What a settings row with a value says, and what pressing it does. The panel asks + -- rather than knowing, so a new setting only needs adding here. + function page.settingValue(key) + if key == "perPage" then + return page.perPage + end + local kinds = milestoneKinds() + local on = 0 + for _, kind in ipairs(kinds) do + if not page.milestoneOff[kind] then + on = on + 1 + end + end + return on .. "/" .. #kinds + end + + -- The card put away from outside: Escape, or the panel closing. + function page.closeKinds() + if page.kindsOpen then + page.kindsOpen = false + page.dirty = true + page.gen = page.gen + 1 + end + end + + function page.settingPress(key, back) + if key == "perPage" then + page.cyclePerPage(back) + return + end + -- The card of kinds opens over the charts and closes on the next press. + page.kindsOpen = not page.kindsOpen + page.dirty = true + page.gen = page.gen + 1 + end + + -- Whether the stat list is shown beside the charts: a grid says what each chart is in + -- its own title, so the list would only take room from it. + function page.listShown() + return not page.gridded() + end + function page.rebuildStatList() local group = ctx.groupByKey[ctx.selectedGroup()] or ctx.GROUPS[1] + if page.group ~= group.key then + -- A group of its own charts: the page shows them rather than staying on the one + -- that was open in the group before. + page.group, page.zoom, page.scroll = group.key, nil, 0 + end local list = {} + do + -- The way back to the grid, kept apart from the stats by a rule. + list[#list + 1] = { key = "overview", label = ctx.i18n("ui.teamStats.graph.overview"), back = true } + list[#list + 1] = { divider = true } + end if group.key == "composition" and ctx.gadgetOn() then list[#list + 1] = { key = "composition", label = ctx.i18n("ui.teamStats.graph.composition") } + elseif group.key == "all" then + -- Every team's shape at a glance, and when things happened to them, before the + -- stats they are made of. + list[#list + 1] = { key = "profile", label = ctx.i18n("ui.teamStats.graph.profile") } + if ctx.gadgetOn() then + list[#list + 1] = { key = "timeline", label = ctx.i18n("ui.teamStats.graph.timeline") } + end end for i = 1, #group.columns do local column = ctx.COLUMNS[group.columns[i]] @@ -158,13 +287,18 @@ function M.new(ctx) end page.statList = list local found = false + ---@type table? + local first = nil for i = 1, #list do if list[i].key == page.stat then found = true end + if not first and list[i].key and not list[i].back and not list[i].divider then + first = list[i] + end end - if not found and list[1] then - page.stat = list[1].key + if not found and first then + page.stat = first.key end page.dirty = true page.gen = page.gen + 1 @@ -182,11 +316,15 @@ function M.new(ctx) -- card spans listY1..listY2 when given, so it lines up with the sidebar's beside it. function page.setLayout(x1, y1, x2, y2, s, listY1, listY2) page.scale = s - local listW = mathFloor(200 * s) + -- Kept so the page can lay itself out again when the list comes or goes without the + -- panel having a reason to. + page.layoutArgs = { x1, y1, x2, y2, s, listY1, listY2 } + page.listWas = page.listShown() local gap = mathFloor(12 * s) + local listW = page.listShown() and mathFloor(200 * s) or -gap local barH = ctx.metrics.rowHeight + mathFloor(8 * s) page.rects = { - list = { x1, listY1 or y1, x1 + listW, listY2 or y2 }, + list = { x1, listY1 or y1, x1 + mathMax(0, listW), listY2 or y2 }, bar = { x1 + listW + gap, y2 - barH, x2, y2 }, chart = { x1 + listW + gap, y1, x2, y2 - barH - mathFloor(4 * s) }, -- The sidebar's entry height, so the two lists run level. @@ -204,8 +342,48 @@ function M.new(ctx) layoutBar() end + -- Where the card of kinds hangs: the rect of the settings row that opens it. + function page.setKindsAnchor(rect) + page.kindsAnchor = rect + end + + -- The grouping switch belongs with the teams it groups: the page draws it at the end of + -- the legend bar rather than among the settings. + ---@type fun(): table? + local groupToggleRect + + -- Where the grouping switch sits on the bar, for whoever needs to point at it. + function page.groupToggle() + return groupToggleRect() + end + + -- The room the grouping switch and its caption take at the end of the bar, which the + -- team blocks leave free. + local function groupReserve() + if ctx.isFFA then + return 0 + end + local font = ctx.font() + local label = ctx.L.switch and ctx.L.switch.groupByTeam or "" + local labelW = font and mathFloor(font:GetTextWidth(label) * ctx.metrics.catFs) or mathFloor(90 * page.scale) + return labelW + mathFloor(38 * page.scale) + ctx.metrics.sidePad + ctx.metrics.rowPad * 2 + end + + function groupToggleRect() + local r = page.rects + if not r or ctx.isFFA then + return nil + end + local togW = mathFloor(38 * page.scale) + local togH = mathFloor(ctx.metrics.rowHeight * 0.52) + local cy = mathFloor((r.bar[2] + r.bar[4]) * 0.5) + local x2 = r.bar[3] - ctx.metrics.sidePad + return { x2 - togW, cy - mathFloor(togH * 0.5), x2, cy - mathFloor(togH * 0.5) + togH } + end + function page.setFont(font, fontSize) chart:configure({ font = font, fontSize = fontSize }) + page.dirty = true end local function statRect(i) @@ -403,6 +581,102 @@ function M.new(ctx) return points end + ---------------------------------------------------------------- + -- Trend lines for the table's cells + ---------------------------------------------------------------- + + -- The table draws a small line of each cell's history behind its number. The rows it + -- wants them for are handed over whenever it rebuilds them ({ key, members } each); + -- a column's lines are then built for every row at once, so they share one range and + -- can be read against each other, and kept until a history grows or the rows change. + ---@type { sig: string, rows: table[], version: integer, lines: table } + local trend = { sig = "", rows = {}, version = -1, lines = {} } + + function page.setTrendRows(list) + local parts = {} + for _, row in ipairs(list) do + parts[#parts + 1] = row.key .. "=" .. table.concat(row.members, ".") + end + local sig = table.concat(parts, "|") + if sig ~= trend.sig then + trend.sig, trend.rows, trend.lines = sig, list, {} + end + end + + -- Every row's run of one column, normalised into the unit square: x is the game time + -- across every row's samples, y the value against the range the rows share (a running + -- total measured from zero, anything else from its own floor). + local function trendLines(key, perMinute) + if trend.version ~= page.version then + trend.version, trend.lines = page.version, {} + end + local cacheKey = key .. (perMinute and "/m" or "") + local held = trend.lines[cacheKey] + if held then + return held + end + rebuildSamples() + local column = ctx.COLUMNS[key] + local runs = {} + local xMin, xMax = mathHuge, -mathHuge + local yMin, yMax = mathHuge, -mathHuge + for _, row in ipairs(trend.rows) do + local points = pointsOf(row.members, key, perMinute, column and column.clamp) + if #points > 1 then + runs[row.key] = points + for _, p in ipairs(points) do + xMin, xMax = mathMin(xMin, p[1]), mathMax(xMax, p[1]) + yMin, yMax = mathMin(yMin, p[2]), mathMax(yMax, p[2]) + end + end + end + local lines = {} + if xMax > xMin then + -- A running total is read against zero; a rate or a level against its own floor. + local lo = (column and column.fmt == "si" and not perMinute) and mathMin(0, yMin) or yMin + local span = yMax - lo + for rowKey, points in pairs(runs) do + local line = {} + for _, p in ipairs(points) do + line[#line + 1] = (p[1] - xMin) / (xMax - xMin) + line[#line + 1] = span > 0 and (p[2] - lo) / span or 0.5 + end + lines[rowKey] = line + end + end + trend.lines[cacheKey] = lines + return lines + end + + -- One cell's line, drawn into the rect the table gives it. Called from the panel's + -- bake, so the run is walked once per rebuild, not once per frame. + function page.drawTrend(rowKey, key, perMinute, x1, y1, x2, y2, color, width) + local line = trendLines(key, perMinute)[rowKey] + if not line or #line < 4 then + return false + end + local w, h = x2 - x1, y2 - y1 + if w <= 2 or h <= 2 then + return false + end + if gl.Smoothing then + gl.Smoothing(false, true, false) + end + gl.LineWidth(width or 1) + gl.Color(color[1], color[2], color[3], color[4] or 0.5) + gl.BeginEnd(GL.LINE_STRIP, function() + for i = 1, #line, 2 do + gl.Vertex(x1 + line[i] * w, y1 + line[i + 1] * h) + end + end) + gl.LineWidth(1) + if gl.Smoothing then + gl.Smoothing(false, false, false) + end + gl.Color(1, 1, 1, 1) + return true + end + ---------------------------------------------------------------- -- Series and the legend bar ---------------------------------------------------------------- @@ -518,6 +792,19 @@ function M.new(ctx) all.labelW = widthOf(all.label) ---@type table[] local blocks = { all } + -- Whoever is playing gets their own team a press away, beside All. + ---@type table? + local mine = nil + for _, unit in ipairs(page.units) do + if unit.isLocal then + mine = unit + end + end + if mine then + local me = { me = true, unit = mine, label = ctx.i18n("ui.teamStats.graph.you"), members = {} } + me.labelW = widthOf(me.label) + blocks[#blocks + 1] = me + end local seen = {} for _, unit in ipairs(page.units) do for _, team in ipairs(unit.teams) do @@ -536,15 +823,20 @@ function M.new(ctx) end end local count, labelW = 0, 0 - for i = 2, #blocks do + local teamBlocks = 0 + for i = 1, #blocks do local b = blocks[i] - count = count + #b.members - b.labelW = widthOf(b.label) - labelW = labelW + b.labelW + pad + if not b.all and not b.me then + count = count + #b.members + b.labelW = widthOf(b.label) + labelW = labelW + b.labelW + pad + teamBlocks = teamBlocks + 1 + end end - local teamBlocks = #blocks - 1 - -- The All button always keeps its caption; the team blocks share the rest. - local avail = r.bar[3] - r.bar[1] - pad * 2 - (all.labelW + pad * 3) + -- The All button always keeps its caption; the team blocks share what is left once + -- the grouping switch at the end of the bar has its room. + local fixed = all.labelW + pad * 3 + (blocks[2] and blocks[2].me and blocks[2].labelW + r.square + pad * 3 or 0) + local avail = r.bar[3] - r.bar[1] - pad * 2 - fixed - groupReserve() local gaps = pad * mathMax(0, teamBlocks - 1) + pad * teamBlocks local square = r.square local withLabels = count * square + gaps + labelW <= avail @@ -560,9 +852,15 @@ function M.new(ctx) for _, b in ipairs(blocks) do -- The plate runs from the caption to the last square, with half a pad of air. b.x1 = x - half - if b.all then + if b.all or b.me then b.labelX = x x = x + b.labelW + if b.me then + -- Your own colour beside the caption, the same square the teams get. + x = x + half + b.swatch = { x, y1, x + square, y2 } + x = x + square + end else if withLabels then b.labelX = x @@ -606,9 +904,25 @@ function M.new(ctx) return list end - -- Whether the Selected only switch applies: the composition is always the selection's. - function page.filterShown() - return page.stat ~= "composition" + -- Whether a rate means anything for what the page is showing: only a running total, + -- and only while that chart is the one open. + function page.rateShown() + if page.gridded() then + return false + end + local column = ctx.COLUMNS[page.zoom or page.stat] + return column ~= nil and column.rate == true + end + + -- Whether the picked stat is drawn over game time: the profile and the composition + -- answer differently, and the panel asks before it lays the switches out. + function page.overTime() + return page.stat ~= "profile" + end + + -- Whether the milestones switch means anything: the timeline is made of them. + function page.milestonesOwn() + return page.stat == "timeline" end -- The units a chart is about, named for its title: every team, one by name, or how many. @@ -624,37 +938,79 @@ function M.new(ctx) -- The milestones of these units' players as pictures on the chart, each framed in its -- player's colour, on the unit's series where `indexByKey` names one, else in the lane. -- The hover text says whose it was, in their colour, then when and what. - local function milestoneMarkers(list, indexByKey) + -- Where a timeline's lanes sit, and how close two pictures may be in x before one has + -- to move out of the other's way. Filled in while the timeline is built. + ---@type table + local lanes = {} + local timelineGap = 0 + + -- The rows a lane's pictures take, alternating above and below its line so the team + -- keeps its height: the first free row whose last picture is far enough behind. + local LANE_ROWS = { 0, 0.24, -0.24, 0.12, -0.12, 0.36, -0.36 } + + -- Whether a kind of milestone says anything about the column being charted. + local function kindFits(kind, column) + if page.milestoneOff[kind] then + return false + end + local groups = MILESTONE_GROUPS[kind] + if not groups or groups.always then + return true + end + -- A chart that is not one column's (the composition, the profile) takes them all. + return column == nil or groups[column.group] == true + end + + local function milestoneMarkers(list, indexByKey, always, column) local markers = {} local live = ctx.live() - if not live or not ctx.filters.milestones then + if not live or not (always or ctx.filters.milestones) then return markers end for _, unit in ipairs(list) do + -- One set of rows per unit: its lane is its own. + local taken = {} for _, team in ipairs(unit.teams) do local teamLive = live[team.id] for _, m in ipairs(teamLive and teamLive.milestones or {}) do - local ud = m.unitDefID and UnitDefs[m.unitDefID] or nil - ---@cast ud table? - local label = ctx.L.milestone[m.key] or m.key - if ud then - label = label .. " (" .. (ud.translatedHumanName or ud.name) .. ")" + -- Kinds switched off in the milestone settings never make a marker. + if kindFits(m.key, column) then + local ud = m.unitDefID and UnitDefs[m.unitDefID] or nil + ---@cast ud table? + local label = ctx.L.milestone[m.key] or m.key + if ud then + label = label .. " (" .. (ud.translatedHumanName or ud.name) .. ")" + end + local whose = (team.nameColor or "") .. team.name + local what = ctx.colors.title .. Graph.frameLabel(m.frame) .. " " .. label + local lane = lanes[unit.key] + local y = nil + if lane then + -- The first row of the lane this one is clear of. + local row = 1 + while row < #LANE_ROWS and (taken[row] or -mathHuge) > m.frame - timelineGap do + row = row + 1 + end + taken[row] = m.frame + y = lane + (LANE_ROWS[row] or 0) + end + markers[#markers + 1] = { + x = m.frame, + y = y, + texture = ud and ("#" .. m.unitDefID) or nil, + text = whose .. "\n" .. what, + series = indexByKey and indexByKey[unit.key] or nil, + frame = { team.accent[1], team.accent[2], team.accent[3] }, + } end - local whose = (team.nameColor or "") .. team.name - local what = ctx.colors.title .. Graph.frameLabel(m.frame) .. " " .. label - markers[#markers + 1] = { - x = m.frame, - texture = ud and ("#" .. m.unitDefID) or nil, - text = whose .. "\n" .. what, - series = indexByKey and indexByKey[unit.key] or nil, - frame = { team.accent[1], team.accent[2], team.accent[3] }, - } end end end return markers end + -- What a unit is worth on each profile axis right now: its teams' current stats added + -- up, the way the table's band totals are. -- The chart's series from the pick and the switches. The same rules for every kind: -- hidden units are left out; with Selected only on and a selection, only the selected -- are plotted, otherwise every shown unit is, the selected ones lit and on top and the @@ -662,16 +1018,54 @@ function M.new(ctx) -- selected. A composition is one whole, so it is the selection's summed (every shown -- team's without one). Share of team plots the players of the plotted ally teams as -- shares of their total. - function page.build() - rebuildSamples() - page.rebuildUnits() - local column = ctx.COLUMNS[page.stat] + local function profileValues(unit) + local values = {} + for ai, axis in ipairs(page.profileAxes) do + local sum = 0 + for _, team in ipairs(unit.teams) do + local v = team.stats and team.stats[axis.key] + if v and v == v then + sum = sum + v + end + end + values[ai] = sum + end + return values + end + + -- The charts a grid page draws, kept and reconfigured rather than made and freed + -- every time the page is scrolled or the grid resized. + ---@type table[] + local pool = {} + + -- A line per plotted unit of one column, and which of them the selection lifts. + local function lineSeries(column, plotted, perMinute) + local series = {} + ---@type table + local lifted = {} + for _, u in ipairs(plotted) do + series[#series + 1] = { + name = u.name, + color = u.color, + points = pointsOf(u.members, column.key, perMinute, column.clamp), + width = 2, + } + lifted[#series] = page.selected[u.key] + end + return series, lifted + end + + -- What a chart of one stat is made of: the same rules for the big chart and for every + -- small one in the grid. `small` leaves out what a little chart has no room for. + -- Returns whether it came out empty. + local function fillChart(target, statKey, small) + local column = ctx.COLUMNS[statKey] local isGrouped = grouped() local perMinute = ctx.filters.perMinute and column and column.rate or false local shown = shownUnits() local picked = pickedUnits() local anyPicked = #picked > 0 - local plotted = (anyPicked and ctx.filters.selectedOnly) and picked or shown + local plotted = shown -- Something stands out only while the selection is not everything plotted. local lifts = anyPicked and #picked < #plotted local marked = anyPicked and picked or plotted @@ -683,7 +1077,75 @@ function M.new(ctx) local lifted = {} local ownLegend = false - if page.stat == "composition" then + lanes, timelineGap = {}, 0 + target.cfg.valueBands = nil + if statKey == "timeline" then + -- A lane per team, drawn from the first frame to now, with its milestones on + -- it: what happened to whom, and when. Every team gets the same height, and + -- pictures that fall close together are stacked into the room around the line + -- rather than piled on one spot. + local n = #plotted + local frame = mathMax(1, ctx.frame()) + local indexByKey = {} + for i, u in ipairs(plotted) do + local lane = n - i + 1 + series[#series + 1] = { + name = u.name, + color = u.color, + points = { { 0, lane }, { frame, lane } }, + width = 2, + } + indexByKey[u.key] = #series + lanes[u.key] = lane + lifted[#series] = page.selected[u.key] + end + -- A faint band of the team's colour behind each lane. + local bands = {} + for i, u in ipairs(plotted) do + local lane = n - i + 1 + bands[#bands + 1] = { + -- A little short of the next lane, so there is a gap between the teams. + from = lane - 0.42, + to = lane + 0.42, + color = { u.color[1], u.color[2], u.color[3], 0.1 }, + } + end + target.cfg.valueBands = bands + -- How far apart two pictures have to be in game frames not to overlap, from the + -- room the chart has for the whole game. + local plotW = page.rects and (page.rects.chart[3] - page.rects.chart[1]) * 0.8 or 600 + timelineGap = frame / mathMax(1, plotW / mathMax(8, mathFloor(ctx.metrics.rowFs * 2.6))) + -- The lanes are named down the axis, and there is a clear row above and below. + target.cfg.yMin, target.cfg.yMax, target.cfg.gridLines = 0, n + 1, n + 1 + yFormat = function(v) + if small then + -- No room beside a small chart: the bands say whose lane is whose. + return "" + end + local u = plotted[n - mathFloor(v + 0.5) + 1] + return u and u.name or "" + end + markers = milestoneMarkers(plotted, indexByKey, true, nil) + title = ctx.i18n("ui.teamStats.graph.timeline") + elseif statKey == "profile" then + kind = "radar" + ownLegend = true + local axes = {} + for _, axis in ipairs(PROFILE_AXES) do + if ctx.gadgetOn() or not axis.gadget then + -- The full caption: three axes are called "produced" on their own. + axes[#axes + 1] = { key = axis.key, label = ctx.columnTitle(ctx.COLUMNS[axis.key]) } + end + end + page.profileAxes = axes + for _, u in ipairs(plotted) do + series[#series + 1] = { name = u.name, color = u.color, values = profileValues(u), width = 2 } + lifted[#series] = page.selected[u.key] + end + -- The axis names need room; a small wheel is read by its shape. + target:configure({ radar = { axes = axes, rings = 4, fill = true, labels = not small } }) + title = ctx.i18n("ui.teamStats.graph.profile") + elseif statKey == "composition" then kind = "stacked" ownLegend = true local of = anyPicked and picked or shown @@ -709,7 +1171,7 @@ function M.new(ctx) end lifts = false title = ctx.i18n("ui.teamStats.graph.composition") .. " \194\183 " .. namesOf(of) - markers = milestoneMarkers(of, nil) + markers = milestoneMarkers(of, nil, false, nil) elseif column and ctx.filters.shareOfTeam and isGrouped and column.fmt == "si" then kind = "stacked" for _, u in ipairs(plotted) do @@ -727,18 +1189,12 @@ function M.new(ctx) .. ctx.L.switch.shareOfTeam .. " \194\183 " .. namesOf(plotted) - markers = milestoneMarkers(marked, nil) + markers = milestoneMarkers(marked, nil, false, column) elseif column then local indexByKey = {} - for _, u in ipairs(plotted) do - series[#series + 1] = { - name = u.name, - color = u.color, - points = pointsOf(u.members, column.key, perMinute, column.clamp), - width = 2, - } - indexByKey[u.key] = #series - lifted[#series] = page.selected[u.key] + series, lifted = lineSeries(column, plotted, perMinute) + for i, u in ipairs(plotted) do + indexByKey[u.key] = i end title = ctx.columnTitle(column) if perMinute then @@ -747,23 +1203,174 @@ function M.new(ctx) if column.fmt == "percent" then yFormat = percentFormat end - markers = milestoneMarkers(marked, indexByKey) + markers = milestoneMarkers(marked, indexByKey, false, column) end - page.empty = true + local empty = true for _, s in ipairs(series) do - if #s.points > 0 then - page.empty = false + -- A run over time carries points; the profile's wheel carries a value per axis. + if #(s.points or s.values or {}) > 0 then + empty = false end end -- The bands of the composition chart are named by the chart itself; teams are -- named by the legend bar. The axis format is set straight: a nil handed to -- configure would leave the last one. - chart.cfg.yFormat = yFormat - chart:configure({ kind = kind, title = title, legend = ownLegend, bandLabels = ownLegend }) - chart:setSeries(series) - chart:setMarkers(markers) - chart:setHighlight(lifts and lifted or nil) + if statKey ~= "timeline" then + target.cfg.yMin, target.cfg.yMax, target.cfg.gridLines = nil, nil, small and 2 or 4 + end + target.cfg.yFormat = yFormat + target:configure({ + kind = kind, + title = title, + legend = ownLegend and not small, + bandLabels = ownLegend and not small, + xTicks = small and 2 or 5, + }) + target:setSeries(series) + target:setMarkers((small and statKey ~= "timeline") and {} or markers) + target:setHighlight(lifts and lifted or nil) + return empty + end + + -- Whether the page is showing a grid of charts rather than one: more than one per page + -- and none opened on its own. + function page.gridded() + return page.zoom == nil + end + + -- The next grid in the list, or the one before it, wrapping round either way; the + -- scroll goes back to the top. + function page.cyclePerPage(back) + local at = 1 + for i, spec in ipairs(PAGES) do + if spec.n == page.perPage then + at = i + end + end + local step = back and -1 or 1 + local next_ = PAGES[(at - 1 + step) % #PAGES + 1] + ---@cast next_ -? + page.perPage = next_.n + page.scroll, page.zoom = 0, nil + page.dirty = true + page.gen = page.gen + 1 + end + + local function pageSpec() + for _, spec in ipairs(PAGES) do + if spec.n == page.perPage then + return spec + end + end + return PAGES[1] + end + + -- The grid's cells, left to right and top to bottom over the chart's room. + local function gridCells(spec) + local r = page.rects + ---@type [number, number, number, number][] + local cells = {} + if not r then + return cells + end + local x1, y1, x2, y2 = r.chart[1], r.chart[2], r.chart[3], r.chart[4] + local gap = mathFloor(8 * page.scale) + local w = mathFloor(((x2 - x1) - gap * (spec.cols - 1)) / spec.cols) + local h = mathFloor(((y2 - y1) - gap * (spec.rows - 1)) / spec.rows) + for i = 1, spec.cols * spec.rows do + local col = (i - 1) % spec.cols + local row = mathFloor((i - 1) / spec.cols) + local cx = x1 + col * (w + gap) + local cy = y2 - (row + 1) * h - row * gap + cells[i] = { cx, cy, cx + w, cy + h } + end + return cells + end + + -- The charts of the page the grid is scrolled to, built into the pool. + function page.buildGrid() + local spec = pageSpec() + ---@type table[] + local list = {} + for _, entry in ipairs(page.statList) do + if entry.key and not entry.back and not entry.divider then + list[#list + 1] = entry + end + end + local cells = gridCells(spec) + local rows = math.ceil(#list / spec.cols) + page.maxScroll = mathMax(0, rows - spec.rows) + page.scroll = mathMax(0, mathMin(page.scroll, page.maxScroll)) + local first = page.scroll * spec.cols + 1 + local last = mathMin(#list, first + spec.cols * spec.rows - 1) + local fs = mathMax(8, mathFloor(ctx.metrics.rowFs * (spec.cols > 3 and 0.8 or 0.9))) + page.miniCharts = {} + for i = first, last do + local entry = list[i] + ---@cast entry -? + local slot = i - first + 1 + local cell = cells[slot] + ---@cast cell -? + local chartOf = pool[slot] + if not chartOf then + chartOf = Graph.new({ + kind = "line", + legend = false, + xUnit = "frames", + lineWidth = 2, + includeZero = true, + look = { plotFill = { 0, 0, 0, 0.16 } }, + }) + pool[slot] = chartOf + end + fillChart(chartOf, entry.key, true) + chartOf:configure({ font = ctx.font(), fontSize = fs, title = entry.label }) + chartOf:setBounds(cell[1], cell[2], cell[3] - cell[1], cell[4] - cell[2]) + page.miniCharts[slot] = { chart = chartOf, key = entry.key, rect = cell, index = i } + end + page.first, page.last = first, last + end + + function page.scrollExtent() + local spec = pageSpec() + local r = page.rects + if not page.gridded() or not r or page.maxScroll <= 0 then + return 0, 0, 0 + end + local rowH = (r.chart[4] - r.chart[2]) / spec.rows + return (page.maxScroll + spec.rows) * rowH, page.scroll * rowH, rowH + end + + -- The bar dragged to an offset in pixels: the nearest row of the grid. + function page.setScrollPixels(offset) + local _, _, rowH = page.scrollExtent() + if rowH <= 0 then + return + end + local to = mathMax(0, mathMin(page.maxScroll, mathFloor(offset / rowH + 0.5))) + if to ~= page.scroll then + page.scroll = to + page.dirty = true + page.gen = page.gen + 1 + end + end + + function page.build() + -- The list takes room from the charts, so opening or closing one lays the page out + -- again before anything is drawn. + local args = page.layoutArgs + if args and page.listWas ~= page.listShown() then + page.setLayout(args[1], args[2], args[3], args[4], args[5], args[6], args[7]) + end + rebuildSamples() + page.rebuildUnits() + if page.gridded() then + page.buildGrid() + page.empty = #page.statList == 0 + else + page.empty = fillChart(chart, page.zoom or page.stat) + end page.dirty = false end @@ -787,6 +1394,10 @@ function M.new(ctx) if b.all then return #pickedUnits() == 0 end + if b.me then + local picked = pickedUnits() + return #picked == 1 and picked[1] == b.unit + end if #b.members == 0 then return false end @@ -810,35 +1421,53 @@ function M.new(ctx) local fs = metrics.catFs local cs = metrics.csSmall - RectRound( - r.list[1], - r.list[2], - r.list[3], - r.list[4], - metrics.csPanel, - 1, - 1, - 1, - 1, - look.sidebarFill, - look.sidebarFillTop - ) - for i, entry in ipairs(page.statList) do + -- The card and its stats, while the page is showing one chart; a grid names its + -- charts itself and takes the room instead. + if page.listShown() then + RectRound( + r.list[1], + r.list[2], + r.list[3], + r.list[4], + metrics.csPanel, + 1, + 1, + 1, + 1, + look.sidebarFill, + look.sidebarFillTop + ) + end + for i, entry in ipairs(page.listShown() and page.statList or {}) do local x1, y1, x2, y2 = statRect(i) - local selected = entry.key == page.stat - if selected then - RectRound(x1 + metrics.catInset, y1, x2 - metrics.catInset, y2, cs, 1, 1, 1, 1, look.selectedFill) - elseif i == page.hover.stat then - Highlight(x1 + metrics.catInset, y1, x2 - metrics.catInset, y2, cs, look.rowHoverOpacity, look.white) + if entry.divider then + local y = mathFloor((y1 + y2) * 0.5) + RectRound(x1 + metrics.sidePad, y, x2 - metrics.sidePad, y + 1, 0, 0, 0, 0, 0, look.rule) + else + -- The way back to the grid is never the pick; it is where the pick came from. + local selected = not entry.back and entry.key == (page.zoom or page.stat) + if selected then + RectRound(x1 + metrics.catInset, y1, x2 - metrics.catInset, y2, cs, 1, 1, 1, 1, look.selectedFill) + elseif i == page.hover.stat then + Highlight( + x1 + metrics.catInset, + y1, + x2 - metrics.catInset, + y2, + cs, + look.rowHoverOpacity, + look.white + ) + end + local label = ctx.text.fit(ctx.font(), entry.label, x2 - x1 - metrics.sidePad * 2, fs) + ctx.queueText( + (selected and colors.selected or colors.dim) .. label, + x1 + metrics.sidePad, + mathFloor((y1 + y2) * 0.5), + fs, + "ov" + ) end - local label = ctx.text.fit(ctx.font(), entry.label, x2 - x1 - metrics.sidePad * 2, fs) - ctx.queueText( - (selected and colors.selected or colors.dim) .. label, - x1 + metrics.sidePad, - mathFloor((y1 + y2) * 0.5), - fs, - "ov" - ) end -- The legend bar: a plate per block like a button, lit and framed warm when it is @@ -866,6 +1495,11 @@ function M.new(ctx) if b.labelX then ctx.queueText((lit and colors.selected or colors.dim) .. b.label, b.labelX, cy, fs, "ov") end + if b.swatch and b.unit then + local c = b.unit.color + Color(c[1], c[2], c[3], (lit or i == page.hover.block) and 1 or 0.85) + Rect(b.swatch[1], b.swatch[2], b.swatch[3], b.swatch[4]) + end end for i, item in ipairs(page.barItems) do local c = item.team.accent @@ -876,11 +1510,26 @@ function M.new(ctx) Rect(item.x1, item.y1, item.x2, item.y2) frame(item.x1, item.y1, item.x2, item.y2, 1, { c[1], c[2], c[3], 0.55 }) else - local alpha = (anyPicked and not page.selected[key]) and 0.4 or 0.9 + local alpha = (anyPicked and not page.selected[key]) and 0.3 or 0.9 Color(c[1], c[2], c[3], hovered and alpha + 0.1 or alpha) Rect(item.x1, item.y1, item.x2, item.y2) end end + -- The grouping switch at the end of the bar, captioned like a settings row. + local tog = groupToggleRect() + if tog then + local label = ctx.L.switch.groupByTeam + local hovered = page.hover.group == 1 + ctx.queueText( + (page.grouped and colors.selected or colors.dim) .. label, + tog[1] - metrics.rowPad, + cy, + fs, + "rov" + ) + ctx.draw.Toggle(tog[1], tog[2], tog[3], tog[4], page.grouped, hovered) + end + if not isGrouped then ---@type table?, table? local first, last = nil, nil @@ -906,7 +1555,7 @@ function M.new(ctx) end Color(1, 1, 1, 1) - if page.empty then + if page.empty and not page.gridded() then local c = r.chart local key = page.anySamples and "ui.teamStats.graph.noData" or "ui.teamStats.graph.waiting" ctx.queueText( @@ -919,19 +1568,153 @@ function M.new(ctx) end end - -- The chart itself, after the panel's list: its own list plus the hover overlay. + -- The card that picks which kinds of milestone go on the charts: a row per kind with a + -- mark for the ones that are on, over the top left of the charts' room. + function page.drawKinds() + page.kindRects = {} + if not page.kindsOpen or not page.rects then + return + end + local look, metrics, colors = ctx.look, ctx.metrics, ctx.colors + local RectRound, Highlight = ctx.draw.RectRound, ctx.draw.Highlight + local kinds = milestoneKinds() + -- The card is drawn after the charts rather than into the panel's baked list, so + -- its text is printed here rather than queued for a batch that has been and gone. + local texts = {} + local fs = metrics.catFs + local rowH = metrics.catRowHeight + local w = mathFloor(240 * page.scale) + -- Beside the settings row that opens it, on a backdrop solid enough to read over + -- whatever it covers; it is drawn after the charts, so nothing lies over it. + local anchor = page.kindsAnchor + local gap = mathFloor(6 * page.scale) + local x1 = anchor and anchor[3] + gap or page.rects.chart[1] + gap + local h = rowH * (#kinds + 1) + metrics.cardLip * 2 + local y1 = anchor and mathMin(anchor[2], page.rects.chart[4] - h) or page.rects.chart[2] + y1 = mathMax(y1, page.rects.chart[2]) + local y2 = y1 + h + RectRound(x1, y1, x1 + w, y2, metrics.csPanel, 1, 1, 1, 1, look.cardFill, look.cardFillTop) + texts[#texts + 1] = { + colors.title .. ctx.i18n("ui.teamStats.switch.milestoneKinds"), + x1 + metrics.sidePad, + y2 - metrics.cardLip - mathFloor(rowH * 0.5), + } + for i, kind in ipairs(kinds) do + local top = y2 - metrics.cardLip - i * rowH + local rect = { x1, top - rowH, x1 + w, top } + page.kindRects[i] = { key = kind, rect = rect } + local on = not page.milestoneOff[kind] + if i == page.hover.kind then + Highlight( + rect[1] + metrics.catInset, + rect[2], + rect[3] - metrics.catInset, + rect[4], + metrics.csSmall, + look.rowHoverOpacity, + look.white + ) + end + local cy = mathFloor((rect[2] + rect[4]) * 0.5) + texts[#texts + 1] = { + (on and colors.selected or colors.faded) .. (ctx.L.milestone[kind] or kind), + rect[1] + metrics.sidePad, + cy, + } + -- The same switch the settings rows use, so it reads as one. + local togW = mathFloor(38 * page.scale) + local togH = mathFloor(rowH * 0.52) + local tx = rect[3] - metrics.sidePad - togW + ctx.draw.Toggle( + tx, + cy - mathFloor(togH * 0.5), + tx + togW, + cy - mathFloor(togH * 0.5) + togH, + on, + i == page.hover.kind + ) + end + -- One batch, with the outline pinned: the font is shared with every other widget. + local font = ctx.font() + if font then + font:Begin() + font:SetOutlineColor(look.outline or { 0, 0, 0, 0.4 }) + for _, t in ipairs(texts) do + font:Print(t[1], t[2], t[3], fs, "ov") + end + font:End() + end + end + + -- The charts, after the panel's list: the grid of the page, or the one chart a pick or + -- a zoom opened, each with its own hover overlay. The chart under the cursor is framed + -- and answers the tooltip. function page.drawChart(mx, my) if page.dirty then page.build() end local r = page.rects - local hit = nil - if r and mx >= r.chart[1] and mx <= r.chart[3] and my >= r.chart[2] and my <= r.chart[4] then - hit = chart:hitTest(mx, my) + local inside = r and mx >= r.chart[1] and mx <= r.chart[3] and my >= r.chart[2] and my <= r.chart[4] + -- The card of kinds lies over the charts: what is under it is not under the cursor. + if page.hover.kind > 0 then + inside = false + end + if page.gridded() then + page.chartHit, page.miniHit = nil, nil + for _, mini in ipairs(page.miniCharts or {}) do + local over = inside + and mx >= mini.rect[1] + and mx <= mini.rect[3] + and my >= mini.rect[2] + and my <= mini.rect[4] + local hit = over and mini.chart:hitTest(mx, my) or nil + if over then + page.miniHit = mini + page.chartHit = hit + end + mini.chart:setHover(hit) + mini.chart:draw() + end + page.drawKinds() + -- The one under the cursor is marked, so it is clear what a press would open: a + -- rounded outline that fades inwards rather than a hard box. + ---@type table? + local mini = page.miniHit + if mini then + ctx.draw.Highlight( + mini.rect[1], + mini.rect[2], + mini.rect[3], + mini.rect[4], + ctx.metrics.csPanel, + ctx.look.rowHoverOpacity, + ctx.look.white + ) + ctx.draw.Color(1, 1, 1, 1) + end + return end - page.chartHit = hit + local hit = inside and chart:hitTest(mx, my) or nil + -- One chart fills the page: no small one answers for the tooltip any more. + page.chartHit, page.miniHit = hit, nil chart:setHover(hit) chart:draw() + page.drawKinds() + end + + -- The wheel over the charts moves the grid a row at a time. Answers whether it took it. + function page.wheel(up) + if not page.gridded() or page.maxScroll <= 0 then + return false + end + local to = mathMax(0, mathMin(page.maxScroll, page.scroll + (up and -1 or 1))) + if to == page.scroll then + return false + end + page.scroll = to + page.dirty = true + page.gen = page.gen + 1 + return true end ---------------------------------------------------------------- @@ -941,15 +1724,25 @@ function M.new(ctx) -- Which list entry, legend square or legend block the cursor is over, for the -- panel's bake signature. function page.hoverAt(mx, my) - page.hover.stat, page.hover.legend, page.hover.block = 0, 0, 0 + page.hover.stat, page.hover.legend, page.hover.block, page.hover.kind, page.hover.group = 0, 0, 0, 0, 0 + local tog = groupToggleRect() + if tog and mx >= tog[3] - groupReserve() and mx <= tog[3] and my >= tog[2] and my <= tog[4] then + page.hover.group = 1 + end + for i, row in ipairs(page.kindRects) do + local r2 = row.rect + if mx >= r2[1] and mx <= r2[3] and my >= r2[2] and my <= r2[4] then + page.hover.kind = i + end + end local r = page.rects if not r then return "0|0|0|0" end - if mx >= r.list[1] and mx <= r.list[3] then + if page.listShown() and mx >= r.list[1] and mx <= r.list[3] then for i = 1, #page.statList do local _, y1, _, y2 = statRect(i) - if my > y1 and my <= y2 then + if my > y1 and my <= y2 and not page.statList[i].divider then page.hover.stat = i end end @@ -965,7 +1758,17 @@ function M.new(ctx) end end end - return page.hover.stat .. "|" .. page.hover.legend .. "|" .. page.hover.block .. "|" .. page.gen + return page.hover.stat + .. "|" + .. page.hover.legend + .. "|" + .. page.hover.block + .. "|" + .. page.hover.kind + .. "|" + .. page.hover.group + .. "|" + .. page.gen end -- The units a press in the bar acts on: a square's; with the grouping switch on the @@ -981,7 +1784,7 @@ function M.new(ctx) ---@cast b -? if b.unit then return { b.unit } - elseif not b.all then + elseif not b.all and not b.me then local list = {} for _, m in ipairs(b.members) do list[#list + 1] = m.unit @@ -1004,16 +1807,62 @@ function M.new(ctx) -- hidden. All clears the selection and shows every team again. function page.mousePress(x, y, button) page.hoverAt(x, y) + if page.kindsOpen then + local row = page.kindRects[page.hover.kind] + if row and button ~= 3 then + page.milestoneOff[row.key] = not page.milestoneOff[row.key] or nil + changed() + return true + end + -- A press anywhere else puts the card away. + page.kindsOpen = false + changed() + return true + end if page.hover.stat > 0 then local entry = page.statList[page.hover.stat] ---@cast entry -? - if button ~= 3 and entry.key ~= page.stat then + if button ~= 3 and entry.back then + -- Back to the grid the chart was opened from. + page.zoom = nil + changed() + elseif button ~= 3 then page.stat = entry.key + -- The list is how a stat is opened, whatever the grid is showing. + page.zoom = entry.key + changed() + end + return true + end + if button ~= 3 and page.miniHit then + -- The grid answered what it is for; this one opens on its own. + page.stat = page.miniHit.key + page.zoom = page.miniHit.key + changed() + return true + end + if button ~= 3 and page.zoom and page.rects then + local c = page.rects.chart + if x >= c[1] and x <= c[3] and y >= c[2] and y <= c[4] then + -- And a press on the opened chart goes back to the grid. + page.zoom = nil changed() + return true end + end + if page.hover.group == 1 and button ~= 3 then + page.setGrouped(not page.grouped) + changed() return true end local block = page.hover.block > 0 and page.barBlocks[page.hover.block] or nil + if block and block.me and button ~= 3 then + -- Your own team alone, whatever was picked before. + page.selected = { [block.unit.key] = true } + page.hidden[block.unit.key] = nil + changed() + return true + end if block and block.all then if button ~= 3 and (next(page.selected) or next(page.hidden)) then page.selected, page.hidden = {}, {} @@ -1073,11 +1922,11 @@ function M.new(ctx) if allHidden then state = "hidden" elseif allSelected then - state = composition and "counted" or (ctx.filters.selectedOnly and "only" or "lit") + state = composition and "counted" or "lit" elseif someSelected then state = "some" elseif anyPicked then - state = composition and "notCounted" or (ctx.filters.selectedOnly and "left" or "faded") + state = composition and "notCounted" or "faded" else state = composition and "allCounted" or "alike" end @@ -1097,29 +1946,44 @@ function M.new(ctx) -- The tooltip for the cursor: the chart's description, a stat's explanation, or the -- units under the cursor in the bar and how the bar works. function page.tooltip() + local kindRow = page.kindRects[page.hover.kind] + if kindRow then + return ctx.L.milestone[kindRow.key] or kindRow.key, ctx.i18n("ui.teamStats.graph.kindHint") + end if page.chartHit then - return chart.cfg.title, chart:describe(page.chartHit) + local hovered = page.miniHit and page.miniHit.chart or chart + return hovered.cfg.title, hovered:describe(page.chartHit) end if page.hover.stat > 0 then local entry = page.statList[page.hover.stat] ---@cast entry -? + if entry.back then + return entry.label, ctx.i18n("ui.teamStats.graph.overviewDesc") + end if entry.column then return entry.label, ctx.L.desc[entry.key] end - return entry.label, ctx.i18n("ui.teamStats.graph.compositionDesc") + -- The charts that are not a column of the table explain themselves by key. + return entry.label, ctx.i18n("ui.teamStats.graph." .. entry.key .. "Desc") + end + if page.hover.group == 1 then + return ctx.L.switch.groupByTeam, ctx.L.switchDesc.groupByTeam end if page.hover.legend > 0 then local item = page.barItems[page.hover.legend] ---@cast item -? - local title = item.team.name + local title = (item.team.nameColor or "") .. item.team.name if item.unit.name ~= item.team.name then - title = item.team.name .. " \194\183 " .. item.unit.name + title = title .. ctx.colors.title .. " \194\183 " .. item.unit.name end return title, barHint({ item.unit }) end if page.hover.block > 0 then local b = page.barBlocks[page.hover.block] ---@cast b -? + if b.me then + return b.label, ctx.i18n("ui.teamStats.graph.youHint") + end if b.all then local tip = ctx.i18n("ui.teamStats.graph.allHint") if ctx.filters.milestones then @@ -1139,16 +2003,42 @@ function M.new(ctx) ---------------------------------------------------------------- function page.getConfig() - return { graphStat = page.stat, graphGroupByTeam = page.grouped } + local off = {} + for kind, on in pairs(page.milestoneOff) do + if on then + off[#off + 1] = kind + end + end + return { + graphStat = page.stat, + graphsOpen = page.open, + graphGroupByTeam = page.grouped, + graphPerPage = page.perPage, + graphMilestonesOff = off, + } end function page.setConfig(data) if type(data.graphStat) == "string" then page.stat = data.graphStat end + if type(data.graphMilestonesOff) == "table" then + page.milestoneOff = {} + for _, kind in ipairs(data.graphMilestonesOff) do + page.milestoneOff[kind] = true + end + end + if data.graphsOpen ~= nil then + page.open = data.graphsOpen == true + end if data.graphGroupByTeam ~= nil then page.grouped = data.graphGroupByTeam == true end + for _, spec in ipairs(PAGES) do + if spec.n == data.graphPerPage then + page.perPage = spec.n + end + end end -- The switches and the sidebar change what the chart is made of. @@ -1158,6 +2048,9 @@ function M.new(ctx) function page.destroy() chart:destroy() + for _, chartOf in ipairs(pool) do + chartOf:destroy() + end end return page diff --git a/luaui/Widgets/gui_awards.lua b/luaui/Widgets/gui_awards.lua index 49bfb728700..52f26a817b2 100644 --- a/luaui/Widgets/gui_awards.lua +++ b/luaui/Widgets/gui_awards.lua @@ -438,12 +438,16 @@ function widget:MousePress(x, y, button) and (y < widgetY + mathFloor((50 + 17 + 5) * widgetScale)) ) then - Spring.SendCommands("endgraph 2") + -- The team stats panel on its Graphs page, not the engine's end-game box: + -- everything that box showed is in there, drawn like the rest of the UI. + if WG.teamstats then + WG.teamstats.showGraphs(true) - if WG.guishader then - WG.guishader.RemoveRect("awards") + if WG.guishader then + WG.guishader.RemoveRect("awards") + end + drawAwards = false end - drawAwards = false end -- Close button @@ -569,7 +573,8 @@ function widget:LanguageChanged() end function widget:Initialize() - Spring.SendCommands("endgraph 2") + -- The engine's end-game box stays off: the stats panel is what the awards hand over to. + Spring.SendCommands("endgraph 0") widget:ViewResize(viewScreenX, viewScreenY) @@ -595,7 +600,7 @@ function widget:GadgetReceiveAwards(awards) end function widget:Shutdown() - Spring.SendCommands("endgraph 2") + Spring.SendCommands("endgraph 0") if Background then gl.DeleteList(Background) end diff --git a/luaui/Widgets/gui_flowui.lua b/luaui/Widgets/gui_flowui.lua index d7d603dfe77..8c262de1492 100644 --- a/luaui/Widgets/gui_flowui.lua +++ b/luaui/Widgets/gui_flowui.lua @@ -3497,11 +3497,14 @@ WG.FlowUI.Draw.SelectHighlight = function(px, py, sx, sy, cs, opacity, color) -- highlight gl.Blending(GL.SRC_ALPHA, GL.ONE) + -- Held inside the body's corners: a strip with corners of its own sticks out past a + -- large chamfer and, drawn additively, lights it up. + local stripInset = mathMax(0, cs - edgeWidth) -- top WG.FlowUI.Draw.RectRound( - px, + px + stripInset, sy - (edgeWidth * 3), - sx, + sx - stripInset, sy, edgeWidth, 1, @@ -3513,9 +3516,9 @@ WG.FlowUI.Draw.SelectHighlight = function(px, py, sx, sy, cs, opacity, color) ) -- bottom WG.FlowUI.Draw.RectRound( - px, + px + stripInset, py, - sx, + sx - stripInset, py + (edgeWidth * 3), edgeWidth, 1, diff --git a/luaui/Widgets/gui_teamstats.lua b/luaui/Widgets/gui_teamstats.lua index fe731098b3a..0d6e992e758 100644 --- a/luaui/Widgets/gui_teamstats.lua +++ b/luaui/Widgets/gui_teamstats.lua @@ -111,7 +111,14 @@ local font local COLUMNS = { name = { group = "", stat = "player", short = "player", fmt = "name" }, damageDealt = { group = "damage", stat = "damageDealt", short = "damageDealt", fmt = "si", rate = true }, - damageReceived = { group = "damage", stat = "damageReceived", short = "shortReceived", fmt = "si", rate = true }, + damageReceived = { + group = "damage", + stat = "damageReceived", + short = "shortReceived", + fmt = "si", + rate = true, + low = true, + }, -- `clamp` bounds what the chart plots of a ratio: over nothing it is infinite, and a -- few thousand percent early on would put every other line on the floor. damageEfficiency = { @@ -123,7 +130,7 @@ local COLUMNS = { }, unitsProduced = { group = "units", stat = "unitsProduced", short = "shortBuilt", fmt = "si", rate = true }, unitsKilled = { group = "units", stat = "unitsKilled", short = "unitsKilled", fmt = "si", rate = true }, - unitsDied = { group = "units", stat = "unitsDied", short = "unitsDied", fmt = "si", rate = true }, + unitsDied = { group = "units", stat = "unitsDied", short = "unitsDied", fmt = "si", rate = true, low = true }, killEfficiency = { group = "units", stat = "killEfficiency", @@ -132,19 +139,35 @@ local COLUMNS = { clamp = { 0, 1000 }, }, unitsCaptured = { group = "units", stat = "unitsCaptured", short = "unitsCaptured", fmt = "si", rate = true }, - unitsStolen = { group = "units", stat = "unitsStolen", short = "unitsStolen", fmt = "si", rate = true }, + unitsStolen = { group = "units", stat = "unitsStolen", short = "unitsStolen", fmt = "si", rate = true, low = true }, unitsReceived = { group = "units", stat = "unitsReceived", short = "unitsReceived", fmt = "si", rate = true }, unitsSent = { group = "units", stat = "unitsSent", short = "unitsSent", fmt = "si", rate = true }, unitsActive = { group = "units", stat = "unitsActive", short = "unitsActive", fmt = "si" }, + -- What the metal spent bought in damage: army worth rather than army size. + damagePerMetal = { group = "damage", stat = "damagePerMetal", short = "shortDamagePerMetal", fmt = "plain" }, metalProduced = { group = "metal", stat = "resourceProduced", short = "shortProduced", fmt = "si", rate = true }, metalUsed = { group = "metal", stat = "resourceUsed", short = "resourceUsed", fmt = "si", rate = true }, - metalExcess = { group = "metal", stat = "resourceExcess", short = "resourceExcess", fmt = "si", rate = true }, + metalExcess = { + group = "metal", + stat = "resourceExcess", + short = "resourceExcess", + fmt = "si", + rate = true, + low = true, + }, metalSent = { group = "metal", stat = "resourceSent", short = "resourceSent", fmt = "si", rate = true }, metalReceived = { group = "metal", stat = "resourceReceived", short = "shortReceived", fmt = "si", rate = true }, metalStored = { group = "metal", stat = "resourceStored", short = "resourceStored", fmt = "si" }, energyProduced = { group = "energy", stat = "resourceProduced", short = "shortProduced", fmt = "si", rate = true }, energyUsed = { group = "energy", stat = "resourceUsed", short = "resourceUsed", fmt = "si", rate = true }, - energyExcess = { group = "energy", stat = "resourceExcess", short = "resourceExcess", fmt = "si", rate = true }, + energyExcess = { + group = "energy", + stat = "resourceExcess", + short = "resourceExcess", + fmt = "si", + rate = true, + low = true, + }, energySent = { group = "energy", stat = "resourceSent", short = "resourceSent", fmt = "si", rate = true }, energyReceived = { group = "energy", stat = "resourceReceived", short = "shortReceived", fmt = "si", rate = true }, energyStored = { group = "energy", stat = "resourceStored", short = "resourceStored", fmt = "si" }, @@ -200,9 +223,21 @@ local COLUMNS = { fmt = "percent", of = { "buildPowerActive", "buildPower" }, }, + -- A running total in build power minutes. Not a rate: the Live view it sits in shows + -- what is happening right now, and a per-minute switch there would say little. + buildPowerIdle = { + group = "industry", + stat = "buildPowerIdle", + short = "shortBuildPowerIdle", + fmt = "si", + gadget = true, + low = true, + }, -- The total sits with the unit counts, so a view showing it alone captions it "Units". unitValue = { group = "units", stat = "unitValue", short = "shortValue", fmt = "si", count = "unitCount" }, valueArmy = { group = "value", stat = "valueArmy", short = "shortArmy", fmt = "si", count = "countArmy" }, + -- How much of what a team has on the field is fighting rather than building it. + armyShare = { group = "value", stat = "armyShare", short = "shortArmyShare", fmt = "percent" }, valueAir = { group = "value", stat = "valueAir", short = "shortAir", fmt = "si", count = "countAir" }, valueSea = { group = "value", stat = "valueSea", short = "shortSea", fmt = "si", count = "countSea" }, valueDefense = { @@ -323,6 +358,7 @@ local GROUPS = { "conversion", "buildPower", "buildPowerUse", + "buildPowerIdle", "unitValue", "unitsActive", }, @@ -356,6 +392,7 @@ local GROUPS = { "killedValue", "lostValue", "valueEfficiency", + "damagePerMetal", "teamKillValue", "comKills", "comLost", @@ -380,6 +417,7 @@ local GROUPS = { columns = { "unitValue", "valueArmy", + "armyShare", "valueAir", "valueSea", "valueDefense", @@ -410,6 +448,7 @@ end -- The engine's own counters, which an ally team's total is the sum of. Everything else a -- row shows is derived from these, for a team and for its ally team alike. local SUMMED = { + "buildPowerIdle", "metalUsed", "metalProduced", "metalExcess", @@ -466,17 +505,25 @@ end local switches = { -- The Graphs page: a mode, so it leads the row; its state is the page's, not a filter. { key = "graphs", mode = true }, + { key = "perPage", page = true, value = true }, -- The Graphs page's alone, beside its switch: the selected teams' milestones on the -- chart, and whether the selection is all the chart shows or only stands out on it. { key = "milestones", page = true }, - { key = "selectedOnly", page = true }, - { key = "groupByTeam" }, + { key = "milestoneKinds", page = true, value = true }, + -- The page's alone: how many charts it shows at once, and which kinds of milestone go + -- on them. Neither is a switch but a value the press changes, so they are drawn with + -- what they are set to instead of a toggle. + { key = "groupByTeam", table = true }, -- Only offered while there are bands to take a share of. { key = "shareOfTeam" }, -- Only offered where a rate means something: a running total on the chart or among -- the picked group's columns. { key = "perMinute" }, - -- The table's alone. + -- The table's alone: every number as the difference from your own, a bar behind it, + -- or the line its history draws. The last two are both painted behind the number, so + -- one turns the other off. + { key = "vsMe", table = true }, + { key = "trend", table = true }, { key = "bars", table = true }, } -- The ally teams whose players are folded away under their band, by ally id. @@ -488,8 +535,9 @@ local filters = { shareOfTeam = false, perMinute = false, bars = false, + trend = false, + vsMe = false, milestones = true, - selectedOnly = false, } -- Excess is waste once it passes a share of what was produced; these say what to compare @@ -548,15 +596,23 @@ local metrics = { -- rows above and below so each reads as its own. accentW = 3, accentPad = 3, - -- The bar behind a number, inside its row by this much top and bottom. + -- The bar behind a number, inside its row by this much top and bottom; the trend line + -- that can take its place is drawn this thick. barInset = 4, + trendW = 1, + -- The mark under the number of a column a row leads: this far above the row's bottom + -- edge, and this thick. + leadDrop = 3, + leadH = 2, -- The line along the bottom of a group caption and of an ally team's band. underlineH = 2, -- Everything that sits against the panel's right edge - the switches and the -- scrollbar - is held off it by this much. edgeInset = 4, - -- The band the title and the switches share, and the gap below. + -- The band the title sits in, and the gap below it. headerH = 34, + -- Where the settings block under the sidebar's groups starts; the groups stop there. + settingsTop = 0, headerGap = 4, -- Clearance between the table header and the first row. tableGap = 4, @@ -599,6 +655,10 @@ local look = { -- The column sits on its own darker card, so it reads apart from the table. sidebarFill = { 0, 0, 0, 0.24 }, sidebarFillTop = { 0, 0, 0, 0.16 }, + -- A card laid over the charts, which has to read over whatever is under it: opaque, or + -- the lines and captions behind it ghost through. + cardFill = { 0.05, 0.05, 0.05, 1 }, + cardFillTop = { 0.1, 0.1, 0.1, 1 }, selectedFill = { 1, 1, 1, 0.13 }, white = { 1, 1, 1 }, -- Rows, entries and captions hover with the same FlowUI highlight the settings list @@ -613,6 +673,14 @@ local look = { rule = { 1, 1, 1, 0.08 }, -- The bar behind a number, scaled to the column's largest. barFill = { 1, 1, 1, 0.07 }, + -- A band's trend line, where the band stands for several players and so for no colour + -- of its own. + trendBand = { 1, 0.86, 0.66, 0.4 }, + -- The mark under the number of a column a row leads. + leadMark = { 1, 0.78, 0.51, 0.55 }, + -- Laid over a switch whose row is dimmed, so its state reads but its colour does not + -- call for a press. + mutedFill = { 0.09, 0.09, 0.09, 0.62 }, -- Every second team row under a band takes this, so the eye keeps its line across a -- dozen columns. Faint: it is a guide, not a highlight. stripeFill = { 1, 1, 1, 0.022 }, @@ -674,7 +742,8 @@ local columns = {} local spans = {} -- What the panel shows: every ally team with its teams and their totals, and the rows the --- table makes of them under the current sort and grouping. +-- table makes of them under the current sort and grouping. The list also carries the +-- viewer's own team and ally team (`me`, `myAlly`), which the comparison switch reads. ---@type table[] local allies = {} ---@type table[] @@ -810,6 +879,11 @@ local function derive(s) else s.damageEfficiency = mathHuge end + if s.metalUsed ~= 0 then + s.damagePerMetal = s.damageDealt / s.metalUsed + else + s.damagePerMetal = 0 + end if s.unitsDied ~= 0 then s.killEfficiency = (s.unitsKilled / s.unitsDied) * 100 else @@ -830,6 +904,7 @@ local function derive(s) s.conversion = ratio.share(s.convUse, s.convCapacity) s.buildPowerUse = ratio.share(s.buildPowerActive, s.buildPower) s.valueEfficiency = ratio.efficiency(s.killedValue, s.lostValue) + s.armyShare = ratio.share(s.valueArmy, s.unitValue) end local function isFinite(v) @@ -857,7 +932,18 @@ local function formatNumber(v) end -- `share` prints an amount as the percentage the share switch turned it into. -local function formatCell(column, v, share) +local function formatCell(column, v, share, mine) + if mine ~= nil then + if not isFinite(v) or not isFinite(mine) then + return "-", nil + end + local d = v - mine + if mathAbs(d) < 0.5 then + return "=", colorDim + end + local better = column.low and d < 0 or (not column.low and d > 0) + return (d > 0 and "+" or "-") .. formatCell(column, mathAbs(d), share), better and colorGood or colorBad + end if column.fmt == "percent" or share then if not isFinite(v) then return "-" @@ -1067,6 +1153,8 @@ local function readTeam(teamID, allyID, frame, live) id = teamID, allyID = allyID, stats = s, + -- The columns this team leads, filled in when the rows are built. + leads = {}, name = name, sortName = stringLower(name), label = label, @@ -1093,7 +1181,8 @@ local function refreshStats() end allies = {} for _, allyID in ipairs(spGetAllyTeamList()) do - local ally = { id = allyID, teams = {}, total = {} } + ---@cast allyID integer + local ally = { id = allyID, teams = {}, total = {}, leads = {} } local teamList = spGetTeamList(allyID) ---@cast teamList -? for _, teamID in ipairs(teamList) do @@ -1102,6 +1191,10 @@ local function refreshStats() if team then team.ally = ally ally.teams[#ally.teams + 1] = team + if team.isLocal then + ---@diagnostic disable-next-line: inject-field + allies.me, allies.myAlly = team, ally + end for i = 1, #SUMMED do local key = SUMMED[i] local v = team.stats[key] @@ -1197,7 +1290,7 @@ rebuildRows = function() for j = 1, #ally.teams do -- Striped by place under the band, so the pattern starts afresh with each -- ally team rather than running on across its band. - rows[#rows + 1] = { type = "team", team = ally.teams[j], stripe = j % 2 == 0 } + rows[#rows + 1] = { type = "team", team = ally.teams[j], stripe = j % 2 == 0, trendKey = false } end end end @@ -1211,26 +1304,77 @@ rebuildRows = function() end tableSort(teams, compareTeams) for i = 1, #teams do - rows[#rows + 1] = { type = "team", team = teams[i], stripe = i % 2 == 0 } + rows[#rows + 1] = { type = "team", team = teams[i], stripe = i % 2 == 0, trendKey = false } + end + end + + -- The rows the trend lines are drawn for, each with the teams it stands for: the page + -- keeps the histories and hands back one line per cell. + if filters.trend then + local list = {} + for i = 1, #rows do + local row = rows[i] + local members = {} + if row.team then + members[1] = row.team.id + row.trendKey = "t" .. row.team.id + else + for j = 1, #row.ally.teams do + members[j] = row.ally.teams[j].id + end + row.trendKey = "a" .. row.ally.id + end + list[#list + 1] = { key = row.trendKey, members = members } end + graphs.setTrendRows(list) end -- The bars are scaled to the largest a column shows among the teams, whatever view - -- is open, so switching views does not walk the rows again. + -- is open, so switching views does not walk the rows again. The same pass marks who + -- leads each column - the largest, or the smallest where less is better - among the + -- players and among the ally teams. colMax = {} + for i = 1, #allies do + allies[i].leads = {} + for j = 1, #allies[i].teams do + allies[i].teams[j].leads = {} + end + end for key, col in pairs(COLUMNS) do - if col.fmt ~= "name" and col.fmt ~= "plain" then + if col.fmt ~= "name" then local top = 0 + ---@type table?, number?, table?, number? + local bestTeam, bestValue, bestAlly, bestTotal = nil, nil, nil, nil for i = 1, #allies do local ally = allies[i] for j = 1, #ally.teams do local v = cellValue(col, ally.teams[j]) - if isFinite(v) and v > top then - top = v + if isFinite(v) then + if v > top then + top = v + end + if bestValue == nil or (col.low and v < bestValue) or (not col.low and v > bestValue) then + bestTeam, bestValue = ally.teams[j], v + end end end + local total = bandValue(col, ally) + if isFinite(total) then + if bestTotal == nil or (col.low and total < bestTotal) or (not col.low and total > bestTotal) then + bestAlly, bestTotal = ally, total + end + end + end + if col.fmt ~= "plain" then + colMax[key] = top + end + -- Nobody leads a column everyone is at zero in. + if bestTeam and bestValue ~= 0 then + bestTeam.leads[key] = true + end + if bestAlly and bestTotal ~= 0 and #allies > 1 then + bestAlly.leads[key] = true end - colMax[key] = top end end end @@ -1343,8 +1487,18 @@ end -- The scrollbar ---------------------------------------------------------------- +-- The bar serves whichever of the two is open: the table's rows, or the grid of charts on +-- the Graphs page, which runs up to the top of the charts. Its band and its thumb come out +-- of the same call, so the page's top is worked out once. local function scrollerThumb() - return UiScrollerAt(barX1, listBottom, area.x2 - metrics.edgeInset, listTop, rowMetrics.totalH, scrollOffset()) + local total, offset = rowMetrics.totalH, scrollOffset() + local top = listTop + if graphs and graphs.open then + total, offset = graphs.scrollExtent() + top = graphs.rects and graphs.rects.chart[4] or listTop + end + local a, b, c, d = UiScrollerAt(barX1, listBottom, area.x2 - metrics.edgeInset, top, total, offset) + return a, b, c, d, top, total, offset end -- Scrolls so the thumb's top sits where the cursor has dragged it. The offset taken at the @@ -1362,6 +1516,14 @@ local function scrollFromY(y) elseif f > 1 then f = 1 end + if graphs and graphs.open then + -- The grid scrolls by whole rows, so the bar asks for the row it landed on. + local total = graphs.scrollExtent() + local c = graphs.rects and graphs.rects.chart + -- The page's own generation is part of the bake signature, so the picture follows. + graphs.setScrollPixels(f * mathMax(0, total - (c and c[4] - c[2] or 0))) + return + end setScroll(mathFloor(f * maxScroll() + 0.5)) end @@ -1412,6 +1574,9 @@ local function sidebarIndexAt(x, y) end for i = 1, #entries do local _, y1, _, y2 = entryRect(i) + if y1 < metrics.settingsTop then + return nil + end if y <= y2 and y > y1 then if entries[i].divider then return nil @@ -1512,8 +1677,7 @@ end -- any running total among the picked group's columns on the table. local function rateShown() if graphs and graphs.open then - local column = COLUMNS[graphs.stat] - return column ~= nil and column.rate == true + return graphs.rateShown() end local group = groupByKey[selectedGroup] for _, key in ipairs(group and group.columns or {}) do @@ -1550,6 +1714,9 @@ local function setLayout() metrics.accentW = mathMax(2, mathFloor(3 * s)) metrics.accentPad = mathMax(1, mathFloor(3 * s)) metrics.barInset = mathMax(2, mathFloor(4 * s)) + metrics.trendW = mathMax(1, mathFloor(1.5 * s)) + metrics.leadDrop = mathMax(2, mathFloor(3 * s)) + metrics.leadH = mathMax(1, mathFloor(2 * s)) metrics.underlineH = mathMax(1, mathFloor(2 * s)) metrics.edgeInset = mathFloor(4 * s) metrics.headerH = mathFloor(34 * s) @@ -1573,9 +1740,11 @@ local function setLayout() metrics.nameIndent = metrics.accentW + metrics.rowPad * 2 listX1 = area.x1 + metrics.sidebarW + metrics.listGap + -- The sidebar's card starts below the panel's title; the table and the page beside it + -- have no title of their own to clear, so they start at the panel's own edge. metrics.bandTop = area.y2 - metrics.headerH - metrics.headerGap -- The table header: group captions, then the stat captions, then a gap to the rows. - metrics.groupTop = metrics.bandTop + metrics.groupTop = area.y2 - metrics.headerGap * 2 metrics.groupBottom = metrics.groupTop - metrics.groupRowHeight metrics.statTop = metrics.groupBottom metrics.statBottom = metrics.statTop - metrics.statRowHeight @@ -1593,54 +1762,68 @@ local function setLayout() graphs.setLayout( area.x1 + metrics.sidebarW + metrics.pageGap, listBottom, - area.x2 - metrics.edgeInset, - metrics.bandTop, + listRight, + metrics.groupTop, s, area.y1, sidebarTop() + metrics.cardLip ) end - -- The header band: the switches, right to left from the panel's edge. A switch this - -- small is a poor click target on its own, so its caption is part of it and the hover - -- covers both. - local rowTop = area.y2 - mathFloor(4 * s) - local rowBottom = area.y2 - metrics.headerH + mathFloor(4 * s) - local togW = mathFloor(38 * s) - local togH = mathFloor((rowTop - rowBottom) * 0.62) - local togY = mathFloor((rowTop + rowBottom) * 0.5) + -- The settings: a row each under the sidebar's groups, the caption on the left and the + -- toggle on the right, laid out from the bottom of the column upwards so the block + -- stays put however many groups there are. A switch the open view cannot use keeps its + -- row and is dimmed, so nothing ever moves when one is pressed. metrics.toggleFs = mathFloor(metrics.rowFs * 1.05) - -- Outlined text spreads past the box it is measured in, so the caption side gets back - -- the room its outline took. - metrics.captionBleed = mathFloor(metrics.toggleFs * 0.2 + 0.5) - ---@type number - local x2 = area.x2 - metrics.edgeInset + local togW = mathFloor(38 * s) + local togH = mathFloor(metrics.catRowHeight * 0.52) + local onGraphs = graphs and graphs.open + -- A share of a team's total needs teams to group by: the page groups its own way while + -- it is open, the table's grouping stands otherwise. + local anyGrouping = onGraphs and (graphs.grouped and not isFFA) or (not onGraphs and grouped()) + ---@diagnostic disable-next-line: undefined-field + local canCompare = allies.me ~= nil + local y = listBottom + metrics.edgeInset for i = #switches, 1, -1 do local sw = switches[i] - local onGraphs = graphs and graphs.open - -- A share of a team's total needs teams to group by: the page groups its own way - -- while it is open, the table's grouping stands otherwise. - local anyGrouping = onGraphs and (graphs.grouped and not isFFA) or (not onGraphs and grouped()) - if - (sw.mode and not (showPlannedPages and handover.on)) - or (sw.key == "perMinute" and not rateShown()) - or (sw.key == "selectedOnly" and not (graphs and graphs.filterShown())) - or (sw.key == "groupByTeam" and isFFA) - or (sw.key == "shareOfTeam" and not anyGrouping) - or (sw.table and onGraphs) - or (sw.page and not onGraphs) - then - sw.draw, sw.hit = nil, nil - else - local labelW = mathFloor(font:GetTextWidth(sw.label) * metrics.toggleFs) - sw.draw = { x2 - togW, togY - mathFloor(togH * 0.5), x2, togY - mathFloor(togH * 0.5) + togH } + -- Which view a switch belongs to; the mode switch belongs to both. + local mine = not ((sw.table and onGraphs) or (sw.page and not onGraphs)) + if sw.mode and not (showPlannedPages and handover.on) then + mine = false + end + if mine then + local cy = y + mathFloor(metrics.catRowHeight * 0.5) sw.hit = { - sw.draw[1] - metrics.rowPad * 2 - labelW - metrics.captionBleed, - rowBottom, - x2 + metrics.rowPad, - rowTop, + area.x1 + metrics.catInset, + y, + area.x1 + metrics.sidebarW - metrics.catInset, + y + metrics.catRowHeight, } - x2 = sw.hit[1] - metrics.switchGap + sw.draw = { + sw.hit[3] - metrics.sidePad - togW, + cy - mathFloor(togH * 0.5), + sw.hit[3] - metrics.sidePad, + cy - mathFloor(togH * 0.5) + togH, + } + -- Nothing to say: the switch stays where it is and greys out. + sw.disabled = (sw.key == "perMinute" and not rateShown()) + or (sw.key == "milestones" and graphs and (not graphs.overTime() or graphs.milestonesOwn())) + or (sw.key == "groupByTeam" and isFFA) + or (sw.key == "vsMe" and not canCompare) + or (sw.key == "shareOfTeam" and not anyGrouping) + or nil + y = y + metrics.catRowHeight + else + sw.draw, sw.hit, sw.disabled = nil, nil, nil + end + end + -- The rule above the block, and where the group list has to stop. + metrics.settingsTop = y + mathFloor(4 * s) + if graphs then + for i = 1, #switches do + if switches[i].key == "milestoneKinds" then + graphs.setKindsAnchor(switches[i].hit) + end end end @@ -1696,22 +1879,33 @@ local function triangleVertices() end end -local function drawSortMark(x, cy) +local function drawTriMark(x, cy, up, right) triX, triY, triW, triH, triUp, triRight = - x, cy - mathFloor(metrics.triH * 0.5), metrics.triW, metrics.triH, sortAscending, false + x, cy - mathFloor(metrics.triH * 0.5), metrics.triW, metrics.triH, up, right glColor(look.sortMark) glBeginEnd(GL_TRIANGLES, triangleVertices) glColor(1, 1, 1, 1) end --- The fold mark after a band's caption: down while its players are shown, right once --- they are folded away. -local function drawFoldMark(x, cy, folded) - triX, triY, triW, triH, triUp, triRight = - x, cy - mathFloor(metrics.triH * 0.5), metrics.triW, metrics.triH, false, folded == true - glColor(look.sortMark) - glBeginEnd(GL_TRIANGLES, triangleVertices) - glColor(1, 1, 1, 1) +-- The mark under the number of a column its row leads: a short bar the width of the +-- number, in the warm hue the panel uses for what stands out. +local function drawLeadMark(column, cell, bottom) + local w = mathFloor(font:GetTextWidth(cell) * metrics.rowFs) + if w < 4 then + return + end + RectRound( + column.x2 - metrics.cellPad - w, + bottom + metrics.leadDrop, + column.x2 - metrics.cellPad, + bottom + metrics.leadDrop + metrics.leadH, + 0, + 0, + 0, + 0, + 0, + look.leadMark + ) end -- What a row shows, fitted to the columns. Cached on the row and redone when the @@ -1732,12 +1926,19 @@ local function fitRow(row) local share = shareMode() local room = nameColumn.x2 - nameColumn.x1 - metrics.nameIndent - metrics.cellPad row.fitName = text.fit(font, team.label, room, metrics.rowFs) + ---@type table? + ---@diagnostic disable-next-line: undefined-field + local me = (filters.vsMe and not team.isLocal) and allies.me or nil for i = 2, #columns do local c = columns[i] local v = cellValue(c, team) row.vals[i] = v - row.cells[i] = formatCell(c, v, share and c.fmt == "si") - row.tones[i] = tone(c, team.stats, v) + if me then + row.cells[i], row.tones[i] = formatCell(c, v, share and c.fmt == "si", cellValue(c, me)) + else + row.cells[i] = formatCell(c, v, share and c.fmt == "si") + row.tones[i] = tone(c, team.stats, v) + end end else local ally = row.ally @@ -1757,12 +1958,19 @@ local function fitRow(row) row.members = nil row.membersW = 0 end + ---@type table? + ---@diagnostic disable-next-line: undefined-field + local mine = (filters.vsMe and allies.myAlly ~= ally) and allies.myAlly or nil for i = 2, #columns do local c = columns[i] local v = bandValue(c, ally) row.vals[i] = v - row.cells[i] = formatCell(c, v) - row.tones[i] = tone(c, ally.total, v) + if mine then + row.cells[i], row.tones[i] = formatCell(c, v, false, bandValue(c, mine)) + else + row.cells[i] = formatCell(c, v) + row.tones[i] = tone(c, ally.total, v) + end end end end @@ -1840,17 +2048,44 @@ local function drawBand(row, top, bottom, hovered) ) end if not row.team then - drawFoldMark( + -- Down while the band's players are shown, right once they are folded away. + drawTriMark( captionX + metrics.rowPad * 2 + row.captionW + (row.membersW or 0), mathFloor((bottom + top - metrics.csSmall) * 0.5), - row.folded + false, + row.folded == true ) end + -- The team's history behind its totals as well, in its colour when the band is one + -- team's, else in the header's own hue. + if filters.trend and row.trendKey then + local accent = row.team and row.team.accent + local color = accent and { accent[1], accent[2], accent[3], 0.65 } or look.trendBand + for i = 2, #columns do + local c = columns[i] + local right = c.x2 - metrics.cellPad - mathFloor(font:GetTextWidth(row.cells[i]) * metrics.rowFs) + graphs.drawTrend( + row.trendKey, + c.key, + c.rate == true, + c.x1 + metrics.cellPad, + bottom + metrics.barInset, + right - metrics.rowPad, + top - metrics.csSmall - metrics.barInset, + color, + metrics.trendW + ) + end + end + local vy = text.baseline(font, bottom, top, metrics.rowFs) for i = 2, #columns do local v = row.vals[i] local valueColor = row.tones[i] or ((v == 0 or not isFinite(v)) and look.zeroText or colorTotal) queueText(valueColor .. row.cells[i], columns[i].x2 - metrics.cellPad, vy, metrics.rowFs, "or") + if row.ally.leads[columns[i].key] then + drawLeadMark(columns[i], row.cells[i], bottom) + end end end @@ -1880,6 +2115,29 @@ local function drawTeamRow(row, top, bottom, hovered) team.accent ) + -- The history behind the number, in the team's own colour: where the number came from + -- rather than only where it stands now. + if filters.trend and row.trendKey then + local color = { team.accent[1], team.accent[2], team.accent[3], (team.dead or team.gone) and 0.4 or 0.75 } + for i = 2, #columns do + local c = columns[i] + -- Beside the number, in what the number leaves of its cell, so neither is read + -- through the other. + local right = c.x2 - metrics.cellPad - mathFloor(font:GetTextWidth(row.cells[i]) * metrics.rowFs) + graphs.drawTrend( + row.trendKey, + c.key, + c.rate == true, + c.x1 + metrics.cellPad, + bottom + metrics.barInset, + right - metrics.rowPad, + top - metrics.barInset, + color, + metrics.trendW + ) + end + end + if filters.bars then for i = 2, #columns do local c = columns[i] @@ -1930,6 +2188,9 @@ local function drawTeamRow(row, top, bottom, hovered) valueColor = colorValue end queueText(valueColor .. row.cells[i], columns[i].x2 - metrics.cellPad, by, metrics.rowFs, "or") + if team.leads[columns[i].key] then + drawLeadMark(columns[i], row.cells[i], bottom) + end end end @@ -2039,13 +2300,13 @@ local function drawTableHeader() if i == 1 then queueText(color .. c.fitStat, c.x1 + metrics.nameIndent, sy, metrics.statFs, "o") if sorted then - drawSortMark(c.x1 + metrics.nameIndent + c.statW + metrics.rowPad, cy) + drawTriMark(c.x1 + metrics.nameIndent + c.statW + metrics.rowPad, cy, sortAscending, false) end else queueText(color .. c.fitStat, c.x2 - metrics.cellPad, sy, metrics.statFs, "or") -- In the padding after the caption, over the right edge the numbers line up on. if sorted then - drawSortMark(c.x2 - metrics.cellPad + 1, cy) + drawTriMark(c.x2 - metrics.cellPad + 1, cy, sortAscending, false) end end end @@ -2074,7 +2335,7 @@ local function drawSidebar() for i = 1, #entries do local e = entries[i] local x1, y1, x2, y2 = entryRect(i) - if y1 < listBottom then + if y1 < metrics.settingsTop then break end if e.divider then @@ -2114,11 +2375,16 @@ end -- The switches and their captions. The plate goes behind the switch and the switch -- lights itself: painting over it would only dull it. -local function drawHeader() +-- The settings block under the groups: a rule, then a row per switch with its caption on +-- the left and its toggle on the right. A row the open view cannot use is dimmed and does +-- not answer the cursor. +local function drawSettings() + local top = 0 for i = 1, #switches do local sw = switches[i] if sw.draw then - local hovered = hover.tog == i + top = mathMax(top, sw.hit[4]) + local hovered = hover.tog == i and not sw.disabled if hovered then Highlight(sw.hit[1], sw.hit[2], sw.hit[3], sw.hit[4], metrics.csSmall, look.rowHoverOpacity, look.white) end @@ -2130,25 +2396,72 @@ local function drawHeader() elseif sw.key == "groupByTeam" and graphs.open then on = graphs.grouped end - UiToggle(sw.draw[1], sw.draw[2], sw.draw[3], sw.draw[4], on, hovered) + local cy = mathFloor((sw.hit[2] + sw.hit[4]) * 0.5) queueText( - (on and colorSelected or colorDim) .. sw.label, - sw.draw[1] - metrics.rowPad, - mathFloor((sw.hit[2] + sw.hit[4]) * 0.5), + (sw.disabled and colorFaded or (on and colorSelected or colorDim)) .. sw.label, + sw.hit[1] + metrics.sidePad, + cy, metrics.toggleFs, - "rov" + "ov" ) + if sw.value then + queueText( + (sw.disabled and colorFaded or colorSelected) .. tostring(graphs.settingValue(sw.key)), + sw.hit[3] - metrics.sidePad, + cy, + metrics.toggleFs, + "rov" + ) + else + UiToggle(sw.draw[1], sw.draw[2], sw.draw[3], sw.draw[4], on, hovered) + if sw.disabled then + -- FlowUI rounds a switch by a tenth of its height and draws an edge + -- around it; the mute covers both. + local edge = mathMax(1, mathFloor((sw.draw[4] - sw.draw[2]) * 0.1)) + RectRound( + sw.draw[1] - edge, + sw.draw[2] - edge, + sw.draw[3] + edge, + sw.draw[4] + edge, + edge * 2, + 1, + 1, + 1, + 1, + look.mutedFill + ) + end + end end end + if top > 0 then + local y = top + mathFloor(metrics.catRowHeight * 0.3) + RectRound( + area.x1 + metrics.sidePad, + y, + area.x1 + metrics.sidebarW - metrics.sidePad, + y + 1, + 0, + 0, + 0, + 0, + 0, + look.rule + ) + end end -- Everything inside the panel: the column, the switches, the table and the scroller. -- Baked and replayed until the cursor, the list or the screen moves. local function drawPanel() drawSidebar() - drawHeader() + drawSettings() if graphs.open then graphs.drawPanel() + local top, total, offset = select(5, scrollerThumb()) + if total > 0 then + UiScroller(barX1, listBottom, area.x2 - metrics.edgeInset, top, total, offset, hover.bar == 1, dragging) + end flushText() glColor(1, 1, 1, 1) return @@ -2227,6 +2540,14 @@ local function panelSignature(mx, my) hover.hcol = 0 hover.tog = switchAt(mx, my) or 0 hover.bar = 0 + if mx >= barX1 and mx <= area.x2 then + -- The thumb itself, not the track: it is the part that can be taken hold of, so it + -- is the part that lights up. + local top, height = scrollerThumb() + if top and my <= top and my >= top - height then + hover.bar = 1 + end + end local extra = "" if graphs.open then @@ -2239,13 +2560,6 @@ local function panelSignature(mx, my) hover.col = columnAt(mx) or 0 end end - elseif mx >= barX1 and mx <= area.x2 then - -- The thumb itself, not the track: it is the part that can be taken hold of, so it - -- is the part that lights up. - local top, height = scrollerThumb() - if top and my <= top and my >= top - height then - hover.bar = 1 - end end return hover.sb @@ -2384,7 +2698,9 @@ local function loadLabels() -- The milestone kinds the gadget records. L.milestones = BAR.I18N("ui.teamStats.milestones") L.milestone = {} - for _, key in ipairs({ "tech2", "tech3", "nuke", "antinuke", "lrpc", "commanderLost", "teamDied" }) do + L.milestoneOrder = + { "tech2", "tech3", "nuke", "antinuke", "lrpc", "firstKill", "firstLoss", "commanderLost", "teamDied" } + for _, key in ipairs(L.milestoneOrder) do L.milestone[key] = BAR.I18N("ui.teamStats.milestone." .. key) end @@ -2440,7 +2756,17 @@ end local function refresh() syncGadgetState(spGetGameFrame()) refreshStats() - if graphs.open then + -- Whether the viewer has a team of their own is only known once the teams are read, + -- which is after the first layout: the comparison switch appears when it is. + ---@diagnostic disable-next-line: undefined-field + local canCompare = allies.me ~= nil + for i = 1, #switches do + if switches[i].key == "vsMe" and canCompare ~= (switches[i].hit ~= nil) then + setLayout() + end + end + -- The page's histories are the table's trend lines too. + if graphs.open or filters.trend then graphs.refresh() end end @@ -2546,7 +2872,7 @@ function widget:DrawScreen() -- Everything about the player, on the name: the columns the open view hides too. local row = rows[scroll + hover.row] if row and row.team then - title = row.team.label + title = (row.team.nameColor or "") .. row.team.label tip = nameCard(row.team) elseif row then title = row.caption @@ -2679,10 +3005,18 @@ local function sortBy(column) end end -local function toggleSwitch(i) +local function toggleSwitch(i, back) local sw = switches[i] or {} + if sw.disabled then + return + end local key = tostring(sw.key) - if sw.mode then + if sw.value then + -- A setting with a value rather than a state: the left button steps it on, the + -- right one back. + graphs.settingPress(key, back) + setLayout() + elseif sw.mode then -- The Graphs page is a mode: on, the sidebar's groups pick the stats it lists. graphs.open = not graphs.open setLayout() @@ -2698,6 +3032,13 @@ local function toggleSwitch(i) setLayout() else filters[key] = not filters[key] + -- Bars and trends both sit behind the numbers: one of them at a time. + if filters[key] and (key == "bars" or key == "trend") then + filters[key == "bars" and "trend" or "bars"] = false + end + if key == "trend" and filters.trend then + graphs.refresh() + end -- Grouping decides whether the share switch is offered, so the header is laid -- out again. The others only change what the columns say: a rate changes every -- value and so the order, and the captions say when they are rates. @@ -2716,6 +3057,12 @@ local function toggleSwitch(i) end function widget:KeyPress(key) + if show and key == 27 and graphs.open and graphs.kindsOpen then + -- ESC closes what is open on the panel before it closes the panel. + graphs.closeKinds() + dropLists() + return true + end if show and key == 27 then -- ESC showOnceMore = true @@ -2738,8 +3085,9 @@ function widget:MouseWheel(up, _value) return false end - -- Nothing scrolls on the Graphs page; the wheel is still the panel's. + -- The Graphs page scrolls its grid of charts; the wheel is the panel's either way. if graphs.open then + graphs.wheel(up) return true end @@ -2783,9 +3131,20 @@ local function mouseEvent(x, y, button, release) end if math_isInRect(x, y, screenX, screenY - screenHeight, screenX + screenWidth, screenY) then - if not release and button == 3 and graphs.open then - -- The Graphs page's legend takes the right button too: it hides a team. - graphs.mousePress(x, y, 3) + if not release and graphs.open and graphs.kindsOpen then + graphs.mousePress(x, y, button) + dropLists() + return true + end + if not release and button == 3 then + local sw = switchAt(x, y) + if sw then + -- A setting with a value steps back on the right button. + toggleSwitch(sw, true) + elseif graphs.open then + -- The Graphs page's legend takes the right button too: it hides a team. + graphs.mousePress(x, y, 3) + end elseif not release and button == 1 then local sw = switchAt(x, y) local i = sidebarIndexAt(x, y) @@ -2802,6 +3161,11 @@ local function mouseEvent(x, y, button, release) toggleSwitch(sw) elseif i then selectEntry(i) + -- The bar owns its column in both views, so it is asked before the table's rows + -- and before the page's charts. + elseif math_isInRect(x, y, barX1, listBottom, area.x2, select(5, scrollerThumb()) or listTop) then + -- The strip between the bar and the panel edge stays grabbable too. + grabScroller(y) elseif graphs.open then local stat = graphs.stat graphs.mousePress(x, y, 1) @@ -2817,9 +3181,6 @@ local function mouseEvent(x, y, button, release) end elseif col then sortBy(columns[col]) - elseif math_isInRect(x, y, barX1, listBottom, area.x2, listTop) then - -- The strip between the bar and the panel edge stays grabbable too. - grabScroller(y) end end @@ -2950,10 +3311,13 @@ function widget:GetConfigData() groupByTeam = filters.groupByTeam, perMinute = filters.perMinute, bars = filters.bars, + trend = filters.trend, + vsMe = filters.vsMe, milestones = filters.milestones, - selectedOnly = filters.selectedOnly, graphStat = graphs.stat, + graphsOpen = graphs.open, graphGroupByTeam = graphs.grouped, + graphPerPage = graphs.perPage, } end @@ -3020,6 +3384,9 @@ graphs = VFS.Include("luaui/Include/teamstats_graphs.lua").new({ RectRoundOutline = function(...) return WG.FlowUI.Draw.RectRoundOutline(...) end, + Toggle = function(...) + return UiToggle(...) + end, }, queueText = queueText, text = text, diff --git a/luaui/Widgets/gui_top_bar_buttons.lua b/luaui/Widgets/gui_top_bar_buttons.lua index 6c6bc49505e..8d5d22d40fb 100644 --- a/luaui/Widgets/gui_top_bar_buttons.lua +++ b/luaui/Widgets/gui_top_bar_buttons.lua @@ -38,7 +38,6 @@ local isScenario = _modOpts ~= nil and _modOpts.scenariooptions ~= nil local chobbyLoaded = false local gameStarted = (Spring.GetGameFrame() > 0) local gameIsOver = false -local graphsWindowVisible = false -- Configuration. The geometry values mirror the Top Bar's, so the strip lines up with -- the bar whether or not that widget is running. @@ -222,9 +221,6 @@ local function updateButtons() addButton("info", BAR.I18N("ui.topbar.button.info"), tostring(changedCount)) end end - if gameIsOver then - addButton("graphs", BAR.I18N("ui.topbar.button.graphs")) - end if WG.scavengerinfo then addButton("scavengers", BAR.I18N("ui.topbar.button.scavengers")) end @@ -340,12 +336,12 @@ end local function updateLayout() widgetScale = (vsy / height) * 0.0425 * ui_scale xPos = mathFloor(vsx * cfg.relXpos) - topbarArea = { mathFloor(xPos + (cfg.borderPadding * widgetScale)), mathFloor(vsy - (height * widgetScale)), vsx, vsy } + topbarArea = + { mathFloor(xPos + (cfg.borderPadding * widgetScale)), mathFloor(vsy - (height * widgetScale)), vsx, vsy } -- Small elements (wind, tidal, coms, buttons) are top-aligned to a fraction of the -- bar height when skew is on. - local smallVPad = cfg.useSkew - and mathFloor((topbarArea[4] - topbarArea[2]) * (1 - cfg.smallElementHeightFraction)) + local smallVPad = cfg.useSkew and mathFloor((topbarArea[4] - topbarArea[2]) * (1 - cfg.smallElementHeightFraction)) or 0 local width = mathFloor((topbarArea[3] - topbarArea[1]) / 4) buttonsArea = { topbarArea[3] - width, topbarArea[2] + smallVPad, topbarArea[3], topbarArea[4] } @@ -739,12 +735,6 @@ local function hideWindows() WG.guishader.setScreenBlur(false) end - if gameIsOver then -- Graphs window can only be open after game end - -- Closing Graphs window if open, no way to tell if it was open or not - Spring.SendCommands("endgraph 0") - graphsWindowVisible = false - end - return closedWindow, stillOpen end @@ -828,13 +818,6 @@ local function applyButtonAction(button) toggleWindow("teamstats") elseif button == "info" then toggleWindow("gameinfo") - elseif button == "graphs" then - isvisible = graphsWindowVisible - hideWindows() - if gameIsOver and not isvisible then - Spring.SendCommands("endgraph 2") - graphsWindowVisible = true - end end end function widget:DrawScreen() diff --git a/recoil-lua-library b/recoil-lua-library index 2860744ee89..24d521d0cbe 160000 --- a/recoil-lua-library +++ b/recoil-lua-library @@ -1 +1 @@ -Subproject commit 2860744ee89559e3f6b89df271922dcd756af2ca +Subproject commit 24d521d0cbe7c860a53c360d545aadceaaae3f17