mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-07-21 04:57:57 +01:00
Adds "Event Logging", or EVLogging, A new debug system that allows us to track individual datums and log events on a timeline (#96035)
## About The Pull Request This Pull Request adds a new logging system that uses a timeline to track and visualize important events for specific datums. This is done via a new window in which you can select a datum for tracking, which adds it to the timeline. If this datum implements the EVLOGGING macros, it can track important events onto this timeline. As an example, we can log whenever an AI is deciding to make a new path, if it decides to generate a new decisionmaking plan, it finishes an action, or it decides to target someone/something. We can select these events to see more information, and optionally get a snapshot of important variables at the time this event was logged (like the blackboard and current plan for AI controllers). You can also filter out specific events / track info, which is done via categories. Each event / piece of track info is given a category and if you disable a category all events / track info in that category is hidden. This lets you filter out things you might not care about. <img width="2346" height="1209" alt="image" src="https://github.com/user-attachments/assets/0763077c-e349-4c7c-b017-23d29e1d089b" /> _whoever thinks we didnt need advanced cleanbot logging is a noob_ In the video below I showcase how this works; https://file.house/7nsOiqdvmSTxlsk3fs-e8g==.mp4 A cleanbot is roaming the halls, I turn on the event logger, click the "pick target" button and click on the datum I'd like to track (the cleanbot). This results in the cleanbot now tracking its events. I spawn some dirt and the cleanbot decides to clean it, and I go through the events; You can see theres different events being listed, such as when the cleanbot starts targetting the dirt, when it cleans plan, when it makes it JPS path and every time it moves over it. The macros I've currently implemented are as follows: **EVLOG_TEXT(DATUM, CATEGORY, INFO)** Only adds text to the event logger window, no world-visuals EVLOG_LOCATION(DATUM, CATEGORY, INFO, TURF) Adds text to the event logger and adds an image to where that turf is. EVLOG_TURFS(DATUM, CATEGORY, INFO, TURFS) Adds text to the event logger and adds an image to each turf in the TURFS list EVLOG_LINES(DATUM, CATEGORY, INFO, TURF_A, TURF_B) Adds text to the event logger and adds a line from turf_a to turf_B EVLOG_PATH(DATUM, CATEGORY, INFO, TURFS) Adds text to the event logger and visualizes a path from A to B (same way as the pathfinding debugger, of which I moved the visualization before to SSPathfinder) In terms of performance, the logger is a singleton, and events are ONLY logged if 1. The logger is running 2. The datum has the DF_EVLOGGING flag. This means most of the time, logging an event is a single var lookup (Since the runner is off by default). The DF_EVLOGGING flag is off by default as well and has to be enabled by the event logger, or set temporarily by a dev in code. This system can easily be extended with more event types / visualization types as well. (I'm thinking of datumizing the ones I have now) The TGUI is still a bit of a mess, I would love some pointers because I'm not really good at react so I just kind of hit it with a hammer until it did what I wanted 😎 Also, all of this is based on VisLogging from Unreal Engine, so it will have some likeness https://unreal-garden.com/tutorials/visual-logger/ ## Why It's Good For The Game This system allows us to debug more complex systems (like basic AI) in an understandable and clear way. While the implementation cases are not super common right now, extending this system could make debugging these systems much more comprehensible, and hopefully lets more developers help us with improving these systems. (plus, we LOVE timelines) ## Changelog 🆑 CabinetOnFire refactor: Implements "Event Logging" an improved way for programmers to debug specific datums. /🆑 --------- Co-authored-by: Lucy <lucy@absolucy.moe>
This commit is contained in:
@@ -13,6 +13,8 @@ GLOBAL_LIST_INIT(bitflags, list(1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 204
|
||||
#define DF_ISPROCESSING (1<<2)
|
||||
/// Placed on datums that have a static, constant reference. Primarily only used for turfs.
|
||||
#define DF_STATIC_OBJECT (1<<3)
|
||||
/// Set on datums that should be tracked by the event logger.
|
||||
#define DF_EVLOGGING (1<<4)
|
||||
|
||||
//FLAGS BITMASK
|
||||
// scroll down before changing the numbers on these
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
/// from datum/tgui/get_payload(user, list/data)
|
||||
#define COMSIG_UI_DATA "ui_data"
|
||||
|
||||
/// fired on a datum when event-logger tracking is enabled on it [/datum/proc/enable_evlogging]: ()
|
||||
#define COMSIG_EVLOGGING_ENABLED "evlogging_enabled"
|
||||
/// fired on a datum when event-logger tracking is disabled on it [/datum/proc/disable_evlogging]: ()
|
||||
#define COMSIG_EVLOGGING_DISABLED "evlogging_disabled"
|
||||
|
||||
/// fired on the source datum whenever an event is added for it in the event logger: (/datum/event_logger_track/track, list/event_data)
|
||||
#define COMSIG_EVLOG_EVENT_ADDED "evlog_event_added"
|
||||
|
||||
/// fires on the target datum when an element is attached to it (/datum/element)
|
||||
#define COMSIG_ELEMENT_ATTACH "element_attach"
|
||||
/// fires on the target datum when an element is detached from it (/datum/element)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Event Logger macros. The logging functions only run if the logger is active and the datum being logged has the DF_EVLOGGING flag set. This makes logging itself relatively cheap when not in use.
|
||||
/// Log a plain text event.
|
||||
#define EVLOG_TEXT(DATUM, CATEGORY, INFO) \
|
||||
if(GLOB.event_logger.running && (DATUM.datum_flags & DF_EVLOGGING)) \
|
||||
{ GLOB.event_logger.log_event_text(DATUM, CATEGORY, INFO) }
|
||||
|
||||
/// Log a location event that highlights a single tile when selected.
|
||||
/// TURF is a /turf. DATUM must have DF_EVLOGGING set.
|
||||
#define EVLOG_LOCATION(DATUM, CATEGORY, INFO, TURF) \
|
||||
if(GLOB.event_logger.running && (DATUM.datum_flags & DF_EVLOGGING)) \
|
||||
{ GLOB.event_logger.log_event_location(DATUM, CATEGORY, INFO, TURF) }
|
||||
|
||||
/// Log a turfs event that highlights a set of tiles when selected.
|
||||
/// TURFS is a list of /turf. DATUM must have DF_EVLOGGING set.
|
||||
#define EVLOG_TURFS(DATUM, CATEGORY, INFO, TURFS) \
|
||||
if(GLOB.event_logger.running && (DATUM.datum_flags & DF_EVLOGGING)) \
|
||||
{ GLOB.event_logger.log_event_turfs(DATUM, CATEGORY, INFO, TURFS) }
|
||||
|
||||
/// Log a line event that highlights a path from one tile to another when selected.
|
||||
/// TURF_A and TURF_B are /turf. DATUM must have DF_EVLOGGING set.
|
||||
#define EVLOG_LINES(DATUM, CATEGORY, INFO, TURF_A, TURF_B) \
|
||||
if(GLOB.event_logger.running && (DATUM.datum_flags & DF_EVLOGGING)) \
|
||||
{ GLOB.event_logger.log_event_lines(DATUM, CATEGORY, INFO, TURF_A, TURF_B) }
|
||||
|
||||
/// Log a path event that renders directional arrows + start/end markers when selected.
|
||||
/// TURFS is an ordered list of /turf. DATUM must have DF_EVLOGGING set.
|
||||
#define EVLOG_PATH(DATUM, CATEGORY, INFO, TURFS) \
|
||||
if(GLOB.event_logger.running && (DATUM.datum_flags & DF_EVLOGGING)) \
|
||||
{ GLOB.event_logger.log_event_path(DATUM, CATEGORY, INFO, TURFS) }
|
||||
|
||||
/// Log a maptext event that renders a floating text string at a turf when selected.
|
||||
/// TURF is a /turf. TEXT is the string to display. DATUM must have DF_EVLOGGING set.
|
||||
#define EVLOG_MAPTEXT(DATUM, CATEGORY, INFO, TURF, TEXT) \
|
||||
if(GLOB.event_logger.running && (DATUM.datum_flags & DF_EVLOGGING)) \
|
||||
{ GLOB.event_logger.log_event_maptext(DATUM, CATEGORY, INFO, TURF, TEXT) }
|
||||
|
||||
|
||||
/// Append a categorized title and entry to a track_info snapshot list.
|
||||
#define EVLOG_TRACK_INFO_ENTRY(LIST, CATEGORY, KEY, VALUE) \
|
||||
LIST += list(list("category" = CATEGORY, "title" = KEY, "entry" = VALUE))
|
||||
|
||||
#define IS_EVLOGGING GLOB.event_logger.running
|
||||
|
||||
|
||||
///All the types of log entries we have.
|
||||
|
||||
#define EVLOG_TYPE_TEXT "text"
|
||||
#define EVLOG_TYPE_LOCATION "location"
|
||||
#define EVLOG_TYPE_TURFS "turfs"
|
||||
#define EVLOG_TYPE_LINES "lines"
|
||||
#define EVLOG_TYPE_PATH "path"
|
||||
#define EVLOG_TYPE_MAPTEXT "maptext"
|
||||
|
||||
|
||||
|
||||
///Categories go here. Put sane names in the string since they get displayed.
|
||||
#define EVLOG_CATEGORY_MOVELOOPS "Moveloops"
|
||||
#define EVLOG_CATEGORY_JPS "JPS"
|
||||
#define EVLOG_CATEGORY_AI_DECISIONMAKING "AI Decisionmaking"
|
||||
#define EVLOG_CATEGORY_AI_BEHAVIORS "AI Behaviors"
|
||||
#define EVLOG_CATEGORY_AI_TARGETING "AI Targeting"
|
||||
@@ -74,6 +74,7 @@
|
||||
/datum/move_loop/proc/loop_stopped()
|
||||
SHOULD_CALL_PARENT(TRUE)
|
||||
status &= ~MOVELOOP_STATUS_RUNNING
|
||||
EVLOG_TEXT(moving, EVLOG_CATEGORY_MOVELOOPS, "Moveloop stopped")
|
||||
SEND_SIGNAL(src, COMSIG_MOVELOOP_STOP)
|
||||
|
||||
/datum/move_loop/proc/info_deleted(datum/source)
|
||||
@@ -127,6 +128,10 @@
|
||||
|
||||
owner?.processing_move_loop_flags = flags
|
||||
var/result = move() //Result is an enum value. Enums defined in __DEFINES/movement.dm
|
||||
|
||||
if(result)
|
||||
EVLOG_PATH(moving, EVLOG_CATEGORY_MOVELOOPS, "Moved using [src]", list(old_loc, moving.loc)) //You might think, this runs a lot; but if not logging, it only does a lookup on the event logger.
|
||||
|
||||
if(moving)
|
||||
var/direction = get_dir(old_loc, moving.loc)
|
||||
SEND_SIGNAL(moving, COMSIG_MOVABLE_MOVED_FROM_LOOP, src, old_dir, direction)
|
||||
@@ -414,6 +419,7 @@
|
||||
. = ..()
|
||||
movement_path = null
|
||||
|
||||
|
||||
/datum/move_loop/has_target/jps/Destroy()
|
||||
avoid = null
|
||||
on_finish_callbacks = null
|
||||
@@ -432,6 +438,7 @@
|
||||
/datum/move_loop/has_target/jps/proc/on_finish_pathing(list/path)
|
||||
movement_path = path
|
||||
is_pathing = FALSE
|
||||
EVLOG_PATH(moving, EVLOG_CATEGORY_JPS, "Planned AI path", movement_path)
|
||||
SEND_SIGNAL(src, COMSIG_MOVELOOP_JPS_FINISHED_PATHING, path)
|
||||
|
||||
/datum/move_loop/has_target/jps/move()
|
||||
@@ -439,6 +446,7 @@
|
||||
if(is_pathing)
|
||||
return MOVELOOP_NOT_READY
|
||||
else
|
||||
EVLOG_TEXT(moving, EVLOG_CATEGORY_JPS, "Path recalculating due to lack of path")
|
||||
INVOKE_ASYNC(src, PROC_REF(recalculate_path))
|
||||
return MOVELOOP_FAILURE
|
||||
|
||||
@@ -453,6 +461,7 @@
|
||||
if(length(movement_path))
|
||||
movement_path.Cut(1,2)
|
||||
else
|
||||
EVLOG_TEXT(moving, EVLOG_CATEGORY_MOVELOOPS, "Path recalculating due to obstruction")
|
||||
INVOKE_ASYNC(src, PROC_REF(recalculate_path))
|
||||
return MOVELOOP_FAILURE
|
||||
|
||||
|
||||
@@ -185,6 +185,39 @@ SUBSYSTEM_DEF(pathfinder)
|
||||
return constructing
|
||||
return null
|
||||
|
||||
/// Builds a single directional arrow image for a path step visualization
|
||||
/datum/controller/subsystem/pathfinder/proc/render_path_arrow(turf/draw, direction)
|
||||
var/image/arrow = image('icons/turf/debug.dmi', draw, "arrow", PATH_ARROW_DEBUG_LAYER, direction)
|
||||
SET_PLANE_EXPLICIT(arrow, BALLOON_CHAT_PLANE, draw)
|
||||
return arrow
|
||||
|
||||
///Renders a full path of arrows, showing the direction taken from each tile.
|
||||
/datum/controller/subsystem/pathfinder/proc/render_path_images(list/turf/draw_list)
|
||||
if(!length(draw_list))
|
||||
return list()
|
||||
var/list/image/turf_images = list()
|
||||
for(var/i in 1 to (length(draw_list) - 1))
|
||||
var/turf/T = draw_list[i]
|
||||
var/turf/next = draw_list[i + 1]
|
||||
turf_images += render_path_arrow(T, get_dir(T, next))
|
||||
return turf_images
|
||||
|
||||
/// Renders a full path visualisation: start marker, directional arrows along the path, end marker.
|
||||
/datum/controller/subsystem/pathfinder/proc/render_path_images_full(list/turf/draw_list)
|
||||
if(!length(draw_list))
|
||||
return list()
|
||||
var/list/image/turf_images = list()
|
||||
var/turf/first = draw_list[1]
|
||||
var/image/start = image('icons/turf/debug.dmi', first, "start", PATH_DEBUG_LAYER)
|
||||
SET_PLANE_EXPLICIT(start, BALLOON_CHAT_PLANE, first)
|
||||
turf_images += start
|
||||
turf_images += render_path_images(draw_list)
|
||||
var/turf/last = draw_list[length(draw_list)]
|
||||
var/image/end = image('icons/turf/debug.dmi', last, "end", PATH_DEBUG_LAYER)
|
||||
SET_PLANE_EXPLICIT(end, BALLOON_CHAT_PLANE, last)
|
||||
turf_images += end
|
||||
return turf_images
|
||||
|
||||
/// Takes a set of pathfind info, returns all valid pathmaps that would work
|
||||
/// Takes an optional minimum range arg
|
||||
/datum/controller/subsystem/pathfinder/proc/get_valid_maps(datum/can_pass_info/pass_info, turf/target, simulated_only = TRUE, turf/exclude, age = MAP_REUSE_INSTANT, min_range = -INFINITY, include_building = FALSE)
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
return
|
||||
clear_movement_target(controller)
|
||||
controller.ai_movement.stop_moving_towards(controller)
|
||||
EVLOG_MAPTEXT(controller, EVLOG_CATEGORY_AI_BEHAVIORS, "[controller.pawn] has [succeeded ? "succeeded" : "failed"] at performing [src]", get_turf(controller.pawn), "Behavior finished: [succeeded ? "Success" : "Failure"]")
|
||||
|
||||
/// Helper proc to ensure consistency in setting the source of the movement target
|
||||
/datum/ai_behavior/proc/set_movement_target(datum/ai_controller/controller, atom/target, datum/ai_movement/new_movement)
|
||||
|
||||
@@ -70,6 +70,7 @@ multiple modular subtrees with behaviors
|
||||
/// are we currently on failed planning timeout?
|
||||
var/on_failed_planning_timeout = FALSE
|
||||
|
||||
|
||||
/datum/ai_controller/New(atom/new_pawn)
|
||||
change_ai_movement_type(ai_movement)
|
||||
init_subtrees()
|
||||
@@ -156,6 +157,8 @@ multiple modular subtrees with behaviors
|
||||
RegisterSignal(pawn, COMSIG_MOB_STATCHANGE, PROC_REF(on_stat_changed))
|
||||
RegisterSignal(pawn, COMSIG_MOB_LOGIN, PROC_REF(on_sentience_gained))
|
||||
RegisterSignal(pawn, COMSIG_QDELETING, PROC_REF(on_pawn_qdeleted))
|
||||
RegisterSignal(pawn, COMSIG_EVLOGGING_ENABLED, PROC_REF(on_pawn_evlogging_enabled))
|
||||
RegisterSignal(pawn, COMSIG_EVLOGGING_DISABLED, PROC_REF(on_pawn_evlogging_disabled))
|
||||
update_able_to_run()
|
||||
setup_able_to_run()
|
||||
|
||||
@@ -305,7 +308,7 @@ multiple modular subtrees with behaviors
|
||||
|
||||
SEND_SIGNAL(src, COMSIG_AI_CONTROLLER_UNPOSSESSED_PAWN)
|
||||
set_ai_status(AI_STATUS_OFF)
|
||||
UnregisterSignal(pawn, list(COMSIG_MOVABLE_Z_CHANGED, COMSIG_MOB_LOGIN, COMSIG_MOB_LOGOUT, COMSIG_MOB_STATCHANGE, COMSIG_QDELETING))
|
||||
UnregisterSignal(pawn, list(COMSIG_MOVABLE_Z_CHANGED, COMSIG_MOB_LOGIN, COMSIG_MOB_LOGOUT, COMSIG_MOB_STATCHANGE, COMSIG_QDELETING, COMSIG_EVLOGGING_ENABLED))
|
||||
clear_able_to_run()
|
||||
if(ai_movement.moving_controllers[src])
|
||||
ai_movement.stop_moving_towards(src)
|
||||
@@ -427,6 +430,15 @@ multiple modular subtrees with behaviors
|
||||
arguments += stored_arguments
|
||||
forgotten_behavior.finish_action(arglist(arguments))
|
||||
|
||||
if(IS_EVLOGGING)
|
||||
var/list/event_text = list()
|
||||
event_text += "New plan starting!"
|
||||
|
||||
for(var/datum/ai_behavior/behavior in planned_behaviors)
|
||||
event_text += "Queued behavior [behavior.type]"
|
||||
|
||||
EVLOG_TEXT(src, EVLOG_CATEGORY_AI_DECISIONMAKING, jointext(event_text, "\n"))
|
||||
|
||||
///This proc handles changing ai status, and starts/stops processing if required.
|
||||
/datum/ai_controller/proc/set_ai_status(new_ai_status, additional_flags = NONE)
|
||||
if(ai_status == new_ai_status)
|
||||
@@ -563,6 +575,7 @@ multiple modular subtrees with behaviors
|
||||
return
|
||||
for(var/datum/ai_behavior/current_behavior as anything in current_behaviors)
|
||||
fail_behavior(current_behavior)
|
||||
EVLOG_TEXT(src, EVLOG_CATEGORY_AI_DECISIONMAKING, "Actions were cancelled!")
|
||||
|
||||
/datum/ai_controller/proc/fail_behavior(datum/ai_behavior/current_behavior)
|
||||
var/list/arguments = list(src, FALSE)
|
||||
@@ -944,6 +957,55 @@ multiple modular subtrees with behaviors
|
||||
|
||||
index += 1
|
||||
|
||||
/// When the pawn gets DF_EVLOGGING, propagate it to this controller too.
|
||||
/datum/ai_controller/proc/on_pawn_evlogging_enabled(datum/source)
|
||||
SIGNAL_HANDLER
|
||||
enable_evlogging(pawn)
|
||||
|
||||
/// When the pawn gets DF_EVLOGGING disabled, propagate it to this controller too.
|
||||
/datum/ai_controller/proc/on_pawn_evlogging_disabled(datum/source)
|
||||
SIGNAL_HANDLER
|
||||
disable_evlogging(pawn)
|
||||
|
||||
///Register for an event being added so we can update track info
|
||||
/datum/ai_controller/enable_evlogging()
|
||||
. = ..()
|
||||
RegisterSignal(src, COMSIG_EVLOG_EVENT_ADDED, PROC_REF(on_evlog_event_added))
|
||||
|
||||
///Unregister the evlog event added event, as we're no longer updating track info
|
||||
/datum/ai_controller/disable_evlogging()
|
||||
. = ..()
|
||||
UnregisterSignal(src, COMSIG_EVLOG_EVENT_ADDED)
|
||||
|
||||
/// Called whenever an event is logged for this controller. Attaches a snapshot of current behaviors and blackboard state to the event via track_info.
|
||||
/datum/ai_controller/proc/on_evlog_event_added(datum/source, datum/event_logger_track/track, list/event_data)
|
||||
SIGNAL_HANDLER
|
||||
var/list/track_info = list()
|
||||
|
||||
var/list/current_behavior_info = list()
|
||||
for(var/datum/ai_behavior/behavior in planned_behaviors)
|
||||
if(behavior in current_behaviors) //Not really ideal; we should find a better way to do this.
|
||||
current_behavior_info += "ACTIVE: [span_bold("[behavior.type]")]"
|
||||
else
|
||||
current_behavior_info += "[behavior.type]"
|
||||
EVLOG_TRACK_INFO_ENTRY(track_info, "Behaviors", "Current Behavior", jointext(current_behavior_info, "\n"))
|
||||
|
||||
for(var/blackboard_key_name, blackboard_value in blackboard)
|
||||
var/value_string
|
||||
if(isatom(blackboard_value))
|
||||
value_string = "[blackboard_value]"
|
||||
else if(islist(blackboard_value))
|
||||
var/list/blackboard_list = blackboard_value
|
||||
value_string = length(blackboard_list) ? jointext(blackboard_list, "\n") : "Empty List"
|
||||
else if(isnull(blackboard_value))
|
||||
value_string = "null"
|
||||
else // I think I covered all cases?
|
||||
value_string = "[blackboard_value]"
|
||||
EVLOG_TRACK_INFO_ENTRY(track_info, "Blackboard", blackboard_key_name, value_string)
|
||||
|
||||
event_data["track_info"] = track_info
|
||||
|
||||
|
||||
#undef TRACK_AI_DATUM_TARGET
|
||||
#undef CLEAR_AI_DATUM_TARGET
|
||||
#undef TRAIT_AI_TRACKING
|
||||
|
||||
@@ -73,6 +73,10 @@ GLOBAL_LIST_INIT(target_interested_atoms, typecacheof(list(/mob, /obj/machinery/
|
||||
return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED
|
||||
|
||||
var/atom/target = pick_final_target(controller, filtered_targets)
|
||||
|
||||
EVLOG_MAPTEXT(controller, EVLOG_CATEGORY_AI_TARGETING, "[controller.pawn] has selected [target] as a target for blackboard key [target_key]! Behavior: [src]", get_turf(target), "Target: [target]")
|
||||
EVLOG_LINES(controller, EVLOG_CATEGORY_AI_TARGETING, "Line to target", get_turf(controller.pawn), get_turf(target))
|
||||
|
||||
controller.set_blackboard_key(target_key, target)
|
||||
controller.set_blackboard_key(BB_BASIC_MOB_TARGET_REFRESH_COOLDOWN, world.time + priority_refresh_cooldown)
|
||||
|
||||
@@ -147,6 +151,8 @@ GLOBAL_LIST_INIT(target_interested_atoms, typecacheof(list(/mob, /obj/machinery/
|
||||
|
||||
// Alright, we found something acceptable, let's use it yeah?
|
||||
var/atom/target = pick_final_target(controller, accepted_targets)
|
||||
EVLOG_MAPTEXT(controller, EVLOG_CATEGORY_AI_TARGETING, "[controller.pawn] has selected [target] as a target for blackboard key [target_key]! Behavior: [src]", get_turf(target), "Target: [target]")
|
||||
EVLOG_LINES(controller, EVLOG_CATEGORY_AI_TARGETING, "Line to target", get_turf(controller.pawn), get_turf(target))
|
||||
controller.set_blackboard_key(target_key, target)
|
||||
|
||||
var/atom/potential_hiding_location = strategy.find_hidden_mobs(pawn, target)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
set_movement_target(controller, target)
|
||||
|
||||
/datum/ai_behavior/mine_wall/perform(seconds_per_tick, datum/ai_controller/controller, target_key)
|
||||
. = ..()
|
||||
var/mob/living/basic/living_pawn = controller.pawn
|
||||
var/turf/closed/mineral/target = controller.blackboard[target_key]
|
||||
var/is_gibtonite_turf = istype(target, /turf/closed/mineral/gibtonite)
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
var/find_this_thing = search_tactic(controller, locate_path, search_range)
|
||||
if(isnull(find_this_thing))
|
||||
return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED
|
||||
|
||||
EVLOG_MAPTEXT(controller, EVLOG_CATEGORY_AI_TARGETING, "[controller.pawn] has selected [find_this_thing] as a target for blackboard key [set_key]! Behavior: [src]", get_turf(find_this_thing), "Target: [find_this_thing]")
|
||||
EVLOG_LINES(controller, EVLOG_CATEGORY_AI_TARGETING, "Line to target", get_turf(controller.pawn), get_turf(find_this_thing))
|
||||
controller.set_blackboard_key(set_key, find_this_thing)
|
||||
return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED
|
||||
|
||||
|
||||
@@ -301,6 +301,7 @@
|
||||
/datum/ai_behavior/perform_emote
|
||||
|
||||
/datum/ai_behavior/perform_emote/perform(seconds_per_tick, datum/ai_controller/controller, emote, speech_sound)
|
||||
. = ..()
|
||||
var/mob/living/living_pawn = controller.pawn
|
||||
if(!istype(living_pawn))
|
||||
return AI_BEHAVIOR_INSTANT
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
if(!valid_dinner(living_mob, possible_dinner, hunt_range, controller, seconds_per_tick))
|
||||
continue
|
||||
controller.set_blackboard_key(hunting_target_key, possible_dinner)
|
||||
EVLOG_MAPTEXT(controller, EVLOG_CATEGORY_AI_TARGETING, "[controller.pawn] has selected [possible_dinner] as a target for blackboard key [hunting_target_key]! Behavior: [src]", get_turf(possible_dinner), "Target: [possible_dinner]")
|
||||
EVLOG_LINES(controller, EVLOG_CATEGORY_AI_TARGETING, "Line to target", get_turf(controller.pawn), get_turf(possible_dinner))
|
||||
return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED
|
||||
return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_FAILED
|
||||
|
||||
|
||||
@@ -296,5 +296,10 @@
|
||||
if(!length(valids))
|
||||
return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED
|
||||
|
||||
controller.set_blackboard_key(set_key, pick_weight(valids))
|
||||
var/mob/living/target = pick_weight(valids)
|
||||
|
||||
EVLOG_MAPTEXT(controller, EVLOG_CATEGORY_AI_TARGETING, "[controller.pawn] has selected [target] as a target for blackboard key [set_key]! Behavior: [src]", get_turf(target), "Target: [target]")
|
||||
EVLOG_LINES(controller, EVLOG_CATEGORY_AI_TARGETING, "Line to target", get_turf(controller.pawn), get_turf(target))
|
||||
|
||||
controller.set_blackboard_key(set_key, target)
|
||||
return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED
|
||||
|
||||
@@ -865,6 +865,9 @@ ADMIN_VERB(give_ai_speech, R_FUN, "Give Random AI Speech", ADMIN_VERB_NO_DESCRIP
|
||||
return
|
||||
our_controller.planning_subtrees = list(GLOB.ai_subtrees[/datum/ai_planning_subtree/random_speech/blackboard]) + our_controller.planning_subtrees
|
||||
|
||||
ADMIN_VERB(open_event_logger, R_DEBUG, "Open Event Logger", "Open the event logger interface.", ADMIN_CATEGORY_DEBUG)
|
||||
GLOB.event_logger.ui_interact(user.mob)
|
||||
|
||||
ADMIN_VERB(new_blackmarket_item, R_BUILD, "Create Black Market Item", "Add an item to the black market for purchase.", ADMIN_CATEGORY_EVENTS, object as text)
|
||||
if(!object)
|
||||
to_chat(user, span_boldwarning("Failed! Provide a full or partial typepath!"))
|
||||
@@ -912,4 +915,5 @@ ADMIN_VERB(new_blackmarket_item, R_BUILD, "Create Black Market Item", "Add an it
|
||||
|
||||
BLACKBOX_LOG_ADMIN_VERB("Create Black Market Item")
|
||||
|
||||
|
||||
#undef STEALTH_MODE_TRAIT
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
/// Global event logger datum, accessible anywhere for debug logging.
|
||||
GLOBAL_DATUM_INIT(event_logger, /datum/event_logger, new())
|
||||
|
||||
/// Enables event logging for a datum. If display_on is supplied then it will report the events as if theyre happening on that datum
|
||||
/datum/proc/enable_evlogging(datum/display_on = null)
|
||||
if(display_on)
|
||||
GLOB.event_logger.display_map[REF(src)] = REF(display_on)
|
||||
if(datum_flags & DF_EVLOGGING)
|
||||
return
|
||||
datum_flags |= DF_EVLOGGING
|
||||
SEND_SIGNAL(src, COMSIG_EVLOGGING_ENABLED)
|
||||
|
||||
/// Disables event logging for a datum. If display_on was supplied during enable_evlogging, it will also remove the display mapping.
|
||||
/datum/proc/disable_evlogging()
|
||||
GLOB.event_logger.display_map -= REF(src)
|
||||
if(!(datum_flags & DF_EVLOGGING))
|
||||
return
|
||||
datum_flags &= ~DF_EVLOGGING
|
||||
SEND_SIGNAL(src, COMSIG_EVLOGGING_DISABLED)
|
||||
|
||||
|
||||
///Datum that holds info for a single track of events in the logger. Each track represents a datum that is being tracked. This track is shown in the timeline and when selected shows information on the track.
|
||||
/datum/event_logger_track
|
||||
/// Display name for this track row.
|
||||
var/name
|
||||
/// String ref key used to identify this track.
|
||||
var/ref
|
||||
/// List of event assoc lists: id, tick, category, kind, info, + kind-specific fields.
|
||||
var/list/events = list()
|
||||
/// Assoc list of string (Title) -> string (Info) displayed in the info panel when this track is selected.
|
||||
var/list/info = list()
|
||||
|
||||
/datum/event_logger_track/New(track_ref, track_name, list/info_data = list())
|
||||
ref = track_ref
|
||||
name = track_name
|
||||
info = info_data
|
||||
|
||||
/datum/event_logger_track/Destroy()
|
||||
events = null
|
||||
info = null
|
||||
return ..()
|
||||
|
||||
///Datum for the event logger. One of these is made, and everyone shares it. So be nice. This keeps tracks of everything related to the logger
|
||||
/datum/event_logger
|
||||
/// Whether the logger is actively recording.
|
||||
var/running = FALSE
|
||||
/// world.time at which logging first started (null until first start).
|
||||
var/time_start = null
|
||||
/// Assoc list: category name -> enabled (TRUE/FALSE).
|
||||
var/list/categories = list()
|
||||
/// Assoc list: category name -> hex color, assigned when a category is first seen.
|
||||
var/list/category_colors = list()
|
||||
|
||||
/// List of colors we use for categories. We loop around once we run out
|
||||
var/list/category_palette = list(
|
||||
"#4fc3f7", "#81c784", "#ffb74d", "#e57373", "#ba68c8",
|
||||
"#4dd0e1", "#fff176", "#f06292", "#a1887f", "#90a4ae",
|
||||
)
|
||||
/// Assoc list: ref string -> /datum/event_logger_track
|
||||
var/list/tracks = list()
|
||||
/// Assoc list: string event id -> event assoc list, for quick look-up when selecting events
|
||||
var/list/events_by_id = list()
|
||||
/// Incrementing id assigned to each logged event.
|
||||
var/next_event_id = 0
|
||||
/// Ref string of the currently selected track (for the info panel).
|
||||
var/selected_ref = null
|
||||
/// Assoc list: mob -> list of /image, for overlay cleanup.
|
||||
var/list/user_overlays = list()
|
||||
/// The mob currently waiting to click a target for pick-target mode, or null.
|
||||
var/mob/awaiting_pick_user = null
|
||||
/// Assoc list: REF(source datum) -> REF(display datum). Events from the source appear under the display datum's track.
|
||||
var/list/display_map = list()
|
||||
|
||||
/datum/event_logger/Destroy()
|
||||
_clear_all_overlays()
|
||||
QDEL_LIST_ASSOC_VAL(tracks)
|
||||
tracks = null
|
||||
categories = null
|
||||
user_overlays = null
|
||||
display_map = null
|
||||
return ..()
|
||||
|
||||
/datum/event_logger/proc/start()
|
||||
if(running)
|
||||
return
|
||||
running = TRUE
|
||||
if(isnull(time_start))
|
||||
time_start = world.time
|
||||
|
||||
/datum/event_logger/proc/stop()
|
||||
running = FALSE
|
||||
|
||||
/// Enter pick-target mode: the next atom the user clicks gets DF_EVLOGGING set.
|
||||
/datum/event_logger/proc/toggle_pick_target(mob/user)
|
||||
if(awaiting_pick_user)
|
||||
_end_pick_target()
|
||||
else
|
||||
awaiting_pick_user = user
|
||||
RegisterSignal(user, COMSIG_MOB_CLICKON, PROC_REF(on_pick_target_click))
|
||||
|
||||
/// Signal handler: fired when the user clicks something while in pick-target mode.
|
||||
/datum/event_logger/proc/on_pick_target_click(mob/source, atom/clicked, list/modifiers)
|
||||
SIGNAL_HANDLER
|
||||
clicked.enable_evlogging()
|
||||
_end_pick_target()
|
||||
return NONE
|
||||
|
||||
/// Ends pick-target mode, unregistering the click signal and clearing the awaiting_pick_user var.
|
||||
/datum/event_logger/proc/_end_pick_target()
|
||||
if(isnull(awaiting_pick_user))
|
||||
return
|
||||
UnregisterSignal(awaiting_pick_user, COMSIG_MOB_CLICKON)
|
||||
awaiting_pick_user = null
|
||||
|
||||
/// Ensures a track exists for ref_string. Safe to call multiple times.
|
||||
/datum/event_logger/proc/add_track(ref_string, track_name, list/info_data)
|
||||
if(tracks[ref_string])
|
||||
return
|
||||
tracks[ref_string] = new /datum/event_logger_track(ref_string, track_name, info_data)
|
||||
|
||||
/// Internal: Sets the event up, making a track if necesary. Also turns the instance into a REF() at this point
|
||||
/datum/event_logger/proc/_add_event(datum/source, list/event_data)
|
||||
if(!running)
|
||||
return
|
||||
// Resolve display routing: if source has a display_on target, log under that datum's track instead
|
||||
var/datum/display_target
|
||||
var/display_ref = display_map[REF(source)]
|
||||
if(display_ref)
|
||||
display_target = locate(display_ref)
|
||||
if(!display_target) // display_on was deleted, remove the stale entry. Yeah this is a bit lame, but I felt like registering QDEL might be more of a hassle if we're using REF() anyway. Feel free to @ me over it
|
||||
display_map -= REF(source)
|
||||
|
||||
var/datum/track_datum = display_target || source
|
||||
var/ref_string = REF(track_datum)
|
||||
if(!tracks[ref_string])
|
||||
var/track_name
|
||||
if(isatom(track_datum))
|
||||
var/atom/resolved_atom = track_datum
|
||||
track_name = "[ref_string] [resolved_atom] [track_datum.type]"
|
||||
else
|
||||
track_name = "[ref_string] [track_datum.type]"
|
||||
add_track(ref_string, track_name, null)
|
||||
var/datum/event_logger_track/track = tracks[ref_string]
|
||||
event_data["id"] = ++next_event_id
|
||||
event_data["tick"] = world.time
|
||||
var/category = event_data["category"]
|
||||
if(!isnull(category) && isnull(categories[category]))
|
||||
categories[category] = TRUE
|
||||
|
||||
var/selected_index = (length(category_colors) + 1) % (length(category_palette) + 1)
|
||||
|
||||
category_colors[category] = category_palette[selected_index]
|
||||
track.events += list(event_data)
|
||||
events_by_id["[event_data["id"]]"] = event_data
|
||||
SEND_SIGNAL(source, COMSIG_EVLOG_EVENT_ADDED, track, event_data)
|
||||
if(display_target)
|
||||
SEND_SIGNAL(display_target, COMSIG_EVLOG_EVENT_ADDED, track, event_data)
|
||||
|
||||
/// Log a plain text event. Has no world-visuals, just puts text into the menu
|
||||
/datum/event_logger/proc/log_event_text(datum/source, category, info_string)
|
||||
_add_event(source, list(
|
||||
"log_type" = EVLOG_TYPE_TEXT,
|
||||
"category" = category,
|
||||
"info" = info_string,
|
||||
))
|
||||
|
||||
/// Log a location event (highlights a single tile). I should remove this one as turfs does the same thing essentially
|
||||
/datum/event_logger/proc/log_event_location(datum/source, category, info_string, turf/T)
|
||||
_add_event(source, list(
|
||||
"log_type" = EVLOG_TYPE_LOCATION,
|
||||
"category" = category,
|
||||
"info" = info_string,
|
||||
"x" = T.x,
|
||||
"y" = T.y,
|
||||
"z" = T.z,
|
||||
))
|
||||
|
||||
/// Log a turfs event (highlights a set of tiles).
|
||||
/datum/event_logger/proc/log_event_turfs(datum/source, category, info_string, list/turfs)
|
||||
var/list/coords = list()
|
||||
for(var/turf/T as anything in turfs)
|
||||
coords += list(list("x" = T.x, "y" = T.y, "z" = T.z))
|
||||
_add_event(source, list(
|
||||
"log_type" = EVLOG_TYPE_TURFS,
|
||||
"category" = category,
|
||||
"info" = info_string,
|
||||
"coords" = coords,
|
||||
))
|
||||
|
||||
/// Log a line event (draws a line between 2 turfs).
|
||||
/datum/event_logger/proc/log_event_lines(datum/source, category, info_string, turf/A, turf/B)
|
||||
_add_event(source, list(
|
||||
"log_type" = EVLOG_TYPE_LINES,
|
||||
"category" = category,
|
||||
"info" = info_string,
|
||||
"x1" = A.x,
|
||||
"y1" = A.y,
|
||||
"z1" = A.z,
|
||||
"x2" = B.x,
|
||||
"y2" = B.y,
|
||||
"z2" = B.z,
|
||||
))
|
||||
|
||||
/// Log a path event (renders directional arrows + start/end markers for a list of turfs in order from start to finish).
|
||||
/datum/event_logger/proc/log_event_path(datum/source, category, info_string, list/turfs)
|
||||
var/list/coords = list()
|
||||
for(var/turf/T as anything in turfs)
|
||||
coords += list(list("x" = T.x, "y" = T.y, "z" = T.z))
|
||||
_add_event(source, list(
|
||||
"log_type" = EVLOG_TYPE_PATH,
|
||||
"category" = category,
|
||||
"info" = info_string,
|
||||
"coords" = coords,
|
||||
))
|
||||
|
||||
/// Log a maptext event (renders a floating text label at the turf when selected). text_string is the string to display at the turf.
|
||||
/datum/event_logger/proc/log_event_maptext(datum/source, category, info_string, turf/T, text_string)
|
||||
_add_event(source, list(
|
||||
"log_type" = EVLOG_TYPE_MAPTEXT,
|
||||
"category" = category,
|
||||
"info" = info_string,
|
||||
"x" = T.x,
|
||||
"y" = T.y,
|
||||
"z" = T.z,
|
||||
"text" = text_string,
|
||||
))
|
||||
|
||||
/// Remove all overlays for a specific user.
|
||||
/datum/event_logger/proc/_clear_user_overlays(mob/user)
|
||||
if(!user_overlays[user])
|
||||
return
|
||||
if(user.client)
|
||||
user.client.images -= user_overlays[user]
|
||||
user_overlays -= user
|
||||
|
||||
/// Remove all overlays for all users.
|
||||
/datum/event_logger/proc/_clear_all_overlays()
|
||||
for(var/mob/user as anything in user_overlays)
|
||||
_clear_user_overlays(user)
|
||||
|
||||
/// Push world-space highlight overlays to the user's client for the given events.
|
||||
/datum/event_logger/proc/_apply_event_overlays(mob/user, list/events_to_show)
|
||||
_clear_user_overlays(user)
|
||||
if(!user.client || !length(events_to_show))
|
||||
return
|
||||
|
||||
var/list/images = list()
|
||||
|
||||
for(var/list/evt as anything in events_to_show)
|
||||
var/color = get_category_color(evt["category"])
|
||||
var/log_type = evt["log_type"]
|
||||
|
||||
if(log_type == EVLOG_TYPE_LOCATION)
|
||||
var/turf/found_turf = locate(evt["x"], evt["y"], evt["z"])
|
||||
if(found_turf)
|
||||
images += _make_tile_image(found_turf, "box_overlay", color, 0.3)
|
||||
|
||||
else if(log_type == EVLOG_TYPE_TURFS)
|
||||
var/list/coords = evt["coords"]
|
||||
for(var/list/coord as anything in coords)
|
||||
var/turf/found_turf = locate(coord["x"], coord["y"], coord["z"])
|
||||
if(found_turf)
|
||||
images += _make_tile_image(found_turf, "box_overlay", color, 0.3)
|
||||
|
||||
else if(log_type == EVLOG_TYPE_LINES)
|
||||
var/turf/turf_A = locate(evt["x1"], evt["y1"], evt["z1"])
|
||||
var/turf/turf_B = locate(evt["x2"], evt["y2"], evt["z2"])
|
||||
if(turf_A && turf_B && turf_A != turf_B)
|
||||
images += _make_line_images(turf_A, turf_B, color)
|
||||
|
||||
//Use SSPathfinder to render the full path
|
||||
else if(log_type == EVLOG_TYPE_PATH)
|
||||
var/list/path_turfs = list()
|
||||
var/list/coords = evt["coords"]
|
||||
for(var/list/coord as anything in coords)
|
||||
var/turf/found_turf = locate(coord["x"], coord["y"], coord["z"])
|
||||
if(found_turf)
|
||||
path_turfs += found_turf
|
||||
images += SSpathfinder.render_path_images_full(path_turfs)
|
||||
|
||||
//Draw maptext on a turf which lets us show important events in-world
|
||||
else if(log_type == EVLOG_TYPE_MAPTEXT)
|
||||
var/turf/found_turf = locate(evt["x"], evt["y"], evt["z"])
|
||||
if(found_turf)
|
||||
var/image/img = image('icons/turf/debug.dmi', found_turf, "circle", PATH_DEBUG_LAYER)
|
||||
SET_PLANE_EXPLICIT(img, BALLOON_CHAT_PLANE, found_turf)
|
||||
img.color = color
|
||||
img.maptext = MAPTEXT(evt["text"])
|
||||
img.maptext_width = 200
|
||||
img.maptext_height = 64
|
||||
img.maptext_x = 0
|
||||
img.maptext_y = 32
|
||||
images += img
|
||||
|
||||
if(length(images))
|
||||
user_overlays[user] = images
|
||||
user.client.images += images
|
||||
|
||||
///Draw a line of images similar to beams but client-side. I couldn't find anything like this yet so here we are. Maybe making this a global is a good idea.
|
||||
/datum/event_logger/proc/_make_line_images(turf/turf_A, turf/turf_B, color)
|
||||
var/list/images = list()
|
||||
var/beam_icon = 'icons/turf/debug.dmi'
|
||||
var/beam_icon_state = "beam"
|
||||
|
||||
var/origin_px = turf_A.pixel_x + turf_A.pixel_w
|
||||
var/origin_py = turf_A.pixel_y + turf_A.pixel_z
|
||||
var/target_px = turf_B.pixel_x + turf_B.pixel_w
|
||||
var/target_py = turf_B.pixel_y + turf_B.pixel_z
|
||||
|
||||
var/Angle = get_angle_raw(turf_A.x, turf_A.y, origin_px, origin_py, turf_B.x, turf_B.y, target_px, target_py)
|
||||
var/matrix/rot_matrix = matrix()
|
||||
rot_matrix.Turn(Angle)
|
||||
|
||||
var/DX = (ICON_SIZE_X * turf_B.x + target_px) - (ICON_SIZE_X * turf_A.x + origin_px)
|
||||
var/DY = (ICON_SIZE_Y * turf_B.y + target_py) - (ICON_SIZE_Y * turf_A.y + origin_py)
|
||||
var/line_length = round(sqrt(DX ** 2 + DY ** 2))
|
||||
|
||||
for(var/N in 0 to line_length - 1 step 32)
|
||||
var/Pixel_x
|
||||
var/Pixel_y
|
||||
if(DX == 0)
|
||||
Pixel_x = 0
|
||||
else
|
||||
Pixel_x = round(sin(Angle) + ICON_SIZE_X * sin(Angle) * (N + 16) / 32)
|
||||
if(DY == 0)
|
||||
Pixel_y = 0
|
||||
else
|
||||
Pixel_y = round(cos(Angle) + ICON_SIZE_Y * cos(Angle) * (N + 16) / 32)
|
||||
|
||||
var/final_x = turf_A.x
|
||||
var/final_y = turf_A.y
|
||||
if(abs(Pixel_x) > ICON_SIZE_X)
|
||||
final_x += Pixel_x > 0 ? round(Pixel_x / ICON_SIZE_X) : ceil(Pixel_x / ICON_SIZE_X)
|
||||
Pixel_x %= ICON_SIZE_X
|
||||
if(abs(Pixel_y) > ICON_SIZE_Y)
|
||||
final_y += Pixel_y > 0 ? round(Pixel_y / ICON_SIZE_Y) : ceil(Pixel_y / ICON_SIZE_Y)
|
||||
Pixel_y %= ICON_SIZE_Y
|
||||
|
||||
var/turf/seg_turf = locate(final_x, final_y, turf_A.z)
|
||||
if(!seg_turf)
|
||||
continue
|
||||
|
||||
var/image/img = image(beam_icon, seg_turf, beam_icon_state, PATH_DEBUG_LAYER)
|
||||
SET_PLANE_EXPLICIT(img, BALLOON_CHAT_PLANE, seg_turf)
|
||||
if(N + 32 > line_length)
|
||||
// Terminal segment: crop the icon to avoid overshooting the target
|
||||
var/icon/clipped = new(beam_icon, beam_icon_state)
|
||||
clipped.DrawBox(null, 1, (line_length - N), 32, 32)
|
||||
img.icon = clipped
|
||||
img.color = color
|
||||
img.transform = rot_matrix
|
||||
img.pixel_x = origin_px + Pixel_x
|
||||
img.pixel_y = origin_py + Pixel_y
|
||||
images += img
|
||||
|
||||
return images
|
||||
|
||||
|
||||
/// Creates a single coloured tile overlay image.
|
||||
/datum/event_logger/proc/_make_tile_image(turf/selected_turf, icon_state, color, alpha_fraction)
|
||||
var/image/img = image('icons/turf/debug.dmi', selected_turf, icon_state, PATH_DEBUG_LAYER)
|
||||
SET_PLANE_EXPLICIT(img, BALLOON_CHAT_PLANE, selected_turf)
|
||||
img.color = color
|
||||
img.alpha = round(alpha_fraction * 255)
|
||||
return img
|
||||
|
||||
/// Returns the hex color string for a category, or white if unknown.
|
||||
/datum/event_logger/proc/get_category_color(category)
|
||||
return category_colors[category] || "#ffffff"
|
||||
|
||||
/// Clears all tracks, categories and overlays.
|
||||
/datum/event_logger/proc/clear()
|
||||
_clear_all_overlays()
|
||||
QDEL_LIST_ASSOC_VAL(tracks)
|
||||
tracks = list()
|
||||
events_by_id = list()
|
||||
categories = list()
|
||||
category_colors = list()
|
||||
next_event_id = 0
|
||||
selected_ref = null
|
||||
time_start = null
|
||||
running = FALSE
|
||||
|
||||
///Tgui stuff below!!
|
||||
|
||||
|
||||
/datum/event_logger/ui_assets(mob/user)
|
||||
return list(
|
||||
get_asset_datum(/datum/asset/simple/chat_dark),
|
||||
)
|
||||
|
||||
/datum/event_logger/ui_interact(mob/user, datum/tgui/ui)
|
||||
ui = SStgui.try_update_ui(user, src, ui)
|
||||
if(!ui)
|
||||
ui = new(user, src, "EventLogger", "Event Logger")
|
||||
ui.open()
|
||||
|
||||
/datum/event_logger/ui_close(mob/user)
|
||||
. = ..()
|
||||
_clear_user_overlays(user)
|
||||
if(awaiting_pick_user == user)
|
||||
_end_pick_target()
|
||||
|
||||
/datum/event_logger/ui_state(mob/user)
|
||||
return ADMIN_STATE(R_DEBUG)
|
||||
|
||||
/datum/event_logger/ui_data(mob/user)
|
||||
var/list/data = list()
|
||||
|
||||
data["running"] = running
|
||||
data["time_start"] = time_start
|
||||
data["time_current"] = world.time
|
||||
data["selected_ref"] = selected_ref
|
||||
data["awaiting_pick"] = !isnull(awaiting_pick_user)
|
||||
|
||||
// Categories
|
||||
var/list/cats = list() //meow
|
||||
for(var/cat, enabled in categories)
|
||||
cats += list(list("name" = cat, "enabled" = enabled))
|
||||
data["categories"] = cats
|
||||
|
||||
// Tracks
|
||||
var/list/track_list = list()
|
||||
for(var/ref, track_val in tracks)
|
||||
var/datum/event_logger_track/track = track_val
|
||||
|
||||
// Converts track info to a list for tgui
|
||||
var/list/info_pairs = list()
|
||||
for(var/title, entry in track.info)
|
||||
info_pairs += list(list("title" = title, "entry" = entry))
|
||||
|
||||
track_list += list(list(
|
||||
"name" = track.name,
|
||||
"ref" = track.ref,
|
||||
"events" = track.events,
|
||||
"info" = info_pairs,
|
||||
))
|
||||
data["tracks"] = track_list
|
||||
|
||||
return data
|
||||
|
||||
/datum/event_logger/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
|
||||
. = ..()
|
||||
if(.)
|
||||
return
|
||||
|
||||
var/mob/user = ui.user
|
||||
|
||||
switch(action)
|
||||
if("toggle_running") //Start or stop the logger
|
||||
if(running)
|
||||
stop()
|
||||
else
|
||||
start()
|
||||
return TRUE
|
||||
|
||||
if("toggle_category") //Enable categories
|
||||
var/cat = params["name"]
|
||||
if(!isnull(categories[cat]))
|
||||
categories[cat] = !categories[cat]
|
||||
return TRUE
|
||||
|
||||
if("select_track") //Select a track on the timeline
|
||||
selected_ref = params["ref"]
|
||||
return TRUE
|
||||
|
||||
if("select_events") ///We selected events on the timeline, clear old overlays, add new ones.
|
||||
var/list/ids = params["ids"]
|
||||
if(!islist(ids) || !length(ids))
|
||||
_clear_user_overlays(user)
|
||||
return TRUE
|
||||
|
||||
var/list/matched = list()
|
||||
for(var/id in ids)
|
||||
var/list/evt = events_by_id["[id]"]
|
||||
if(evt)
|
||||
matched += list(evt)
|
||||
|
||||
_apply_event_overlays(user, matched)
|
||||
return TRUE
|
||||
|
||||
if("clear")
|
||||
clear()
|
||||
return TRUE
|
||||
|
||||
if("start_pick_target")
|
||||
toggle_pick_target(user)
|
||||
return TRUE
|
||||
|
||||
if("disable_evlogging")
|
||||
var/track_ref = params["ref"]
|
||||
var/datum/target = locate(track_ref)
|
||||
if(!target)
|
||||
return FALSE
|
||||
if(tgui_alert(user, "Stop tracking [target]?", "Confirm", list("Stop Tracking", "Cancel")) != "Stop Tracking")
|
||||
return FALSE
|
||||
target.disable_evlogging()
|
||||
return TRUE
|
||||
|
||||
if("remove_track") // Removes a track and all its events from the logger entirely, and stops tracking the datum
|
||||
var/track_ref = params["ref"]
|
||||
var/datum/event_logger_track/track = tracks[track_ref]
|
||||
if(!track)
|
||||
return FALSE
|
||||
// Remove all events from events_by_id
|
||||
for(var/list/evt as anything in track.events)
|
||||
events_by_id -= "[evt["id"]]"
|
||||
// Disable evlogging on the datum if it still exists
|
||||
var/datum/target = locate(track_ref)
|
||||
if(target)
|
||||
target.disable_evlogging()
|
||||
// Clear overlays and deselect if this was the selected track
|
||||
_clear_user_overlays(user)
|
||||
if(selected_ref == track_ref)
|
||||
selected_ref = null
|
||||
qdel(track)
|
||||
tracks -= track_ref
|
||||
return TRUE
|
||||
|
||||
if("teleport_to_event")
|
||||
var/target_id = params["id"]
|
||||
var/list/found_evt = events_by_id["[target_id]"]
|
||||
if(!found_evt)
|
||||
return FALSE
|
||||
var/dest_x
|
||||
var/dest_y
|
||||
var/dest_z
|
||||
switch(found_evt["log_type"])
|
||||
if(EVLOG_TYPE_LOCATION, EVLOG_TYPE_MAPTEXT)
|
||||
dest_x = found_evt["x"]
|
||||
dest_y = found_evt["y"]
|
||||
dest_z = found_evt["z"]
|
||||
if(EVLOG_TYPE_TURFS, EVLOG_TYPE_PATH)
|
||||
var/list/coords = found_evt["coords"]
|
||||
if(!length(coords))
|
||||
return FALSE
|
||||
var/list/first = coords[1]
|
||||
dest_x = first["x"]
|
||||
dest_y = first["y"]
|
||||
dest_z = first["z"]
|
||||
if(EVLOG_TYPE_LINES)
|
||||
dest_x = found_evt["x1"]
|
||||
dest_y = found_evt["y1"]
|
||||
dest_z = found_evt["z1"]
|
||||
else
|
||||
return FALSE
|
||||
var/turf/dest = locate(dest_x, dest_y, dest_z)
|
||||
if(!dest)
|
||||
return FALSE
|
||||
user.forceMove(dest)
|
||||
return TRUE
|
||||
@@ -103,22 +103,10 @@ GLOBAL_DATUM_INIT(pathfind_dude, /obj/pathfind_guy, new())
|
||||
return
|
||||
|
||||
/datum/action/innate/path_debug/proc/render_path(list/turf/draw_list)
|
||||
if(!length(draw_list))
|
||||
return list()
|
||||
|
||||
var/list/image/turf_images = list()
|
||||
// Render everything but the first and last
|
||||
for(var/i in 1 to (length(draw_list) - 1))
|
||||
var/turf/problem_child = draw_list[i]
|
||||
var/turf/next = draw_list[i + 1]
|
||||
turf_images += render_turf(problem_child, get_dir(problem_child, next))
|
||||
|
||||
return turf_images
|
||||
return SSpathfinder.render_path_images(draw_list)
|
||||
|
||||
/datum/action/innate/path_debug/proc/render_turf(turf/draw, direction)
|
||||
var/image/arrow = image('icons/turf/debug.dmi', draw, "arrow", PATH_ARROW_DEBUG_LAYER, direction)
|
||||
SET_PLANE_EXPLICIT(arrow, BALLOON_CHAT_PLANE, draw)
|
||||
return arrow
|
||||
return SSpathfinder.render_path_arrow(draw, direction)
|
||||
|
||||
/datum/action/innate/path_debug/jps
|
||||
name = "JPS Test"
|
||||
|
||||
@@ -33,3 +33,9 @@
|
||||
)
|
||||
|
||||
#endif
|
||||
|
||||
/datum/asset/simple/chat_dark
|
||||
keep_local_name = FALSE
|
||||
assets = list(
|
||||
"tgui-chat-dark.bundle.css" = file("tgui/public/tgui-chat-dark.bundle.css"),
|
||||
)
|
||||
|
||||
@@ -107,11 +107,15 @@
|
||||
///set the target if we can reach them
|
||||
/datum/ai_controller/basic_controller/bot/proc/set_if_can_reach(key, target, duration, distance = 10, bypass_add_to_blacklist = FALSE)
|
||||
if(can_reach_target(target, distance))
|
||||
EVLOG_MAPTEXT(src, EVLOG_CATEGORY_AI_TARGETING, "[pawn] has selected [target] as a target for blackboard key [key]!", get_turf(target), "Target: [target]")
|
||||
EVLOG_LINES(src, EVLOG_CATEGORY_AI_TARGETING, "Line to target", get_turf(pawn), get_turf(target))
|
||||
set_blackboard_key(key, target)
|
||||
return TRUE
|
||||
if(bypass_add_to_blacklist)
|
||||
return FALSE
|
||||
var/final_duration = duration || blackboard[BB_UNREACHABLE_LIST_COOLDOWN]
|
||||
EVLOG_MAPTEXT(src, EVLOG_CATEGORY_AI_TARGETING, "[pawn] has added [target] to its targetting blacklist!", get_turf(target), "Target: [target]")
|
||||
EVLOG_LINES(src, EVLOG_CATEGORY_AI_TARGETING, "Line to target", get_turf(pawn), get_turf(target))
|
||||
add_to_blacklist(target, final_duration)
|
||||
return FALSE
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -98,6 +98,7 @@
|
||||
#include "code\__DEFINES\dye_keys.dm"
|
||||
#include "code\__DEFINES\economy.dm"
|
||||
#include "code\__DEFINES\electrified_buckle.dm"
|
||||
#include "code\__DEFINES\event_logger.dm"
|
||||
#include "code\__DEFINES\events.dm"
|
||||
#include "code\__DEFINES\exosuit_fab.dm"
|
||||
#include "code\__DEFINES\experisci.dm"
|
||||
@@ -3163,6 +3164,7 @@
|
||||
#include "code\modules\admin\chat_commands.dm"
|
||||
#include "code\modules\admin\check_antagonists.dm"
|
||||
#include "code\modules\admin\create_mob.dm"
|
||||
#include "code\modules\admin\event_logger.dm"
|
||||
#include "code\modules\admin\force_event.dm"
|
||||
#include "code\modules\admin\fun_balloon.dm"
|
||||
#include "code\modules\admin\greyscale_modify_menu.dm"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import '../tgui-panel/styles/tgchat/chat-dark.scss';
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Box, Button, Stack } from 'tgui-core/components';
|
||||
|
||||
import type { Category } from './types';
|
||||
|
||||
export type CategoryBarProps = {
|
||||
categories: Category[];
|
||||
colors: Record<string, string>;
|
||||
act: (action: string, params?: object) => void;
|
||||
};
|
||||
|
||||
export function CategoryBar(props: CategoryBarProps) {
|
||||
const { categories, colors, act } = props;
|
||||
if (!categories.length) {
|
||||
return (
|
||||
<Box p={0.5} color="label" italic>
|
||||
No categories logged yet.
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack wrap p={0.5}>
|
||||
{categories.map((cat) => (
|
||||
<Stack.Item key={cat.name}>
|
||||
<Button
|
||||
selected={cat.enabled}
|
||||
style={{ borderLeft: `4px solid ${colors[cat.name] || '#888'}` }}
|
||||
onClick={() => act('toggle_category', { name: cat.name })}
|
||||
>
|
||||
{cat.name}
|
||||
</Button>
|
||||
</Stack.Item>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Box, Button, Stack } from 'tgui-core/components';
|
||||
|
||||
export type ControlBarProps = {
|
||||
running: boolean;
|
||||
zoom: number;
|
||||
awaitingPick: boolean;
|
||||
autoScroll: boolean;
|
||||
onAutoScrollChange: (val: boolean) => void;
|
||||
act: (action: string, params?: object) => void;
|
||||
};
|
||||
|
||||
export function ControlBar(props: ControlBarProps) {
|
||||
const { running, zoom, awaitingPick, autoScroll, onAutoScrollChange, act } =
|
||||
props;
|
||||
return (
|
||||
<Stack align="center" p={0.5}>
|
||||
<Stack.Item>
|
||||
<Button
|
||||
icon={running ? 'stop' : 'play'}
|
||||
color={running ? 'bad' : 'good'}
|
||||
onClick={() => act('toggle_running')}
|
||||
>
|
||||
{running ? 'Stop' : 'Start'}
|
||||
</Button>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<Button
|
||||
icon="trash"
|
||||
color="average"
|
||||
onClick={() => act('clear')}
|
||||
disabled={running}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<Button
|
||||
icon="crosshairs"
|
||||
color={awaitingPick ? 'caution' : 'transparent'}
|
||||
selected={awaitingPick}
|
||||
onClick={() => act('start_pick_target')}
|
||||
>
|
||||
{awaitingPick ? 'Click a target...' : 'Pick Target'}
|
||||
</Button>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<Button
|
||||
icon="angles-right"
|
||||
selected={autoScroll}
|
||||
tooltip="Auto-scroll timeline to the latest event"
|
||||
onClick={() => onAutoScrollChange(!autoScroll)}
|
||||
>
|
||||
Auto-Scroll
|
||||
</Button>
|
||||
</Stack.Item>
|
||||
<Stack.Item>
|
||||
<Box inline color="label">
|
||||
Zoom: {zoom < 1 ? zoom.toFixed(1) : zoom}x
|
||||
</Box>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Button, LabeledList, Section, Stack } from 'tgui-core/components';
|
||||
import { sanitizeText } from '../../sanitize';
|
||||
import type { Track, TrackInfoEntry } from './types';
|
||||
import { CATEGORY_PALETTE } from './types';
|
||||
|
||||
export type InfoPanelProps = {
|
||||
tracks: Track[];
|
||||
selectedRef: string | null;
|
||||
selectedEventIds: Set<number>;
|
||||
};
|
||||
|
||||
export function InfoPanel(props: InfoPanelProps) {
|
||||
const { tracks, selectedRef, selectedEventIds } = props;
|
||||
const track = selectedRef ? tracks.find((t) => t.ref === selectedRef) : null;
|
||||
|
||||
const [disabledInfoCats, setDisabledInfoCats] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// Resolve which track_info snapshot to display and whether it's event-specific
|
||||
let snapshotEntries: TrackInfoEntry[] | null = null;
|
||||
let snapshotTrack: Track | null = null;
|
||||
let isLatest = false;
|
||||
|
||||
if (selectedEventIds.size > 0) {
|
||||
// Find the highest-id selected event that carries a snapshot
|
||||
let bestId = -1;
|
||||
for (const t of tracks) {
|
||||
for (const evt of t.events) {
|
||||
if (
|
||||
selectedEventIds.has(evt.id) &&
|
||||
evt.track_info?.length &&
|
||||
evt.id > bestId
|
||||
) {
|
||||
bestId = evt.id;
|
||||
snapshotEntries = evt.track_info;
|
||||
snapshotTrack = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshotEntries && track) {
|
||||
let latestId = -1;
|
||||
for (const evt of track.events) {
|
||||
if (evt.track_info?.length && evt.id > latestId) {
|
||||
latestId = evt.id;
|
||||
snapshotEntries = evt.track_info;
|
||||
}
|
||||
}
|
||||
if (snapshotEntries) {
|
||||
snapshotTrack = track;
|
||||
isLatest = true;
|
||||
}
|
||||
}
|
||||
|
||||
const infoCategoryOrder: string[] = [];
|
||||
const infoCategoryColors: Record<string, string> = {};
|
||||
if (snapshotEntries) {
|
||||
for (const entry of snapshotEntries) {
|
||||
const cat = entry.category || 'Info';
|
||||
if (!infoCategoryColors[cat]) {
|
||||
infoCategoryColors[cat] =
|
||||
CATEGORY_PALETTE[infoCategoryOrder.length % CATEGORY_PALETTE.length];
|
||||
infoCategoryOrder.push(cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleInfoCat(cat: string) {
|
||||
setDisabledInfoCats((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(cat)) next.delete(cat);
|
||||
else next.add(cat);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function renderSnapshot(entries: TrackInfoEntry[]) {
|
||||
const grouped: Record<string, TrackInfoEntry[]> = {};
|
||||
for (const entry of entries) {
|
||||
const cat = entry.category || 'Info';
|
||||
if (disabledInfoCats.has(cat)) continue;
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(entry);
|
||||
}
|
||||
return Object.entries(grouped).map(([cat, items]) => (
|
||||
<Section key={cat} title={cat}>
|
||||
<LabeledList>
|
||||
{items.map((item) => (
|
||||
<LabeledList.Item key={item.title} label={item.title}>
|
||||
<span
|
||||
style={{ whiteSpace: 'pre-wrap' }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeText(item.entry, false, undefined, [
|
||||
'style',
|
||||
'background',
|
||||
]),
|
||||
}}
|
||||
/>
|
||||
</LabeledList.Item>
|
||||
))}
|
||||
</LabeledList>
|
||||
</Section>
|
||||
));
|
||||
}
|
||||
|
||||
const displayName = snapshotTrack?.name ?? track?.name ?? 'Track Info';
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={isLatest ? `${displayName} (latest snapshot)` : displayName}
|
||||
fill
|
||||
scrollable
|
||||
>
|
||||
{!track && !snapshotEntries ? (
|
||||
<Box color="label" italic>
|
||||
No track selected. Click a row in the timeline.
|
||||
</Box>
|
||||
) : !snapshotEntries ? (
|
||||
<Box color="label" italic>
|
||||
No snapshot available. Events on this track do not carry info
|
||||
snapshots.
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{infoCategoryOrder.length > 1 && (
|
||||
<Stack wrap mb={0.5}>
|
||||
{infoCategoryOrder.map((cat) => (
|
||||
<Stack.Item key={cat}>
|
||||
<Button
|
||||
selected={!disabledInfoCats.has(cat)}
|
||||
style={{
|
||||
borderLeft: `4px solid ${infoCategoryColors[cat]}`,
|
||||
}}
|
||||
onClick={() => toggleInfoCat(cat)}
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
</Stack.Item>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{renderSnapshot(snapshotEntries)}
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Box, Button, Section } from 'tgui-core/components';
|
||||
import { sanitizeText } from '../../sanitize';
|
||||
import type { EventEntry, Track } from './types';
|
||||
import {
|
||||
getEventPrimaryCoord,
|
||||
LOG_TYPE_COLORS,
|
||||
LOG_TYPE_LABELS,
|
||||
} from './types';
|
||||
|
||||
type LogTypeBadgeProps = { log_type: string };
|
||||
|
||||
export function LogTypeBadge(props: LogTypeBadgeProps) {
|
||||
const { log_type } = props;
|
||||
return (
|
||||
<Box
|
||||
inline
|
||||
style={{
|
||||
background: LOG_TYPE_COLORS[log_type] || '#555',
|
||||
color: '#000',
|
||||
borderRadius: '3px',
|
||||
padding: '0 4px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 'bold',
|
||||
marginRight: '6px',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
{LOG_TYPE_LABELS[log_type] || log_type.toUpperCase()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export type SelectedEventsPanelProps = {
|
||||
tracks: Track[];
|
||||
selectedEventIds: Set<number>;
|
||||
act: (action: string, params?: object) => void;
|
||||
};
|
||||
|
||||
export function SelectedEventsPanel(props: SelectedEventsPanelProps) {
|
||||
const { tracks, selectedEventIds, act } = props;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const eventById: Record<number, EventEntry> = {};
|
||||
for (const track of tracks) {
|
||||
for (const evt of track.events) {
|
||||
eventById[evt.id] = evt;
|
||||
}
|
||||
}
|
||||
|
||||
const selected = [...selectedEventIds]
|
||||
.sort((a, b) => a - b)
|
||||
.map((id) => eventById[id])
|
||||
.filter(Boolean);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [selectedEventIds.size]);
|
||||
|
||||
return (
|
||||
<Section title="Selected Events" fill>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{ overflowY: 'auto', height: '100%', padding: '4px' }}
|
||||
>
|
||||
{!selected.length ? (
|
||||
<Box color="label" italic>
|
||||
No events selected. Click marks in the timeline. Hold Ctrl to select
|
||||
multiple.
|
||||
</Box>
|
||||
) : (
|
||||
selected.map((evt) => {
|
||||
const coord = getEventPrimaryCoord(evt);
|
||||
return (
|
||||
<Box
|
||||
key={evt.id}
|
||||
mb={0.5}
|
||||
style={{
|
||||
borderLeft: '3px solid #444',
|
||||
paddingLeft: '6px',
|
||||
fontSize: '11px',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<LogTypeBadge log_type={evt.log_type} />
|
||||
{coord && (
|
||||
<Button
|
||||
icon="location-arrow"
|
||||
compact
|
||||
tooltip={`TP to (${coord.x},${coord.y},${coord.z})`}
|
||||
onClick={() => act('teleport_to_event', { id: evt.id })}
|
||||
/>
|
||||
)}
|
||||
<Box inline color="label" mr={0.5}>
|
||||
#{evt.id}
|
||||
</Box>
|
||||
<span
|
||||
style={{ whiteSpace: 'pre-wrap', flex: 1 }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeText(evt.info, false, undefined, [
|
||||
'style',
|
||||
'background',
|
||||
]),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type TimelineMark<T = unknown> = {
|
||||
rowId: string;
|
||||
tick: number;
|
||||
color: string;
|
||||
isCircle: boolean;
|
||||
isSelected: boolean;
|
||||
isMulti: boolean;
|
||||
count: number;
|
||||
tooltip: string;
|
||||
tooltipHtml?: string | null;
|
||||
data: T;
|
||||
};
|
||||
|
||||
export type TimelineRow = {
|
||||
id: string;
|
||||
renderLabel: () => ReactNode;
|
||||
};
|
||||
|
||||
export type TimelineProps<T = unknown> = {
|
||||
rows: TimelineRow[];
|
||||
marks: TimelineMark<T>[];
|
||||
timeStart: number | null;
|
||||
timeCurrent: number;
|
||||
zoom: number;
|
||||
running: boolean;
|
||||
autoScroll: boolean;
|
||||
/** Ticks between interval ruler lines (default 50 = 5 s at 10 t/s). */
|
||||
intervalTicks?: number;
|
||||
/** How many ticks are in one second (default 10 as byond is in deciseconds). */
|
||||
ticksPerSecond?: number;
|
||||
onMarkClick: (mark: TimelineMark<T>, ctrl: boolean) => void;
|
||||
onEmptyClick: () => void;
|
||||
onZoomChange: (next: number) => void;
|
||||
};
|
||||
|
||||
// Constants
|
||||
|
||||
const LABEL_WIDTH = 240;
|
||||
const ROW_HEIGHT = 36;
|
||||
const RULER_HEIGHT = 20;
|
||||
const MARK_RADIUS = 7;
|
||||
const ZOOM_MAX = 40;
|
||||
|
||||
// Component
|
||||
|
||||
export function Timeline<T = unknown>(props: TimelineProps<T>) {
|
||||
const {
|
||||
rows,
|
||||
marks,
|
||||
timeStart,
|
||||
timeCurrent,
|
||||
zoom,
|
||||
running,
|
||||
autoScroll,
|
||||
intervalTicks = 50,
|
||||
ticksPerSecond = 10,
|
||||
onMarkClick,
|
||||
onEmptyClick,
|
||||
onZoomChange,
|
||||
} = props;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const anchorRef = useRef<{ tick: number; px: number } | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [hoverTooltip, setHoverTooltip] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
html: string;
|
||||
} | null>(null);
|
||||
|
||||
// Track container width so we can compute fit-zoom
|
||||
useEffect(() => {
|
||||
const el = outerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
setContainerWidth(entries[0].contentRect.width);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Derived timing
|
||||
|
||||
const elapsed = timeStart !== null ? Math.max(0, timeCurrent - timeStart) : 0;
|
||||
|
||||
const contentAreaWidth = Math.max(0, containerWidth - LABEL_WIDTH);
|
||||
|
||||
/**
|
||||
* The virtual duration used for layout. Always at least contentAreaWidth ticks,
|
||||
* so at 1x zoom (1px/tick) the timeline always fills the full viewport.
|
||||
* As real elapsed time exceeds this, fitZoom drops below 1 and you can zoom out.
|
||||
*/
|
||||
const effectiveElapsed = Math.max(elapsed, contentAreaWidth);
|
||||
|
||||
/**
|
||||
* The zoom level at which the full timeline exactly fills the content area.
|
||||
* fitZoom == 1 until elapsed overflows the viewport, then shrinks below 1.
|
||||
*/
|
||||
const fitZoom =
|
||||
effectiveElapsed > 0 && contentAreaWidth > 0
|
||||
? contentAreaWidth / effectiveElapsed
|
||||
: 1;
|
||||
|
||||
// Whenever the stored zoom is below fitZoom, bring it back up (e.g. on open).
|
||||
useEffect(() => {
|
||||
if (zoom < fitZoom) {
|
||||
onZoomChange(fitZoom);
|
||||
}
|
||||
}, [fitZoom]); // eslint-disable-line react-hooks/estive-deps
|
||||
|
||||
/** px per tick at current zoom — never less than fitZoom */
|
||||
const pxPerTick = Math.max(fitZoom, zoom);
|
||||
|
||||
// Ruler interval: auto-double when lines get too close
|
||||
let rulerInterval = intervalTicks;
|
||||
const minRulerSpacingPx = 60;
|
||||
while (rulerInterval * pxPerTick < minRulerSpacingPx) {
|
||||
rulerInterval *= 2;
|
||||
}
|
||||
|
||||
/** Total content width in px — uses effectiveElapsed so ruler/rows fill the viewport */
|
||||
const contentWidth = effectiveElapsed * pxPerTick + rulerInterval * pxPerTick;
|
||||
|
||||
// Auto-scroll: only scroll if the playhead would be off-screen to the right
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoScroll || !running || !scrollRef.current) return;
|
||||
const container = scrollRef.current;
|
||||
const playheadLeft = elapsed * pxPerTick;
|
||||
const visibleRight = container.scrollLeft + container.clientWidth;
|
||||
if (playheadLeft > visibleRight) {
|
||||
container.scrollLeft = playheadLeft - container.clientWidth + 40;
|
||||
}
|
||||
});
|
||||
|
||||
// Zoom on wheel
|
||||
|
||||
function handleWheel(e: React.WheelEvent<HTMLDivElement>) {
|
||||
e.preventDefault();
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Save anchor: which tick is under the mouse
|
||||
const rect = container.getBoundingClientRect();
|
||||
const localX = Math.max(0, e.clientX - rect.left);
|
||||
const mouseX = localX + container.scrollLeft;
|
||||
const tickUnderMouse = mouseX / pxPerTick;
|
||||
anchorRef.current = { tick: tickUnderMouse, px: localX };
|
||||
|
||||
const delta = e.deltaY > 0 ? -1 : 1;
|
||||
let next: number;
|
||||
if (pxPerTick < 1) {
|
||||
next = pxPerTick + delta * 0.1;
|
||||
} else {
|
||||
next = pxPerTick + delta * 1;
|
||||
}
|
||||
// Floor is fitZoom so you can never see empty space past the end
|
||||
next = Math.max(fitZoom, Math.min(ZOOM_MAX, parseFloat(next.toFixed(1))));
|
||||
onZoomChange(next);
|
||||
}
|
||||
|
||||
// Re-anchor scroll after zoom change
|
||||
useEffect(() => {
|
||||
const anchor = anchorRef.current;
|
||||
const container = scrollRef.current;
|
||||
if (!anchor || !container) return;
|
||||
anchorRef.current = null;
|
||||
container.scrollLeft = anchor.tick * pxPerTick - anchor.px;
|
||||
}, [pxPerTick]);
|
||||
|
||||
// Render
|
||||
|
||||
const contentHeight = rows.length * ROW_HEIGHT + RULER_HEIGHT;
|
||||
|
||||
// Build a map from rowId -> row index for fast position lookup
|
||||
const rowIndex: Record<string, number> = {};
|
||||
rows.forEach((row, i) => {
|
||||
rowIndex[row.id] = i;
|
||||
});
|
||||
|
||||
// Ruler tick labels
|
||||
|
||||
const rulerLabels: { tick: number; label: string; leftPx: number }[] = [];
|
||||
for (let t = 0; t <= effectiveElapsed + rulerInterval; t += rulerInterval) {
|
||||
const seconds = t / ticksPerSecond;
|
||||
const leftPx = Math.max(0, t * pxPerTick);
|
||||
rulerLabels.push({ tick: t, label: `${seconds}s`, leftPx });
|
||||
}
|
||||
|
||||
// Render ruler vertical guide lines (behind rows)
|
||||
|
||||
const rulerGuides = rulerLabels.map(({ tick, leftPx }) => (
|
||||
<div
|
||||
key={tick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${leftPx}px`,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '1px',
|
||||
background: 'rgba(255,255,255,0.07)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
// Render mark dots grouped by row
|
||||
|
||||
const markEls = marks.map((mark, i) => {
|
||||
const idx = rowIndex[mark.rowId];
|
||||
if (idx === undefined) return null;
|
||||
const tickOffset = timeStart !== null ? mark.tick - timeStart : mark.tick;
|
||||
const rawLeft = Math.max(0, tickOffset * pxPerTick);
|
||||
const topPx = RULER_HEIGHT + idx * ROW_HEIGHT + ROW_HEIGHT / 2;
|
||||
|
||||
const size = mark.isMulti ? MARK_RADIUS * 2 + 4 : MARK_RADIUS * 2;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkClick(mark, e.ctrlKey || e.metaKey);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (mark.tooltipHtml) {
|
||||
setHoverTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
html: mark.tooltipHtml,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (mark.tooltipHtml) {
|
||||
setHoverTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
html: mark.tooltipHtml,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setHoverTooltip(null)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${rawLeft - size / 2}px`,
|
||||
top: `${topPx - size / 2}px`,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
borderRadius: mark.isCircle ? '50%' : '2px',
|
||||
background: mark.color,
|
||||
opacity: mark.isSelected ? 1 : 0.75,
|
||||
border: mark.isSelected
|
||||
? '2px solid #fff'
|
||||
: '1px solid rgba(0,0,0,0.4)',
|
||||
cursor: 'pointer',
|
||||
zIndex: mark.isSelected ? 2 : 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '9px',
|
||||
fontWeight: 'bold',
|
||||
color: '#000',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{mark.isMulti && mark.count > 1 ? mark.count : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: '#1a1a1a',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
ref={outerRef}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{/* Row labels column */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: `${LABEL_WIDTH}px`,
|
||||
background: '#1a1a1a',
|
||||
borderRight: '1px solid #333',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Ruler header space */}
|
||||
<div
|
||||
style={{
|
||||
height: `${RULER_HEIGHT}px`,
|
||||
borderBottom: '1px solid #333',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{/* Row labels */}
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
style={{
|
||||
height: `${ROW_HEIGHT}px`,
|
||||
padding: '0 6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
borderBottom: '1px solid #222',
|
||||
fontSize: '11px',
|
||||
color: '#ccc',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{row.renderLabel()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{/* Inner content panel */}
|
||||
<div
|
||||
onClick={onEmptyClick}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: `${contentWidth}px`,
|
||||
height: `${contentHeight}px`,
|
||||
minHeight: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Ruler strip */}
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: `${RULER_HEIGHT}px`,
|
||||
width: '100%',
|
||||
zIndex: 4,
|
||||
background: '#111',
|
||||
borderBottom: '1px solid #333',
|
||||
}}
|
||||
>
|
||||
{rulerLabels.map(({ tick, label, leftPx }) => (
|
||||
<div
|
||||
key={tick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${leftPx}px`,
|
||||
top: 0,
|
||||
fontSize: '10px',
|
||||
color: '#888',
|
||||
paddingLeft: '3px',
|
||||
lineHeight: `${RULER_HEIGHT}px`,
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Row stripes + guides (behind marks) */}
|
||||
{rulerGuides}
|
||||
{rows.map((row, i) => (
|
||||
<div
|
||||
key={row.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${RULER_HEIGHT + i * ROW_HEIGHT}px`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: `${ROW_HEIGHT}px`,
|
||||
background:
|
||||
i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent',
|
||||
borderBottom: '1px solid #222',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Marks */}
|
||||
{markEls}
|
||||
{/* Playhead */}
|
||||
{!!running && timeStart !== null && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${elapsed * pxPerTick}px`,
|
||||
top: `${RULER_HEIGHT}px`,
|
||||
bottom: 0,
|
||||
width: '2px',
|
||||
background: '#ff5252',
|
||||
zIndex: 3,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hoverTooltip && (
|
||||
<div
|
||||
className="evlog-mark-tooltip"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${hoverTooltip.x + 14}px`,
|
||||
top: `${hoverTooltip.y - 10}px`,
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: hoverTooltip.html }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useState } from 'react';
|
||||
import { Stack } from 'tgui-core/components';
|
||||
import { useBackend } from '../../backend';
|
||||
import { Window } from '../../layouts';
|
||||
import { sanitizeText } from '../../sanitize';
|
||||
|
||||
import { CategoryBar } from './CategoryBar';
|
||||
import { ControlBar } from './ControlBar';
|
||||
import { InfoPanel } from './InfoPanel';
|
||||
import { SelectedEventsPanel } from './SelectedEventsPanel';
|
||||
import type { TimelineMark, TimelineRow } from './Timeline';
|
||||
import { Timeline } from './Timeline';
|
||||
import type { Category, EventEntry, EventLoggerData, Track } from './types';
|
||||
import { buildCategoryColors } from './types';
|
||||
|
||||
// Build the marks in the track
|
||||
|
||||
function buildMarks(
|
||||
tracks: Track[],
|
||||
categories: Category[],
|
||||
colors: Record<string, string>,
|
||||
timeStart: number | null,
|
||||
selectedEventIds: Set<number>,
|
||||
): TimelineMark<EventEntry[]>[] {
|
||||
const marks: TimelineMark<EventEntry[]>[] = [];
|
||||
|
||||
const enabledCats = new Set(
|
||||
categories.filter((c) => c.enabled).map((c) => c.name),
|
||||
);
|
||||
|
||||
for (const track of tracks) {
|
||||
// Group events by tick
|
||||
const byTick: Map<number, EventEntry[]> = new Map();
|
||||
for (const evt of track.events) {
|
||||
if (!enabledCats.has(evt.category)) continue;
|
||||
const existing = byTick.get(evt.tick);
|
||||
if (existing) {
|
||||
existing.push(evt);
|
||||
} else {
|
||||
byTick.set(evt.tick, [evt]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [tick, evts] of byTick) {
|
||||
const isSelected = evts.some((e) => selectedEventIds.has(e.id));
|
||||
const isMulti = evts.length > 1;
|
||||
// Use first event's category color; if mixed, still use first
|
||||
const color = colors[evts[0].category] || '#888';
|
||||
const isSpatial = evts.some((e) => e.log_type !== 'text');
|
||||
|
||||
marks.push({
|
||||
rowId: track.ref,
|
||||
tick,
|
||||
color,
|
||||
isCircle: !isSpatial,
|
||||
isSelected,
|
||||
isMulti,
|
||||
count: evts.length,
|
||||
tooltip: isMulti
|
||||
? `${evts.length} events at tick ${tick - (timeStart ?? 0)}`
|
||||
: evts[0].info.replace(/<[^>]*>/g, ''),
|
||||
tooltipHtml: isMulti
|
||||
? null
|
||||
: sanitizeText(evts[0].info, false, undefined, [
|
||||
'style',
|
||||
'background',
|
||||
]),
|
||||
data: evts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return marks;
|
||||
}
|
||||
|
||||
// Event logger root
|
||||
|
||||
export function EventLogger() {
|
||||
const { act, data } = useBackend<EventLoggerData>();
|
||||
const {
|
||||
running,
|
||||
time_start,
|
||||
time_current,
|
||||
categories,
|
||||
tracks,
|
||||
selected_ref,
|
||||
awaiting_pick,
|
||||
} = data;
|
||||
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [selectedEventIds, setSelectedEventIds] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const colors = buildCategoryColors(categories);
|
||||
|
||||
const rows: TimelineRow[] = tracks.map((track) => ({
|
||||
id: track.ref,
|
||||
renderLabel: () => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
gap: '4px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title={track.name.replace(/<[^>]*>/g, '')}
|
||||
onClick={() => handleSelectTrack(track)}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeText(track.name) }}
|
||||
/>
|
||||
<button
|
||||
title="Remove track and all its events"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
act('remove_track', { ref: track.ref });
|
||||
}}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: 'rgba(255,80,80,0.15)',
|
||||
border: '1px solid rgba(255,80,80,0.4)',
|
||||
borderRadius: '3px',
|
||||
color: '#ff8080',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px',
|
||||
lineHeight: 1,
|
||||
padding: '1px 4px',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const marks = buildMarks(
|
||||
tracks,
|
||||
categories,
|
||||
colors,
|
||||
time_start,
|
||||
selectedEventIds,
|
||||
);
|
||||
|
||||
function handleSelectTrack(track: Track) {
|
||||
act('select_track', { ref: track.ref });
|
||||
setSelectedEventIds(new Set());
|
||||
}
|
||||
|
||||
function handleMarkClick(mark: TimelineMark<EventEntry[]>, ctrl: boolean) {
|
||||
const evtIds = mark.data.map((e) => e.id);
|
||||
|
||||
// Find the track this mark belongs to so we can select it
|
||||
const track = tracks.find((t) => t.ref === mark.rowId);
|
||||
if (track && selected_ref !== track.ref) {
|
||||
act('select_track', { ref: track.ref });
|
||||
}
|
||||
|
||||
// Select events on server (for overlay rendering)
|
||||
act('select_events', { ids: evtIds });
|
||||
|
||||
setSelectedEventIds((prev) => {
|
||||
if (ctrl) {
|
||||
// Toggle group: if all already selected, deselect; else add
|
||||
const allSelected = evtIds.every((id) => prev.has(id));
|
||||
const next = new Set(prev);
|
||||
if (allSelected) {
|
||||
for (const id of evtIds) next.delete(id);
|
||||
} else {
|
||||
for (const id of evtIds) next.add(id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
// Replace selection
|
||||
return new Set(evtIds);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Window title="Event Logger" width={1600} height={1080}>
|
||||
<Window.Content>
|
||||
<Stack vertical fill className="EventLogger">
|
||||
{/* Control bar */}
|
||||
<Stack.Item>
|
||||
<ControlBar
|
||||
running={running}
|
||||
zoom={zoom}
|
||||
awaitingPick={awaiting_pick}
|
||||
autoScroll={autoScroll}
|
||||
onAutoScrollChange={setAutoScroll}
|
||||
act={act}
|
||||
/>
|
||||
</Stack.Item>
|
||||
|
||||
{/* Category toggles */}
|
||||
<Stack.Item>
|
||||
<CategoryBar categories={categories} colors={colors} act={act} />
|
||||
</Stack.Item>
|
||||
|
||||
{/* Timeline */}
|
||||
<Stack.Item grow={2} basis={0} style={{ minHeight: 0 }}>
|
||||
<div style={{ height: '100%' }}>
|
||||
<Timeline<EventEntry[]>
|
||||
rows={rows}
|
||||
marks={marks}
|
||||
timeStart={time_start}
|
||||
timeCurrent={time_current}
|
||||
zoom={zoom}
|
||||
running={running}
|
||||
autoScroll={autoScroll}
|
||||
onMarkClick={handleMarkClick}
|
||||
onEmptyClick={() => {
|
||||
setSelectedEventIds(new Set());
|
||||
act('select_events', { ids: [] });
|
||||
}}
|
||||
onZoomChange={setZoom}
|
||||
/>
|
||||
</div>
|
||||
</Stack.Item>
|
||||
|
||||
{/* Bottom row: track info + selected events */}
|
||||
<Stack.Item grow={3} basis={0} style={{ minHeight: 0 }}>
|
||||
<Stack fill>
|
||||
<Stack.Item grow={1} basis={0}>
|
||||
<InfoPanel
|
||||
tracks={tracks}
|
||||
selectedRef={selected_ref}
|
||||
selectedEventIds={selectedEventIds}
|
||||
/>
|
||||
</Stack.Item>
|
||||
<Stack.Item grow={1} basis={0}>
|
||||
<SelectedEventsPanel
|
||||
tracks={tracks}
|
||||
selectedEventIds={selectedEventIds}
|
||||
act={act}
|
||||
/>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Stack.Item>
|
||||
</Stack>
|
||||
</Window.Content>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// ── Palette / label constants ─────────────────────────────────────────────────
|
||||
|
||||
export const CATEGORY_PALETTE: string[] = [
|
||||
'#4fc3f7',
|
||||
'#81c784',
|
||||
'#ffb74d',
|
||||
'#e57373',
|
||||
'#ba68c8',
|
||||
'#4dd0e1',
|
||||
'#fff176',
|
||||
'#f06292',
|
||||
'#a1887f',
|
||||
'#90a4ae',
|
||||
];
|
||||
|
||||
export const LOG_TYPE_LABELS: Record<string, string> = {
|
||||
text: 'TXT',
|
||||
location: 'LOC',
|
||||
turfs: 'TURF',
|
||||
lines: 'LINE',
|
||||
path: 'PATH',
|
||||
maptext: 'MAPTXT',
|
||||
};
|
||||
|
||||
export const LOG_TYPE_COLORS: Record<string, string> = {
|
||||
text: '#aaaaaa',
|
||||
location: '#4fc3f7',
|
||||
turfs: '#81c784',
|
||||
lines: '#ffb74d',
|
||||
path: '#ce93d8',
|
||||
maptext: '#ffe082',
|
||||
};
|
||||
|
||||
// ── Data types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Category = { name: string; enabled: boolean };
|
||||
|
||||
export type TrackInfoEntry = { category: string; title: string; entry: string };
|
||||
|
||||
export type BaseEvent = {
|
||||
id: number;
|
||||
tick: number;
|
||||
category: string;
|
||||
log_type: 'text' | 'location' | 'turfs' | 'lines' | 'path' | 'maptext';
|
||||
info: string;
|
||||
track_info?: TrackInfoEntry[];
|
||||
};
|
||||
|
||||
export type TextEvent = BaseEvent & { log_type: 'text' };
|
||||
|
||||
export type LocationEvent = BaseEvent & {
|
||||
log_type: 'location';
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
|
||||
export type TurfsEvent = BaseEvent & {
|
||||
log_type: 'turfs';
|
||||
coords: Array<{ x: number; y: number; z: number }>;
|
||||
};
|
||||
|
||||
export type LinesEvent = BaseEvent & {
|
||||
log_type: 'lines';
|
||||
x1: number;
|
||||
y1: number;
|
||||
z1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
z2: number;
|
||||
};
|
||||
|
||||
export type PathEvent = BaseEvent & {
|
||||
log_type: 'path';
|
||||
coords: Array<{ x: number; y: number; z: number }>;
|
||||
};
|
||||
|
||||
export type MapTextEvent = BaseEvent & {
|
||||
log_type: 'maptext';
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type EventEntry =
|
||||
| TextEvent
|
||||
| LocationEvent
|
||||
| TurfsEvent
|
||||
| LinesEvent
|
||||
| PathEvent
|
||||
| MapTextEvent;
|
||||
|
||||
export type InfoPair = { title: string; entry: string };
|
||||
|
||||
export type Track = {
|
||||
name: string;
|
||||
ref: string;
|
||||
events: EventEntry[];
|
||||
info: InfoPair[];
|
||||
};
|
||||
|
||||
export type EventLoggerData = {
|
||||
running: boolean;
|
||||
time_start: number | null;
|
||||
time_current: number;
|
||||
categories: Category[];
|
||||
tracks: Track[];
|
||||
selected_ref: string | null;
|
||||
awaiting_pick: boolean;
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildCategoryColors(
|
||||
categories: Category[],
|
||||
): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
categories.forEach((cat, i) => {
|
||||
map[cat.name] = CATEGORY_PALETTE[i % CATEGORY_PALETTE.length];
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
export function getEventPrimaryCoord(
|
||||
evt: EventEntry,
|
||||
): { x: number; y: number; z: number } | null {
|
||||
switch (evt.log_type) {
|
||||
case 'location':
|
||||
case 'maptext':
|
||||
return { x: evt.x, y: evt.y, z: evt.z };
|
||||
case 'turfs':
|
||||
case 'path':
|
||||
return evt.coords.length > 0 ? evt.coords[0] : null;
|
||||
case 'lines':
|
||||
return { x: evt.x1, y: evt.y1, z: evt.z1 };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Custom HTML tooltip shown on timeline mark hover, rendered at fixed position
|
||||
.evlog-mark-tooltip {
|
||||
position: fixed;
|
||||
background: #1e1e2e;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
min-width: 120px;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
@@ -50,6 +50,7 @@
|
||||
@include meta.load-css('./interfaces/ColorPicker.scss');
|
||||
@include meta.load-css('./interfaces/AnomalyTower.scss');
|
||||
@include meta.load-css('./interfaces/CivCargoHoldTerminal.scss');
|
||||
@include meta.load-css('./interfaces/EventLogger.scss');
|
||||
|
||||
// Layouts
|
||||
@include meta.load-css('./layouts/Layout.scss');
|
||||
|
||||
@@ -29,6 +29,7 @@ export default defineConfig({
|
||||
tgui: './packages/tgui',
|
||||
'tgui-panel': './packages/tgui-panel',
|
||||
'tgui-say': './packages/tgui-say',
|
||||
'tgui-chat-dark': './packages/tgui-chat-dark',
|
||||
},
|
||||
mode: 'production',
|
||||
module: {
|
||||
|
||||
Reference in New Issue
Block a user