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:
CabinetOnFire
2026-05-13 14:37:56 +02:00
committed by GitHub
parent 57b1c24fcc
commit af8f69da13
31 changed files with 1974 additions and 16 deletions
+2
View File
@@ -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)
+61
View File
@@ -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
+33
View File
@@ -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)
+1
View File
@@ -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)
+63 -1
View File
@@ -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)
+3
View File
@@ -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
+6 -1
View File
@@ -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
+4
View File
@@ -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
+551
View File
@@ -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
+2 -14
View File
@@ -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"
+6
View File
@@ -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

+2
View File
@@ -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"
+1
View File
@@ -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);
}
+1
View File
@@ -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');
+1
View File
@@ -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: {