366 lines
14 KiB
Lua

local mod_gui = require("__core__.lualib.mod-gui")
local radar = require("radar")
local radar_ui = {}
local GRID = 32
local CELL_STYLE = {
unscanned = "pvp_radar_cell_gray",
charted = "pvp_radar_cell_green",
fresh = "pvp_radar_cell_blue",
jammed = "pvp_radar_cell_red",
scanning = "pvp_radar_cell_purple"
}
local STATE_LABEL = {
unscanned = "unscanned",
charted = "scanned",
fresh = "just scanned",
jammed = "JAMMED"
}
-- Viewport origin and per-player state; scan and jam data live in shared storage.
local function get_state(player)
storage.radar_ui = storage.radar_ui or {}
storage.radar_ui[player.index] = storage.radar_ui[player.index] or {ox = 0, oy = 0}
return storage.radar_ui[player.index]
end
-- Planet the player can see right now: below their platform, or the surface
-- they stand on. Traveling platforms show nothing.
local function viewed_planet(player)
local surface = player.surface
if not surface then return nil end
local platform = surface.platform
if platform then
local location = platform.space_location
if location and game.planets[location.name] then return location.name end
return nil
end
return game.planets[surface.name] and surface.name or nil
end
function radar_ui.create_button(player)
if not (player and player.valid) then return end
-- Created inside synced events, so every peer has the same entries; refresh
-- paths must never insert here or local GUI state would desync the save.
storage.radar_ui = storage.radar_ui or {}
storage.radar_ui[player.index] = storage.radar_ui[player.index] or {ox = 0, oy = 0}
local flow = mod_gui.get_button_flow(player)
if flow.pvp_radar_button then return end
flow.add{
type = "sprite-button",
name = "pvp_radar_button",
sprite = "entity/radar",
tooltip = "Orbital radar"
}
end
-- Per-player paint cache. Client-local on purpose: it only mirrors GUI state,
-- so keeping it out of storage is safe for multiplayer.
local ui_cache = {}
local function get_cache(player)
local c = ui_cache[player.index]
if not c then
c = {disp = {}, fresh = {}, cursor = 1, planet = false,
had_jammed = false, jam_version = -1, last_dirty_seen = 0}
ui_cache[player.index] = c
end
return c
end
local function set_cell(cell, sx, sy, display)
local style_name = CELL_STYLE[display]
local tags = cell.tags
if cell.style.name == style_name and tags.sx == sx and tags.sy == sy then return end
cell.style = style_name
cell.tags = {sx = sx, sy = sy}
cell.tooltip = "Sector (" .. sx .. ", " .. sy .. ") - " .. STATE_LABEL[display]
end
-- Full repaints happen in slices (rows top-to-bottom) so a 256-cell repaint
-- spreads over a few seconds of ticks instead of one spike, and reads as a
-- radar sweep. Read-only on storage: a player's open window is client-local,
-- so writing synced state here would desync multiplayer.
local PAINT_ROWS_PER_TICK = 1
local PAINT_PER_TICK = PAINT_ROWS_PER_TICK * GRID
local function update_labels(player, frame, planet_name)
frame.pvp_radar_planet.caption = "Planet: " .. (planet_name or "none (platform traveling)")
if not planet_name then
frame.pvp_radar_stats.caption = ""
return
end
local stats = radar.scan_stats(player.force.name, planet_name)
frame.pvp_radar_stats.caption = string.format("Scans/min: %.1f Power: %d%% Radars: %d",
stats.rate, math.floor(stats.power * 100 + 0.5), stats.radars)
end
local function cell_display(player, surface, sx, sy, cell, jammed_set)
local tags = cell.tags
local was_charted = tags.sx == sx and tags.sy == sy
and (cell.style.name == CELL_STYLE.charted or cell.style.name == CELL_STYLE.fresh)
return radar.sector_display_state(player.force, surface, sx, sy, game.tick, was_charted, jammed_set)
end
local function paint_next_slice(player)
local frame = player.gui.screen.pvp_radar_frame
if not (frame and frame.valid) then return end
local state = storage.radar_ui and storage.radar_ui[player.index]
if not state then return end
local cache = get_cache(player)
local i0 = cache.repaint_from
if not i0 then return end
local planet_name = viewed_planet(player)
cache.planet = planet_name or false
cache.ox, cache.oy = state.ox, state.oy
cache.jam_version = storage.jam_version
local children = frame.pvp_radar_grid.children
local surface = planet_name and game.planets[planet_name].surface
local jammed_set = surface and radar.jammed_sector_set(surface, player.force)
if i0 == 1 then
cache.had_jammed = jammed_set and next(jammed_set) ~= nil or false
-- Scans during the sweep stay dirty and get repainted after it ends.
cache.last_dirty_seen = game.tick
end
-- Resolve the slice painted purple last pass to its true colors.
for i = math.max(1, i0 - PAINT_PER_TICK), math.min(i0 - 1, GRID * GRID) do
local sx = state.ox + (i - 1) % GRID
local sy = state.oy + math.floor((i - 1) / GRID)
local display = "unscanned"
if surface then display = cell_display(player, surface, sx, sy, children[i], jammed_set) end
cache.disp[i] = display
cache.fresh[i] = display == "fresh" and game.tick or nil
set_cell(children[i], sx, sy, display)
end
-- Paint this slice purple; the next pass resolves it. The purple edge is
-- the visible scan line.
for i = i0, math.min(i0 + PAINT_PER_TICK - 1, GRID * GRID) do
local sx = state.ox + (i - 1) % GRID
local sy = state.oy + math.floor((i - 1) / GRID)
local display = "unscanned"
if surface then display = cell_display(player, surface, sx, sy, children[i], jammed_set) end
cache.disp[i] = display
cache.fresh[i] = display == "fresh" and game.tick or nil
local cell = children[i]
cell.style = CELL_STYLE.scanning
cell.tags = {sx = sx, sy = sy}
cell.tooltip = "Sector (" .. sx .. ", " .. sy .. ") - scanning"
end
-- One extra pass after the last slice resolves the final purple row.
cache.repaint_from = i0 <= GRID * GRID and i0 + PAINT_PER_TICK or nil
end
local function request_repaint(player)
local frame = player.gui.screen.pvp_radar_frame
if not (frame and frame.valid) then return end
get_cache(player).repaint_from = 1
update_labels(player, frame, viewed_planet(player))
paint_next_slice(player) -- first slice now so the window never looks dead
end
local function build_window(player)
get_state(player)
if player.gui.screen.pvp_radar_frame then player.gui.screen.pvp_radar_frame.destroy() end
local frame = player.gui.screen.add{
type = "frame",
name = "pvp_radar_frame",
caption = "ORBITAL RADAR",
direction = "vertical"
}
frame.auto_center = true
frame.add{type = "label", name = "pvp_radar_planet", caption = ""}
frame.add{type = "label", name = "pvp_radar_stats", caption = ""}
local grid = frame.add{type = "table", name = "pvp_radar_grid", column_count = GRID}
grid.style.horizontal_spacing = 1
grid.style.vertical_spacing = 1
for i = 1, GRID * GRID do
grid.add{type = "button", name = "pvp_radar_cell_" .. i, style = CELL_STYLE.unscanned, tags = {sx = 0, sy = 0}}
end
-- dx/dy are in sectors; one GRID is a page. Panning only moves the viewport.
local pan = frame.add{type = "flow", direction = "horizontal"}
for i, spec in ipairs({
{"<<", -GRID, 0}, {"<", -1, 0}, {">", 1, 0}, {">>", GRID, 0},
{"^^", 0, -GRID}, {"^", 0, -1}, {"v", 0, 1}, {"vv", 0, GRID},
}) do
pan.add{type = "button", name = "pvp_radar_pan_" .. i, caption = spec[1],
style = "tool_button", tags = {dx = spec[2], dy = spec[3]}}
end
pan.add{type = "button", name = "pvp_radar_close", caption = "Close"}
request_repaint(player)
end
-- Spam-clicking pans or cells must not trigger a full 1024-cell repaint per
-- click; repaint at most every 6 ticks and let the periodic refresh catch up.
local last_paint = {}
local function repaint_throttled(player)
local last = last_paint[player.index] or -60
if game.tick - last >= 6 then
last_paint[player.index] = game.tick
request_repaint(player)
end
end
function radar_ui.on_gui_click(event)
local element = event.element
if not (element and element.valid) then return false end
local player = game.get_player(event.player_index)
if not (player and player.valid) then return false end
local name = element.name
if name == "pvp_radar_button" then
if player.gui.screen.pvp_radar_frame then
player.gui.screen.pvp_radar_frame.destroy()
else
build_window(player)
end
return true
end
if name == "pvp_radar_close" then
if player.gui.screen.pvp_radar_frame then player.gui.screen.pvp_radar_frame.destroy() end
return true
end
if name:find("^pvp_radar_pan_") then
local tags = element.tags
local state = get_state(player)
state.ox = state.ox + (tags.dx or 0)
state.oy = state.oy + (tags.dy or 0)
repaint_throttled(player)
return true
end
if name:find("^pvp_radar_cell_") then
-- Click centres the viewport on that sector.
local tags = element.tags
local state = get_state(player)
state.ox = (tags.sx or 0) - math.floor(GRID / 2)
state.oy = (tags.sy or 0) - math.floor(GRID / 2)
repaint_throttled(player)
return true
end
return false
end
-- Cheap per-open-window update: only repaint cells whose sector was scanned
-- since last pass, expire blue flashes, and probe a few gray cells for ground
-- charting. Full repaints only on pan/planet/jam changes.
local PROBES_PER_TICK = 64
local function update_open_frame(player, frame, state)
local cache = get_cache(player)
local planet_name = viewed_planet(player)
-- Viewport moved since last paint (pan click may have been throttled).
if state.ox ~= cache.ox or state.oy ~= cache.oy
or (planet_name or false) ~= cache.planet then
request_repaint(player)
return
end
if not planet_name then return end
local surface = game.planets[planet_name].surface
if not surface then return end
local children = frame.pvp_radar_grid.children
-- A sweep in progress owns the grid until it finishes.
if cache.repaint_from then paint_next_slice(player) return end
update_labels(player, frame, planet_name)
if storage.jam_version ~= cache.jam_version then
-- Zones rebuilt this second; repaint only if this view is affected.
if cache.had_jammed then
request_repaint(player)
return
end
local set = radar.jammed_sector_set(surface, player.force)
if set and next(set) then request_repaint(player) return end
cache.jam_version = storage.jam_version
end
-- Sectors charted by orbital scans since our last pass.
local dirty = storage.scan_dirty
and storage.scan_dirty[player.force.name]
and storage.scan_dirty[player.force.name][planet_name]
if dirty then
for key, tick in pairs(dirty) do
if tick > cache.last_dirty_seen then
local sep = key:find(",")
local sx, sy = tonumber(key:sub(1, sep - 1)), tonumber(key:sub(sep + 1))
local gx, gy = sx - state.ox, sy - state.oy
if gx >= 0 and gx < GRID and gy >= 0 and gy < GRID then
local i = gy * GRID + gx + 1
local display = radar.sector_display_state(player.force, surface, sx, sy, game.tick, true)
set_cell(children[i], sx, sy, display)
cache.disp[i] = display
cache.fresh[i] = display == "fresh" and tick or nil
end
end
end
end
cache.last_dirty_seen = game.tick
-- Blue flash expired: we charted it, so it is charted now, no API call.
for i, tick in pairs(cache.fresh) do
if game.tick - tick >= radar.FRESH_TICKS then
set_cell(children[i], children[i].tags.sx, children[i].tags.sy, "charted")
cache.disp[i] = "charted"
cache.fresh[i] = nil
end
end
-- Ground sources can chart sectors we never scanned; probe a bounded slice
-- of still-gray cells each pass instead of polling the whole grid.
local grid_n = GRID * GRID
local probed, tries = 0, 0
while probed < PROBES_PER_TICK and tries < grid_n do
local i = cache.cursor
cache.cursor = cache.cursor % grid_n + 1
tries = tries + 1
if cache.disp[i] == "unscanned" then
probed = probed + 1
local sx = state.ox + (i - 1) % GRID
local sy = state.oy + math.floor((i - 1) / GRID)
if player.force.is_chunk_charted(surface, {x = sx, y = sy}) then
set_cell(children[i], sx, sy, "charted")
cache.disp[i] = "charted"
end
end
end
end
function radar_ui.on_ui_tick()
if not storage.radar_ui then return end
for index, state in pairs(storage.radar_ui) do
local player = game.get_player(index)
if player and player.connected then
local frame = player.gui.screen.pvp_radar_frame
if frame and frame.valid then update_open_frame(player, frame, state) end
end
end
end
function radar_ui.on_player_changed_surface(event)
local player = game.get_player(event.player_index)
if player and player.gui.screen.pvp_radar_frame then request_repaint(player) end
end
function radar_ui.on_platform_state_changed(event)
local platform = event.platform
local surface = platform and platform.valid and platform.surface
if not surface then return end
for _, player in pairs(game.connected_players) do
if player.surface == surface and player.gui.screen.pvp_radar_frame then
request_repaint(player)
end
end
end
return radar_ui