Fixed radar performance issues. Added orbital radar dialog

This commit is contained in:
William Lewis 2026-10-17 22:05:09 -05:00
parent 8ff8ba10a2
commit a640c5488d
35 changed files with 3501 additions and 169 deletions

View File

@ -1,5 +1,6 @@
local observer = require("observer")
local radar = require("radar")
local radar_ui = require("radar_ui")
local drop_zone = require("drop_zone")
local base = require("base")
local setup = require("setup")
@ -12,6 +13,7 @@ script.on_configuration_changed(function(event)
setup.on_configuration_changed(event)
for _, player in pairs(game.players) do
roster.create_button(player)
radar_ui.create_button(player)
base.player_color(player)
end
end)
@ -19,11 +21,13 @@ end)
script.on_event(defines.events.on_player_created, function(event)
player_system.on_player_created(event)
roster.create_button(game.get_player(event.player_index))
radar_ui.create_button(game.get_player(event.player_index))
setup.on_player_created(event)
end)
script.on_event(defines.events.on_player_joined_game, function(event)
player_system.on_player_joined_game(event)
roster.create_button(game.get_player(event.player_index))
radar_ui.create_button(game.get_player(event.player_index))
base.player_color(game.get_player(event.player_index))
setup.on_player_joined_game(event)
end)
@ -35,19 +39,37 @@ end)
script.on_event(defines.events.on_player_changed_surface, function(event)
player_system.on_player_changed_surface(event)
drop_zone.on_player_changed_surface(event)
radar_ui.on_player_changed_surface(event)
end)
script.on_event(defines.events.on_space_platform_changed_state, radar_ui.on_platform_state_changed)
script.on_event(defines.events.on_chunk_generated, setup.on_chunk_generated)
script.on_event(defines.events.on_built_entity, function(event)
radar.on_built_entity(event)
base.on_built_entity(event)
end)
script.on_event(defines.events.on_robot_built_entity, radar.on_built_entity)
script.on_nth_tick(60, radar.on_jam_tick)
script.on_event(defines.events.on_entity_died, base.on_entity_died)
script.on_event(defines.events.on_space_platform_built_entity, radar.on_built_entity)
script.on_event(defines.events.script_raised_built, radar.on_built_entity)
script.on_event(defines.events.script_raised_revive, radar.on_built_entity)
script.on_event(defines.events.on_entity_cloned, radar.on_built_entity)
-- One handler per nth-tick period: jam check and radar power sampling share 60.
script.on_nth_tick(60, function()
radar.on_jam_tick()
radar.on_power_sample_tick()
end)
script.on_event(defines.events.on_entity_died, function(event)
radar.on_entity_removed(event)
base.on_entity_died(event)
end)
script.on_event(defines.events.on_player_mined_entity, radar.on_entity_removed)
script.on_event(defines.events.on_robot_mined_entity, radar.on_entity_removed)
script.on_event(defines.events.on_space_platform_mined_entity, radar.on_entity_removed)
script.on_event(defines.events.script_raised_destroy, radar.on_entity_removed)
script.on_event(defines.events.on_player_died, base.on_player_died)
script.on_event(defines.events.on_pre_player_mined_item, base.on_pre_player_mined_item)
script.on_event(defines.events.on_robot_pre_mined, base.on_robot_pre_mined)
script.on_nth_tick(1800, radar.on_orbital_scan_tick)
script.on_nth_tick(12, radar.on_scan_queue_tick)
-- No writable "save loaded" event exists; a local flag on the permanent on_tick
-- handler fires once per load. on_nth_tick(1) cannot be used: registering a
-- transient handler makes join-time event checks fail for multiplayer clients.
@ -56,13 +78,16 @@ script.on_event(defines.events.on_entity_damaged, drop_zone.on_entity_damaged)
script.on_event(defines.events.on_lua_shortcut, drop_zone.on_lua_shortcut)
script.on_event(defines.events.on_player_selected_area, drop_zone.on_player_selected_area)
script.on_event(defines.events.on_gui_click, function(event)
if not (observer.on_gui_click(event) or roster.on_gui_click(event)) then setup.on_gui_click(event) end
if not (observer.on_gui_click(event) or roster.on_gui_click(event) or radar_ui.on_gui_click(event)) then
setup.on_gui_click(event)
end
end)
script.on_event("planet-pvp-toggle-observer", function(event)
local player = game.get_player(event.player_index)
if player then observer.toggle_window(player) end
end)
script.on_nth_tick(5, observer.on_tick)
script.on_nth_tick(15, radar_ui.on_ui_tick)
script.on_nth_tick(120, function()
player_system.on_light_tick()
for _, player in pairs(game.connected_players) do base.player_color(player) end

View File

@ -136,6 +136,32 @@ data:extend({
}
})
-- Reusable cell styles for the orbital radar grid: one flat PNG per state,
-- same image for every interaction state so cells never change shape on hover.
local gui_styles = data.raw["gui-style"].default
local function radar_cell_style(color)
local image_set = {base = {
filename = "__Planet-PvP-Green__/Graphics/Radar/cell_" .. color .. ".png",
position = {0, 0},
size = {16, 16},
corner_size = 1
}}
return {
type = "button_style",
size = 16,
padding = 0,
default_graphical_set = image_set,
hovered_graphical_set = image_set,
clicked_graphical_set = image_set,
disabled_graphical_set = image_set
}
end
gui_styles["pvp_radar_cell_gray"] = radar_cell_style("gray")
gui_styles["pvp_radar_cell_green"] = radar_cell_style("green")
gui_styles["pvp_radar_cell_red"] = radar_cell_style("red")
gui_styles["pvp_radar_cell_blue"] = radar_cell_style("blue")
gui_styles["pvp_radar_cell_purple"] = radar_cell_style("purple")
data:extend({
{
type = "technology",

442
Planet-PvP-Green/radar.lua Normal file
View File

@ -0,0 +1,442 @@
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

View File

@ -0,0 +1,365 @@
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

165
radar.lua
View File

@ -1,165 +0,0 @@
local radar = {}
local GRID_SIZE = 16
local MAX_RADIUS = 3200
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.max_distance_of_nearby_sector_revealed 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
local function sector_jammed(surface, position, force)
for _, source in pairs(surface.find_entities_filtered{type = {"radar", "roboport"}}) do
if source.active and powered(source) and enemy_vision(source, force) then
local half = vision_half_size(source)
if math.abs(position.x - source.position.x) <= half and math.abs(position.y - source.position.y) <= half then
return true
end
end
end
return false
end
radar.sector_jammed = sector_jammed
local function scan_orbit(entity, planet_name)
local surface = game.planets[planet_name] and game.planets[planet_name].surface
if not surface then return end
storage.grid_pos = storage.grid_pos or {}
local scan_key = entity.force.name .. "_" .. planet_name
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 blocked = sector_jammed(surface, position, entity.force)
local scan_range = 1
if not blocked then
surface.request_to_generate_chunks(position, scan_range)
entity.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)}
})
end
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
function radar.on_orbital_scan_tick()
storage.radar_scan_progress = storage.radar_scan_progress or {}
for _, surface in pairs(game.surfaces) do
local platform = surface.platform
local location = platform and platform.space_location
if location and game.planets[location.name] then
for _, entity in pairs(surface.find_entities_filtered{type = "radar"}) do
if entity.valid and entity.active then
local progress = (storage.radar_scan_progress[entity.unit_number] or 0) + scan_power(entity)
if progress >= 1 then
progress = progress - 1
scan_orbit(entity, location.name)
end
storage.radar_scan_progress[entity.unit_number] = progress
end
end
end
end
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 {}
for unit_number, entity in pairs(storage.jammed_entities) do
if entity.valid then entity.active = true end
storage.jammed_entities[unit_number] = nil
end
for _, surface in pairs(game.surfaces) do
if not surface.platform then
local sources = {}
for _, entity in pairs(surface.find_entities_filtered{type = {"radar", "roboport"}}) do
if entity.valid and entity.active and powered(entity) then sources[#sources + 1] = entity end
end
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
for _, entity in pairs({a, b}) do
if entity.type == "radar" then
entity.active = false
storage.jammed_entities[entity.unit_number] = entity
end
end
end
end
end
end
end
end
end
function radar.on_built_entity(event)
local entity = event.entity
if not (entity and entity.valid) or entity.type ~= "radar" or entity.surface.platform then return end
-- 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
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

19
release.sh Executable file
View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Build a Factorio release zip: copy src/ into Planet-PvP-Green/ then zip it
# as Planet-PvP-Green_<version>.zip, with <version> read from src/info.json.
set -euo pipefail
cd "$(dirname "$0")"
MOD_DIR="Planet-PvP-Green"
VERSION=$(python3 -c 'import json; print(json.load(open("src/info.json"))["version"])')
ZIP_NAME="${MOD_DIR}_${VERSION}.zip"
rm -rf "$MOD_DIR"
mkdir -p "$MOD_DIR"
cp -a src/. "$MOD_DIR/"
rm -f "$ZIP_NAME"
zip -rq "$ZIP_NAME" "$MOD_DIR"
echo "Built $ZIP_NAME"

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View File

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

181
src/base.lua Normal file
View File

@ -0,0 +1,181 @@
local drop_zone = require("drop_zone")
local base = {}
local COLORS = {
{r = .90, g = .45, b = .10, a = 1}, {r = .95, g = .20, b = .20, a = 1},
{r = .70, g = .20, b = .90, a = 1}, {r = .20, g = .85, b = .20, a = 1},
{r = .20, g = .60, b = .95, a = 1},
}
local function notify(force_name, message)
local force = game.forces[force_name]
if force then for _, player in pairs(force.players) do if player.valid then player.print(message) end end end
end
local function count(force_name)
local total = 0
for _ in pairs(storage.force_bases[force_name] or {}) do total = total + 1 end
return total
end
local function color_for(force_name)
return storage.force_colors[force_name] or {r = 1, g = 1, b = 1, a = 1}
end
function base.assign_force_color(force_name)
if storage.force_colors[force_name] then return end
local used = 0
for _ in pairs(storage.force_colors) do used = used + 1 end
storage.force_colors[force_name] = COLORS[(used % #COLORS) + 1]
end
function base.player_color(player)
if not (player and player.valid and player.force) then return end
local force_name = player.force.name
local is_team = false
for _, name in pairs(storage.planet_teams or {}) do
if name == force_name then is_team = true; break end
end
if not is_team then return end
storage.force_colors = storage.force_colors or {}
base.assign_force_color(force_name)
player.color = storage.force_colors[force_name]
end
function base.register(entity, silent)
if not (entity and entity.valid) then return end
local force_name = entity.force.name
storage.force_bases[force_name] = storage.force_bases[force_name] or {}
storage.force_bases[force_name][entity.unit_number] = true
local color = color_for(force_name)
entity.color = color
storage.base_label_ids[entity.unit_number] = rendering.draw_text{
text = force_name, surface = entity.surface, target = entity, target_offset = {0, -3.5},
color = color, scale = 2, font = "default-bold", alignment = "center", visible = true,
}
if not silent then
notify(force_name, "[color=green][BASE] A base has been established at (" .. math.floor(entity.position.x)
.. ", " .. math.floor(entity.position.y) .. ") on " .. entity.surface.name .. ".[/color]")
end
end
function base.spawn_initial(force_name, planet_name)
local planet = game.planets[planet_name]
if not planet then return end
local surface = planet.create_surface()
local position = surface.find_non_colliding_position("market", {8, 0}, 200, 1) or {x = 8, y = 0}
local entity = surface.create_entity{name = "pvp-base", position = position, force = game.forces[force_name], raise_built = true}
if entity and entity.valid then base.register(entity) end
end
local function destroyed(entity)
local force_name = entity.force.name
if storage.force_bases[force_name] then storage.force_bases[force_name][entity.unit_number] = nil end
local label = storage.base_label_ids[entity.unit_number]
if label and label.valid then label.destroy() end
storage.base_label_ids[entity.unit_number] = nil
local remaining = count(force_name)
if remaining > 0 then
notify(force_name, "[color=orange][BASE] A base was destroyed! You have " .. remaining .. " base(s) remaining. Respawn is still active.[/color]")
else
notify(force_name, "[color=red][BASE] ALL BASES DESTROYED! Any player who dies now is PERMANENTLY ELIMINATED. Rebuild a base core to restore respawning.[/color]")
end
end
local function spectator(player)
player.set_controller{type = defines.controllers.spectator}
player.print("[color=red]You have been permanently eliminated.[/color]")
end
function base.on_built_entity(event)
if event.entity and event.entity.valid and event.entity.name == "pvp-base" then base.register(event.entity) end
end
function base.on_entity_died(event)
if event.entity and event.entity.valid and event.entity.name == "pvp-base" then destroyed(event.entity) end
end
function base.on_player_died(event)
local player = game.players[event.player_index]
if not (player and player.valid) then return end
if not (storage.player_team_joined and storage.player_team_joined[player.index]) then return end
local force_name = player.force.name
if storage.permadead[force_name] and storage.permadead[force_name][player.index] then return end
if count(force_name) > 0 then
player.print("[color=yellow]You died. Respawning... (Base is alive)[/color]")
else
storage.permadead[force_name] = storage.permadead[force_name] or {}
storage.permadead[force_name][player.index] = true
storage.pending_spectator[player.index] = force_name
player.print("[color=red]You died while your team had no base. You are PERMANENTLY ELIMINATED.[/color]")
end
end
function base.on_player_respawned(event)
local player = game.players[event.player_index]
if not (player and player.valid) then return end
base.player_color(player)
if not (storage.player_team_joined and storage.player_team_joined[player.index]) then return end
local force_name = player.force.name
if storage.permadead[force_name] and storage.permadead[force_name][player.index] then spectator(player); return end
if count(force_name) == 0 then
storage.permadead[force_name] = storage.permadead[force_name] or {}
storage.permadead[force_name][player.index] = true
spectator(player)
return
end
for planet_name, assigned_force in pairs(storage.planet_teams or {}) do
if assigned_force == force_name then
local planet = game.planets[planet_name]
if planet and planet.surface then drop_zone.teleport_to_planet(player, planet.surface) end
return
end
end
end
function base.on_pre_player_mined_item(event)
local entity, player = event.entity, game.players[event.player_index]
if entity and entity.valid and entity.name == "pvp-base" and player and player.valid then
player.mining_state = {mining = false}
player.print("[color=red]The base cannot be moved once placed. Destroy it to retrieve the core.[/color]")
end
end
function base.on_robot_pre_mined(event)
local entity = event.entity
if entity and entity.valid and entity.name == "pvp-base" then entity.cancel_deconstruction("all") end
end
function base.on_tick(event)
if not storage.crash_site_cleanup_done then
local nauvis = game.planets["nauvis"] and game.planets["nauvis"].surface
if nauvis then
local crash_names = {
"crash-site-spaceship", "crash-site-spaceship-wreck-big-1", "crash-site-spaceship-wreck-big-2",
"crash-site-spaceship-wreck-medium-1", "crash-site-spaceship-wreck-medium-2", "crash-site-spaceship-wreck-medium-3",
"crash-site-spaceship-wreck-small-1", "crash-site-spaceship-wreck-small-2", "crash-site-spaceship-wreck-small-3",
"crash-site-spaceship-wreck-small-4", "crash-site-spaceship-wreck-small-5", "crash-site-spaceship-wreck-small-6",
"crash-site-chest-1", "crash-site-chest-2",
}
for _, name in pairs(crash_names) do
for _, entity in pairs(nauvis.find_entities_filtered{name = name}) do if entity.valid then entity.destroy() end end
end
for _, type in pairs({"fire", "smoke", "explosion", "particle"}) do
for _, entity in pairs(nauvis.find_entities_filtered{type = type, area = {{-200, -200}, {200, 200}}}) do if entity.valid then entity.destroy() end end
end
if event.tick >= 300 then storage.crash_site_cleanup_done = true end
end
end
if event.tick % 10 ~= 0 or not next(storage.pending_spectator) then return end
for index in pairs(storage.pending_spectator) do
local player = game.players[index]
if player and player.valid and not player.character then spectator(player); storage.pending_spectator[index] = nil end
end
end
function base.status_command(event)
local player = game.players[event.player_index]
if player then for force_name in pairs(storage.force_bases) do player.print(force_name .. ": " .. count(force_name) .. " base(s)") end end
end
return base

23
src/changelog.txt Normal file
View File

@ -0,0 +1,23 @@
---------------------------------------------------------------------------------------------------
Version: 1.0.1
Date: 2026-10-16
Major features:
- Team roster button added to the top-left shortcut bar. Lists every team and its players,
online status, and playtime on the save.
- Team colors are now enforced. Players are automatically colored to match their team on join,
respawn, and continuously while connected.
- Allies are now visible to each other. Teammates get a colored name over their character, plus
a name and dot on the map and minimap. Enemies see nothing.
Features:
- Saves migrating from the original Planet-PvP mod now carry over automatically. Teams, bases,
force colors, and observers are rebuilt from the world, players keep their team assignments,
and a roster summary is printed to chat after migration.
- Map pins that target an enemy player or character are now removed automatically. Pinning
enemies to track them no longer works.
Bugfixes:
- Fixed a multiplayer desync that kicked joining clients ("script-event-mismatch") caused by
transient on_nth_tick registrations. Drop-zone teleports no longer register temporary tick
handlers.
- Fixed a crash when opening the team roster (ticks_played -> online_time).
- Fixed a crash when an ally marker followed a character across surfaces.
- Base registration during migration no longer spams "base established" messages.

120
src/control.lua Normal file
View File

@ -0,0 +1,120 @@
local observer = require("observer")
local radar = require("radar")
local radar_ui = require("radar_ui")
local drop_zone = require("drop_zone")
local base = require("base")
local setup = require("setup")
local player_system = require("player")
local roster = require("roster")
-- One registration per Factorio event lets the separate systems share events safely.
script.on_init(function() setup.on_init(observer); player_system.on_init() end)
script.on_configuration_changed(function(event)
setup.on_configuration_changed(event)
for _, player in pairs(game.players) do
roster.create_button(player)
radar_ui.create_button(player)
base.player_color(player)
end
end)
script.on_event(defines.events.on_player_created, function(event)
player_system.on_player_created(event)
roster.create_button(game.get_player(event.player_index))
radar_ui.create_button(game.get_player(event.player_index))
setup.on_player_created(event)
end)
script.on_event(defines.events.on_player_joined_game, function(event)
player_system.on_player_joined_game(event)
roster.create_button(game.get_player(event.player_index))
radar_ui.create_button(game.get_player(event.player_index))
base.player_color(game.get_player(event.player_index))
setup.on_player_joined_game(event)
end)
script.on_event(defines.events.on_player_respawned, function(event)
player_system.on_player_respawned(event)
base.on_player_respawned(event)
setup.on_player_respawned(event)
end)
script.on_event(defines.events.on_player_changed_surface, function(event)
player_system.on_player_changed_surface(event)
drop_zone.on_player_changed_surface(event)
radar_ui.on_player_changed_surface(event)
end)
script.on_event(defines.events.on_space_platform_changed_state, radar_ui.on_platform_state_changed)
script.on_event(defines.events.on_chunk_generated, setup.on_chunk_generated)
script.on_event(defines.events.on_built_entity, function(event)
radar.on_built_entity(event)
base.on_built_entity(event)
end)
script.on_event(defines.events.on_robot_built_entity, radar.on_built_entity)
script.on_event(defines.events.on_space_platform_built_entity, radar.on_built_entity)
script.on_event(defines.events.script_raised_built, radar.on_built_entity)
script.on_event(defines.events.script_raised_revive, radar.on_built_entity)
script.on_event(defines.events.on_entity_cloned, radar.on_built_entity)
-- One handler per nth-tick period: jam check and radar power sampling share 60.
script.on_nth_tick(60, function()
radar.on_jam_tick()
radar.on_power_sample_tick()
end)
script.on_event(defines.events.on_entity_died, function(event)
radar.on_entity_removed(event)
base.on_entity_died(event)
end)
script.on_event(defines.events.on_player_mined_entity, radar.on_entity_removed)
script.on_event(defines.events.on_robot_mined_entity, radar.on_entity_removed)
script.on_event(defines.events.on_space_platform_mined_entity, radar.on_entity_removed)
script.on_event(defines.events.script_raised_destroy, radar.on_entity_removed)
script.on_event(defines.events.on_player_died, base.on_player_died)
script.on_event(defines.events.on_pre_player_mined_item, base.on_pre_player_mined_item)
script.on_event(defines.events.on_robot_pre_mined, base.on_robot_pre_mined)
script.on_nth_tick(1800, radar.on_orbital_scan_tick)
script.on_nth_tick(12, radar.on_scan_queue_tick)
-- No writable "save loaded" event exists; a local flag on the permanent on_tick
-- handler fires once per load. on_nth_tick(1) cannot be used: registering a
-- transient handler makes join-time event checks fail for multiplayer clients.
script.on_event(defines.events.on_cargo_pod_finished_ascending, drop_zone.on_cargo_pod_finished_ascending)
script.on_event(defines.events.on_entity_damaged, drop_zone.on_entity_damaged)
script.on_event(defines.events.on_lua_shortcut, drop_zone.on_lua_shortcut)
script.on_event(defines.events.on_player_selected_area, drop_zone.on_player_selected_area)
script.on_event(defines.events.on_gui_click, function(event)
if not (observer.on_gui_click(event) or roster.on_gui_click(event) or radar_ui.on_gui_click(event)) then
setup.on_gui_click(event)
end
end)
script.on_event("planet-pvp-toggle-observer", function(event)
local player = game.get_player(event.player_index)
if player then observer.toggle_window(player) end
end)
script.on_nth_tick(5, observer.on_tick)
script.on_nth_tick(15, radar_ui.on_ui_tick)
script.on_nth_tick(120, function()
player_system.on_light_tick()
for _, player in pairs(game.connected_players) do base.player_color(player) end
end)
local save_loaded_fired = false
script.on_event(defines.events.on_tick, function(event)
if not save_loaded_fired then
save_loaded_fired = true
setup.on_save_loaded()
end
base.on_tick(event)
drop_zone.on_tick(event)
end)
commands.add_command("basestatus", "Show base count for every force", base.status_command)
commands.add_command("ping", nil, function(command)
local player = game.players[command.player_index]
if not (player and player.valid and player.character) then return end
for _, mode in pairs({"game", "chart"}) do
rendering.draw_circle{color = {r = 1, g = 1, b = 0, a = 1}, radius = 3, width = 10, filled = false, target = player.position, surface = player.surface, players = player.force.players, time_to_live = 300, render_mode = mode}
rendering.draw_text{text = "PING", surface = player.surface, target = {position = player.position, offset = {0, -2.5}}, color = {r = 1, g = 1, b = 0, a = 1}, players = player.force.players, alignment = "center", scale = 3, time_to_live = 300, render_mode = mode}
end
for _, target in pairs(player.force.players) do if target.valid then target.print(player.name .. " pinged their location") end end
end)
commands.add_command("observer", "Become an observer", function(command)
local player = game.get_player(command.player_index)
if not player then return end
if not player.admin then player.print("Only admins can become observers."); return end
observer.enable(player)
end)

195
src/data.lua Normal file
View File

@ -0,0 +1,195 @@
-- Both mods define the same prototypes; give a clear error instead of duplicate-name crashes.
if mods["Planet-PvP"] then
error("Planet PvP Green cannot be enabled at the same time as the original Planet-PvP mod. Disable one of them.")
end
data:extend({
{
type = "selection-tool",
name = "drop-zone-selector",
icon = "__base__/graphics/icons/artillery-targeting-remote.png",
icon_size = 64,
flags = {"not-stackable", "only-in-cursor"},
stack_size = 1,
select = {
border_color = {r = 0, g = 1, b = 0},
cursor_box_type = "copy",
mode = {"nothing"}
},
alt_select = {
border_color = {r = 1, g = 0.5, b = 0},
cursor_box_type = "not-allowed",
mode = {"nothing"}
}
},
{
type = "shortcut",
name = "lock_drop_zone_shortcut",
action = "lua",
order = "z[spidertron-remote-b]",
localised_name = "Drop Zone Selector",
icon = "__base__/graphics/icons/artillery-targeting-remote.png",
small_icon = "__base__/graphics/icons/artillery-targeting-remote.png"
}
})
data:extend({
{
type = "simple-entity-with-owner",
name = "pvp-base",
icon = "__base__/graphics/icons/market.png",
icon_size = 64,
flags = { "placeable-neutral", "player-creation", "not-rotatable" },
weight = 1000001,
max_health = 2000,
resistances = {
{ type = "fire", decrease = 15, percent = 30 },
{ type = "physical", decrease = 10, percent = 20 },
{ type = "explosion", decrease = 20, percent = 40 },
{ type = "laser", decrease = 5, percent = 20 },
},
loot = {
{ type = "item", name = "pvp-base-core", amount_min = 1, amount_max = 1, independent_probability = 1 }
},
collision_box = { {-1.2, -1.2}, {1.2, 1.2} },
selection_box = { {-1.5, -1.5}, {1.5, 1.5} },
collision_mask = { layers = { object = true, player = true } },
picture = {
filename = "__base__/graphics/entity/market/market.png",
priority = "extra-high",
width = 156,
height = 127,
shift = { 1, 0.0001 },
scale = 1.0,
},
minable = nil,
map_color = { r = 0.9, g = 0.2, b = 0.2 },
friendly_map_color = { r = 0.2, g = 0.8, b = 0.2 },
alert_when_damaged = true,
is_military_target = true,
}
})
data:extend({
{
type = "item",
name = "pvp-base-building",
icon = "__base__/graphics/icons/market.png",
icon_size = 64,
subgroup = "other",
order = "z[pvp-base-building]",
stack_size = 1,
weight = 1000001,
place_result = "pvp-base",
}
})
data:extend({
{
type = "item",
name = "pvp-base-core",
icon = "__Planet-PvP-Green__/Graphics/Base/base_core.png",
icon_size = 128,
subgroup = "other",
order = "z[pvp-base-core]",
stack_size = 1,
weight = 1000001,
}
})
data:extend({
{
type = "recipe",
name = "pvp-base-repair",
enabled = true,
categories = {"crafting"},
energy_required = 60,
ingredients = {
{ type = "item", name = "pvp-base-core", amount = 1 },
{ type = "item", name = "repair-pack", amount = 1 },
},
results = {
{ type = "item", name = "pvp-base-building", amount = 1 },
},
}
})
if data.raw["character"] and data.raw["character"]["character"] then
data.raw["character"]["character"].light = {
{
type = "basic",
intensity = 0,
size = 0,
color = {r=0, g=0, b=0}
}
}
data.raw["character"]["character"].flashlight = nil
end
data:extend({
{
type = "custom-input",
name = "planet-pvp-toggle-observer",
key_sequence = "O",
consuming = "game-only",
enabled_while_spectating = true
}
})
-- Reusable cell styles for the orbital radar grid: one flat PNG per state,
-- same image for every interaction state so cells never change shape on hover.
local gui_styles = data.raw["gui-style"].default
local function radar_cell_style(color)
local image_set = {base = {
filename = "__Planet-PvP-Green__/Graphics/Radar/cell_" .. color .. ".png",
position = {0, 0},
size = {16, 16},
corner_size = 1
}}
return {
type = "button_style",
size = 16,
padding = 0,
default_graphical_set = image_set,
hovered_graphical_set = image_set,
clicked_graphical_set = image_set,
disabled_graphical_set = image_set
}
end
gui_styles["pvp_radar_cell_gray"] = radar_cell_style("gray")
gui_styles["pvp_radar_cell_green"] = radar_cell_style("green")
gui_styles["pvp_radar_cell_red"] = radar_cell_style("red")
gui_styles["pvp_radar_cell_blue"] = radar_cell_style("blue")
gui_styles["pvp_radar_cell_purple"] = radar_cell_style("purple")
data:extend({
{
type = "technology",
name = "planet-discovery-nauvis",
icon = "__Planet-PvP-Green__/Graphics/Tech/nauvis-tech.png",
icon_size = 210,
essential = true,
effects = {
{
type = "unlock-space-location",
space_location = "nauvis",
use_icon_overlay_constant = true
},
{
type = "unlock-travel-to-space-platforms",
modifier = true
}
},
prerequisites = {"space-platform-thruster"},
unit = {
count = 1000,
ingredients = {
{"automation-science-pack", 1},
{"logistic-science-pack", 1},
{"chemical-science-pack", 1},
{"space-science-pack", 1}
},
time = 60
}
}
})

90
src/drop_zone.lua Normal file
View File

@ -0,0 +1,90 @@
local radar = require("radar")
local drop_zone = {}
function drop_zone.teleport_to_planet(player, surface)
local planet_name = surface.name
storage.player_targets = storage.player_targets or {}
local target = storage.player_targets[player.force.name .. "_" .. planet_name]
local position = target and target.position
or surface.find_non_colliding_position("character", {0, 0}, 500, 2) or {x = 0, y = 0}
-- Retries live in storage and are pumped from the permanent on_tick;
-- transient on_nth_tick registrations break multiplayer join checks.
storage.pending_teleports = storage.pending_teleports or {}
storage.pending_teleports[player.index] = {position = position, surface_index = surface.index, attempts = 0}
end
function drop_zone.on_tick()
local pending = storage.pending_teleports
if not pending or not next(pending) then return end
for index, data in pairs(pending) do
local player = game.players[index]
local surface = game.surfaces[data.surface_index]
if player and player.valid and surface and data.attempts < 30 then
player.teleport(data.position, surface)
if player.character then player.set_controller{type = defines.controllers.character, character = player.character} end
data.attempts = data.attempts + 1
else
pending[index] = nil
end
end
end
function drop_zone.on_player_changed_surface(event)
local player = game.players[event.player_index]
if not (player and player.valid) then return end
local surface = player.surface
-- render_mode is only "game" when physically present; "chart"/"chart_zoomed_out"
-- mean the surface is just being viewed on the map, so don't teleport.
if event.surface_index == surface.index or surface.platform or player.render_mode ~= defines.render_mode.game then return end
storage.player_surfaces = storage.player_surfaces or {}
if storage.player_surfaces[player.index] == surface.index then return end
storage.player_surfaces[player.index] = surface.index
if player.controller_type == defines.controllers.character or player.controller_type == defines.controllers.cutscene then
drop_zone.teleport_to_planet(player, surface)
end
end
function drop_zone.on_cargo_pod_finished_ascending(event)
local pod = event.cargo_pod
if not (pod and pod.valid) or not pod.cargo_pod_destination then return end
local surface = pod.cargo_pod_destination.surface
if not surface or surface.platform then return end
local target = storage.player_targets and storage.player_targets[pod.force.name .. "_" .. surface.name]
if target then pod.cargo_pod_destination = {type = defines.cargo_destination.surface, surface = surface, position = target.position} end
end
function drop_zone.on_entity_damaged(event)
local entity, cause = event.entity, event.cause
if entity and entity.valid and entity.type == "character" and cause and cause.valid and cause.name == "cargo-pod" and cause.force == entity.force then
entity.health = entity.health + event.final_damage_amount
end
end
function drop_zone.on_lua_shortcut(event)
if event.prototype_name == "lock_drop_zone_shortcut" then
local player = game.players[event.player_index]
if player and player.cursor_stack then player.cursor_stack.set_stack{name = "drop-zone-selector", count = 1} end
end
end
function drop_zone.on_player_selected_area(event)
if event.item ~= "drop-zone-selector" then return end
local player, surface = game.players[event.player_index], event.surface
storage.player_targets = storage.player_targets or {}
local pos = {x = (event.area.left_top.x + event.area.right_bottom.x) / 2, y = (event.area.left_top.y + event.area.right_bottom.y) / 2}
if not player.force.is_chunk_charted(surface, {math.floor(pos.x / 32), math.floor(pos.y / 32)}) then
player.print("Cannot lock drop zone: area is not scanned!")
else
if radar.sector_jammed(surface, pos, player.force) then
player.print("Cannot lock drop zone: area is jammed by an enemy!")
if player.cursor_stack then player.cursor_stack.clear() end
return
end
storage.player_targets[player.force.name .. "_" .. surface.name] = {position = pos}
player.print("Drop Zone LOCKED for " .. surface.name .. " at [" .. math.floor(pos.x) .. ", " .. math.floor(pos.y) .. "]")
end
if player.cursor_stack then player.cursor_stack.clear() end
end
return drop_zone

View File

@ -1,6 +1,6 @@
{
"name": "Planet-PvP-Green",
"version": "1.0.1",
"version": "1.0.3",
"title": "Planet PvP Green",
"author": "Blizliam",
"factorio_version": "2.1",

570
src/observer.lua Normal file
View File

@ -0,0 +1,570 @@
local mod_gui = require("__core__.lualib.mod-gui")
local observer = {}
local PLANETS = {
"nauvis",
"vulcanus",
"fulgora",
"gleba",
"aquilo"
}
local function get_data(player)
storage.observers = storage.observers or {}
if not storage.observers[player.index] then
storage.observers[player.index] = {
following = nil,
perspective = "observer",
gui_open = false
}
end
return storage.observers[player.index]
end
local function is_observer(player)
return player.controller_type == defines.controllers.spectator
end
local function create_observer_force()
local force = game.forces["observer"]
if not force then
force = game.create_force("observer")
end
return force
end
observer.create_force = create_observer_force
function observer.create_button(player)
if not is_observer(player) then
return
end
local flow = mod_gui.get_button_flow(player)
if flow.observer_button then
return
end
flow.add{
type = "sprite-button",
name = "observer_button",
sprite = "utility/search_icon",
tooltip = "Observer"
}
end
function observer.destroy_button(player)
local flow = mod_gui.get_button_flow(player)
if flow.observer_button then
flow.observer_button.destroy()
end
if player.gui.screen.observer_window then
player.gui.screen.observer_window.destroy()
end
end
local function build_window(player)
local data = get_data(player)
local selected_force_name = nil
if data.perspective
and data.perspective ~= "observer"
and storage.planet_teams then
selected_force_name = storage.planet_teams[data.perspective]
end
if player.gui.screen.observer_window then
player.gui.screen.observer_window.destroy()
end
local frame = player.gui.screen.add{
type = "frame",
name = "observer_window",
direction = "vertical",
caption = "Observer"
}
frame.auto_center = true
player.opened = frame
local perspective_header = frame.add{
type = "flow",
direction = "horizontal"
}
perspective_header.add{
type = "sprite",
sprite = "utility/search_icon"
}
perspective_header.add{
type = "label",
caption = "Perspective"
}
local perspective_flow = frame.add{
type = "flow",
direction = "horizontal"
}
for _, planet in ipairs(PLANETS) do
perspective_flow.add{
type = "sprite-button",
name = "observer_perspective_" .. planet,
sprite = "space-location/" .. planet,
tooltip = planet:gsub("^%l", string.upper),
style = "slot_button",
toggled = data.perspective == planet
}
end
frame.add{
type = "line"
}
local perspective_caption = "None selected"
if selected_force_name then
perspective_caption = selected_force_name
end
frame.add{
type = "label",
caption = "Viewing: " .. perspective_caption
}
frame.add{
type = "line"
}
local platform_header = frame.add{
type = "flow",
direction = "horizontal"
}
platform_header.add{
type = "sprite",
sprite = "item/space-platform-foundation"
}
platform_header.add{
type = "label",
caption = "Team Platforms"
}
local platform_scroll = frame.add{
type = "scroll-pane",
name = "observer_platforms"
}
platform_scroll.style.maximal_height = 220
local platforms_found = 0
if selected_force_name then
for _, surface in pairs(game.surfaces) do
local platform = surface.platform
if platform
and platform.valid
and platform.force
and platform.force.name == selected_force_name then
local caption =
"[img=item/space-platform-foundation] "
.. platform.name
if platform.space_location then
caption = caption
.. " (" .. platform.space_location.name .. ")"
elseif platform.space_connection then
caption = caption .. " (traveling)"
end
platform_scroll.add{
type = "button",
name = "observer_platform_" .. platform.index,
caption = caption
}
platforms_found = platforms_found + 1
end
end
end
if not selected_force_name then
platform_scroll.add{
type = "label",
caption = "Select a team perspective."
}
elseif platforms_found == 0 then
platform_scroll.add{
type = "label",
caption = "No platforms owned by this team."
}
end
frame.add{
type = "line"
}
local player_header = frame.add{
type = "flow",
direction = "horizontal"
}
player_header.add{
type = "sprite",
sprite = "entity/character"
}
player_header.add{
type = "label",
caption = "Team Players"
}
local scroll = frame.add{
type = "scroll-pane",
name = "observer_players"
}
scroll.style.maximal_height = 320
local players_found = 0
for _, target in pairs(game.connected_players) do
local target_is_observer =
storage.observers
and storage.observers[target.index] ~= nil
local belongs_to_selected_team =
selected_force_name
and target.force
and target.force.name == selected_force_name
if belongs_to_selected_team and not target_is_observer then
local caption = "[img=entity/character] " .. target.name
if not target.character then
caption = caption .. " (dead)"
elseif target.surface and target.surface.platform then
caption = caption .. " (platform)"
elseif target.surface then
caption = caption .. " (" .. target.surface.name .. ")"
end
scroll.add{
type = "button",
name = "observer_follow_" .. target.index,
caption = caption
}
players_found = players_found + 1
end
end
if not selected_force_name then
scroll.add{
type = "label",
caption = "Select a team perspective."
}
elseif players_found == 0 then
scroll.add{
type = "label",
caption = "No connected non-observer players on this team."
}
end
frame.add{
type = "line"
}
frame.add{
type = "button",
name = "observer_stop_follow",
caption = "[img=utility/close] Stop Following"
}
data.gui_open = true
end
function observer.toggle_window(player)
if not player or not player.valid then
return
end
if not is_observer(player) then
return
end
local data = get_data(player)
local window = player.gui.screen.observer_window
if window and window.valid then
if player.opened == window then
player.opened = nil
end
window.destroy()
data.gui_open = false
else
build_window(player)
end
end
function observer.on_gui_click(event)
local player = game.get_player(event.player_index)
if not player then
return false
end
if not is_observer(player) then
return false
end
local element = event.element
if not (element and element.valid) then
return false
end
local data = get_data(player)
if element.name == "observer_button" then
if player.gui.screen.observer_window then
player.gui.screen.observer_window.destroy()
data.gui_open = false
else
build_window(player)
end
return true
end
if element.name:find("^observer_perspective_") then
local perspective =
element.name:gsub("^observer_perspective_", "")
local force_name =
storage.planet_teams
and storage.planet_teams[perspective]
local force =
force_name
and game.forces[force_name]
if force then
player.force = force
data.perspective = perspective
data.following = nil
local surface = game.surfaces[perspective]
if surface then
player.teleport({0, 0}, surface)
end
player.print("Perspective: " .. force.name)
build_window(player)
else
player.print(
"Force not found for perspective: "
.. tostring(perspective)
)
end
return true
end
if element.name:find("^observer_follow_") then
local id = tonumber(element.name:match("%d+"))
if id then
local target = game.get_player(id)
if target and target.valid and target.character and target.character.valid then
data.following = id
player.centered_on = target.character
player.print("Now following " .. target.name)
else
player.print("That player is not currently available to follow.")
end
end
return true
end
if element.name:find("^observer_platform_") then
local platform_index =
tonumber(element.name:match("%d+"))
local selected_platform = nil
if platform_index then
for _, surface in pairs(game.surfaces) do
local platform = surface.platform
if platform
and platform.valid
and platform.index == platform_index then
selected_platform = platform
break
end
end
end
if selected_platform
and selected_platform.surface
and selected_platform.surface.valid then
data.following = nil
player.teleport(
{0, 0},
selected_platform.surface
)
player.print(
"Viewing platform: "
.. selected_platform.name
)
else
player.print("Platform is no longer available.")
build_window(player)
end
return true
end
if element.name == "observer_stop_follow" then
data.following = nil
player.print("Stopped following.")
return true
end
end
function observer.on_tick(event)
if not storage.observers then
return
end
for player_index, data in pairs(storage.observers) do
local observer_player = game.get_player(player_index)
if observer_player
and observer_player.valid
and observer_player.controller_type == defines.controllers.spectator
and data.following then
local target = game.get_player(data.following)
if target
and target.valid
and target.connected
and target.character then
if observer_player.surface ~= target.surface then
observer_player.teleport(
target.position,
target.surface
)
else
local dx = observer_player.position.x - target.position.x
local dy = observer_player.position.y - target.position.y
if math.abs(dx) > 2
or math.abs(dy) > 2 then
observer_player.teleport(
target.position,
target.surface
)
end
end
else
data.following = nil
observer_player.print("Target unavailable.")
end
end
end
end
function observer.enable(player)
local force = create_observer_force()
player.force = force
player.set_controller{
type = defines.controllers.spectator
}
player.print("Perspective: " .. force.name)
player.show_on_map = false
player.set_controller{
type = defines.controllers.spectator
}
get_data(player)
observer.create_button(player)
end
function observer.disable(player)
observer.destroy_button(player)
if storage.observers then
storage.observers[player.index] = nil
end
end
--------------------------------------------------------------------------------
return observer

134
src/player.lua Normal file
View File

@ -0,0 +1,134 @@
local player_system = {}
local function hide_dot(player)
if player and player.valid then player.show_on_map = false end
end
local function maintain_light(player)
if not (player and player.valid) then return end
storage.player_lights = storage.player_lights or {}
local light = storage.player_lights[player.index]
if not player.character or not player.character.valid then
if light and light.valid then light.destroy() end
storage.player_lights[player.index] = nil
return
end
if not light or not light.valid then
storage.player_lights[player.index] = rendering.draw_light{sprite = "utility/light_medium", target = player.character, surface = player.character.surface, players = {player}, scale = 1.2, intensity = .8, minimum_darkness = .2}
end
end
-- show_on_map is all-or-nothing, so allies are revealed with rendering objects
-- whose players list is restricted to the owner's force instead.
-- Name over the character in-world and on the map, plus a dot on the map.
-- Each is separate because render_mode is per-object.
local ALLY_MARKER_SPECS = {
{key = "name_game", mode = "game", text = true},
{key = "name_chart", mode = "chart", text = true},
{key = "dot_chart", mode = "chart", radius = 2},
}
local function is_team_force(force_name)
for _, name in pairs(storage.planet_teams or {}) do
if name == force_name then return true end
end
return false
end
local function maintain_marker(player)
if not (player and player.valid) then return end
storage.ally_markers = storage.ally_markers or {}
local markers = storage.ally_markers[player.index]
local viewers = {}
if player.character and player.character.valid and player.force and is_team_force(player.force.name) then
for _, ally in pairs(player.force.players) do
viewers[#viewers + 1] = ally.index
end
end
if #viewers == 0 then
if markers then for _, marker in pairs(markers) do if marker.valid then marker.destroy() end end end
storage.ally_markers[player.index] = nil
return
end
local color = (storage.force_colors and storage.force_colors[player.force.name]) or {r = 1, g = 1, b = 1, a = 1}
markers = markers or {}
for _, spec in pairs(ALLY_MARKER_SPECS) do
local marker = markers[spec.key]
if marker and marker.valid and marker.surface ~= player.character.surface then
marker.destroy()
marker = nil
markers[spec.key] = nil
end
if not (marker and marker.valid) then
if spec.text then
marker = rendering.draw_text{
text = player.name, surface = player.character.surface, target = player.character,
target_offset = {0, -2.5}, color = color, scale = 1.5, render_mode = spec.mode, players = viewers,
}
else
marker = rendering.draw_circle{
surface = player.character.surface, target = player.character, color = color,
radius = spec.radius, filled = true, render_mode = spec.mode, players = viewers,
}
end
markers[spec.key] = marker
else
marker.players = viewers
marker.target = player.character
marker.color = color
end
end
storage.ally_markers[player.index] = markers
end
-- Vanilla pins can bind to a player or their character, leaking a live
-- tracker arrow that defeats show_on_map = false. Drop any pin that targets
-- a player/character of another force.
local function clear_enemy_pins(player)
for _, pin in pairs(player.get_pins()) do
local pinned = pin.player
local leak = pinned and pinned.valid and pinned.force ~= player.force
if not leak then
for _, target in pairs(pin.targets or {}) do
if target.valid and target.type == "character" and target.force ~= player.force then
leak = true
break
end
end
end
if leak and pin.valid then pin.destroy() end
end
end
function player_system.on_init()
storage.player_lights = storage.player_lights or {}
end
function player_system.on_player_created(event)
local player = game.get_player(event.player_index)
hide_dot(player); maintain_light(player)
end
function player_system.on_player_joined_game(event) hide_dot(game.get_player(event.player_index)) end
function player_system.on_player_respawned(event) hide_dot(game.get_player(event.player_index)); maintain_light(game.get_player(event.player_index)) end
function player_system.on_player_changed_surface(event)
local player = game.get_player(event.player_index)
if player and storage.player_lights[player.index] then
local light = storage.player_lights[player.index]
if light and light.valid then light.destroy() end
storage.player_lights[player.index] = nil
end
maintain_light(player)
end
function player_system.on_light_tick()
for _, player in pairs(game.connected_players) do
maintain_light(player)
maintain_marker(player)
clear_enemy_pins(player)
if player.character and player.character.valid then player.character.disable_flashlight() end
end
end
return player_system

442
src/radar.lua Normal file
View File

@ -0,0 +1,442 @@
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

365
src/radar_ui.lua Normal file
View File

@ -0,0 +1,365 @@
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

80
src/roster.lua Normal file
View File

@ -0,0 +1,80 @@
local mod_gui = require("__core__.lualib.mod-gui")
local roster = {}
local PLANETS = {"nauvis", "vulcanus", "fulgora", "gleba"}
local function format_playtime(ticks)
local total_seconds = math.floor((ticks or 0) / 60)
local hours = math.floor(total_seconds / 3600)
local minutes = math.floor((total_seconds % 3600) / 60)
return string.format("%d:%02d", hours, minutes)
end
function roster.create_button(player)
if not (player and player.valid) then return end
local flow = mod_gui.get_button_flow(player)
if flow.pvp_roster_button then return end
flow.add{
type = "sprite-button",
name = "pvp_roster_button",
sprite = "entity/character",
tooltip = "Team roster",
}
end
local function build_window(player)
if player.gui.screen.pvp_roster_frame then player.gui.screen.pvp_roster_frame.destroy() end
local frame = player.gui.screen.add{type = "frame", name = "pvp_roster_frame", caption = "TEAM ROSTER", direction = "vertical"}
frame.auto_center = true
local any_team = false
for _, planet in ipairs(PLANETS) do
local force_name = storage.planet_teams and storage.planet_teams[planet]
if force_name then
any_team = true
local color = storage.force_colors and storage.force_colors[force_name]
local header = frame.add{type = "label", caption = force_name .. " (" .. planet .. ")", style = "caption_label"}
if color then header.style.font_color = color end
local found = false
for _, target in pairs(game.players) do
if target.force and target.force.name == force_name then
found = true
local status = target.connected and "" or " (offline)"
local line = frame.add{type = "label", caption = " " .. target.name .. status .. " - " .. format_playtime(target.online_time)}
if color then line.style.font_color = color end
end
end
if not found then frame.add{type = "label", caption = " (no players)"} end
end
end
if not any_team then frame.add{type = "label", caption = "No teams initialized yet."} end
local buttons = frame.add{type = "flow", direction = "horizontal"}
buttons.add{type = "button", name = "pvp_roster_refresh", caption = "Refresh"}
buttons.add{type = "button", name = "pvp_roster_close", caption = "Close"}
end
function roster.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
if element.name == "pvp_roster_button" then
if player.gui.screen.pvp_roster_frame then
player.gui.screen.pvp_roster_frame.destroy()
else
build_window(player)
end
return true
end
if element.name == "pvp_roster_refresh" then
build_window(player)
return true
end
if element.name == "pvp_roster_close" then
if player.gui.screen.pvp_roster_frame then player.gui.screen.pvp_roster_frame.destroy() end
return true
end
return false
end
return roster

420
src/setup.lua Normal file
View File

@ -0,0 +1,420 @@
local base = require("base")
local drop_zone = require("drop_zone")
local observer = require("observer")
local setup = {}
local PLANETS = {"nauvis", "vulcanus", "fulgora", "gleba"}
local HUB_SURFACE_NAME = "planet-pvp-hub"
local HUB_MIN = -50
local HUB_MAX = 49
local HUB_VOID_MIN = -80
local HUB_VOID_MAX = 79
local HUB_LAYOUT_VERSION = 2
local LOADOUTS = {
nauvis = {{name = "burner-mining-drill", count = 1}, {name = "stone-furnace", count = 1}, {name = "iron-plate", count = 8}},
vulcanus = {{name = "burner-mining-drill", count = 1}, {name = "stone-furnace", count = 1}, {name = "iron-plate", count = 8}},
fulgora = {{name = "burner-mining-drill", count = 1}, {name = "stone-furnace", count = 1}, {name = "iron-plate", count = 8}},
gleba = {{name = "burner-mining-drill", count = 1}, {name = "stone-furnace", count = 1}, {name = "iron-plate", count = 8}},
}
local UNIVERSAL = { ["automation-science-pack"] = true, ["logistic-science-pack"] = true, ["military-science-pack"] = true, ["chemical-science-pack"] = true, ["production-science-pack"] = true, ["utility-science-pack"] = true, ["space-science-pack"] = true }
local TRIGGERS = {
["planet-discovery-nauvis"] = "nauvis", ["planet-discovery-vulcanus"] = "vulcanus", ["planet-discovery-fulgora"] = "fulgora", ["planet-discovery-gleba"] = "gleba", ["planet-discovery-aquilo"] = "aquilo",
["uranium-mining"] = "nauvis", ["bitter-egg-handling"] = "nauvis", ["recycling"] = "fulgora", ["holmium-processing"] = "fulgora", ["electromagnetic-plant"] = "fulgora", ["electromagnetic-science-pack"] = "fulgora",
["jellynut"] = "gleba", ["yumako"] = "gleba", ["heating-tower"] = "gleba", ["agriculture"] = "gleba", ["biochamber"] = "gleba", ["artificial-soil"] = "gleba", ["bioflux"] = "gleba", ["bacteria-cultivation"] = "gleba", ["bioflux-processing"] = "gleba", ["agricultural-science-pack"] = "gleba",
["tungsten-carbide"] = "vulcanus", ["calcite-processing"] = "vulcanus", ["foundry"] = "vulcanus", ["big-mining-drill"] = "vulcanus", ["tungsten-steel"] = "vulcanus", ["metallurgic-science-pack"] = "vulcanus",
["lithium-processing"] = "aquilo", ["cryogenic-plant"] = "aquilo", ["cryogenic-science-pack"] = "aquilo",
}
local BASIC = {["steam-power"] = true, ["electronics"] = true, ["automation-science-pack"] = true, ["steel-axe"] = true, ["oil-processing"] = true}
local function protect_hub_walls(surface)
for _, wall in pairs(surface.find_entities_filtered{name = "stone-wall", area = {{HUB_MIN - 1, HUB_MIN - 1}, {HUB_MAX + 1, HUB_MAX + 1}}}) do
wall.destructible = false
wall.minable_flag = false
end
end
local function build_hub_layout(surface)
surface.request_to_generate_chunks({0, 0}, 2)
surface.force_generate_chunk_requests()
local tiles = {}
for x = HUB_VOID_MIN, HUB_VOID_MAX do
for y = HUB_VOID_MIN, HUB_VOID_MAX do
tiles[#tiles + 1] = {name = "out-of-map", position = {x, y}}
end
end
for x = HUB_MIN, HUB_MAX do
for y = HUB_MIN, HUB_MAX do
tiles[#tiles + 1] = {name = "refined-concrete", position = {x, y}}
end
end
surface.set_tiles(tiles, true)
-- Replace any older lobby border before placing the updated perimeter.
for _, wall in pairs(surface.find_entities_filtered{name = "stone-wall", area = {{HUB_VOID_MIN, HUB_VOID_MIN}, {HUB_VOID_MAX + 1, HUB_VOID_MAX + 1}}}) do
wall.destroy()
end
local function create_wall(position)
local wall = surface.create_entity{name = "stone-wall", position = position, force = game.forces.player}
if wall then wall.destructible = false; wall.minable_flag = false end
end
for x = HUB_MIN, HUB_MAX do
create_wall({x, HUB_MIN})
create_wall({x, HUB_MAX})
end
for y = HUB_MIN + 1, HUB_MAX - 1 do
create_wall({HUB_MIN, y})
create_wall({HUB_MAX, y})
end
storage.hub_layout_version = HUB_LAYOUT_VERSION
end
local function get_hub_surface()
local surface = game.surfaces[HUB_SURFACE_NAME]
if not surface then
surface = game.create_surface(HUB_SURFACE_NAME, {
seed = 7082026,
peaceful_mode = true,
autoplace_controls = {},
autoplace_settings = {
entity = {treat_missing_as_default = false, settings = {}},
tile = {treat_missing_as_default = false, settings = {}},
},
})
end
if storage.hub_layout_version ~= HUB_LAYOUT_VERSION then
build_hub_layout(surface)
else
protect_hub_walls(surface)
end
return surface
end
local function teleport_to_hub(player)
if not (player and player.valid) or player.controller_type == defines.controllers.spectator then return end
local surface = get_hub_surface()
if player.controller_type == defines.controllers.cutscene then player.exit_cutscene() end
local position = surface.find_non_colliding_position("character", {0, 0}, 48, 1) or {0, 0}
player.teleport(position, surface)
storage.player_surfaces[player.index] = surface.index
end
local function has_selected_team(player)
if storage.player_team_joined and storage.player_team_joined[player.index] then return true end
for _, force_name in pairs(storage.planet_teams or {}) do
if player.force and player.force.name == force_name then
storage.player_team_joined = storage.player_team_joined or {}
storage.player_team_joined[player.index] = true
return true
end
end
return false
end
local function assign_fulgora_attractors(surface, area)
local force_name = storage.planet_teams and storage.planet_teams.fulgora
local force = force_name and game.forces[force_name]
if not (surface and surface.valid and force) then return end
for _, attractor in pairs(surface.find_entities_filtered{
name = "fulgoran-ruin-attractor",
area = area,
}) do
attractor.force = force
end
end
local function set_pvp_relations()
for i, a_name in ipairs(PLANETS) do
local a = game.forces[a_name]
if a then for j, b_name in ipairs(PLANETS) do
local b = game.forces[b_name]
if i ~= j and b then a.set_friend(b, false); b.set_friend(a, false); a.set_cease_fire(b, false); b.set_cease_fire(a, false) end
end end
end
end
local function requirement(tech)
local found
for _, ingredient in pairs(tech.research_unit_ingredients) do
if not UNIVERSAL[ingredient.name] then if found and found ~= ingredient.name then return "mixed" end; found = ingredient.name end
end
return found or "base"
end
local function unlock(force, planet_name)
force.unlock_space_location(planet_name)
for _, tech in pairs(force.technologies) do
if tech.enabled then
local assigned = TRIGGERS[tech.name]
local unlock_tech = BASIC[tech.name] or (requirement(tech) == "base" and not tech.prototype.upgrade)
if assigned then unlock_tech = assigned == planet_name and not tech.prototype.upgrade end
if tech.name == "metallurgic-science-pack" or tech.name == "electromagnetic-science-pack" or tech.name == "agricultural-science-pack" or tech.name == "cryogenic-science-pack" then unlock_tech = false end
if (tech.name:find("productivity%-module") or tech.name:find("speed%-module") or tech.name:find("efficiency%-module") or tech.name:find("quality%-module")) and tech.prototype.max_level < 3 then unlock_tech = true end
tech.researched = unlock_tech
end
end
end
-- Surfaces are cheap until chunks get requested; create them all so orbital
-- radar can scout planets before any player has landed.
local function init_planet_surfaces()
for _, planet_name in ipairs(PLANETS) do
local planet = game.planets[planet_name]
if planet then planet.create_surface() end
end
end
local RESERVED_FORCES = {player = true, enemy = true, neutral = true, observer = true}
-- storage is per-mod, so a Planet-PvP save loses everything on the swap. Team
-- forces persist in the save itself; find which force owns a planet via its
-- surviving base, then its researched discovery tech, then unlocked locations.
local function find_team_force(planet_name)
local planet = game.planets[planet_name]
local surface = planet and planet.surface
if surface then
for _, entity in pairs(surface.find_entities_filtered{name = "pvp-base"}) do
if entity.valid and not RESERVED_FORCES[entity.force.name] then return entity.force.name end
end
end
local tech_name = "planet-discovery-" .. planet_name
for _, force in pairs(game.forces) do
if not RESERVED_FORCES[force.name] then
local tech = force.technologies[tech_name]
if tech and tech.researched then return force.name end
end
end
-- Nauvis can be unlocked by default, so only trust a force whose single
-- unlocked planet of the four is the one being matched.
for _, force in pairs(game.forces) do
if not RESERVED_FORCES[force.name] then
local unlocked = {}
for _, p in ipairs(PLANETS) do
if force.is_space_location_unlocked(p) then unlocked[#unlocked + 1] = p end
end
if #unlocked == 1 and unlocked[1] == planet_name then return force.name end
end
end
end
local function migrate_from_planet_pvp()
for _, planet_name in ipairs(PLANETS) do
if not storage.planet_teams[planet_name] then
local force_name = find_team_force(planet_name)
if force_name then
storage.planet_teams[planet_name] = force_name
base.assign_force_color(force_name)
end
end
end
local team_forces = {}
for _, force_name in pairs(storage.planet_teams) do team_forces[force_name] = true end
for planet_name, force_name in pairs(storage.planet_teams) do
local planet = game.planets[planet_name]
if planet and planet.surface then
for _, entity in pairs(planet.surface.find_entities_filtered{name = "pvp-base", force = force_name}) do
if entity.valid then
base.register(entity, true)
storage.team_kit_spawned[force_name] = true
end
end
end
end
for _, player in pairs(game.players) do
if player.force and team_forces[player.force.name] then
storage.team_kit_spawned[player.force.name] = true
storage.player_team_joined[player.index] = true
base.player_color(player)
end
if player.controller_type == defines.controllers.spectator and player.force.name == "observer" then
storage.observers[player.index] = {following = nil, perspective = "observer", gui_open = false}
end
end
if next(storage.planet_teams) then
storage.game_started = true
storage.crash_site_cleanup_done = true
local names = {}
for force_name in pairs(team_forces) do names[#names + 1] = force_name end
for i, a_name in ipairs(names) do
for j, b_name in ipairs(names) do
if i ~= j then
local a, b = game.forces[a_name], game.forces[b_name]
if a and b then a.set_friend(b, false); a.set_cease_fire(b, false) end
end
end
end
for planet_name, force_name in pairs(storage.planet_teams) do
local members = {}
for _, player in pairs(game.players) do
if player.force and player.force.name == force_name then members[#members + 1] = player.name end
end
game.print("[Planet PvP] " .. planet_name .. " = " .. force_name .. " (" .. #members .. "): " .. table.concat(members, ", "))
end
end
for _, player in pairs(game.players) do
if player.force and not team_forces[player.force.name] and not RESERVED_FORCES[player.force.name] then
game.print("[Planet PvP] WARNING: " .. player.name .. " is on unknown force '" .. player.force.name .. "'. They were sent to the lobby to pick a team.")
end
end
end
local function show_admin(player)
if player.gui.screen.pvp_admin_frame then player.gui.screen.pvp_admin_frame.destroy() end
local frame = player.gui.screen.add{type = "frame", name = "pvp_admin_frame", caption = "ADMIN: CONFIGURE PLANET TEAMS", direction = "vertical"}
frame.auto_center = true
local table_ui = frame.add{type = "table", column_count = 2, name = "setup_table"}
for _, planet in pairs(PLANETS) do
table_ui.add{type = "label", caption = planet:gsub("^%l", string.upper) .. " Force Name:"}
table_ui.add{type = "textfield", name = "tf_" .. planet, text = "Team " .. planet:gsub("^%l", string.upper)}
end
frame.add{type = "button", name = "confirm_admin_setup", caption = "INITIALIZE WORLD", style = "confirm_button"}
end
local function show_join(player)
if player.gui.screen.pvp_join_frame then player.gui.screen.pvp_join_frame.destroy() end
if not storage.game_started or not storage.planet_teams then return end
local frame = player.gui.screen.add{type = "frame", name = "pvp_join_frame", caption = "CHOOSE YOUR HOME PLANET", direction = "vertical"}
frame.auto_center = true
for planet, force_name in pairs(storage.planet_teams) do frame.add{type = "button", name = "join_btn_" .. planet, caption = "Join " .. force_name .. " (" .. planet .. ")", style = "confirm_button"} end
end
local function show_waiting(player)
if player.gui.screen.pvp_waiting_frame then player.gui.screen.pvp_waiting_frame.destroy() end
local frame = player.gui.screen.add{type = "frame", name = "pvp_waiting_frame", caption = "PLANET PvP LOBBY", direction = "vertical"}
frame.auto_center = true
frame.add{type = "label", caption = "The admin is setting up the game. You can choose a team when setup is complete."}
end
function setup.on_init(observer)
storage.planet_teams, storage.player_targets, storage.grid_pos = {}, {}, {}
storage.game_started, storage.team_kit_spawned, storage.player_surfaces = false, {}, {}
storage.player_team_joined = {}
storage.force_bases, storage.permadead, storage.pending_spectator, storage.force_colors, storage.base_label_ids = {}, {}, {}, {}, {}
storage.player_lights, storage.crash_site_cleanup_done = {}, false
observer.create_force()
set_pvp_relations()
get_hub_surface()
for _, player in pairs(game.players) do player.set_shortcut_available("lock_drop_zone_shortcut", true) end
end
function setup.on_configuration_changed(event)
if event and event.mod_changes and event.mod_changes["Planet-PvP"] then
game.print("Save migrated from Planet-PvP to Planet PvP Green. All teams, bases and drop zones carry over.")
end
for _, key in pairs({"planet_teams", "team_kit_spawned", "player_surfaces", "force_bases", "permadead", "pending_spectator", "force_colors", "base_label_ids", "player_targets", "grid_pos", "player_lights", "player_team_joined", "observers", "radar_scan_progress", "ally_markers", "pending_teleports"}) do storage[key] = storage[key] or {} end
if event and event.mod_changes and event.mod_changes["Planet-PvP"] then migrate_from_planet_pvp() end
get_hub_surface()
if storage.game_started then init_planet_surfaces() end
for planet_name, force_name in pairs(storage.planet_teams or {}) do
local force = game.forces[force_name]
if force then
force.unlock_space_location(planet_name)
local tech = force.technologies["planet-discovery-nauvis"]
if tech and planet_name == "nauvis" then tech.researched = true end
end
end
local fulgora = game.planets.fulgora
if fulgora and fulgora.surface then assign_fulgora_attractors(fulgora.surface) end
for _, player in pairs(game.players) do
player.set_shortcut_available("lock_drop_zone_shortcut", true)
-- Restore observer UI for spectators whose button may not have survived a mod swap.
if player.controller_type == defines.controllers.spectator then observer.create_button(player) end
end
for _, player in pairs(game.connected_players) do
if not has_selected_team(player) then
teleport_to_hub(player)
if storage.game_started then show_join(player) elseif player.admin then show_admin(player) else show_waiting(player) end
end
end
end
-- No writable "save loaded" event exists, so control.lua fires this via a
-- one-shot nth_tick(1). Covers saves hosted from before surfaces were
-- initialized at game start, where on_configuration_changed may not run.
function setup.on_save_loaded()
if storage.game_started then init_planet_surfaces() end
end
function setup.on_chunk_generated(event)
if event.surface and event.surface.name == "fulgora" then
assign_fulgora_attractors(event.surface, event.area)
end
end
function setup.on_player_created(event)
local player = game.players[event.player_index]
storage.player_team_joined = storage.player_team_joined or {}
player.set_shortcut_available("lock_drop_zone_shortcut", true)
if player.controller_type == defines.controllers.cutscene then player.exit_cutscene() end
teleport_to_hub(player)
if storage.game_started then show_join(player) elseif player.admin then show_admin(player) else show_waiting(player) end
end
function setup.on_player_joined_game(event)
local player = game.players[event.player_index]
if not (player and player.valid) then return end
storage.player_team_joined = storage.player_team_joined or {}
if has_selected_team(player) or (storage.observers and storage.observers[player.index]) then return end
teleport_to_hub(player)
if storage.game_started then show_join(player) elseif player.admin then show_admin(player) else show_waiting(player) end
end
function setup.on_player_respawned(event)
local player = game.players[event.player_index]
if player and player.valid and not has_selected_team(player) then
teleport_to_hub(player)
end
end
function setup.on_gui_click(event)
local element, player = event.element, game.players[event.player_index]
if not (element and element.valid and player and player.valid) then return end
if element.name == "confirm_admin_setup" then
storage.planet_teams = {}
for _, planet in pairs(PLANETS) do
local textfield = element.parent.setup_table["tf_" .. planet]
local name = textfield and textfield.text ~= "" and textfield.text or "Team " .. planet
if not game.forces[name] then game.create_force(name) end
unlock(game.forces[name], planet); base.assign_force_color(name); storage.planet_teams[planet] = name
end
init_planet_surfaces()
local fulgora = game.planets.fulgora
if fulgora and fulgora.surface then assign_fulgora_attractors(fulgora.surface) end
storage.game_started = true; element.parent.destroy()
for _, target in pairs(game.players) do
if target.valid and not (storage.player_team_joined and storage.player_team_joined[target.index]) then
teleport_to_hub(target)
if target.gui.screen.pvp_waiting_frame then target.gui.screen.pvp_waiting_frame.destroy() end
show_join(target)
end
end
elseif element.name:find("^join_btn_") then
local planet_name = element.name:gsub("^join_btn_", "")
local force_name = storage.planet_teams[planet_name]
if force_name then
player.force = game.forces[force_name]; player.clear_items_inside()
base.player_color(player)
storage.player_team_joined = storage.player_team_joined or {}
storage.player_team_joined[player.index] = true
local planet = game.planets[planet_name]
if planet then
local surface = planet.create_surface()
if planet_name == "fulgora" then assign_fulgora_attractors(surface) end
if not storage.team_kit_spawned[force_name] then
storage.team_kit_spawned[force_name] = true
local position = surface.find_non_colliding_position("steel-chest", {x = 5, y = 0}, 100, .5) or {x = 5, y = 0}
local chest = surface.create_entity{name = "steel-chest", position = position, force = player.force, raise_built = true}
if chest and chest.valid then
local inventory = chest.get_inventory(defines.inventory.chest)
if inventory then for _, item in pairs(LOADOUTS[planet_name]) do if prototypes.item[item.name] then inventory.insert(item) end end end
end
base.spawn_initial(force_name, planet_name)
end
drop_zone.teleport_to_planet(player, surface)
end
end
if element.parent and element.parent.valid then element.parent.destroy() end
end
end
return setup

View File

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 3.1 MiB