wesnoth/data/lua/core/map.lua
Celtic Minstrel 2a26a68025 Some updates to Lua map location operations
* Rename the vector operations to hex_vector (to emphasize that they are NOT standard vector ops) and document them as official API
* Add new get_hexes_at_radius, which returns an unfilled ring (as opposed to get_hexes_in_radius which returns a filled circle)
* Expose the new cubic coordinate conversions
2024-09-15 15:27:39 -04:00

470 lines
19 KiB
Lua

--[========[Map module]========]
print("Loading map module...")
---Splits a terrain code into base and overlay
---@param code string
---@return string #The base terrain, if any - an empty string if there's no base
---@return string|nil #The overlay, if any - nil if there's no overlay
function wesnoth.map.split_terrain_code(code)
return table.unpack(code:split('^', {remove_empty = false}))
end
---Read a location from the front of a variable argument list.
---@alias read_location_count
---| 0 #Indicates no location was found.
---| 1 #A location-like object was found - either an array of two integers, or a table or userdata with x and y keys.
---| 2 #Two integer arguments were found and interpreted as the x and y coordinates respectively.
---@return location|nil #The location, if one was found, or nil otherwise
---@return read_location_count count #The number of arguments used to extract the location.
function wesnoth.map.read_location(...)
local x, y = ...
if x == nil then return nil, 0 end
if y == nil or type(x) == 'table' or type(x) == 'userdata' then
if type(x.x) == 'number' and type(x.y) == 'number' then
x, y = x.x, x.y
elseif type(x[1]) == 'number' and type(x[2]) == 'number' then
x, y = table.unpack(x)
else
return nil, 0
end
return wesnoth.named_tuple({x, y}, {'x', 'y'}), 1
elseif type(x) == 'number' and type(y) == 'number' then
return wesnoth.named_tuple({x, y}, {'x', 'y'}), 2
end
return nil, 0
end
if wesnoth.kernel_type() ~= "Application Lua Kernel" then
-- possible terrain string inputs:
-- A A^ A^B ^ ^B
-- implied mode:
-- both base both overlay overlay
---Adjusts a terrain code to produce one that will replace the base terrain only,
---when the adjusted code is assigned to a terrain hex on the map
---@param code string A terrain code
---@return string #The adjusted terrain code
function wesnoth.map.replace_base(code)
local base, overlay = wesnoth.map.split_terrain_code(code)
if base == nil then -- ^ or ^B
-- There's no base to replace with, so do nothing
return ''
else
-- Use the specified base but ignore the overlay
return base .. '^'
end
end
---Adjusts a terrain code to produce one that will replace the overlay terrain only,
---when the adjusted code is assigned to a terrain hex on the map
---@param code string A terrain code
---@return string #The adjusted terrain code
function wesnoth.map.replace_overlay(code)
local base, overlay = wesnoth.map.split_terrain_code(code)
if overlay == nil or overlay == '' then -- A or A^
-- No overlay was specified, so we want to clear the overlay without touching the base
return '^'
else
-- An overlay was specified, so use that and ignore the base
return '^' .. overlay
end
end
---Adjusts a terrain code to produce one that will replace both the base and overlay terrains,
---when the adjusted code is assigned to a terrain hex on the map
---@param code string A terrain code
---@return string #The adjusted terrain code
function wesnoth.map.replace_both(code)
local base, overlay = wesnoth.map.split_terrain_code(code)
if base == '' then -- ^ or ^B
-- There's no way to find a base to replace with in this case.
-- Could use the existing base, but that's not really replacing both, is it?
error('replace_both: no base terrain specified')
elseif overlay == '' then -- A^
-- This would normally mean replace base while preserving overlay,
-- but we actually want to replace base and clear overlay.
return base
else
-- It's already going to replace both, so return unchanged
return code
end
end
---Iterate over on-map hexes adjacent to a given hex.
---@param map terrain_map
---@return fun():integer?,integer?
---@overload fun(map:terrain_map, loc:location) : fun():integer?,integer?
---@overload fun(map:terrain_map, x:integer, y:integer) : fun():integer?,integer?
function wesnoth.map.iter_adjacent(map, ...)
local where, n = wesnoth.map.read_location(...)
if n == 0 then error('wesnoth.map.iter_adjacent: missing location') end
local with_borders = select(n + 1, ...)
local adj = {wesnoth.map.get_adjacent_hexes(where)}
local i = 0
return function()
while i < #adj do
i = i + 1
local u, v = adj[i].x, adj[i].y
if map:on_board(u, v, with_borders) then
return u, v
end
end
return nil
end
end
end
if wesnoth.kernel_type() == "Game Lua Kernel" then
---Represents a reference to a single hex on the map
---@class terrain_hex : location
---@field fogged boolean Whether the hex is fogged
---@field shrouded boolean Whether the hex is shrouded
---@field team_label? string|tstring The label on this hex visible to the current team
---@field global_label? string|tstring The label on this hex visible to teams who don't have a team label there
---@field label? string|tstring The visible label on this hex
---@field terrain string The terrain code of the hex
---@field base_terrain string The terrain code without the overlay
---@field overlay_terrain string The overlay terrain code without the base
---@field info terrain_info The properties of this terrain
---@field time_of_day time_info The base time of day on this hex from the schedule
---@field illuminated_time time_info The time of day on this hex, adjusted for illumination effects
local hex_methods = {}
local hex_mt = {__metatable = 'terrain hex reference'}
function hex_mt.__index(self, key)
if key == 'fogged' then
return self:fogged_for(wesnoth.current.side)
elseif key == 'shrouded' then
return self:shrouded_for(wesnoth.current.side)
elseif key == 'team_label' then
local label = self:label_for(wesnoth.current.side)
if label then return label.text end
return nil
elseif key == 'global_label' then
local label = self:label_for(nil)
if label then return label.text end
return nil
elseif key == 'label' then
return self.team_label or self.global_label
elseif key == 'terrain' then
return wesnoth.current.map[self]
elseif key == 'base_terrain' then
return self.terrain:split('^')[1]
elseif key == 'overlay_terrain' then
return self.terrain:split('^', {remove_empty=false})[2]
elseif key == 'info' then
return wesnoth.terrain_types[wesnoth.current.map[self]]
elseif key == 'time_of_day' then
return wesnoth.schedule.get_time_of_day(self)
elseif key == 'illuminated_time' then
return wesnoth.schedule.get_illumination(self)
elseif key == 1 then
return self.x
elseif key == 2 then
return self.y
elseif type(key) ~= 'string' or (#key > 0 and key[0] ~= '_') then
return hex_methods[key]
end
end
function hex_mt.__newindex(self, key, val)
if key == 'fogged' then
self:set_fogged(wesnoth.current.side, val)
elseif key == 'shrouded' then
self:set_shrouded(wesnoth.current.side, val)
elseif key == 'team_label' or key == 'global_label' or key == 'label' then
local cfg
if type(val) == 'string' or (type(val) == 'userdata' and getmetatable(val) == 'translatable string') then
cfg = {x = self.x, y = self.y, text = val}
else
cfg = wml.parsed(val)
cfg.x, cfg.y = self.x, self.y
end
if key == 'team_label' then
cfg.side = wesnoth.current.side
cfg.team_name = wesnoth.sides[wesnoth.current.side].team_name
elseif key == 'global_label' then
cfg.side = 0
cfg.team_name = nil
elseif cfg.side == nil and cfg.team_name == nil then
-- If side or team name explicitly specified, use that, otherwise use current side and no team
cfg.side = wesnoth.current.side
cfg.team_name = nil
end
wesnoth.map.add_label(cfg)
elseif key == 'terrain' then
wesnoth.current.map[self] = val
elseif key == 'base_terrain' then
wesnoth.current.map[self] = wesnoth.map.replace_base(val)
elseif key == 'overlay_terrain' then
wesnoth.current.map[self] = wesnoth.map.replace_overlay(val)
elseif key == 1 then
self.x = val
elseif key == 2 then
self.y = val
elseif key == 'info' or key == 'time_of_day' or key == 'illuminated_time' then
error(string.format('hex.%s is read-only', key), 1)
else
-- If it's not a known key, just set it
rawset(self, key, val)
end
end
---Test if the hex is under fog for a specific side
---@param side integer|side
---@return boolean
function hex_methods:fogged_for(side)
return wesnoth.sides.is_fogged(side, self)
end
---Test if the hex is under shroud for a specific side
---@param side integer|side
---@return boolean
function hex_methods:shrouded_for(side)
return wesnoth.sides.is_shrouded(side, self)
end
---Set whether the hex is shrouded for a specific side
---@param side integer|side
---@param val boolean
function hex_methods:set_shrouded(side, val)
if val then
wesnoth.sides.place_shroud(side, {self})
else
wesnoth.sides.remove_shroud(side, {self})
end
end
---Set whether the hex is fogged for a specific side
---@param side integer|side
---@param val boolean
function hex_methods:set_fogged(side, val)
if val then
wesnoth.sides.place_fog(side, {self})
else
wesnoth.sides.remove_fog(side, {self})
end
end
---Get a label placed by a specific side
---@param who integer
---@return label_info?
function hex_methods:label_for(who)
return wesnoth.map.get_label(self.x, self.y, who)
end
---Test if the hex matches a filter
---@param filter WML
---@return boolean
function hex_methods:matches(filter)
return wesnoth.map.matches(self.x, self.y, filter)
end
-- Backwards compatibility - length is always 2
hex_mt.__len = wesnoth.deprecate_api('#location', 'nil', 3, '1.17', function() return 2 end, 'Using the length of a location as a validity test is no longer supported. You should represent an invalid location by nil instead.')
---Get a hex reference to alias specific location
---@param x integer
---@param y integer
---@return terrain_hex
---@overload fun(loc:location):terrain_hex
function wesnoth.map.get(x, y)
-- Make the linter assume the location is non-nil.
-- It's not smart enough to realize that n==0 means that loc is non-nil.
---@type location
local loc, n = wesnoth.map.read_location(x, y)
if n == 0 then error('Missing or invalid coordinate') end
---@diagnostic disable-next-line: return-type-mismatch
return setmetatable({x = loc.x, y = loc.y}, hex_mt)
end
local find_locations = wesnoth.map.find
---Find a list of locations matching a filter
---@param cfg WML
---@param ref_unit? unit
---@return terrain_hex[]
function wesnoth.map.find(cfg, ref_unit) ---@diagnostic disable-line: duplicate-set-field
local hexes = find_locations(cfg, ref_unit)
for i = 1, #hexes do
hexes[i] = wesnoth.map.get(hexes[i][1], hexes[i][2])
end
return hexes
end
wesnoth.terrain_mask = wesnoth.deprecate_api('wesnoth.terrain_mask', 'wesnoth.current.map:terrain_mask', 1, nil, function(...)
wesnoth.current.map:terrain_mask(...)
end)
wesnoth.get_terrain = wesnoth.deprecate_api('wesnoth.get_terrain', 'wesnoth.current.map[loc]', 1, nil, function(x, y)
local loc = wesnoth.map.read_location(x, y)
if loc == nil then error('get_terrain: expected location') end
return wesnoth.current.map[loc]
end)
wesnoth.set_terrain = wesnoth.deprecate_api('wesnoth.set_terrain', 'wesnoth.current.map[loc]=', 1, nil, function(...)
local loc, n = wesnoth.map.read_location(...)
if n == 0 then error('set_terrain: expected location') end
local new_ter, mode, replace_if_failed = select(n + 1, ...)
if new_ter == '' or type(new_ter) ~= 'string' then error('set_terrain: expected terrain string') end
if replace_if_failed then
mode = mode or 'both'
new_ter = wesnoth.map.replace_if_failed(new_ter, mode)
elseif mode == 'both' or mode == 'base' or mode == 'overlay' then
new_ter = wesnoth.map['replace_' .. mode](new_ter)
elseif mode ~= nil then
error('set_terrain: invalid mode')
end
wesnoth.current.map[loc] = new_ter
end)
wesnoth.get_map_size = wesnoth.deprecate_api('wesnoth.get_map_size', 'wesnoth.current.map.playable_width,playable_height,border_size', 1, nil, function()
local m = wesnoth.current.map
return m.playable_width, m.playable_height, m.border_size
end)
wesnoth.special_locations = wesnoth.deprecate_api('wesnoth.special_locations', 'wesnoth.current.map.special_locations', 1, nil, setmetatable({}, {
__index = function(_, k) return wesnoth.current.map.special_locations[k] end,
__newindex = function(_, k, v) wesnoth.current.map.special_locations[k] = v end,
__len = function(_)
local n = 0
for k,v in pairs(wesnoth.current.map.special_locations) do
n = n + 1
end
return n
end,
---@diagnostic disable-next-line: redundant-return-value
__pairs = function(_) return pairs(wesnoth.current.map.special_locations) end,
}), 'Note: the length operator has been removed')
wesnoth.get_village_owner = wesnoth.deprecate_api('wesnoth.get_village_owner', 'wesnoth.map.get_owner', 1, nil, wesnoth.map.get_owner)
wesnoth.set_village_owner = wesnoth.deprecate_api('wesnoth.set_village_owner', 'wesnoth.map.set_owner', 1, nil, wesnoth.map.set_owner)
wesnoth.label = wesnoth.deprecate_api('wesnoth.label', 'wesnoth.map.add_label', 1, nil, wesnoth.map.add_label)
wesnoth.add_time_area = wesnoth.deprecate_api('wesnoth.add_time_area', 'wesnoth.map.place_area', 1, nil, wesnoth.map.place_area)
wesnoth.remove_time_area = wesnoth.deprecate_api('wesnoth.remove_time_area', 'wesnoth.map.remove_area', 1, nil, wesnoth.map.remove_area)
wesnoth.get_locations = wesnoth.deprecate_api('wesnoth.get_locations', 'wesnoth.map.find', 1, nil, wesnoth.map.find)
wesnoth.get_villages = wesnoth.deprecate_api('wesnoth.get_locations', 'wesnoth.map.find', 1, nil, function(cfg)
return wesnoth.map.find{gives_income = true, wml.tag["and"](cfg)}
end)
wesnoth.match_location = wesnoth.deprecate_api('wesnoth.match_location', 'wesnoth.map.matches', 1, nil, wesnoth.map.matches)
wesnoth.get_terrain_info = wesnoth.deprecate_api('wesnoth.get_terrain_info', 'wesnoth.terrain_types', 1, nil, function(t) return wesnoth.terrain_types[t] end)
end
if wesnoth.kernel_type() == "Mapgen Lua Kernel" then
wesnoth.map.filter_tags = {
---Match specific terrains
---@param terrain string
---@return terrain_filter_tag
terrain = function(terrain)
return { "terrain", terrain }
end,
---Match all the nested filters
---@vararg terrain_filter_tag
---@return terrain_filter_tag
all = function(...)
return { "all", ... }
end,
---Match at least one of the nested filters
---@vararg terrain_filter_tag
any = function(...)
return { "any", ... }
end,
---Match none of the nested filters
---@vararg terrain_filter_tag
---@return terrain_filter_tag
none = function(...)
return { "none", ... }
end,
---Match not all of the nested filters
---@vararg terrain_filter_tag
---@return terrain_filter_tag
notall = function(...)
return { "notall", ... }
end,
---Match adjacent hexes
---@param f terrain_filter_tag
---@param adj? direction[]|string
---@param count? integer|string A range list
---@return terrain_filter_tag
adjacent = function(f, adj, count)
if type(adj) == 'table' then
adj = stringx.join(',', adj)
end
return { "adjacent", f, adjacent = adj, count = count }
end,
---Match hexes from a separate list.
---When passing a locset_ref, specify the list
---in the second argument to wesnoth.map.filter()
---
---For example:
---```
---local M = wesnoth.map.create(128, 128, 'Gg')
---local f = wesnoth.map.filter_tags
---local found = M:find(f.find_in("choices"), {choices = {{1,2}, {5,6}}})
---```
---@param x integer
---@param y integer
---@return terrain_filter_tag
---@overload fun(xs:string, ys:string):terrain_filter_tag
---@overload fun(loc:location):terrain_filter_tag
---@overload fun(locs:location[]):terrain_filter_tag
---@overload fun(locset_ref:string):terrain_filter_tag
find_in = function(x, y)
return { "find_in", x, y }
end,
---Match hexes within a given distance
---@param r integer
---@param f terrain_filter_tag
---@param f_r? terrain_filter_tag
---@return terrain_filter_tag
radius = function(r, f, f_r)
return { "radius", r, f, filter_radius = f_r}
end,
---Match hexes by x coordinate
---@param terrain integer|string A range list
---@return terrain_filter_tag
x = function(terrain)
return { "x", terrain }
end,
---Match hexes by y coordinate
---@param terrain integer|string A range list
---@return terrain_filter_tag
y = function(terrain)
return { "y", terrain }
end,
---Match a specific location
---@param x integer
---@param y integer
---@return terrain_filter_tag
---@overload fun(loc:location):terrain_filter_tag
is_loc = function(x, y)
local loc = wesnoth.map.read_location(x, y)
if not loc then return { "any" } end
return f.all(f.x(loc.x), f.y(loc.y))
end,
---Match terrain by Wesnoth Formula Language
---@param formula string|formula
---@return terrain_filter_tag
formula = function(formula)
return { "formula", formula }
end,
---Match any hex not on the playable area of the map,
---ie hexes on the border.
---@return terrain_filter_tag
onborder = function()
return { "onborder" }
end,
}
-- More map module stuff
wesnoth.create_filter = wesnoth.deprecate_api('wesnoth.create_filter', 'wesnoth.map.filter', 1, nil, wesnoth.map.filter)
wesnoth.create_map = wesnoth.deprecate_api('wesnoth.create_map', 'wesnoth.map.create', 1, nil, wesnoth.map.create)
wesnoth.default_generate_height_map = wesnoth.deprecate_api('wesnoth.default_generate_height_map', 'wesnoth.map.generate_height_map', 1, nil, wesnoth.map.generate_height_map)
wesnoth.generate_default_map = wesnoth.deprecate_api('wesnoth.generate_default_map', 'wesnoth.map.generate', 1, nil, wesnoth.map.generate)
-- These were originally only on the map metatable, so the deprecated versions also need to be in the map module
wesnoth.map.get_locations = wesnoth.deprecate_api('map:get_locations', 'map:find', 1, nil, wesnoth.map.find)
wesnoth.map.get_tiles_radius = wesnoth.deprecate_api('map:get_tiles_radius', 'map:find_in_radius', 1, nil, function(map, locs, filter, radius)
return wesnoth.map.find_in_radius(map, locs, radius, filter)
end, 'The filter is now the last parameter, instead of the radius')
wesnoth.map.vector_sum = wesnoth.deprecate_api('wesnoth.map.vector_sum', 'wesnoth.map.hex_vector_sum', 1, nil, wesnoth.map.hex_vector_sum)
wesnoth.map.vector_diff = wesnoth.deprecate_api('wesnoth.map.vector_diff', 'wesnoth.map.hex_vector_dif', 1, nil, wesnoth.map.hex_vector_diff)
wesnoth.map.vector_negation = wesnoth.deprecate_api('wesnoth.map.vector_negation', 'wesnoth.map.hex_vector_negation', 1, nil, wesnoth.map.hex_vector_negation)
end
wesnoth.map.tiles_adjacent = wesnoth.deprecate_api('wesnoth.map.tiles_adjacent', 'wesnoth.map.are_hexes_adjacent', 1, nil, wesnoth.map.are_hexes_adjacent)
wesnoth.map.get_adjacent_tiles = wesnoth.deprecate_api('wesnoth.map.get_adjacent_tiles', 'wesnoth.map.get_adjacent_hexes', 1, nil, wesnoth.map.get_adjacent_hexes)