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