Files
silicons 4ab234f10c maploader / overmaps update - struct system, overmaps location bindings, misc backend improvements (#6403)
tl;dr level collections that can easily be logically restructured/moved
as necessary

will eventually be used for radio and overmaps location resolution.

this is the last part of
https://github.com/Citadel-Station-13/Citadel-Station-13-RP/pull/4841
that has not yet been integrated.
this will be a draft for a while.

---------

Co-authored-by: LetterN <24603524+LetterN@users.noreply.github.com>
2025-11-06 17:49:23 -08:00

461 lines
17 KiB
Plaintext

//* This file is explicitly licensed under the MIT license. *//
//* Copyright (c) 2025 Citadel Station Developers *//
/**
* # /datum/map
*
* * clusters of zlevels, basically.
* * when maps are loaded, areas are cached together to preserve byond-like behavior.
* * A map can make a /datum/map_struct. This is used to initialize its overmaps,
* if an overmap initializer is provided.
* * While multiple map structs can be made per map with some modifications, this
* functionality is not included as there is little reason to support such
* behavior. Maps should use the dependencies system to chain-load other maps,
* not have multiple planets in one map datum.
* * Loading more than one instance of a map is not supported at this time,
* and will likely never be supported. An upcoming system will be added to handle
* instanced planets and similar things.
*
* ## Loading
*
* Generally, the load process is like so;
* * call ready() to initialize anything lazy-loaded
* * call validate() to check for soundness of the map
* * call construct() to set all variables as needed. this will recheck all current levels
* and set needed struct vars on ourselves and them, computing things like sparse
* size as needed. if the levels already exist, they'll be updated.
*/
/datum/map
abstract_type = /datum/map
//* Core *//
/// id - must be unique
var/id
/// mangling id override
var/mangling_id
/// override map id for persistence so two maps are considered the same
/// two maps should **never** be loaded at the same time with the same persistence ID!
var/persistence_id
/// Is this registered in SSmapping? Once registered, our reference belongs
/// to the mapping system, and we can no longer be deleted.
var/tmp/registered = FALSE
/// are we modified from our prototype?
var/tmp/modified = FALSE
//* Chainload *//
/// dependencies by id or path of other maps - these are critical maps to always load in
/// * resolved during ready()
var/list/dependencies
/// lateload by id or path of other maps - these are non-critical maps to always load in
/// * resolved during ready()
var/list/lateload
//* Config *//
/// Allow gateway mission to load?
var/conf_load_gateway_mission = TRUE
//* Identity *//
/// in-code name
var/name = "Unknown Map"
/// in-code category
var/category = "Misc"
//* Levels *//
/// /datum/map_level datums. starts off as paths, inits later.
/// * Automatic ordering will be enforced unless specifically overridden, but we still prefer you order it properly
/// for style / review reasons.
var/list/datum/map_level/levels
/// force mangling ids of levels to be the same
/// you usually want this to be on!
var/levels_match_mangling_id = TRUE
//* Properties *//
/// declared width = must match all levels
var/width
/// declared height - must match all levels
var/height
//* Overmaps *//
/// our overmap initializer
///
/// * if specified, our overmap location will be a /datum/overmap_location/map
/// * will not be re-fired if overmaps side is what caused us to be loaded. remember,
/// /datum/overmap_initializer is a bi-directional binding to and from /datum/map!
var/datum/overmap_initializer/map/overmap_initializer
//* Load Options *//
/// orientation - defaults to south
var/load_orientation = SOUTH
/// crop if too big, instead of panic
var/load_auto_crop = FALSE
/// center us if we're smaller than world size
var/load_auto_center = TRUE
/// use map-wide area cache instead of individual level area caches; has no effect on submap loading, only level loading.
/// * don't touch this unless you know what you're doing.
var/load_shared_area_cache = TRUE
//* Simulation *//
/// Ceiling heights for levels that don't specify it, as well as the blank space between levels
var/ceiling_height_default = 5
//* World State *//
/// Are we loaded in?
var/tmp/loaded = FALSE
/// Quick access - loaded indices
/// * This should be in the same order as [levels].
var/tmp/list/loaded_z_indices
/// list of stringified z coordinates to the level datum
///
/// * "x,y,z" is the format
var/tmp/list/loaded_z_grid
/// total width of all levels
///
/// * if a level is at 0,0,0 and another is at 5,0,0 with nothing in between, our width is 5
var/tmp/loaded_sparse_size_x
/// total height of all levels
///
/// * if a level is at 0,0,0 and another is at 0,5,0 with nothing in between, our height is 5
var/tmp/loaded_sparse_size_y
/// total depth of all levels
///
/// * if a level is at 0,0,0 and another is at 0,0,5 with nothing in between, our depth is 5
var/tmp/loaded_sparse_size_z
/// z-stacks, addressed by string key "[x],[y]"
var/tmp/list/loaded_z_stacks
/// z-planes, addressed by string key "[z]"
var/tmp/list/loaded_z_planes
//* Bindings - Overmaps *//
/// the overmap location that is binding to us
///
/// * If this exists, it will be deleted if we are somehow deleted. This can result
/// in some very weird things.
var/datum/overmap_location/map/overmap_binding
//! legacy : spawn these shuttle datums on load
var/list/legacy_assert_shuttle_datums
/datum/map/New()
// resolve overmap initializer
if(ispath(overmap_initializer) || IS_ANONYMOUS_TYPEPATH(overmap_initializer))
overmap_initializer = new overmap_initializer
/datum/map/Destroy()
if(loaded)
. = QDEL_HINT_LETMELIVE
CRASH("UH OH, SOMETHING TRIED TO DELETE AN INSTANTIATED MAP.")
for(var/datum/map_level/level in levels)
if(level.parent_map != src)
stack_trace("how?")
continue
level.parent_map = null
levels = null
return ..()
/datum/map/serialize()
. = ..()
// TODO: verify all variables are in here
.["id"] = id
.["name"] = name
var/list/serialized_levels = (.["levels"] = list())
for(var/datum/map_level/level as anything in levels)
if(!istype(level))
serialized_levels += level // isn't init'd, probably path or id
continue
serialized_levels += json_encode(level.serialize())
.["dependencies"] = dependencies
.["lateload"] = lateload
.["width"] = width
.["height"] = height
.["load_auto_crop"] = load_auto_crop
.["load_auto_center"] = load_auto_center
.["load_orientation"] = load_orientation
.["load_shared_area_cache"] = load_shared_area_cache
/datum/map/deserialize(list/data)
if(loaded)
CRASH("attempted deserialize while loaded")
. = ..()
// TODO: verify all variables are in here
id = data["id"]
name = data["name"]
levels = list()
for(var/serialized_level in data["levels"])
var/is_it_a_path = text2path(serialized_level)
// path
if(ispath(is_it_a_path, /datum/map_level))
levels += is_it_a_path
continue
// json
if(serialized_level[1] == "{")
var/datum/map_level/level = new
level.deserialize(json_decode(serialized_level))
levels += level
continue
dependencies = data["dependencies"]
lateload = data["lateload"]
width = data["width"]
height = data["height"]
if(!isnull(data["load_auto_crop"]))
load_auto_crop = data["load_auto_crop"]
if(!isnull(data["load_auto_center"]))
load_auto_center = data["load_auto_center"]
if(!isnull(data["load_orientation"]))
load_orientation = data["load_orientation"]
if(!isnull(data["load_shared_area_cache"]))
load_shared_area_cache = data["load_shared_area_cache"]
// todo: implement clone()
/**
* called before we load in
* * should be called before validate()
* * instances any levels not instanced
*
* @return TRUE / FALSE
*/
/datum/map/proc/ready()
. = TRUE
for(var/i in 1 to length(levels))
if(ispath(levels[i]))
var/datum/map_level/level_path = levels[i]
var/datum/map_level/level_instance = new level_path(src)
levels[i] = level_instance
if(levels_match_mangling_id)
level_instance.mangling_id = mangling_id || id
for(var/i in 1 to length(dependencies))
if(ispath(dependencies[i]))
var/datum/map/resolving = dependencies[i]
dependencies[i] = initial(resolving.id)
for(var/i in 1 to length(lateload))
if(ispath(lateload[i]))
var/datum/map/resolving = lateload[i]
lateload[i] = initial(resolving.id)
/**
* validates that everything works
*
* @params
* * for_load - (optional) validate for loading, not just that it makes semantic sense
* * out_errors - (optional) human readable errors get added to this list if provided
*
* @return TRUE / FALSE
*/
/datum/map/proc/validate(for_load, list/out_errors)
. = TRUE
var/list/struct_position_strs = list()
var/list/keyed_levels = list()
var/list/levels_in_plane_by_xy = list()
// validate levels individually
for(var/level_idx in 1 to length(levels))
var/datum/map_level/level = levels[level_idx]
if(!istype(level))
out_errors?.Add("level: index [level_idx] is not a valid map level datum.")
. = FALSE
continue
if(level.id)
if(keyed_levels[level.id])
out_errors?.Add("level: index [level_idx] collides with index [levels.Find(keyed_levels[level.id])] on level id '[level.id]'")
. = FALSE
else
keyed_levels[level.id] = level
if(level.has_map_path())
var/datum/dmm_parsed/parsed = level.parse_map_path()
if(!parsed)
out_errors?.Add("level: index [level_idx] couldn't find its map file.")
. = FALSE
else if(!parsed.parsed)
out_errors?.Add("level: index [level_idx] parse failed. is the .dmm malformed?")
. = FALSE
else
if(parsed.width > world.maxx || parsed.height > world.maxy)
out_errors?.Add("level: index [level_idx] dim [parsed.width]x[parsed.height] > [world.maxx]x[world.maxy]")
. = FALSE
var/level_struct_enabled = level.struct_x && level.struct_y && level.struct_z
if(level_struct_enabled)
if(!level.id)
out_errors?.Add("level: index [level_idx] has no id but is in a struct; struct-levels must have IDs.")
. = FALSE
var/level_struct_valid = TRUE
if(!isnum(level.struct_x))
out_errors?.Add("level: index [level_idx] struct_x not num")
level_struct_valid = FALSE
else if(!ISINRANGE(level.struct_x, -(SHORT_REAL_LIMIT * 0.5), (SHORT_REAL_LIMIT * 0.5)) || (round(level.struct_x) != level.struct_x))
out_errors?.Add("level: index [level_idx] struct_x out of bounds or fractional")
level_struct_valid = FALSE
if(!isnum(level.struct_y))
out_errors?.Add("level: index [level_idx] struct_y not num")
level_struct_valid = FALSE
else if(!ISINRANGE(level.struct_y, -(SHORT_REAL_LIMIT * 0.5), (SHORT_REAL_LIMIT * 0.5)) || (round(level.struct_y) != level.struct_y))
out_errors?.Add("level: index [level_idx] struct_y out of bounds or fractional")
level_struct_valid = FALSE
if(!isnum(level.struct_z))
out_errors?.Add("level: index [level_idx] struct_z not num")
level_struct_valid = FALSE
else if(!ISINRANGE(level.struct_z, -(SHORT_REAL_LIMIT * 0.5), (SHORT_REAL_LIMIT * 0.5)) || (round(level.struct_z) != level.struct_z))
out_errors?.Add("level: index [level_idx] struct_z out of bounds or fractional")
level_struct_valid = FALSE
if(level_struct_valid)
var/level_struct_position_str = "[level.struct_x],[level.struct_y],[level.struct_z]"
var/level_struct_plane_xy = "[level.struct_x],[level.struct_y]"
if(struct_position_strs[level_struct_position_str])
out_errors?.Add("level: index [level_idx] collides with level index [levels.Find(struct_position_strs[level_struct_position_str])] on struct position '[level_struct_position_str]'")
. = FALSE
else
struct_position_strs[level_struct_position_str] = level
if(!levels_in_plane_by_xy[level_struct_plane_xy])
levels_in_plane_by_xy[level_struct_plane_xy] = list()
levels_in_plane_by_xy[level_struct_plane_xy] += level
// TODO: invoke level datum validation, including for base turf/area validity (see spawn flags & area not `special`)
// can't run overall checks if any levels are individually invalid
if(!.)
return
for(var/plane_str in levels_in_plane_by_xy)
var/list/datum/map_level/plane_levels = levels_in_plane_by_xy[plane_str]
var/found_ceiling_height
for(var/datum/map_level/plane_level as anything in plane_levels)
if(isnull(plane_level.ceiling_height))
continue
if(plane_level.ceiling_height == 0)
out_errors?.Add("Plane [plane_str] has a zero ceiling height level.")
. = FALSE
break
if(!isnull(found_ceiling_height) && found_ceiling_height != plane_level.ceiling_height)
out_errors?.Add("Plane [plane_str] has mismatching ceiling heights.")
. = FALSE
break
else
found_ceiling_height = plane_level.ceiling_height
if(for_load && !validate_load(out_errors, .))
. = FALSE
/**
* validate that we can currently load
* * this checks for things like collisions with current loaded maps, levels, world state, etc
*
* @params
* * out_errors - (optional) human readable errors get added to this list if provided
* * structure_validity - (optional) pass in if structure is valid; this makes it so we don't run
* future tests that would always fail if the map's contents for whatever reason
* are considered invalid.
*
* @return TRUE / FALSE
*/
/datum/map/proc/validate_load(list/out_errors, structure_validity)
. = TRUE
for(var/level_idx in 1 to length(levels))
var/datum/map_level/level = levels[level_idx]
if(SSmapping.keyed_levels[level.id])
. = FALSE
out_errors?.Add("level: index [level_idx] has id of '[level.id]' which is already taken by a currently loaded level.")
/**
* rebuilds state, setting variables as needed on ourselves and our level
* * should be called after validate(); validate() doesn't require this to work
* * if loaded maps have their multiz things change, this'll have ssmapping rebuild its cache
* and also rebuild the transitions
* * this will trample any custom linkages set on levels if they're not set to LINKAGE_FORCED!
* * this will **not** trample reciprocal custom linkages. if another level links to a level in us,
* that level will not be unbound despite our level now being unbound.
*
* TODO: mode to only re-assert internal linkages, without disrupting linkages to other levels outside of ours.
*
* @params
* * skip_validation - skip data validation. this should only be done if you already validate()'d before construct().
* * skip_loaded_rebuild - do not rebuild loaded zlevels immediately.
*/
/datum/map/proc/construct(skip_validation, skip_loaded_rebuild)
var/list/validation_errors = list()
if(!validate(TRUE, validation_errors))
CRASH("validation failed at construct() with errors: [english_list(validation_errors)]; this shouldn't be failing this far in the pipeline, please remember to validate() maps yourself!")
var/list/datum/map_level/loaded_levels_requiring_immediate_rebuild_to_dirs = list()
var/z_grid = list()
var/z_stacks = list()
var/z_planes = list()
// collect data
for(var/datum/map_level/level as anything in levels)
level.struct_active = level.is_in_struct()
if(!level.struct_active)
continue
z_grid["[level.struct_x],[level.struct_y],[level.struct_z]"] = level
var/x_y_str = "[level.struct_x],[level.struct_y]"
if(!z_stacks[x_y_str])
z_stacks[x_y_str] = list()
z_stacks[x_y_str] += level
var/z_str = "[level.struct_z]"
if(!z_planes[z_str])
z_planes[z_str] = list()
z_planes[z_str] += level
// sweep levels
for(var/datum/map_level/level as anything in levels)
level.load_orientation = load_orientation
level.load_center = load_auto_center
level.load_crop = load_auto_crop
if(level.struct_active)
var/level_multiz_changed_dirs = NONE
switch(level.linkage)
if(Z_LINKAGE_FORCED)
if(Z_LINKAGE_NORMAL)
var/datum/map_level/level_partner
level_partner = z_grid["[level.struct_x+1],[level.struct_y],[level.struct_z]"]
if(level.link_east_id != level_partner?.id)
level.link_east_id = level_partner?.id
level_multiz_changed_dirs |= EAST
level_partner = z_grid["[level.struct_x-1],[level.struct_y],[level.struct_z]"]
if(level.link_west_id != level_partner?.id)
level.link_west_id = level_partner?.id
level_multiz_changed_dirs |= WEST
level_partner = z_grid["[level.struct_x],[level.struct_y+1],[level.struct_z]"]
if(level.link_north_id != level_partner?.id)
level.link_north_id = level_partner?.id
level_multiz_changed_dirs |= NORTH
level_partner = z_grid["[level.struct_x],[level.struct_y-1],[level.struct_z]"]
if(level.link_south_id != level_partner?.id)
level.link_south_id = level_partner?.id
level_multiz_changed_dirs |= SOUTH
level_partner = z_grid["[level.struct_x],[level.struct_y],[level.struct_z+1]"]
if(level.link_above_id != level_partner?.id)
level.link_above_id = level_partner?.id
level_multiz_changed_dirs |= UP
level_partner = z_grid["[level.struct_x],[level.struct_y],[level.struct_z-1]"]
if(level.link_below_id != level_partner?.id)
level.link_below_id = level_partner?.id
level_multiz_changed_dirs |= DOWN
if(level.loaded && level_multiz_changed_dirs)
loaded_levels_requiring_immediate_rebuild_to_dirs[level] = level_multiz_changed_dirs
for(var/datum/map_level/rebuilding_level as anything in loaded_levels_requiring_immediate_rebuild_to_dirs)
var/rebuild_dirs = loaded_levels_requiring_immediate_rebuild_to_dirs[rebuilding_level]
SSmapping.rebuild_multiz_lookup(rebuilding_level.z_index, rebuild_dirs)
if(!skip_loaded_rebuild && rebuilding_level.loaded)
if(rebuild_dirs & (NORTH|SOUTH|EAST|WEST))
rebuilding_level.rebuild_multiz_horizontal()
else
rebuilding_level.rebuild_multiz_vertical()
return TRUE
/**
* Get levels sorted into z-loading order
*/
/datum/map/proc/get_sorted_levels()
// this only works because tim_sort is stable;
// cmp_map_level_load_sequence doesn't touch order if structs aren't active.
return tim_sort(levels.Copy(), /proc/cmp_map_level_load_sequence)