Fixed radar performance issues. Added orbital radar dialog
@ -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
|
||||
@ -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
@ -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
Planet-PvP-Green/radar_ui.lua
Normal 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
@ -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
@ -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"
|
||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
BIN
src/Graphics/Radar/cell_blue.png
Normal file
|
After Width: | Height: | Size: 79 B |
BIN
src/Graphics/Radar/cell_gray.png
Normal file
|
After Width: | Height: | Size: 77 B |
BIN
src/Graphics/Radar/cell_green.png
Normal file
|
After Width: | Height: | Size: 79 B |
BIN
src/Graphics/Radar/cell_purple.png
Normal file
|
After Width: | Height: | Size: 79 B |
BIN
src/Graphics/Radar/cell_red.png
Normal file
|
After Width: | Height: | Size: 79 B |
BIN
src/Graphics/Tech/fulgora.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
BIN
src/Graphics/Tech/starmap-planet-nauvis.png
Normal file
|
After Width: | Height: | Size: 442 KiB |
181
src/base.lua
Normal 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
@ -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
@ -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
@ -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
@ -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
|
||||
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
|
||||
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 3.1 MiB |