443 lines
20 KiB
Lua
443 lines
20 KiB
Lua
local radar = {}
|
|
|
|
local GRID_SIZE = 16
|
|
local MAX_RADIUS = 3200
|
|
local SECTORS_PER_INTERVAL = 1 -- earned per radar per 30 s window at full power
|
|
local MAX_CREDIT = 60 -- cap on banked sectors per force+planet group
|
|
local SECTOR_TILES = GRID_SIZE * 2 -- one sector = one 32x32 chunk
|
|
local FRESH_TICKS = 180 -- a fresh scan shows blue for 3 s
|
|
radar.SECTOR_TILES = SECTOR_TILES
|
|
radar.FRESH_TICKS = FRESH_TICKS
|
|
|
|
local function rlog(message)
|
|
log("[PVP-RADAR] " .. message)
|
|
end
|
|
|
|
local function powered(entity)
|
|
return entity.energy > 0
|
|
end
|
|
|
|
-- Half-size of the square active-vision footprint, matching what the entity reveals on the map.
|
|
local function vision_half_size(entity)
|
|
if entity.type == "radar" then
|
|
return (entity.prototype.get_max_distance_of_nearby_sector_revealed(entity.quality.name) or 3) * 32
|
|
end
|
|
local cell = entity.logistic_cell
|
|
return cell and cell.construction_radius or 0
|
|
end
|
|
|
|
local function enemy_vision(entity, force)
|
|
return entity.force ~= force and entity.force.name ~= "neutral"
|
|
end
|
|
|
|
-- Zones are rebuilt once per jam tick so scans never search entities themselves.
|
|
-- quiet skips logging; the UI polls this every refresh and would spam the log.
|
|
local function sector_jammed(surface, position, force, quiet)
|
|
local zones = storage.jam_zones and storage.jam_zones[surface.index]
|
|
if not zones then return false end
|
|
for _, zone in pairs(zones) do
|
|
if zone.force ~= force.name and zone.force ~= "neutral"
|
|
and math.abs(position.x - zone.x) <= zone.half
|
|
and math.abs(position.y - zone.y) <= zone.half then
|
|
if not quiet then
|
|
rlog("sector_jammed=true surface=" .. surface.name .. " pos=(" .. position.x .. "," .. position.y
|
|
.. ") force=" .. force.name .. " by " .. zone.name .. " force=" .. zone.force
|
|
.. " pos=(" .. zone.x .. "," .. zone.y .. ")")
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
radar.sector_jammed = sector_jammed
|
|
|
|
local function scan_orbit(force, surface, scan_key)
|
|
storage.grid_pos = storage.grid_pos or {}
|
|
if not storage.grid_pos[scan_key] then
|
|
storage.grid_pos[scan_key] = {x = 0, y = 0, d = 0, step_limit = 1, steps_taken = 0, iteration = 0}
|
|
end
|
|
|
|
local state = storage.grid_pos[scan_key]
|
|
local position = {x = state.x, y = state.y}
|
|
local scan_range = 1
|
|
local chunk = {x = position.x / SECTOR_TILES, y = position.y / SECTOR_TILES}
|
|
|
|
if not surface.is_chunk_generated(chunk) then
|
|
-- Ask for the chunk and retry this sector next turn; the cursor does not
|
|
-- advance, so world gen is limited to one sector per queue tick.
|
|
surface.request_to_generate_chunks(position, scan_range)
|
|
return
|
|
end
|
|
|
|
if sector_jammed(surface, position, force) then
|
|
rlog("scan_orbit blocked: key=" .. scan_key .. " pos=(" .. position.x .. "," .. position.y .. ")")
|
|
-- Remember attempted-but-jammed sectors; the UI only shows red where the
|
|
-- force actually tried to scan, not the whole enemy coverage map.
|
|
storage.jammed_attempts = storage.jammed_attempts or {}
|
|
local af = storage.jammed_attempts[force.name] or {}
|
|
storage.jammed_attempts[force.name] = af
|
|
local as = af[surface.name] or {}
|
|
af[surface.name] = as
|
|
as[chunk.x .. "," .. chunk.y] = game.tick
|
|
else
|
|
force.chart(surface, {
|
|
{position.x - (GRID_SIZE * scan_range), position.y - (GRID_SIZE * scan_range)},
|
|
{position.x + (GRID_SIZE * scan_range), position.y + (GRID_SIZE * scan_range)}
|
|
})
|
|
-- Timestamped so the UI can flash freshly scanned sectors blue.
|
|
storage.recent_scans = storage.recent_scans or {}
|
|
local by_force = storage.recent_scans[force.name] or {}
|
|
storage.recent_scans[force.name] = by_force
|
|
local by_surface = by_force[surface.name] or {}
|
|
by_force[surface.name] = by_surface
|
|
by_surface[chunk.x .. "," .. chunk.y] = game.tick
|
|
end
|
|
|
|
-- Flag the sector for open radar windows so they repaint only this cell.
|
|
storage.scan_dirty = storage.scan_dirty or {}
|
|
local dirty_force = storage.scan_dirty[force.name] or {}
|
|
storage.scan_dirty[force.name] = dirty_force
|
|
local dirty_surface = dirty_force[surface.name] or {}
|
|
dirty_force[surface.name] = dirty_surface
|
|
dirty_surface[chunk.x .. "," .. chunk.y] = game.tick
|
|
|
|
if state.d == 0 then state.x = state.x + GRID_SIZE * scan_range * 2
|
|
elseif state.d == 1 then state.y = state.y + GRID_SIZE * scan_range * 2
|
|
elseif state.d == 2 then state.x = state.x - GRID_SIZE * scan_range * 2
|
|
else state.y = state.y - GRID_SIZE * scan_range * 2 end
|
|
|
|
state.steps_taken = state.steps_taken + 1
|
|
if state.steps_taken >= state.step_limit then
|
|
state.steps_taken = 0
|
|
state.d = (state.d + 1) % 4
|
|
state.iteration = state.iteration + 1
|
|
if state.iteration % 2 == 0 then state.step_limit = state.step_limit + 1 end
|
|
end
|
|
if math.abs(state.x) > MAX_RADIUS or math.abs(state.y) > MAX_RADIUS then
|
|
storage.grid_pos[scan_key] = {x = 0, y = 0, d = 0, step_limit = 1, steps_taken = 0, iteration = 0}
|
|
end
|
|
end
|
|
|
|
-- Stored charge as a fraction of the buffer; drives how fast a radar accumulates scans.
|
|
local function scan_power(entity)
|
|
local buffer = entity.electric_buffer_size
|
|
if not buffer or buffer == 0 then return 1 end
|
|
return math.min(1, entity.energy / buffer)
|
|
end
|
|
|
|
local function register_orbital_radar(entity)
|
|
if entity.type == "radar" and entity.surface.platform then
|
|
storage.orbital_radars = storage.orbital_radars or {}
|
|
storage.orbital_radars[entity.unit_number] = {entity = entity}
|
|
end
|
|
end
|
|
|
|
-- One-time seed for saves that predate the registry; afterwards events keep it current.
|
|
local function rebuild_registry()
|
|
storage.orbital_radars = {}
|
|
for _, surface in pairs(game.surfaces) do
|
|
if surface.platform then
|
|
for _, entity in pairs(surface.find_entities_filtered{type = "radar"}) do
|
|
if entity.valid then storage.orbital_radars[entity.unit_number] = {entity = entity} end
|
|
end
|
|
end
|
|
end
|
|
rlog("orbital radar registry rebuilt: " .. tostring(table_size(storage.orbital_radars)) .. " radars")
|
|
end
|
|
|
|
-- Ground vision sources (radars + roboports on planet surfaces), kept by events
|
|
-- so the jam tick never searches entities. Seeded once for old saves.
|
|
local function register_ground_source(entity)
|
|
if (entity.type == "radar" or entity.type == "roboport")
|
|
and not entity.surface.platform and entity.unit_number then
|
|
storage.ground_sources = storage.ground_sources or {}
|
|
storage.ground_sources[entity.unit_number] = entity
|
|
end
|
|
end
|
|
|
|
local function rebuild_ground_sources()
|
|
storage.ground_sources = {}
|
|
for _, surface in pairs(game.surfaces) do
|
|
if not surface.platform then
|
|
for _, entity in pairs(surface.find_entities_filtered{type = {"radar", "roboport"}}) do
|
|
if entity.valid and entity.unit_number then
|
|
storage.ground_sources[entity.unit_number] = entity
|
|
end
|
|
end
|
|
end
|
|
end
|
|
rlog("ground source registry rebuilt: " .. tostring(table_size(storage.ground_sources)) .. " entities")
|
|
end
|
|
|
|
function radar.on_entity_removed(event)
|
|
-- Not every dying entity has a unit_number (trees, rocks, decoratives).
|
|
local unit_number = event.entity and event.entity.unit_number
|
|
if not unit_number then return end
|
|
if storage.orbital_radars then storage.orbital_radars[unit_number] = nil end
|
|
if storage.ground_sources then storage.ground_sources[unit_number] = nil end
|
|
end
|
|
|
|
-- Runs once per second. Accumulates power readings and refreshes each radar's
|
|
-- force and target planet, so platforms moving between planets just work.
|
|
function radar.on_power_sample_tick()
|
|
if not storage.orbital_radars then rebuild_registry() end
|
|
local power = storage.radar_power or {}
|
|
storage.radar_power = power
|
|
for unit_number, entry in pairs(storage.orbital_radars) do
|
|
local entity = entry.entity
|
|
if not entity.valid then
|
|
storage.orbital_radars[unit_number] = nil
|
|
power[unit_number] = nil
|
|
else
|
|
local platform = entity.surface.platform
|
|
local location = platform and platform.space_location
|
|
if platform and entity.active and location and game.planets[location.name] then
|
|
entry.force = entity.force.name
|
|
entry.target = location.name
|
|
local acc = power[unit_number] or {sum = 0, count = 0}
|
|
acc.sum = acc.sum + scan_power(entity)
|
|
acc.count = acc.count + 1
|
|
power[unit_number] = acc
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Runs every 30 s. Converts each radar's average power into sector credit,
|
|
-- pooled per force+planet so duplicate coverage is never queued twice.
|
|
function radar.on_orbital_scan_tick()
|
|
local power = storage.radar_power
|
|
if not power then return end
|
|
storage.scan_credit = storage.scan_credit or {}
|
|
for unit_number, acc in pairs(power) do
|
|
local entry = storage.orbital_radars and storage.orbital_radars[unit_number]
|
|
if entry and entry.force and entry.target and acc.count > 0 then
|
|
local planets = storage.scan_credit[entry.force] or {}
|
|
storage.scan_credit[entry.force] = planets
|
|
planets[entry.target] = math.min(MAX_CREDIT,
|
|
(planets[entry.target] or 0) + (acc.sum / acc.count) * SECTORS_PER_INTERVAL)
|
|
end
|
|
end
|
|
storage.radar_power = {}
|
|
storage.scan_dirty = {} -- UI drains entries fast; 30 s of leftovers is enough
|
|
|
|
-- Recent-scan marks only matter for the blue flash; drop stale ones.
|
|
local scans = storage.recent_scans
|
|
if scans then
|
|
local cutoff = game.tick - FRESH_TICKS * 4
|
|
for _, by_surface in pairs(scans) do
|
|
for _, sectors in pairs(by_surface) do
|
|
for key, tick in pairs(sectors) do
|
|
if tick < cutoff then sectors[key] = nil end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Shared sector coordinate conversion: sector (sx, sy) covers the 32x32 chunk at
|
|
-- chunk position (sx, sy); its centre tile is (sx * 32, sy * 32). UI, scans and
|
|
-- jam checks all use this so a cell always maps to the same ground area.
|
|
-- already_charted lets the UI skip the is_chunk_charted API call for cells it
|
|
-- has drawn green before; a charted chunk can never revert.
|
|
local function sector_display_state(force, surface, sx, sy, tick, already_charted, jammed_set)
|
|
-- Red only where the force attempted a scan and the zone is still live.
|
|
local in_zone = jammed_set and jammed_set[sx .. "," .. sy]
|
|
or (not jammed_set and sector_jammed(surface, {x = sx * SECTOR_TILES, y = sy * SECTOR_TILES}, force, true))
|
|
if in_zone then
|
|
local attempts = storage.jammed_attempts and storage.jammed_attempts[force.name]
|
|
local attempted = attempts and attempts[surface.name] and attempts[surface.name][sx .. "," .. sy]
|
|
if attempted then return "jammed" end
|
|
end
|
|
local scans = storage.recent_scans and storage.recent_scans[force.name]
|
|
local last = scans and scans[surface.name] and scans[surface.name][sx .. "," .. sy]
|
|
if last and tick - last < FRESH_TICKS then return "fresh" end
|
|
if already_charted or force.is_chunk_charted(surface, {x = sx, y = sy}) then return "charted" end
|
|
return "unscanned"
|
|
end
|
|
radar.sector_display_state = sector_display_state
|
|
|
|
-- Enemy jam zones converted to a sector set ("sx,sy" keys) so a UI refresh does
|
|
-- one zone pass per frame instead of one zone loop per cell.
|
|
function radar.jammed_sector_set(surface, force)
|
|
local zones = storage.jam_zones and storage.jam_zones[surface.index]
|
|
if not zones then return nil end
|
|
local set = {}
|
|
for _, zone in pairs(zones) do
|
|
if zone.force ~= force.name and zone.force ~= "neutral" then
|
|
for sx = math.floor((zone.x - zone.half) / SECTOR_TILES), math.floor((zone.x + zone.half) / SECTOR_TILES) do
|
|
for sy = math.floor((zone.y - zone.half) / SECTOR_TILES), math.floor((zone.y + zone.half) / SECTOR_TILES) do
|
|
if math.abs(sx * SECTOR_TILES - zone.x) <= zone.half
|
|
and math.abs(sy * SECTOR_TILES - zone.y) <= zone.half then
|
|
set[sx .. "," .. sy] = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return set
|
|
end
|
|
|
|
-- Expected scan rate (sectors/min) and mean power fraction for a force+planet,
|
|
-- from the per-radar averages accumulated so far this interval.
|
|
function radar.scan_stats(force_name, planet_name)
|
|
local power_acc = storage.radar_power or {}
|
|
local radars, power_sum, rate = 0, 0, 0
|
|
for unit_number, entry in pairs(storage.orbital_radars or {}) do
|
|
if entry.force == force_name and entry.target == planet_name then
|
|
local acc = power_acc[unit_number]
|
|
local avg = acc and acc.count > 0 and acc.sum / acc.count or 0
|
|
radars = radars + 1
|
|
power_sum = power_sum + avg
|
|
rate = rate + avg * SECTORS_PER_INTERVAL * 2 -- two 30 s windows per minute
|
|
end
|
|
end
|
|
return {rate = rate, power = radars > 0 and power_sum / radars or 0, radars = radars}
|
|
end
|
|
|
|
-- Runs every 12 ticks (200 ms). Drains exactly one sector per call, round-robin
|
|
-- across groups, so per-update work stays fixed no matter how deep the credit is.
|
|
function radar.on_scan_queue_tick()
|
|
local credit = storage.scan_credit
|
|
if not credit then return end
|
|
local ready = {}
|
|
for force_name, planets in pairs(credit) do
|
|
for planet_name, amount in pairs(planets) do
|
|
if amount >= 1 then ready[#ready + 1] = {f = force_name, p = planet_name} end
|
|
end
|
|
end
|
|
if #ready == 0 then return end
|
|
table.sort(ready, function(a, b) if a.f == b.f then return a.p < b.p end return a.f < b.f end)
|
|
local cursor = (storage.scan_queue_cursor or 0) % #ready + 1
|
|
storage.scan_queue_cursor = cursor
|
|
local pick = ready[cursor]
|
|
|
|
local force = game.forces[pick.f]
|
|
local planet = game.planets[pick.p]
|
|
local surface = planet and planet.surface
|
|
if not (force and surface) then
|
|
credit[pick.f][pick.p] = nil
|
|
return
|
|
end
|
|
|
|
scan_orbit(force, surface, pick.f .. "_" .. pick.p)
|
|
local left = credit[pick.f][pick.p] - 1
|
|
credit[pick.f][pick.p] = left > 0 and left or nil
|
|
end
|
|
|
|
-- Powered overlapping enemy vision squares jam each other. The engine cannot
|
|
-- darken only the overlap, so an overlapped radar is disabled entirely until
|
|
-- the other side dies, loses power, or moves out of range. Roboports count as
|
|
-- vision sources but are never disabled themselves.
|
|
function radar.on_jam_tick()
|
|
storage.jammed_entities = storage.jammed_entities or {}
|
|
storage.jam_zones = storage.jam_zones or {}
|
|
for unit_number, entity in pairs(storage.jammed_entities) do
|
|
if entity.valid then
|
|
entity.active = true
|
|
rlog("unjammed " .. entity.name .. "@" .. unit_number .. " on " .. entity.surface.name)
|
|
else
|
|
rlog("dropping stale jammed entity @" .. unit_number .. " (invalid)")
|
|
end
|
|
storage.jammed_entities[unit_number] = nil
|
|
end
|
|
if not storage.ground_sources then rebuild_ground_sources() end
|
|
local sources_by_surface = {}
|
|
for unit_number, entity in pairs(storage.ground_sources) do
|
|
if not entity.valid then
|
|
storage.ground_sources[unit_number] = nil
|
|
elseif entity.active and powered(entity) and not entity.surface.platform then
|
|
local list = sources_by_surface[entity.surface.index]
|
|
if not list then
|
|
list = {}
|
|
sources_by_surface[entity.surface.index] = list
|
|
end
|
|
list[#list + 1] = entity
|
|
end
|
|
end
|
|
for _, surface in pairs(game.surfaces) do
|
|
if not surface.platform then
|
|
local sources = sources_by_surface[surface.index] or {}
|
|
for i = 1, #sources do
|
|
for j = i + 1, #sources do
|
|
local a, b = sources[i], sources[j]
|
|
if enemy_vision(a, b.force) then
|
|
local reach = vision_half_size(a) + vision_half_size(b)
|
|
if math.abs(a.position.x - b.position.x) <= reach and math.abs(a.position.y - b.position.y) <= reach then
|
|
rlog("vision overlap on " .. surface.name .. ": " .. a.name .. "@" .. a.unit_number
|
|
.. " force=" .. a.force.name .. " pos=(" .. a.position.x .. "," .. a.position.y .. ")"
|
|
.. " vs " .. b.name .. "@" .. b.unit_number .. " force=" .. b.force.name
|
|
.. " pos=(" .. b.position.x .. "," .. b.position.y .. ")")
|
|
for _, entity in pairs({a, b}) do
|
|
if entity.type == "radar" then
|
|
entity.active = false
|
|
storage.jammed_entities[entity.unit_number] = entity
|
|
rlog("jammed radar @" .. entity.unit_number .. " force=" .. entity.force.name
|
|
.. " on " .. surface.name)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
-- Cache still-active vision sources for cheap jam checks during scans.
|
|
local zones = {}
|
|
for _, source in pairs(sources) do
|
|
if source.valid and source.active then
|
|
zones[#zones + 1] = {name = source.name, x = source.position.x, y = source.position.y,
|
|
half = vision_half_size(source), force = source.force.name}
|
|
end
|
|
end
|
|
storage.jam_zones[surface.index] = zones
|
|
end
|
|
end
|
|
storage.jam_version = (storage.jam_version or 0) + 1
|
|
end
|
|
|
|
function radar.on_built_entity(event)
|
|
local entity = event.entity
|
|
if not (entity and entity.valid) then return end
|
|
if entity.surface.platform then
|
|
if entity.type == "radar" then
|
|
rlog("radar built @" .. entity.unit_number .. " name=" .. entity.name .. " force=" .. entity.force.name
|
|
.. " surface=" .. entity.surface.name .. " pos=(" .. entity.position.x .. "," .. entity.position.y .. ")"
|
|
.. " platform=true")
|
|
register_orbital_radar(entity)
|
|
end
|
|
return
|
|
end
|
|
if entity.type == "radar" or entity.type == "roboport" then
|
|
register_ground_source(entity)
|
|
end
|
|
if entity.type ~= "radar" then return end
|
|
rlog("radar built @" .. entity.unit_number .. " name=" .. entity.name .. " force=" .. entity.force.name
|
|
.. " surface=" .. entity.surface.name .. " pos=(" .. entity.position.x .. "," .. entity.position.y .. ")"
|
|
.. " platform=false")
|
|
|
|
-- A fresh vision source jams any enemy drop-zone lock inside its footprint.
|
|
local jammer_force, jammer_pos = entity.force, entity.position
|
|
local jammer_half = vision_half_size(entity)
|
|
for _, enemy_force in pairs(game.forces) do
|
|
if enemy_force.name ~= jammer_force.name and enemy_force.name ~= "neutral" then
|
|
storage.player_targets = storage.player_targets or {}
|
|
local key = enemy_force.name .. "_" .. entity.surface.name
|
|
local target = storage.player_targets[key]
|
|
if target then
|
|
local dx, dy = math.abs(target.position.x - jammer_pos.x), math.abs(target.position.y - jammer_pos.y)
|
|
if dx <= jammer_half and dy <= jammer_half then
|
|
rlog("jammed drop zone: force=" .. enemy_force.name .. " key=" .. key
|
|
.. " target=(" .. target.position.x .. "," .. target.position.y .. ")"
|
|
.. " by radar@" .. entity.unit_number)
|
|
storage.player_targets[key] = nil
|
|
local message = "[color=red][JAMMER] Your drop zone at [" .. math.floor(target.position.x)
|
|
.. ", " .. math.floor(target.position.y) .. "] on " .. entity.surface.name
|
|
.. " has been JAMMED by " .. jammer_force.name .. "![/color]"
|
|
for _, player in pairs(enemy_force.players) do if player.valid then player.print(message) end end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return radar
|