diff --git a/base.lua b/Planet-PvP-Green/base.lua similarity index 100% rename from base.lua rename to Planet-PvP-Green/base.lua diff --git a/control.lua b/Planet-PvP-Green/control.lua similarity index 75% rename from control.lua rename to Planet-PvP-Green/control.lua index d6d7fc4..92d6e58 100644 --- a/control.lua +++ b/Planet-PvP-Green/control.lua @@ -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 diff --git a/data.lua b/Planet-PvP-Green/data.lua similarity index 81% rename from data.lua rename to Planet-PvP-Green/data.lua index 36254f5..5a6fdbd 100644 --- a/data.lua +++ b/Planet-PvP-Green/data.lua @@ -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", diff --git a/drop_zone.lua b/Planet-PvP-Green/drop_zone.lua similarity index 100% rename from drop_zone.lua rename to Planet-PvP-Green/drop_zone.lua diff --git a/observer.lua b/Planet-PvP-Green/observer.lua similarity index 100% rename from observer.lua rename to Planet-PvP-Green/observer.lua diff --git a/player.lua b/Planet-PvP-Green/player.lua similarity index 100% rename from player.lua rename to Planet-PvP-Green/player.lua diff --git a/Planet-PvP-Green/radar.lua b/Planet-PvP-Green/radar.lua new file mode 100644 index 0000000..c91ba8d --- /dev/null +++ b/Planet-PvP-Green/radar.lua @@ -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 diff --git a/Planet-PvP-Green/radar_ui.lua b/Planet-PvP-Green/radar_ui.lua new file mode 100644 index 0000000..6c143e1 --- /dev/null +++ b/Planet-PvP-Green/radar_ui.lua @@ -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 diff --git a/roster.lua b/Planet-PvP-Green/roster.lua similarity index 100% rename from roster.lua rename to Planet-PvP-Green/roster.lua diff --git a/setup.lua b/Planet-PvP-Green/setup.lua similarity index 100% rename from setup.lua rename to Planet-PvP-Green/setup.lua diff --git a/radar.lua b/radar.lua deleted file mode 100644 index 3c433da..0000000 --- a/radar.lua +++ /dev/null @@ -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 diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..f983825 --- /dev/null +++ b/release.sh @@ -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_.zip, with 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" diff --git a/Graphics/Base/base_core.png b/src/Graphics/Base/base_core.png similarity index 100% rename from Graphics/Base/base_core.png rename to src/Graphics/Base/base_core.png diff --git a/src/Graphics/Radar/cell_blue.png b/src/Graphics/Radar/cell_blue.png new file mode 100644 index 0000000..e5c98c4 Binary files /dev/null and b/src/Graphics/Radar/cell_blue.png differ diff --git a/src/Graphics/Radar/cell_gray.png b/src/Graphics/Radar/cell_gray.png new file mode 100644 index 0000000..00e772c Binary files /dev/null and b/src/Graphics/Radar/cell_gray.png differ diff --git a/src/Graphics/Radar/cell_green.png b/src/Graphics/Radar/cell_green.png new file mode 100644 index 0000000..43ae805 Binary files /dev/null and b/src/Graphics/Radar/cell_green.png differ diff --git a/src/Graphics/Radar/cell_purple.png b/src/Graphics/Radar/cell_purple.png new file mode 100644 index 0000000..f110168 Binary files /dev/null and b/src/Graphics/Radar/cell_purple.png differ diff --git a/src/Graphics/Radar/cell_red.png b/src/Graphics/Radar/cell_red.png new file mode 100644 index 0000000..0780909 Binary files /dev/null and b/src/Graphics/Radar/cell_red.png differ diff --git a/src/Graphics/Tech/fulgora.png b/src/Graphics/Tech/fulgora.png new file mode 100644 index 0000000..42ab160 Binary files /dev/null and b/src/Graphics/Tech/fulgora.png differ diff --git a/Graphics/Tech/nauvis-tech.png b/src/Graphics/Tech/nauvis-tech.png similarity index 100% rename from Graphics/Tech/nauvis-tech.png rename to src/Graphics/Tech/nauvis-tech.png diff --git a/src/Graphics/Tech/starmap-planet-nauvis.png b/src/Graphics/Tech/starmap-planet-nauvis.png new file mode 100644 index 0000000..1963bb6 Binary files /dev/null and b/src/Graphics/Tech/starmap-planet-nauvis.png differ diff --git a/src/base.lua b/src/base.lua new file mode 100644 index 0000000..af431f7 --- /dev/null +++ b/src/base.lua @@ -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 diff --git a/src/changelog.txt b/src/changelog.txt new file mode 100644 index 0000000..2a29426 --- /dev/null +++ b/src/changelog.txt @@ -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. diff --git a/src/control.lua b/src/control.lua new file mode 100644 index 0000000..92d6e58 --- /dev/null +++ b/src/control.lua @@ -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) diff --git a/src/data.lua b/src/data.lua new file mode 100644 index 0000000..5a6fdbd --- /dev/null +++ b/src/data.lua @@ -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 + } + } +}) \ No newline at end of file diff --git a/src/drop_zone.lua b/src/drop_zone.lua new file mode 100644 index 0000000..37b4054 --- /dev/null +++ b/src/drop_zone.lua @@ -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 diff --git a/info.json b/src/info.json similarity index 88% rename from info.json rename to src/info.json index 8ce4ac4..4810147 100644 --- a/info.json +++ b/src/info.json @@ -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", diff --git a/locale/en/locale.cfg b/src/locale/en/locale.cfg similarity index 100% rename from locale/en/locale.cfg rename to src/locale/en/locale.cfg diff --git a/src/observer.lua b/src/observer.lua new file mode 100644 index 0000000..a2be213 --- /dev/null +++ b/src/observer.lua @@ -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 diff --git a/src/player.lua b/src/player.lua new file mode 100644 index 0000000..ddb9b25 --- /dev/null +++ b/src/player.lua @@ -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 diff --git a/src/radar.lua b/src/radar.lua new file mode 100644 index 0000000..c91ba8d --- /dev/null +++ b/src/radar.lua @@ -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 diff --git a/src/radar_ui.lua b/src/radar_ui.lua new file mode 100644 index 0000000..6c143e1 --- /dev/null +++ b/src/radar_ui.lua @@ -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 diff --git a/src/roster.lua b/src/roster.lua new file mode 100644 index 0000000..8992888 --- /dev/null +++ b/src/roster.lua @@ -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 diff --git a/src/setup.lua b/src/setup.lua new file mode 100644 index 0000000..eeb30fa --- /dev/null +++ b/src/setup.lua @@ -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 diff --git a/thumbnail.png b/src/thumbnail.png similarity index 100% rename from thumbnail.png rename to src/thumbnail.png