1
0
mirror of https://github.com/Oarcinae/FactorioScenarioMultiplayerSpawn.git synced 2024-12-04 09:43:00 +02:00
FactorioScenarioMultiplayer.../lib/oarc_utils.lua

738 lines
24 KiB
Lua

-- My general purpose utility functions and constants for factorio
-- Also contains some constants
-- (Ignore the diagnostic warning.)
---@diagnostic disable-next-line: different-requires
require("lib/oarc_gui_utils")
require("mod-gui")
local util = require("util")
--------------------------------------------------------------------------------
-- Useful constants
--------------------------------------------------------------------------------
CHUNK_SIZE = 32
MAX_FORCES = 64
TICKS_PER_SECOND = 60
TICKS_PER_MINUTE = TICKS_PER_SECOND * 60
TICKS_PER_HOUR = TICKS_PER_MINUTE * 60
MAX_INT32_POS = 2147483647
MAX_INT32_NEG = -2147483648
--------------------------------------------------------------------------------
-- --------------------------------------------------------------------------------
-- -- General Helper Functions
-- --------------------------------------------------------------------------------
-- Get a printable GPS string
---@param surface_name string
---@param position MapPosition
---@return string
function GetGPStext(surface_name, position)
return "[gps=" .. position.x .. "," .. position.y .. "," .. surface_name .. "]"
end
---Render some text on the ground. Visible to all players. Forever.
---@param surface LuaSurface
---@param position MapPosition
---@param scale number
---@param text string
---@param color Color
---@param alignment TextAlign?
---@return nil
function RenderPermanentGroundText(surface, position, scale, text, color, alignment)
rendering.draw_text { text = text,
surface = surface,
target = position,
color = color,
scale = scale,
--Allowed fonts: default-dialog-button default-game compilatron-message-font default-large default-large-semibold default-large-bold heading-1 compi
font = "compi",
alignment = alignment,
draw_on_ground = true }
end
---A standardized helper text that fades out over time
---@param text string|LocalisedString
---@param surface LuaSurface
---@param position MapPosition
---@param ttl number
---@param alignment TextAlign?
---@return nil
function TemporaryHelperText(text, surface, position, ttl, alignment)
local render_object = rendering.draw_text { text = text,
surface = surface,
target = position,
color = { 0.7, 0.7, 0.7, 0.7 },
scale = 1,
font = "compi",
time_to_live = ttl,
alignment = alignment,
draw_on_ground = false }
local rid = render_object.id
table.insert(storage.oarc_renders_fadeout, rid)
end
---Every tick, check a global table to see if we have any rendered thing that needs fading out.
---@return nil
function FadeoutRenderOnTick()
if (storage.oarc_renders_fadeout and (#storage.oarc_renders_fadeout > 0)) then
for k, rid in pairs(storage.oarc_renders_fadeout) do
local render_object = rendering.get_object_by_id(rid)
if (render_object and render_object.valid) then
local ttl = render_object.time_to_live
if ((ttl > 0) and (ttl < 200)) then
local color = render_object.color
if (color.a > 0.005) then
render_object.color = { r = color.r, g = color.g, b = color.b, a = color.a - 0.005 }
end
end
else
storage.oarc_renders_fadeout[k] = nil
end
end
end
end
---@type boolean?
local has_better_chat = nil
--- Safely attempts to print via the Better Chatting's interface
---@param recipient LuaGameScript|LuaForce|LuaPlayer
---@param msg LocalisedString
---@param print_settings PrintSettings?
---@return nil
function CompatSend(recipient, msg, print_settings)
if has_better_chat == nil then
local better_chat = remote.interfaces["better-chat"]
has_better_chat = better_chat and better_chat["send"]
end
if not has_better_chat then return recipient.print(msg, print_settings) end
print_settings = print_settings or {}
---@type "global"|"force"|"player", int?
local send_level, send_index
local recipient_type = recipient.object_name
if recipient_type == "LuaGameScript" then
send_level = "global"
else
---@cast recipient -LuaGameScript
send_index = recipient.index
if recipient_type == "LuaForce" then
send_level = "force"
elseif recipient_type == "LuaPlayer" then
send_level = "player"
else
error("Invalid Recipient", 2)
end
end
remote.call("better-chat", "send", {
message = msg,
send_level = send_level,
color = print_settings.color,
recipient = send_index,
})
end
--- Broadcast messages to all connected players
---@param msg LocalisedString
---@param print_settings PrintSettings?
---@return nil
function SendBroadcastMsg(msg, print_settings)
CompatSend(game, msg, print_settings)
end
---Send an error message to a player using their name, but first safely checks if they exist and are online.
---@param player_name string
---@param msg LocalisedString
---@return nil
function SendErrorMsgUsingName(player_name, msg)
local player = game.players[player_name]
if ((player ~= nil) and (player.connected)) then
SendErrorMsg(player, msg)
end
end
---@param player LuaPlayer
---@param msg LocalisedString
---@return nil
function SendErrorMsg(player, msg)
CompatSend(player, msg, { color = { r = 1, g = 0.2, b = 0.2 }, sound_path = "utility/cannot_build" })
end
---Checks if a string starts with another string
---@param string string The string to check
---@param start string The starting string to look for
function StringStartsWith(string, start)
return string:sub(1, #start) == start
end
---Checks if a surface is blacklisted based on the storage.ocfg settings
---@param surface_name string
---@return boolean --true if blacklisted
function IsSurfaceBlacklisted(surface_name)
if (storage.ocfg.surfaces_blacklist ~= nil) then
for _,name in pairs(storage.ocfg.surfaces_blacklist) do
if (name == surface_name) then
return true
end
end
end
if (storage.ocfg.surfaces_blacklist_match ~= nil) then
for _,match in pairs(storage.ocfg.surfaces_blacklist_match) do
if (StringStartsWith(surface_name, match)) then
return true
end
end
end
return false
end
---Useful for displaying game time in mins:secs format
---@param ticks number
---@return string
function FormatTime(ticks)
local seconds = ticks / 60
local minutes = math.floor((seconds)/60)
local seconds = math.floor(seconds - 60*minutes)
return string.format("%dm:%02ds", minutes, seconds)
end
---Useful for displaying game time in hrs:mins format
---@param ticks number
---@return string
function FormatTimeHoursSecs(ticks)
local seconds = ticks / 60
local total_minutes = math.floor((seconds)/60)
local hours = math.floor((total_minutes)/60)
local minutes = math.floor(total_minutes - 60*hours)
return string.format("%dh:%02dm", hours, minutes)
end
-- Fisher-Yares shuffle
-- https://stackoverflow.com/questions/35572435/how-do-you-do-the-fisher-yates-shuffle-in-lua
---@param T table
---@return table
function FYShuffle(T)
local tReturn = {}
for i = #T, 1, -1 do
local j = math.random(i)
T[i], T[j] = T[j], T[i]
table.insert(tReturn, T[i])
end
return tReturn
end
---Check if a table contains a value
---@param table table
---@param val any
---@return boolean
function TableContains(table, val)
for _, value in pairs(table) do
if value == val then
return true
end
end
return false
end
---Get a key from a table given a value (if it exists)
---@param table table
---@param val any
---@return any
function GetTableKey(table, val)
for k, v in pairs(table) do
if v == val then
return k
end
end
return nil
end
function TableRemoveOneUsingPairs(t, val)
for k,v in pairs(t) do
if v == val then
table.remove(t, k)
return
end
end
end
---Gets a random point within a circle of a given radius and center point.
---@param radius number
---@param center MapPosition
---@return MapPosition
function GetRandomPointWithinCircle(radius, center)
local angle = math.random() * 2 * math.pi
local distance = math.random() * radius
local x = center.x + distance * math.cos(angle)
local y = center.y + distance * math.sin(angle)
return {x=x, y=y}
end
-- Chart area for a force
---@param force string|integer|LuaForce
---@param position MapPosition
---@param chunkDist number
---@param surface LuaSurface|string|integer
function ChartArea(force, position, chunkDist, surface)
force.chart(surface,
{ { position.x - (CHUNK_SIZE * chunkDist),
position.y - (CHUNK_SIZE * chunkDist) },
{ position.x + (CHUNK_SIZE * chunkDist),
position.y + (CHUNK_SIZE * chunkDist) } })
end
--- Better than util.insert_safe because we also check for 0 count items.
---@param entity LuaEntity|LuaPlayer
---@param item_dict table
---@return nil
function OarcsSaferInsert(entity, item_dict)
if not (entity and entity.valid and item_dict) then return end
local items = prototypes.item
local insert = entity.insert
for name, count in pairs(item_dict) do
if items[name] and count > 0 then
insert { name = name, count = count }
else
log("Item to insert not valid: " .. name)
end
end
end
--- Better than util.remove_safe because we also check for 0 count items.
---@param entity LuaEntity|LuaPlayer
---@param item_dict table
---@return nil
function OarcsSaferRemove(entity, item_dict)
if not (entity and entity.valid and item_dict) then return end
local items = prototypes.item
local remove = entity.remove_item
for name, count in pairs(item_dict) do
if items[name] and count > 0 then
remove { name = name, count = count }
else
log("Item to remove not valid: " .. name)
end
end
end
---Gives the player the respawn items if there are any
---@param player LuaPlayer
---@return nil
function GivePlayerRespawnItems(player)
local surface_name = player.character.surface.name
if (storage.ocfg.surfaces_config[surface_name] == nil) then
error("GivePlayerRespawnItems - Missing surface config! " .. surface_name)
return
end
local respawnItems = storage.ocfg.surfaces_config[surface_name].starting_items.player_respawn_items
OarcsSaferInsert(player, respawnItems)
end
---Gives the player the starter items if there are any
---@param player LuaPlayer
---@return nil
function GivePlayerStarterItems(player)
local surface_name = player.character.surface.name
if (storage.ocfg.surfaces_config[surface_name] == nil) then
error("GivePlayerStarterItems - Missing surface config! " .. surface_name)
return
end
local startItems = storage.ocfg.surfaces_config[surface_name].starting_items.player_start_items
OarcsSaferInsert(player, startItems)
end
---Half-heartedly attempts to remove starter items from the player. Probably more trouble than it's worth.
---@param player LuaPlayer
---@return nil
function RemovePlayerStarterItems(player)
if player == nil or player.character == nil then return end
local surface_name = player.character.surface.name
if (storage.ocfg.surfaces_config[surface_name]) ~= nil then
local startItems = storage.ocfg.surfaces_config[surface_name].starting_items.player_start_items
OarcsSaferRemove(player, startItems)
end
end
--- Delete all chunks on a surface
--- @param surface LuaSurface
--- @return nil
function DeleteAllChunks(surface)
for chunk in surface.get_chunks() do
surface.delete_chunk({chunk.x, chunk.y})
end
end
---Get position for buddy spawn (for buddy placement)
---@param position MapPosition
---@param surface_name string
---@param moat_enabled boolean
---@return MapPosition
function GetBuddySpawnPosition(position, surface_name, moat_enabled)
local spawn_config = storage.ocfg.surfaces_config[surface_name].spawn_config
local x_offset = storage.ocfg.spawn_general.spawn_radius_tiles * spawn_config.radius_modifier * 2
x_offset = x_offset + storage.ocfg.spawn_general.moat_width_tiles
-- distance = distance + 5 -- EXTRA BUFFER?
-- Create that spawn in the global vars
local buddy_position = table.deepcopy(position)
-- The x_offset must be big enough to ensure the spawns DO NOT overlap!
buddy_position.x = buddy_position.x + x_offset
return buddy_position
end
-- Safer teleport
---@param player LuaPlayer
---@param surface LuaSurface
---@param target_pos MapPosition
function SafeTeleport(player, surface, target_pos)
local safe_pos = surface.find_non_colliding_position("character", target_pos, CHUNK_SIZE, 1)
if (not safe_pos) then
player.teleport(target_pos, surface, true)
else
player.teleport(safe_pos, surface, true)
end
end
---Check if given position is in area bounding box
---@param point MapPosition
---@param area BoundingBox
---@return boolean
function CheckIfInArea(point, area)
if ((point.x >= area.left_top.x) and (point.x < area.right_bottom.x)) then
if ((point.y >= area.left_top.y) and (point.y < area.right_bottom.y)) then
return true
end
end
return false
end
---Configures the friend and cease fire relationships between all player forces.
---@param cease_fire boolean
---@param friends boolean
---@return nil
function ConfigurePlayerForceRelationships(cease_fire, friends)
local player_forces = {}
for name, force in pairs(game.forces) do
if name ~= ABANDONED_FORCE_NAME and not TableContains(ENEMY_FORCES_NAMES_INCL_NEUTRAL, name) then
table.insert(player_forces, force)
end
end
for _, force1 in pairs(player_forces) do
for _, force2 in pairs(player_forces) do
if force1.name ~= force2.name then
force1.set_cease_fire(force2, cease_fire)
force1.set_friend(force2, friends)
force2.set_cease_fire(force1, cease_fire)
force2.set_friend(force1, friends)
end
end
end
end
---For each other player force, share a chat msg.
---@param player LuaPlayer
---@param msg LocalisedString
---@return nil
function ShareChatBetweenForces(player, msg)
for _,force in pairs(game.forces) do
if (force ~= nil) then
if ((force.name ~= "enemy") and
(force.name ~= "neutral") and
(force.name ~= "player") and
(force ~= player.force)) then
CompatSend(force, {"", player.name, ": ", msg}, { color = player.color })
end
end
end
end
-- -- Merges force2 INTO force1 but keeps all research between both forces.
-- function MergeForcesKeepResearch(force1, force2)
-- for techName,luaTech in pairs(force2.technologies) do
-- if (luaTech.researched) then
-- force1.technologies[techName].researched = true
-- force1.technologies[techName].level = luaTech.level
-- end
-- end
-- game.merge_forces(force2, force1)
-- end
-- -- Undecorator
-- function RemoveDecorationsArea(surface, area)
-- surface.destroy_decoratives{area=area}
-- end
-- -- Remove fish
-- function RemoveFish(surface, area)
-- for _, entity in pairs(surface.find_entities_filtered{area = area, type="fish"}) do
-- entity.destroy()
-- end
-- end
-- -- Render a path
-- function RenderPath(path, ttl, players)
-- local last_pos = path[1].position
-- local color = {r = 1, g = 0, b = 0, a = 0.5}
-- for i,v in pairs(path) do
-- if (i ~= 1) then
-- color={r = 1/(1+(i%3)), g = 1/(1+(i%5)), b = 1/(1+(i%7)), a = 0.5}
-- rendering.draw_line{color=color,
-- width=2,
-- from=v.position,
-- to=last_pos,
-- surface=game.surfaces[GAME_SURFACE_NAME],
-- players=players,
-- time_to_live=ttl}
-- end
-- last_pos = v.position
-- end
-- end
---Get a random 1 or -1
---@return number
function RandomNegPos()
if (math.random(0,1) == 1) then
return 1
else
return -1
end
end
---Create a random direction vector to look in, returns normalized vector
---@return MapPosition
function GetRandomVector()
local randVec = {x=0,y=0}
while ((randVec.x == 0) and (randVec.y == 0)) do
randVec.x = math.random() * 2 - 1
randVec.y = math.random() * 2 - 1
end
-- Normalize the vector
local magnitude = math.sqrt((randVec.x^2) + (randVec.y^2))
randVec.x = randVec.x / magnitude
randVec.y = randVec.y / magnitude
return randVec
end
---Check for ungenerated chunks around a specific chunk +/- chunkDist in x and y directions
---@param chunkPos MapPosition
---@param chunkDist integer
---@param surface LuaSurface
---@return boolean
function IsChunkAreaUngenerated(chunkPos, chunkDist, surface)
for x=-chunkDist, chunkDist do
for y=-chunkDist, chunkDist do
local checkPos = {x=chunkPos.x+x,
y=chunkPos.y+y}
if (surface.is_chunk_generated(checkPos)) then
return false
end
end
end
return true
end
-- Clear out enemies around an area with a certain distance
---@param pos MapPosition
---@param safeDist number
---@param surface LuaSurface
function ClearNearbyEnemies(pos, safeDist, surface)
local safeArea = {
left_top =
{
x = pos.x - safeDist,
y = pos.y - safeDist
},
right_bottom =
{
x = pos.x + safeDist,
y = pos.y + safeDist
}
}
for _, entity in pairs(surface.find_entities_filtered { area = safeArea, force = "enemy" }) do
entity.destroy()
end
end
---Pick a random direction, go at least the minimum distance, and start looking for ungenerated chunks
---We try a few times (hardcoded) and then try a different random direction if we fail (up to max_tries)
---@param surface_name string Surface name because we might need to force the creation of a new surface
---@param minimum_distance_chunks number Distance in chunks to start looking for ungenerated chunks
---@param max_tries integer Maximum number of tries to find a spawn point
---@return MapPosition
function FindUngeneratedCoordinates(surface_name, minimum_distance_chunks, max_tries)
local final_position = {x=0,y=0}
-- If surface is nil, it is probably a planet? Check and create if needed.
local surface = game.surfaces[surface_name]
if (surface == nil) then
if (game.planets[surface_name] == nil) then
error("ERROR! No surface or planet found for requested player spawn!")
return final_position
end
surface = game.planets[surface_name].create_surface()
if (surface == nil) then
error("ERROR! Failed to create planet surface for player spawn!")
return final_position
end
end
--- Get a random vector, figure out how many times to multiply it to get the minimum distance
local direction_vector = GetRandomVector()
local start_distance_tiles = minimum_distance_chunks * CHUNK_SIZE
local tries_remaining = max_tries - 1
-- Starting search position
local search_pos = {
x=direction_vector.x * start_distance_tiles,
y=direction_vector.y * start_distance_tiles
}
-- We check up to THIS many times, each jump moves out by minimum_distance_to_existing_chunks
local jumps_count = 3
local minimum_distance_to_existing_chunks = storage.ocfg.gameplay.minimum_distance_to_existing_chunks
-- Keep checking chunks in the direction of the vector, assumes this terminates...
while(true) do
local chunk_position = GetChunkPosFromTilePos(search_pos)
if (jumps_count <= 0) then
if (tries_remaining > 0) then
return FindUngeneratedCoordinates(surface_name, minimum_distance_chunks, tries_remaining)
else
log("WARNING - FindUngeneratedCoordinates - Hit max distance!")
break
end
-- If chunk is already generated, keep looking further out
elseif (surface.is_chunk_generated(chunk_position)) then
-- For debugging, ping the map
-- SendBroadcastMsg("GENERATED: " .. GetGPStext(surface.name, {x=chunk_position.x*32, y=chunk_position.y*32}))
-- Move out a bit more to give some space and then check the surrounding area
search_pos.x = search_pos.x + (direction_vector.x * CHUNK_SIZE * minimum_distance_to_existing_chunks)
search_pos.y = search_pos.y + (direction_vector.y * CHUNK_SIZE * minimum_distance_to_existing_chunks)
-- Found a possible ungenerated area
elseif IsChunkAreaUngenerated(chunk_position, minimum_distance_to_existing_chunks, surface) then
-- For debugging, ping the map
-- SendBroadcastMsg("SUCCESS: " .. GetGPStext(surface.name, {x=chunk_position.x*32, y=chunk_position.y*32}))
-- Place the spawn in the center of a chunk
final_position.x = (chunk_position.x * CHUNK_SIZE) + (CHUNK_SIZE/2)
final_position.y = (chunk_position.y * CHUNK_SIZE) + (CHUNK_SIZE/2)
break
-- The area around the chunk is not clear, keep looking
else
-- For debugging, ping the map
-- SendBroadcastMsg("NOT CLEAR: " .. GetGPStext(surface.name, {x=chunk_position.x*32, y=chunk_position.y*32}))
-- Move out a bit more to give some space and then check the surrounding area
search_pos.x = search_pos.x + (direction_vector.x * CHUNK_SIZE * minimum_distance_to_existing_chunks)
search_pos.y = search_pos.y + (direction_vector.y * CHUNK_SIZE * minimum_distance_to_existing_chunks)
end
jumps_count = jumps_count - 1
end
if (final_position.x == 0 and final_position.y == 0) then
log("WARNING! FindUngeneratedCoordinates - Failed to find a spawn point!")
end
return final_position
end
---Get a square area given a position and distance. Square length = 2x distance
---@param pos MapPosition
---@param dist number
---@return BoundingBox
function GetAreaAroundPos(pos, dist)
return {
left_top =
{
x = pos.x - dist,
y = pos.y - dist
},
right_bottom =
{
x = pos.x + dist,
y = pos.y + dist
}
}
end
---Gets chunk position of a tile.
---@param tile_pos TilePosition
---@return ChunkPosition
function GetChunkPosFromTilePos(tile_pos)
return {x=math.floor(tile_pos.x/32), y=math.floor(tile_pos.y/32)}
end
---Removes the entity type from the area given. Only if it is within given distance from given position.
---@param surface LuaSurface
---@param area BoundingBox
---@param type string|string[]
---@param pos MapPosition
---@param dist number
---@return nil
function RemoveInCircle(surface, area, type, pos, dist)
for _, entity in pairs(surface.find_entities_filtered { area = area, type = type }) do
if entity.valid and entity and entity.position then
if ((pos.x - entity.position.x) ^ 2 + (pos.y - entity.position.y) ^ 2 < dist ^ 2) then
entity.destroy()
end
end
end
end
---Removes the entity type from the area given. Only if it is within given distance from given position.
---@param surface LuaSurface
---@param area BoundingBox
---@param type string|string[]
---@param pos MapPosition
---@param dist number
---@return nil
function RemoveInSquare(surface, area, type, pos, dist)
for _, entity in pairs(surface.find_entities_filtered { area = area, type = type }) do
if entity.valid and entity and entity.position then
local max_distance = math.max(math.abs(pos.x - entity.position.x), math.abs(pos.y - entity.position.y))
if (max_distance < dist) then
entity.destroy()
end
end
end
end