Files
Bubberstation/code/modules/lighting/lighting_source.dm
LemonInTheDark 77823ad210 Pathfinding Visualization, JPS fixes, Misc Improvement (#90233)
## About The Pull Request

[cleans up poor namespacing on light debugging
tools](93cc9070d5)

[Implements a pathfinding visualization
tool](ed91f69ac4)

It holds a set of inputs from the client, and uses them to generate and
display paths from source/target. Left click sets the source, right
click sets the target.

Pathmap support too, if no target is set we display the paths from every
turf in the map to the source, if one is set we build a path TO it from
the source.

I had to add COMSIG_MOB_CLICKON to observers to make this work (tho idk
why it didn't exist already), I also removed the everpresent colorblind
datum from admin datums, only needs to exist if they're using it.

[Adds a mutable_appearance helper that dirlocks them, wallening port
which I thought might be useful here, and will likely be useful
elsewhere in
future](87f752e7c3)

[Fixes an infinite loop in pathmaps if we tried to pull a cached path to
an unreachable target, && not ||
4head](10086a655d)

[Fixes JPS not dealing with border objects properly. They violate some
of the assumptions JPS makes, so we need to backfill them with checks.
These basically read as (if the thing that should normally take this
path can't reach this turf, can
we?)](f56cc4dd43)

## Why It's Good For The Game

Maybe deals with #80619?

Adds more robust testing tools for pathfinding, should allow people to
better understand/debug these systems. I added this with the idea of
adding multiz support but I don't have the time for that rn.

JPS will work around thindows better, that's nice.

https://file.house/IrBiR0bGxoKw1jJJoxgMRQ==.mp4

## Changelog
🆑
fix: Fixed our pathfinding logic getting deeply confused by border
objects
admin: Added clientside displayed pathfinding debug tools, give em a go
/🆑
2025-03-28 01:51:57 +02:00

561 lines
22 KiB
Plaintext

/// Cached global list of generated lighting sheets. See: datum/light_source/proc/get_sheet()
GLOBAL_LIST_EMPTY(lighting_sheets)
// This is where the fun begins.
// These are the main datums that emit light.
/datum/light_source
///The atom we're emitting light from (for example a mob if we're from a flashlight that's being held).
var/atom/top_atom
///The atom that we belong to.
var/atom/source_atom
///The turf under the source atom.
var/turf/source_turf
/// How much to x shift our light by when displaying it
var/offset_x = 0
/// How much to y shift our light by when displaying it
var/offset_y = 0
/// How much larger our light sheet should be, based off offset_x and y
/// We clamp to at least 1, so if offset_x is 0.1, then this'll be 1
var/visual_offset
///Intensity of the emitter light.
var/light_power
/// The range of the emitted light.
var/light_range
/// The colour of the light, string, decomposed by parse_light_color()
var/light_color
/// The height of the light. The larger this is, the dimmer we'll start
var/light_height
// Variables for keeping track of the colour.
var/lum_r
var/lum_g
var/lum_b
/// What direction our angled light is pointed
var/light_dir = NONE
/// How many degrees of a circle should our light show. 360 is all of it, 180 is half, etc
var/light_angle = 360
// The lumcount values used to apply the light.
var/tmp/applied_lum_r
var/tmp/applied_lum_g
var/tmp/applied_lum_b
/// List used to store how much we're affecting corners.
var/list/datum/lighting_corner/effect_str
/// Whether we have applied our light yet or not.
var/applied = FALSE
/// whether we are to be added to SSlighting's sources_queue list for an update
var/needs_update = LIGHTING_NO_UPDATE
/datum/light_source/New(atom/owner, atom/top)
source_atom = owner // Set our new owner.
add_to_light_sources(source_atom)
top_atom = top
if (top_atom != source_atom)
add_to_light_sources(top_atom)
source_turf = top_atom
light_power = source_atom.light_power
light_range = source_atom.light_range
light_color = source_atom.light_color
PARSE_LIGHT_COLOR(src)
update()
if(GLOB.light_debug_enabled)
source_atom.debug_lights()
/datum/light_source/Destroy(force)
remove_lum()
if (source_atom)
remove_from_light_sources(source_atom)
if (top_atom)
remove_from_light_sources(top_atom)
if (needs_update)
SSlighting.sources_queue -= src
SSlighting.current_sources -= src
top_atom = null
source_atom = null
source_turf = null
return ..()
///add this light source to new_atom_host's light_sources list. updating movement registrations as needed
/datum/light_source/proc/add_to_light_sources(atom/new_atom_host)
if(QDELETED(new_atom_host))
return FALSE
LAZYADD(new_atom_host.light_sources, src)
//yes, we register the signal to the top atom too, this is intentional and ensures contained lighting updates properly
if(ismovable(new_atom_host))
RegisterSignal(new_atom_host, COMSIG_MOVABLE_MOVED, PROC_REF(update_host_lights))
RegisterSignal(new_atom_host, COMSIG_TURF_NO_LONGER_BLOCK_LIGHT, PROC_REF(force_update))
return TRUE
///remove this light source from old_atom_host's light_sources list, unsetting movement registrations
/datum/light_source/proc/remove_from_light_sources(atom/old_atom_host)
if(QDELETED(old_atom_host))
return FALSE
LAZYREMOVE(old_atom_host.light_sources, src)
if(ismovable(old_atom_host))
UnregisterSignal(old_atom_host, COMSIG_MOVABLE_MOVED)
UnregisterSignal(old_atom_host, COMSIG_TURF_NO_LONGER_BLOCK_LIGHT)
return TRUE
// Yes this doesn't align correctly on anything other than 4 width tabs.
// If you want it to go switch everybody to elastic tab stops.
// Actually that'd be great if you could!
#define EFFECT_UPDATE(level) \
if (needs_update == LIGHTING_NO_UPDATE) { \
SSlighting.sources_queue += src; \
} \
if (needs_update < level) { \
needs_update = level; \
}
///signal handler for when our host atom moves and we need to update our effects
/datum/light_source/proc/update_host_lights(atom/movable/host)
SIGNAL_HANDLER
if(QDELETED(host))
return
// If the host is our owner, we want to call their update so they can decide who the top atom should be
if(host == source_atom)
host.update_light()
return
// Otherwise, our top atom just moved, so we trigger a normal rebuild
EFFECT_UPDATE(LIGHTING_CHECK_UPDATE)
/// This proc will cause the light source to update the top atom, and add itself to the update queue.
/datum/light_source/proc/update(atom/new_top_atom)
// This top atom is different.
if (new_top_atom && new_top_atom != top_atom)
if(top_atom != source_atom && top_atom.light_sources) // Remove ourselves from the light sources of that top atom.
remove_from_light_sources(top_atom)
top_atom = new_top_atom
if (top_atom != source_atom)
add_to_light_sources(top_atom)
EFFECT_UPDATE(LIGHTING_CHECK_UPDATE)
// Will force an update without checking if it's actually needed.
/datum/light_source/proc/force_update()
EFFECT_UPDATE(LIGHTING_FORCE_UPDATE)
// Will cause the light source to recalculate turfs that were removed or added to visibility only.
/datum/light_source/proc/vis_update()
EFFECT_UPDATE(LIGHTING_VIS_UPDATE)
// This exists so we can cache the vars used in this macro, and save MASSIVE time :)
// Most of this is saving off datum var accesses, tho some of it does actually cache computation
// You will NEED to call this before you call APPLY_CORNER
#define SETUP_CORNERS_CACHE(lighting_source) \
var/_turf_x = lighting_source.source_turf.x; \
var/_turf_y = lighting_source.source_turf.y; \
var/_turf_z = lighting_source.source_turf.z; \
var/list/_sheet = get_sheet(); \
var/list/_multiz_sheet = list(); \
if(!!GET_LOWEST_STACK_OFFSET(source_turf.z)) { \
_multiz_sheet = get_sheet(multiz = TRUE); \
} \
var/_range_offset = CEILING(lighting_source.light_range, 1) + 0.5 + 1 + lighting_source.visual_offset; \
var/_multiz_offset = SSmapping.max_plane_offset + 1; \
var/_light_power = lighting_source.light_power; \
var/_applied_lum_r = lighting_source.applied_lum_r; \
var/_applied_lum_g = lighting_source.applied_lum_g; \
var/_applied_lum_b = lighting_source.applied_lum_b; \
var/_lum_r = lighting_source.lum_r; \
var/_lum_g = lighting_source.lum_g; \
var/_lum_b = lighting_source.lum_b;
#define SETUP_CORNERS_REMOVAL_CACHE(lighting_source) \
var/_applied_lum_r = lighting_source.applied_lum_r; \
var/_applied_lum_g = lighting_source.applied_lum_g; \
var/_applied_lum_b = lighting_source.applied_lum_b;
// Read out of our sources light sheet, a map of offsets -> the luminosity to use
#define LUM_FALLOFF(C) _sheet[C.x - _turf_x + _range_offset][C.y - _turf_y + _range_offset]
#define LUM_FALLOFF_MULTIZ(C) _multiz_sheet[C.z - _turf_z + _multiz_offset][C.x - _turf_x + _range_offset][C.y - _turf_y + _range_offset]
// Macro that applies light to a new corner.
// It is a macro in the interest of speed, yet not having to copy paste it.
// If you're wondering what's with the backslashes, the backslashes cause BYOND to not automatically end the line.
// As such this all gets counted as a single line.
// The braces and semicolons are there to be able to do this on a single line.
#define APPLY_CORNER(C) \
if(C.z == _turf_z) { \
. = LUM_FALLOFF(C); \
} \
else { \
. = LUM_FALLOFF_MULTIZ(C) \
} \
. *= _light_power; \
var/OLD = effect_str[C]; \
\
C.update_lumcount \
( \
(. * _lum_r) - (OLD * _applied_lum_r), \
(. * _lum_g) - (OLD * _applied_lum_g), \
(. * _lum_b) - (OLD * _applied_lum_b) \
);
#define REMOVE_CORNER(C) \
. = -effect_str[C]; \
C.update_lumcount \
( \
. * _applied_lum_r, \
. * _applied_lum_g, \
. * _applied_lum_b \
);
/// Returns a list of lists, indexed with ints, that can be read to get the lighting multiplier at any one point
/// If the requested sheet is multiz, this will be 3 lists deep, first handling z level then x and y
/// otherwise it's just two, x then y
/datum/light_source/proc/get_sheet(multiz = FALSE)
var/range = max(1, light_range);
var/key = "[range]-[visual_offset]-[offset_x]-[offset_y]-[light_dir]-[light_angle]-[light_height]-[multiz]"
var/list/hand_back = GLOB.lighting_sheets[key]
if(!hand_back)
if(multiz)
hand_back = generate_sheet_multiz(range, visual_offset, offset_x, offset_y, light_dir, light_angle, light_height)
else
hand_back = generate_sheet(range, visual_offset, offset_x, offset_y, light_dir, light_angle, light_height)
GLOB.lighting_sheets[key] = hand_back
return hand_back
/// Returns a list of lists that encodes the light falloff of our source
/// Takes anything that impacts our generation as input
/// This function should be "pure", no side effects or reads from the source object
/datum/light_source/proc/generate_sheet(range, visual_offset, x_offset, y_offset, center_dir, angle, height, z_level = 0)
var/list/encode = list()
// How far away the turfs we get are, and how many there are are often not the same calculation
// So we need to include the visual offset, so we can ensure our sheet is large enough to accept all the distance differences
var/bound_range = CEILING(range, 1) + visual_offset
// Corners are placed at 0.5 offsets
// We need our coords to reflect that (though x_offsets that change the basis for how things are calculated are fine too)
for(var/x in (-(bound_range) + x_offset - 0.5) to (bound_range + x_offset + 0.5))
var/list/row = list()
for(var/y in (-(bound_range) + y_offset - 0.5) to (bound_range + y_offset + 0.5))
row += falloff_at_coord(x, y, z_level, range, center_dir, angle, height)
encode += list(row)
return encode
/// Returns a THREE dimensional list of lists that encodes the lighting falloff of our source
/// Takes anything that impacts our generation as input
/// This function should be "pure", no side effects or reads from the passed object
/datum/light_source/proc/generate_sheet_multiz(range, visual_offset, x_offset, y_offset, center_dir, angle, height)
var/list/encode = list()
var/z_range = SSmapping.max_plane_offset // Let's just be safe yeah?
for(var/z in -z_range to z_range)
var/list/sheet = generate_sheet(range, visual_offset, x_offset, y_offset, center_dir, angle, height, z)
encode += list(sheet)
return encode
/// Takes x y and z offsets from the source as input, alongside our source's range
/// Returns a value between 0 and 1, 0 being dark on that tile, 1 being fully lit
/datum/light_source/proc/falloff_at_coord(x, y, z, range, center_dir, angle, height)
var/range_divisor = max(1, range)
// You may notice we use squares here even though there are three components
// Because z diffs are so functionally small, cubes and cube roots are too aggressive
// The larger the distance is, the less bright our light will be
var/multiplier = 1 - CLAMP01(sqrt(x ** 2 + y ** 2 + z ** 2 + height) / range_divisor)
if(angle >= 360 || angle <= 0)
return multiplier
// Turn our positional offset into an angle
var/coord_angle = delta_to_angle(x, y)
// Get the difference between the angle we want, and the angle we have
var/center_angle = dir2angle(center_dir)
var/angle_delta = abs(center_angle - coord_angle)
// Now we have to normalize the angle delta to be between 0 and 180, instead of 0 and 360
// This ensures removing say, 15 degrees removes it from both sides, rather then just one
// Turns an unfurling fan into a pair of scissors
if(angle_delta > 180)
angle_delta = 180 - (angle_delta - 180)
// We allow angle deltas to a certian amount, angle / 2
// If we pass that, then it starts effecting the visuals
// Oh and we'll scale it so 30 degrees is the "0" point, where things become fully dark
// This could be variable, it just isn't yet yaknow?
return max(multiplier * (1 - max(angle_delta - (angle / 2), 0) / 30), 0)
/// Dumps the content of a lighting sheet to chat, for debugging
/datum/light_source/proc/print_sheet()
var/list/sheet = get_sheet()
var/list/output = list()
var/multiz_depth = 1
// If we have a list 3 layers down we're multiz
if(length(sheet[1][1]))
multiz_depth = length(sheet)
var/column_seperator = ""
for(var/i in 1 to length(sheet))
column_seperator += "----"
output += column_seperator
for(var/i in 1 to multiz_depth)
for(var/list/column in sheet)
var/list/print_column = list()
for(var/row in column)
print_column += round(row, 0.1)
output += print_column.Join(", ")
output += column_seperator
to_chat(usr, "\n[output.Join("\n")]")
/// Debug proc, for when lighting sheets fuck up
/// Accepts the sheet (2 or 3 (multiz) dimensional list of lighting values at some offset)
/// alongside x and y delta values and the sheet's "offset", which is the amount required to ensure everything indexes at 1
/// Optionally, you can pass similar values for multiz stuff
/proc/read_sheet(list/sheet, x, y, offset, z, z_offset)
var/list/working = sheet
var/offset_x = x + offset
var/offset_y = y + offset
var/offset_z = z + z_offset
if(z)
working = sheet[offset_z]
var/list/line = working[offset_x]
var/word = line[offset_y]
return word
/// This is the define used to calculate falloff.
/datum/light_source/proc/remove_lum()
SETUP_CORNERS_REMOVAL_CACHE(src)
applied = FALSE
for (var/datum/lighting_corner/corner as anything in effect_str)
REMOVE_CORNER(corner)
LAZYREMOVE(corner.affecting, src)
effect_str = null
/datum/light_source/proc/recalc_corner(datum/lighting_corner/corner)
SETUP_CORNERS_CACHE(src)
LAZYINITLIST(effect_str)
if (effect_str[corner]) // Already have one.
REMOVE_CORNER(corner)
effect_str[corner] = 0
APPLY_CORNER(corner)
effect_str[corner] = .
// Keep in mind. Lighting corners accept the bottom left (northwest) set of cords to them as input
#define GENERATE_MISSING_CORNERS(gen_for) \
if (!gen_for.lighting_corner_NE) { \
gen_for.lighting_corner_NE = new /datum/lighting_corner(gen_for.x, gen_for.y, gen_for.z); \
} \
if (!gen_for.lighting_corner_SE) { \
gen_for.lighting_corner_SE = new /datum/lighting_corner(gen_for.x, gen_for.y - 1, gen_for.z); \
} \
if (!gen_for.lighting_corner_SW) { \
gen_for.lighting_corner_SW = new /datum/lighting_corner(gen_for.x - 1, gen_for.y - 1, gen_for.z); \
} \
if (!gen_for.lighting_corner_NW) { \
gen_for.lighting_corner_NW = new /datum/lighting_corner(gen_for.x - 1, gen_for.y, gen_for.z); \
} \
gen_for.lighting_corners_initialised = TRUE;
#define INSERT_CORNERS(insert_into, draw_from) \
if (!draw_from.lighting_corners_initialised) { \
GENERATE_MISSING_CORNERS(draw_from); \
} \
insert_into[draw_from.lighting_corner_NE] = 0; \
insert_into[draw_from.lighting_corner_SE] = 0; \
insert_into[draw_from.lighting_corner_SW] = 0; \
insert_into[draw_from.lighting_corner_NW] = 0;
/// Refreshes our lighting source to match its parent atom
/// Returns TRUE if an update is needed, FALSE otherwise
/datum/light_source/proc/refresh_values()
var/update = FALSE
var/atom/source_atom = src.source_atom
var/turf/old_source_turf = source_turf
if (QDELETED(source_atom))
qdel(src)
return FALSE
if (source_atom.light_power != light_power)
light_power = source_atom.light_power
update = TRUE
if (source_atom.light_range != light_range)
light_range = source_atom.light_range
update = TRUE
if (!top_atom)
top_atom = source_atom
update = TRUE
if (!light_range || !light_power)
qdel(src)
return FALSE
var/atom/visual_source = source_atom
if(isturf(top_atom))
visual_source = source_atom
if(source_turf != top_atom)
source_turf = top_atom
update = TRUE
else
visual_source = top_atom
if(top_atom.loc != source_turf)
source_turf = top_atom.loc
update = TRUE
if (!isturf(source_turf))
if (applied)
remove_lum()
return FALSE
if (light_range && light_power && !applied)
update = TRUE
if (source_atom.light_color != light_color)
light_color = source_atom.light_color
PARSE_LIGHT_COLOR(src)
update = TRUE
else if (applied_lum_r != lum_r || applied_lum_g != lum_g || applied_lum_b != lum_b)
update = TRUE
if(source_atom.light_dir != light_dir)
light_dir = source_atom.light_dir
update = TRUE
if (source_atom.light_angle != light_angle)
light_angle = source_atom.light_angle
update = TRUE
if(source_atom.light_height != light_height)
light_height = source_atom.light_height
update = TRUE
var/list/visual_offsets = calculate_light_offset(visual_source)
if(visual_offsets[1] != offset_x || visual_offsets[2] != offset_y || source_turf != old_source_turf)
offset_x = visual_offsets[1]
offset_y = visual_offsets[2]
visual_offset = max(CEILING(abs(offset_x), 1), CEILING(abs(offset_y), 1))
update = TRUE
// If we need to update, well, update
if (update)
needs_update = LIGHTING_CHECK_UPDATE
applied = TRUE
return TRUE
// Otherwise, go off the needs_update var. If it requires an update provide one, otherwise we're kosher
if (needs_update == LIGHTING_CHECK_UPDATE)
return FALSE //nothing's changed
return TRUE
/// Returns a list of lighting corners this source impacts
/datum/light_source/proc/impacted_corners()
var/list/datum/lighting_corner/corners = list()
if (!source_turf)
return list()
var/oldlum = source_turf.luminosity
var/working_range = CEILING(light_range + visual_offset, 1)
source_turf.luminosity = working_range
var/uses_multiz = !!GET_LOWEST_STACK_OFFSET(source_turf.z)
if(!uses_multiz) // Yes I know this could be acomplished with an if in the for loop, but it's fukin lighting code man
for(var/turf/T in view(working_range, source_turf))
if(IS_OPAQUE_TURF(T))
continue
INSERT_CORNERS(corners, T)
source_turf.luminosity = oldlum
return corners
for(var/turf/T in view(working_range, source_turf))
if(IS_OPAQUE_TURF(T))
continue
INSERT_CORNERS(corners, T)
var/turf/below = GET_TURF_BELOW(T)
var/turf/previous = T
while(below)
// If we find a non transparent previous, end
if(!istransparentturf(previous))
break
if(IS_OPAQUE_TURF(below))
// If we're opaque but the tile above us is transparent, then we should be counted as part of the potential "space"
// Of this corner
break
// Now we do lighting things to it
INSERT_CORNERS(corners, below)
// ANNND then we add the one below it
previous = below
below = GET_TURF_BELOW(below)
var/turf/above = GET_TURF_ABOVE(T)
while(above)
// If we find a non transparent turf, end
if(!istransparentturf(above) || IS_OPAQUE_TURF(above))
break
INSERT_CORNERS(corners, above)
above = GET_TURF_ABOVE(above)
source_turf.luminosity = oldlum
return corners
/datum/light_source/proc/update_corners()
if(!refresh_values())
return
var/list/datum/lighting_corner/corners = impacted_corners()
SETUP_CORNERS_CACHE(src)
var/list/datum/lighting_corner/new_corners = (corners - src.effect_str)
LAZYINITLIST(src.effect_str)
for (var/datum/lighting_corner/corner as anything in new_corners)
APPLY_CORNER(corner)
if (. != 0)
LAZYADD(corner.affecting, src)
effect_str[corner] = .
// New corners are a subset of corners. so if they're both the same length, there are NO old corners!
if(needs_update != LIGHTING_VIS_UPDATE && length(corners) != length(new_corners))
for (var/datum/lighting_corner/corner as anything in corners - new_corners) // Existing corners
APPLY_CORNER(corner)
if (. != 0)
effect_str[corner] = .
else
LAZYREMOVE(corner.affecting, src)
effect_str -= corner
var/list/datum/lighting_corner/gone_corners = effect_str - corners
for (var/datum/lighting_corner/corner as anything in gone_corners)
REMOVE_CORNER(corner)
LAZYREMOVE(corner.affecting, src)
effect_str -= gone_corners
applied_lum_r = lum_r
applied_lum_g = lum_g
applied_lum_b = lum_b
UNSETEMPTY(src.effect_str)
#undef APPLY_CORNER
#undef EFFECT_UPDATE
#undef GENERATE_MISSING_CORNERS
#undef INSERT_CORNERS
#undef LUM_FALLOFF
#undef LUM_FALLOFF_MULTIZ
#undef REMOVE_CORNER
#undef SETUP_CORNERS_CACHE
#undef SETUP_CORNERS_REMOVAL_CACHE