TargetSpy

Table of Contents

1. About

TargetSpy shows enemy health, tag, and other things.

TargetSpy is a part of DoomToolbox.

Features:

  • extensible modular structure;
  • target finders: autoaim, shootable, non-shootable, no blockmap;
  • target filters: blocklist, monsters, light level, health, hidden, enemies, dormant, active;
  • target information: health, max health, armor, class, tag, flags;
  • target frame;
  • text crosshair with options and PreciseCrosshair support.

1.1. Extending TargetSpy

1.1.1. Finders, filters, getters, and views

TargetSpy can be extended by declaring special classes. To be detected by TargetSpy, a class must inherit Actor and meet specific requirements, listed below.

There are four types of TargetSpy extension classes:

  1. Finders find targets. They must have a name that starts with tsFinder_ and have static Actor find() function.
  2. Filters remove unwanted targets. They must have a name that starts with tsFilter_ and have static bool isAllowed(Actor) function.
  3. Getters collect information about a target. They must have a name that starts with tsGetter_ and have static int, string, int, int makeInformation(Actor) function. This function returns:

    • int: value of ts_Kind enum, which tells what kind of information is provided;
    • string: string value, used only for ts_Kind.Text;
    • int: current value, used for ts_Kind.Value, ts_Kind.ValueAndMax, and ts_Kind.Bar;
    • int: max value, used for ts_Kind.ValueAndMax and ts_Kind.Bar.

    Additionally, a getter can have static Font getFont(Actor) function to select a font for an actor.

  4. Views show information on screen. They must have a name that starts with tsView_ and have static ui void draw(Array<ts_InformationPiece> informationPieces, double alpha, RenderEvent event, vector3 targetPosition, vector2 targetSize) function. See here for reference what ts_InformationPiece contains.

Note that Finders, Filters, and Getters classes don't depend on TargetSpy types in any way, and therefore can be safely included in other mods to provide TargetSpy integration when loaded together.

Extension classes are not meant to be instantiated, only their static functions are used. Extension classes can have a translatable user string defined in LANGUAGE lump. The string key is simply a class name. Extension classes are automatically added to TargetSpy option menus, and their toggle state is saved in game configuration.

All extensions are automatically added to the corresponding toggle menus, from where they can be turned on and off.

In fact, all out-of-the-box TargetSpy finders, filters, getters, and views are implemented as extensions, and can be used as examples for implementing custom extensions.

1.1.2. Blocklist

To make TargetSpy ignore classes, put them into ts_blocklist lump. Any number of ts_blocklist lumps can be loaded. Blocklist filter must be enabled.

1.1.3. Follower classes

To make TargetSpy to recognize actors that represent parts of another actors, like headshots and enemy parts in some mods, put follower classes in ts_followers lump. Follower classes must have the real enemy actor set in their master field.

2. License

GPL-3.0-only

SPDX-FileCopyrightText: © 2026 Alexander Kromm <mmaulwurff@gmail.com>
SPDX-License-Identifier: GPL-3.0-only

3. Options

ts_OptionsTitle = "TargetSpy \cd⌖";
AddOptionMenu OptionsMenu       { Submenu "$ts_OptionsTitle", ts_Options }
AddOptionMenu OptionsMenuSimple { Submenu "$ts_OptionsTitle", ts_Options }

OptionMenu ts_Options
{
  Title "$ts_OptionsTitle"
  ts_PlainTranslator

  Option "TargetSpy on", ts_on, OnOff

  StaticText ""
  StaticText "Toggles"
  Submenu "Finders", ts_FinderToggles
  Submenu "Filters", ts_FilterToggles
  Submenu "Getters", ts_GetterToggles
  Submenu "Views",   ts_ViewToggles

  StaticText ""
  Submenu "Filter options",    ts_FilterOptions
  Submenu "Getters order",     ts_GettersOrder
  Submenu "Text view options", ts_TextViewOptions
  Submenu "Frame options",     ts_FrameOptions
  Submenu "Crosshair options", ts_CrosshairOptions
}

4. Source

4.1. Finders

Range for finding targets:

4000.0

4.1.1. Aim targets

tsFinder_Aim = "Aim target";
class tsFinder_Aim : Actor
{
  static Actor find()
  {
    return players[consolePlayer].mo.aimTarget();
  }
}

4.1.2. Shootable

tsFinder_Shootable = "Shootable only";
class tsFinder_Shootable : Actor
{
  static Actor find()
  {
    PlayerInfo player = players[consolePlayer];
    PlayerPawn pawn = player.mo;
    FLineTraceData lineTraceData;
    double angle = pawn.angle;
    double pitch = pawn.pitch;
    double viewHeight = player.viewHeight;
    pawn.lineTrace(angle, 4000.0, pitch, 0, viewHeight, 0.0, 0.0, lineTraceData);

    return lineTraceData.hitActor;
  }
}

4.1.3. Non-shootable

tsFinder_NonShootable = "Non-shootable too";
class tsFinder_NonShootable : Actor
{
  static Actor find()
  {
    PlayerPawn pawn = players[consolePlayer].mo;
    FTranslatedLineTarget lineTarget;
    pawn.aimLineAttack(pawn.angle, 4000.0, lineTarget, 0, AIM_FLAGS);
    return lineTarget.lineTarget;
  }

  const AIM_FLAGS = ALF_CheckNonShootable | ALF_ForceNoSmart;
}

4.1.4. Noblockmap actor finder

tsFinder_Noblockmap = "Everything";
// SPDX-FileCopyrightText: © 2020 proydoha

class tsFinder_Noblockmap : Actor
{
  static Actor find()
  {
    PlayerInfo player = players[consolePlayer];
    return lineAttackNoBlockmap(player.mo, player.viewheight);
  }

  static Actor lineAttackNoBlockmap(Actor a, double offsetz)
  {
    FLineTraceData lineTraceData;
    a.LineTrace(a.angle, 4000.0, a.pitch, 0, offsetz, 0.0, 0.0, lineTraceData);

    if (lineTraceData.HitType == TRACE_HitActor)
    {
      return NULL;
    }

    ThinkerIterator noBlockmapActors = ThinkerIterator.Create();
    Actor nbmActor;
    Actor closestNbmActor;
    while (nbmActor = Actor(noBlockmapActors.Next()))
    {
      if (!nbmActor.bNoBlockmap)
      {
        continue;
      }

      // Do not target inventory items that belong to somebody.
      let inv = Inventory(nbmActor);
      if (inv && inv.Owner)
      {
        continue;
      }

      // Detect NoBlockmap actors by checking if line from LineTrace
      // intersects sphere they are in.
      //
      // Line equation is:
      // P = LineStart + Direction * t
      //
      // Sphere equation is:
      // (P - SphereCenter) dot (P - SphereCenter) = SphereRadius * SphereRadius
      //
      // Line and Sphere share points (P) if they intersect:
      //
      // Combined equation:
      // (LineStart + Direction * t - SphereCenter)
      //   dot (LineStart + Direction * t - SphereCenter)
      //
      // Same equation rearranged:
      // t * t * (Direction dot Direction) +
      // + 2 * t * (Direction dot (LineStart - SphereCenter)) +
      // + ((LineStart - SphereCenter) dot (LineStart - SphereCenter)) -
      // - SphereRadius * SphereRadius = 0
      //
      // This is quadratic equation:
      // t * t * a + t * b + c = 0

      vector3 sphereCenter = (nbmActor.pos.x,
                              nbmActor.pos.y,
                              nbmActor.pos.z + nbmActor.height/2);
      double  sphereRadius = max(nbmActor.height, nbmActor.radius * 2) / 2;

      vector3 lineStart = (a.pos.x, a.pos.y, a.pos.z + offsetz);
      vector3 lineEnd   = lineTraceData.HitLocation;
      vector3 direction = (lineEnd - lineStart).Unit();

      // a, b, c of the quadratic equation:
      double a = direction dot direction;
      double b = 2 * (direction dot (lineStart - sphereCenter));
      double c = (lineStart - sphereCenter) dot (lineStart - sphereCenter)
               - sphereRadius * sphereRadius;

      // Line intersects or touches Sphere if t has solutions
      // t has solution(s) if discriminant >= 0
      // discriminant = b * b - 4 * a * c
      // t = ( -b ± sqrt(discriminant) ) / 2 * a
      double discriminant = b * b - 4 * a * c;

      bool isDiscriminantNonNegative = (discriminant >= 0);
      if (!isDiscriminantNonNegative)
      {
        continue;
      }

      double t1 = (-b + sqrt(discriminant)) / (2 * a);
      double t2 = (-b - sqrt(discriminant)) / (2 * a);

      // if both of those solutions are positive target is in front of the player
      bool areSolutionsPositive = (t1 > 0 && t2 > 0);
      if (!areSolutionsPositive)
      {
        continue;
      }

      // Discard actors that are further than lineEnd (most likely behind the wall)
      bool isFurther = ((lineStart - lineEnd).Length()
                        < (lineStart - nbmActor.pos).Length());
      if (isFurther)
      {
        continue;
      }

      if (closestNbmActor == NULL)
      {
        closestNbmActor = nbmActor;
        continue;
      }

      // Pick an actor closest to the player
      if ((LineStart - nbmActor.pos).Length()
          < (LineStart - closestNbmActor.pos).Length())
      {
        closestNbmActor = nbmActor;
      }
    }

    return closestNbmActor;
  }
}

4.2. Filters

4.2.1. Blocklist

tsFilter_Blocklist = "Blocklist";
class tsFilter_Blocklist : Actor
{
  static bool isAllowed(Actor anActor)
  {
    string className = anActor.getClassName();
    return !ts_EventHandler.getInstance().getBlocklist().contains(className);
  }
}

Autoautosave

AutoautosaveAlertToken
AutoautosaveAlerter
AutoautosaveBossAlerter
AutoautosaveToken
m8f_aas_token

The Adventures of Square

Cow
CowboyCow
MummyCow
WinterCow
SpaceCow
SpaceCowFloating

???

ShieldDefense
ShieldDefense2
VoidField

Universal Gibs

UGGib_Corpse_Shootable

4.2.2. Monsters

tsFilter_Monsters = "Monsters only";
class tsFilter_Monsters : Actor
{
  static bool isAllowed(Actor anActor) { return anActor.bIsMonster; }
}

4.2.3. Light level

tsFilter_LightLevel = "Lit only";
class tsFilter_LightLevel : Actor
{
  static bool isAllowed(Actor anActor)
  {
    PlayerPawn pawn = players[consolePlayer].mo;
    if (pawn.findInventory("PowerLightAmp")
      || pawn.findInventory("PowerInvulnerable")) return true;

    Sector aSector = anActor.curSector;
    int lightLevel = aSector.lightLevel;
    ts_Options options = ts_EventHandler.getInstance().getOptions();

    return lightLevel >= options.getMinLightLevel();
  }
}
int getMinLightLevel() const { return mMinLightLevelCvar.getInt(); }
private Cvar mMinLightLevelCvar;
mMinLightLevelCvar = ts_ExistingCvar.find("ts_min_light_level");
user int ts_min_light_level = 120;
Slider "Minimal light level", ts_min_light_level, 0, 250, 10, 0

4.2.4. Health

tsFilter_Health = "Positive health only";
class tsFilter_Health : Actor
{
  static bool isAllowed(Actor anActor) { return anActor.health > 0; }
}

4.2.5. Hidden

tsFilter_Hidden = "Not hidden only";
class tsFilter_Hidden : Actor
{
  static bool isAllowed(Actor anActor)
  {
    return !anActor.bShadow && !anActor.bStealth;
  }
}

4.2.6. Enemies

tsFilter_Enemies = "Enemies only";
class tsFilter_Enemies : Actor
{
  static bool isAllowed(Actor anActor) { return !anActor.bFriendly; }
}

4.2.7. Dormant

tsFilter_Dormant = "Not dormant only";
class tsFilter_Dormant : Actor
{
  static bool isAllowed(Actor anActor) { return !anActor.bDormant; }
}

4.2.8. Active

tsFilter_Active = "Active only";
class tsFilter_Active : Actor
{
  static bool isAllowed(Actor anActor) { return anActor.target != NULL; }
}

4.3. Getters

4.3.1. Health

tsGetter_Health = "Health";
tsGetter_Health = "Здоровье";
class tsGetter_Health : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    return ts_Kind.Value, "", anActor.health, 0;
  }
}

4.3.2. Health and max health

tsGetter_HealthAndMax = "Health and max health";
class tsGetter_HealthAndMax : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    int maxHealth = ts_Utils.getActorMaxHealth(anActor);
    return ts_Kind.ValueAndMax, "", anActor.health, maxHealth;
  }
}

4.3.3. Silent health and max health

This getter is somewhat special. It is intended to:

  • carry health and max health even when other health getters are disabled.
  • be invisible.
  • be always on.
class tsGetter_SilentHealth : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    int maxHealth = ts_Utils.getActorMaxHealth(anActor);
    return ts_Kind.Empty, "", anActor.health, maxHealth;
  }
}
{
  "tsGetter_SilentHealth": "on"
}

4.3.4. Health bar

tsGetter_HealthBar = "Health bar";
class tsGetter_HealthBar : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    int maxHealth = ts_Utils.getActorMaxHealth(anActor);
    return ts_Kind.Bar, "", anActor.health, maxHealth;
  }
}

4.3.5. Armor bar

tsGetter_ArmorBar = "Armor bar";
class tsGetter_ArmorBar : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    let armor = BasicArmor(anActor.findInventory("BasicArmor"));

    if (armor == NULL || armor.amount == 0)
      return ts_Kind.Empty, "", 0, 0;

    return ts_Kind.Bar, "", armor.amount, armor.actualSaveAmount;
  }
}

4.3.6. Class

tsGetter_Class = "Class name";
class tsGetter_Class : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    return ts_Kind.PlainText, anActor.getClassName(), 0, 0;
  }
}

4.3.7. Tag

tsGetter_Tag = "Tag";
class tsGetter_Tag : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    string tag = anActor.player ? anActor.player.getUserName() : anActor.getTag();
    return ts_Kind.PlainText, tag, 0, 0;
  }

  // Example:
  //static Font getFont(Actor anActor)
  //{
  //  return Font.findFont(anActor is "Zombieman" ? "BigFont" : "NewSmallFont");
  //}
}

4.3.8. Tag and class

tsGetter_TagAndClass = "Tag (class)";
class tsGetter_TagAndClass : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    string tag    = anActor.player ? anActor.player.getUserName() : anActor.getTag();
    string aClass = anActor.getClassName();
    string result = (aClass != tag)
      ? string.format("%s (%s)", tag, aClass)
      : string.format("%s", aClass);
    return ts_Kind.PlainText, result, 0, 0;
  }
}

4.3.9. Flags

tsGetter_Flags  = "Flags";

ts_Friendly     = "Friendly";
ts_Invulnerable = "Invulnerable";
ts_Boss         = "Boss";
ts_Dormant      = "Dormant";
ts_Buddha       = "Buddha";
ts_Undamageable = "Undamageable";
ts_NoBlockmap   = "No blockmap";
ts_Boss = "Босс";
class tsGetter_Flags : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    string result;

    if (anActor.bFriendly && !anActor.player)
      append(result, translate("ts_Friendly"));
    if (anActor.bInvulnerable) append(result, translate("ts_Invulnerable"));
    if (anActor.bBoss        ) append(result, translate("ts_Boss"));
    if (anActor.bDormant     ) append(result, translate("ts_Dormant"));
    if (anActor.bBuddha      ) append(result, translate("ts_Buddha"));
    if (anActor.bNoDamage    ) append(result, translate("ts_Undamageable"));
    if (anActor.bNoBlockmap  ) append(result, translate("ts_NoBlockmap"));

    return ts_Kind.PlainText, result, 0, 0;
  }

  private static string translate(string tag)
  {
    return StringTable.localize(tag, false);
  }

  private static void append(out string result, string appendix)
  {
    if (result.length() == 0)
      result = appendix;
    else
      result.appendFormat(", %s", appendix);
  }
}

4.4. Views

4.4.1. Fader

class ts_Fader
{
  void initialize() { mElapsed = FADE_TIME_TICS; }

  double getAlpha(double fracTic) const
  {
    return min(1, (FADE_TIME_TICS - (mElapsed + fracTic)) / FADE_TIME_TICS);
  }

  void reset() { mElapsed = -2; } // -2 for one-tick buffer to prevent flickering.
  void tick() { ++mElapsed; }

  const FADE_TIME_TICS = TICRATE / 3;
  private double mElapsed;
}

4.4.2. Text view

tsView_Text = "Plain text";
class tsView_Text : Actor
{

  static ui void draw(Array<ts_InformationPiece> informationPieces,
                      double alpha,
                      RenderEvent event,
                      vector3 targetPosition,
                      vector2 targetSize)
  {
    if (alpha <= 0.0) return;
    if (informationPieces.size() == 0) return;

    ts_Options options = ts_EventHandler.getInstance().getOptions();
    double scale = options.getScale();

    Array<string> lines;
    Array<double> lineWidths;
    Array<double> lineHeights;
    Array<Font>   fonts;

    double totalWidth  = 0;
    double totalHeight = 0;

    foreach (piece : informationPieces)
    {
      string line = ts_ViewUtils.toText(piece, options);
      if (line.length() == 0) continue;

      lines.push(line);
      double lineWidth  = round(scale * piece.font.stringWidth(line));
      double lineHeight = round(scale * piece.font.getHeight());

      totalWidth = max(totalWidth, piece.font.stringWidth(line));
      totalHeight += lineHeight;

      lineWidths.push(lineWidth);
      lineHeights.push(lineHeight);
      fonts.push(piece.font);
    }

    int linesCount = lines.size();
    if (linesCount == 0) return;

    // Note: stringWidth underestimates.
    totalWidth = totalWidth * 1.1 * scale;

    int screenWidth = Screen.getWidth();
    int screenHeight = Screen.getHeight();
    vector2 position = getPosition(options, event, targetPosition, targetSize);
    if (options.getPlace() == ts_Options.PlaceAboveTarget)
      position.y -= totalHeight;
    position.x = clamp(position.x, totalWidth / 2, screenWidth - totalWidth / 2);

    int color = options.isUsingHealthBarColors()
      ? ts_Utils.getHealthColor(options, informationPieces)
      : Font.CR_Gray;

    bool isBackgroundEnabled = options.isBackgroundEnabled();
    for (int i = 0; i < linesCount; ++i)
    {
      double lineHeight = lineHeights[i];
      position.y = clamp(position.y, 0, screenHeight - totalHeight + lineHeight);

      if (isBackgroundEnabled)
      {
        double additionalWidth = fonts[i].stringWidth("  ") * scale;
        ts_ViewUtils.drawBackground(lineWidths[i] + additionalWidth,
                                    lineHeight,
                                    position,
                                    alpha);
      }

      ts_ViewUtils.drawText(fonts[i],
                            lines[i],
                            lineWidths[i],
                            position,
                            alpha,
                            scale,
                            color);

      position.y += lineHeight;
    }
  }

  static ui vector2 getPosition(ts_Options options,
                                RenderEvent event,
                                vector3 targetPosition,
                                vector2 targetSize)
  {
    int place = options.getPlace();
    if (place == ts_Options.PlaceFixed)
      return (options.getXPosition() * Screen.getWidth(),
        options.getYPosition() * Screen.getHeight());

    ts_EventHandler eventHandler = ts_EventHandler.getInstance();
    ts_Projector projector = eventHandler.getProjector();

    if (place == ts_Options.PlaceAboveTarget)
      targetPosition.z += targetSize.y * 1.1; // Room for sprites above height.

    return eventHandler
      .interpolateTextScreenPosition(projector.makePosition(event, targetPosition));
  }
}
static ui void draw(Array<ts_InformationPiece> informationPieces,
                    double alpha,
                    RenderEvent event,
                    vector3 targetPosition,
                    vector2 targetSize)
{
  if (alpha <= 0.0) return;
  if (informationPieces.size() == 0) return;

  ts_Options options = ts_EventHandler.getInstance().getOptions();
  double scale = options.getScale();

  Array<string> lines;
  Array<double> lineWidths;
  Array<double> lineHeights;
  Array<Font>   fonts;

  double totalWidth  = 0;
  double totalHeight = 0;

  foreach (piece : informationPieces)
  {
    string line = ts_ViewUtils.toText(piece, options);
    if (line.length() == 0) continue;

    lines.push(line);
    double lineWidth  = round(scale * piece.font.stringWidth(line));
    double lineHeight = round(scale * piece.font.getHeight());

    totalWidth = max(totalWidth, piece.font.stringWidth(line));
    totalHeight += lineHeight;

    lineWidths.push(lineWidth);
    lineHeights.push(lineHeight);
    fonts.push(piece.font);
  }

  int linesCount = lines.size();
  if (linesCount == 0) return;

  // Note: stringWidth underestimates.
  totalWidth = totalWidth * 1.1 * scale;

  int screenWidth = Screen.getWidth();
  int screenHeight = Screen.getHeight();
  vector2 position = getPosition(options, event, targetPosition, targetSize);
  if (options.getPlace() == ts_Options.PlaceAboveTarget)
    position.y -= totalHeight;
  position.x = clamp(position.x, totalWidth / 2, screenWidth - totalWidth / 2);

  int color = options.isUsingHealthBarColors()
    ? ts_Utils.getHealthColor(options, informationPieces)
    : Font.CR_Gray;

  bool isBackgroundEnabled = options.isBackgroundEnabled();
  for (int i = 0; i < linesCount; ++i)
  {
    double lineHeight = lineHeights[i];
    position.y = clamp(position.y, 0, screenHeight - totalHeight + lineHeight);

    if (isBackgroundEnabled)
    {
      double additionalWidth = fonts[i].stringWidth("  ") * scale;
      ts_ViewUtils.drawBackground(lineWidths[i] + additionalWidth,
                                  lineHeight,
                                  position,
                                  alpha);
    }

    ts_ViewUtils.drawText(fonts[i],
                          lines[i],
                          lineWidths[i],
                          position,
                          alpha,
                          scale,
                          color);

    position.y += lineHeight;
  }
}

static ui vector2 getPosition(ts_Options options,
                              RenderEvent event,
                              vector3 targetPosition,
                              vector2 targetSize)
{
  int place = options.getPlace();
  if (place == ts_Options.PlaceFixed)
    return (options.getXPosition() * Screen.getWidth(),
      options.getYPosition() * Screen.getHeight());

  ts_EventHandler eventHandler = ts_EventHandler.getInstance();
  ts_Projector projector = eventHandler.getProjector();

  if (place == ts_Options.PlaceAboveTarget)
    targetPosition.z += targetSize.y * 1.1; // Room for sprites above height.

  return eventHandler
    .interpolateTextScreenPosition(projector.makePosition(event, targetPosition));
}
double getScale() const { return mScaleCvar.getFloat(); }
double getXPosition() const { return mXPositionCvar.getFloat(); }
double getYPosition() const { return mYPositionCvar.getFloat(); }
enum Places { PlaceFixed, PlaceAboveTarget, PlaceBelowTarget }
int getPlace() const { return mPlaceCvar.getInt(); }
bool isBackgroundEnabled() const { return mBackgroundCvar.getInt(); }
bool isUsingHealthBarColors() const { return mHealthColors.getInt(); }
private Cvar mScaleCvar;
private Cvar mXPositionCvar;
private Cvar mYPositionCvar;
private Cvar mPlaceCvar;
private Cvar mBackgroundCvar;
private Cvar mHealthColors;
mScaleCvar      = ts_ExistingCvar.find("ts_scale");
mXPositionCvar  = ts_ExistingCvar.find("ts_x_position");
mYPositionCvar  = ts_ExistingCvar.find("ts_y_position");
mPlaceCvar      = ts_ExistingCvar.find("ts_place");
mBackgroundCvar = ts_ExistingCvar.find("ts_background");
mHealthColors   = ts_ExistingCvar.find("ts_using_health_bar_colors");
user float ts_scale = 1.0;
user float ts_x_position = 0.5;
user float ts_y_position = 0.2;
user int   ts_place = 0;
user bool  ts_background = false;
user bool  ts_using_health_bar_colors = false;
OptionMenu ts_TextViewOptions
{
  Title "Text view options"

  Slider "Scale",      ts_scale, 0.1, 4.0, 0.1, 1
  Slider "X position", ts_x_position, 0, 1.0, 0.01, 2
  Slider "Y position", ts_y_position, 0, 1.0, 0.01, 2
  Option "Place",      ts_place, ts_Places
  Option "Background", ts_background, OnOff
  Option "Use health bar colors", ts_using_health_bar_colors, OnOff

  StaticText ""
  Submenu "Default bar options", ts_DefaultBarOptions
  Submenu "Health bar options",  ts_HealthBarOptions
  Submenu "Armor bar options",   ts_ArmorBarOptions
}

OptionValue ts_Places
{
  0, "Fixed"
  1, "Above target"
  2, "Below target"
}

4.4.3. Frame

tsView_Frame = "Frame";
class tsView_Frame : Actor
{
  static ui void draw(Array<ts_InformationPiece> informationPieces,
                      double alpha,
                      RenderEvent event,
                      vector3 targetPosition,
                      vector2 targetSize)
  {
    if (alpha <= 0.0) return;
    ts_EventHandler eventHandler = ts_EventHandler.getInstance();
    ts_FrameMemory  memory       = eventHandler.getFrameMemory();
    ts_Projector    projector    = eventHandler.getProjector();
    ts_FrameOptions options      = memory.getOptions();
    ts_Options      mainOptions  = eventHandler.getOptions();

    Font   aFont = memory.getFont();
    double scale = options.getScale();
    int    color = options.isUsingHealthBarColors()
      ? ts_Utils.getHealthColor(mainOptions, informationPieces)
      : Font.CR_Gray;
    double halfLineHeight = aFont.getHeight() * scale / 2;

    vector3 worldPositions[8] =
    {
      targetPosition + ( targetSize.x,  targetSize.x, 0),
      targetPosition + ( targetSize.x, -targetSize.x, 0),
      targetPosition + (-targetSize.x,  targetSize.x, 0),
      targetPosition + (-targetSize.x, -targetSize.x, 0),
      targetPosition + ( targetSize.x,  targetSize.x, targetSize.y),
      targetPosition + ( targetSize.x, -targetSize.x, targetSize.y),
      targetPosition + (-targetSize.x,  targetSize.x, targetSize.y),
      targetPosition + (-targetSize.x, -targetSize.x, targetSize.y)
    };

    vector2 allScreenPositions[8];

    for (int i = 0; i < 8; ++i)
      allScreenPositions[i] = projector.makePosition(event, worldPositions[i]);

    double left   = double.max;
    double right  = -1;
    double top    = double.max;
    double bottom = -1;

    for (int i = 0; i < 8; ++i)
    {
      vector2 screenPosition = allScreenPositions[i];
      left   = min(left,   screenPosition.x);
      right  = max(right,  screenPosition.x);
      top    = min(top,    screenPosition.y);
      bottom = max(bottom, screenPosition.y);
    }

    vector2 positions[4] =
    {
      (left,  top    - halfLineHeight),
      (right, top    - halfLineHeight),
      (left,  bottom - halfLineHeight),
      (right, bottom - halfLineHeight)
    };

    for (int i = 0; i < 4; ++i)
      positions[i] = memory.interpolateScreenPosition(positions[i], i);

    for (int i = 0; i < 4; ++i)
    {
      string part = options.getFrame(i);
      double width = aFont.stringWidth(part) * scale;
      ts_ViewUtils.drawText(aFont,
                            part,
                            width,
                            positions[i],
                            alpha,
                            scale,
                            color);
    }
  }
}
class ts_FrameMemory
{
  void initialize(ts_TargetSource targetSource)
  {
    mFont = Font.findFont("NewSmallFont");
    (mOptions = new("ts_FrameOptions")).initialize();

    for (int i = 0; i < 4; ++i)
    {
      (mInterpolators[i] = new("ts_TargetScreenPositionInterpolator"))
        .initialize(targetSource);
    }
  }

  ui vector2 interpolateScreenPosition(vector2 position, int index)
  {
    return mInterpolators[index].interpolateScreenPosition(position);
  }

  ui Font getFont() const { return mFont; }
  ui ts_FrameOptions getOptions() const { return mOptions; }

  private Font mFont;
  private ts_TargetScreenPositionInterpolator[4] mInterpolators;
  private ts_FrameOptions mOptions;
}
(mFrameMemory = new("ts_FrameMemory")).initialize(mCurrentTarget);
private ts_FrameMemory mFrameMemory;
clearscope ts_FrameMemory getFrameMemory() { return mFrameMemory; }
class ts_FrameOptions
{
  void initialize()
  {
    mCustomEnabled     = ts_ExistingCvar.find("ts_frame_custom_enabled");
    mSelected          = ts_ExistingCvar.find("ts_frame_selected");
    mCustomTopLeft     = ts_ExistingCvar.find("ts_frame_custom_top_left");
    mCustomTopRight    = ts_ExistingCvar.find("ts_frame_custom_top_right");
    mCustomBottomLeft  = ts_ExistingCvar.find("ts_frame_custom_bottom_left");
    mCustomBottomRight = ts_ExistingCvar.find("ts_frame_custom_bottom_right");
    mScale             = ts_ExistingCvar.find("ts_frame_scale");
    mWithHealthColors  = ts_ExistingCvar.find("ts_frame_using_health_bar_colors");
  }

  string getFrame(int i) const
  {
    if (mCustomEnabled.getInt())
    {
      switch (i)
      {
        case 0: return mCustomTopLeft.getString();
        case 1: return mCustomTopRight.getString();
        case 2: return mCustomBottomLeft.getString();
        case 3: return mCustomBottomRight.getString();
        default: return ".";
      }
    }

    int selectedFrame = clamp(mSelected.getInt(), 0, 7);
    switch (selectedFrame)
    {
      case 0: return mFrames0[i];
      case 1: return mFrames1[i];
      case 2: return mFrames2[i];
      case 3: return mFrames3[i];
      case 4: return mFrames4[i];
      case 5: return mFrames5[i];
      case 6: return mFrames6[i];
      case 7: return mFrames7[i];
    }
    return "";
  }

  double getScale() const { return mScale.getFloat(); }
  bool isUsingHealthBarColors() const { return mWithHealthColors.getInt(); }

  private Cvar mCustomEnabled;
  private Cvar mSelected;
  private Cvar mCustomTopLeft;
  private Cvar mCustomTopRight;
  private Cvar mCustomBottomLeft;
  private Cvar mCustomBottomRight;
  private Cvar mScale;
  private Cvar mWithHealthColors;

  static const string mFrames0[] = {"▛", "▜", "▙", "▟"};
  static const string mFrames1[] = {"┌", "┐", "└", "┘"};
  static const string mFrames2[] = {"┏", "┓", "┗", "┛"};
  static const string mFrames3[] = {"▞", "▚", "▚", "▞"};
  static const string mFrames4[] = {"╔", "╗", "╚", "╝"};
  static const string mFrames5[] = {"╭", "╮", "╰", "╯"};
  static const string mFrames6[] = {"/", '\', '\', "/"};
  static const string mFrames7[] = {"↘", "↙", "↗", "↖"};
}
user bool   ts_frame_custom_enabled = false;
user int    ts_frame_selected = 0;
user string ts_frame_custom_top_left     = ".";
user string ts_frame_custom_top_right    = ".";
user string ts_frame_custom_bottom_left  = ".";
user string ts_frame_custom_bottom_right = ".";
user float  ts_frame_scale = 1.0;
user bool   ts_frame_using_health_bar_colors = true;
OptionMenu ts_FrameOptions
{
  Title "Frame options"

  Slider "Scale",  ts_frame_scale, 0.1, 4.0, 0.1, 1
  Option "Use health bar colors", ts_frame_using_health_bar_colors, OnOff

  StaticText ""
  Option "Custom characters", ts_frame_custom_enabled, OnOff

  StaticText ""
  Slider "Style", ts_frame_selected, 0, 7, 1, 0, ts_frame_custom_enabled, 1

  StaticText ""
  StaticText "Custom characters"
  TextField "Top left",     ts_frame_custom_top_left,     ts_frame_custom_enabled, 0
  TextField "Top right",    ts_frame_custom_top_right,    ts_frame_custom_enabled, 0
  TextField "Bottom left",  ts_frame_custom_bottom_left,  ts_frame_custom_enabled, 0
  TextField "Bottom right", ts_frame_custom_bottom_right, ts_frame_custom_enabled, 0
}

4.4.4. Crosshair

tsView_Crosshair = "Crosshair";
class tsView_Crosshair : Actor
{
  static ui void draw(Array<ts_InformationPiece> informationPieces,
                      double alpha,
                      RenderEvent event,
                      vector3 targetPosition,
                      vector2 targetSize)
  {
    ts_Options options = ts_EventHandler.getInstance().getOptions();

    if (options.getCrosshairAlways())
      alpha = 1.0;
    else if (alpha <= 0.0)
      return;

    Font    aFont  = Font.findFont("NewSmallFont");
    double  scale  = options.getCrosshairScale();
    double  halfLineHeight = aFont.getHeight() * scale / 2;
    vector2 center = (Screen.getWidth() / 2, options.getCrosshairY());
    double  height = options.getCrosshairHeight();
    vector2 positions[3] =
    {
      (0, -halfLineHeight) + center - (0, height),
      (0, -halfLineHeight) + center,
      (0, -halfLineHeight) + center + (0, height)
    };
    string  parts[3] =
    {
      options.getCrosshairTop(),
      options.getCrosshairCenter(),
      options.getCrosshairBottom()
    };
    int    color = options.getCrosshairHealth()
      ? ts_Utils.getHealthColor(options, informationPieces)
      : Font.CR_Gray;

    for (int i = 0; i < 3; ++i)
    {
      string part  = parts[i];
      double width = aFont.stringWidth(part) * scale;
      ts_ViewUtils.drawText(aFont,
                            part,
                            width,
                            positions[i],
                            alpha,
                            scale,
                            color);
    }
  }
}
double getCrosshairHeight() const { return mCrosshairHeight.getFloat(); }
double getCrosshairScale()  const { return mCrosshairScale.getFloat(); }
string getCrosshairTop()    const { return mCrosshairTop   .getString(); }
string getCrosshairCenter() const { return mCrosshairCenter.getString(); }
string getCrosshairBottom() const { return mCrosshairBottom.getString(); }
bool   getCrosshairAlways() const { return mCrosshairAlways.getInt(); }
bool   getCrosshairHealth() const { return mCrosshairHealth.getInt(); }

double getCrosshairY() const
{
  // TODO: cache this Cvar. Cannot be cached like others because
  // it doesn't exist yet if loaded before PreciseCrosshair.
  Cvar crosshairY = Cvar.getCvar("pc_y", players[consolePlayer]);
  if (crosshairY) return crosshairY.getFloat();

  let [x, y, width, height] = Screen.getViewWindow();
  return y + height / 2;
}
private Cvar mCrosshairHeight;
private Cvar mCrosshairScale;
private Cvar mCrosshairTop;
private Cvar mCrosshairCenter;
private Cvar mCrosshairBottom;
private Cvar mCrosshairAlways;
private Cvar mCrosshairHealth;
mCrosshairHeight = ts_ExistingCvar.find("ts_crosshair_height");
mCrosshairScale  = ts_ExistingCvar.find("ts_crosshair_scale");
mCrosshairTop    = ts_ExistingCvar.find("ts_crosshair_top");
mCrosshairCenter = ts_ExistingCvar.find("ts_crosshair_center");
mCrosshairBottom = ts_ExistingCvar.find("ts_crosshair_bottom");
mCrosshairAlways = ts_ExistingCvar.find("ts_crosshair_always");
mCrosshairHealth = ts_ExistingCvar.find("ts_crosshair_health");
user float  ts_crosshair_height = 10.0;
user float  ts_crosshair_scale  = 1.0;
user string ts_crosshair_top    = "v";
user string ts_crosshair_center = "> <";
user string ts_crosshair_bottom = "^";
user bool   ts_crosshair_always = false;
user bool   ts_crosshair_health = true;
OptionMenu ts_CrosshairOptions
{
  Title "Crosshair options"

  Option "Always enabled", ts_crosshair_always, OnOff
  Option "Use health bar colors", ts_crosshair_health, OnOff

  StaticText ""
  Slider "Height", ts_crosshair_height, 0.5, 40, 0.1
  Slider "Scale",  ts_crosshair_scale,  0.5,  4, 0.1

  StaticText ""
  TextField "Top",    ts_crosshair_top
  TextField "Center", ts_crosshair_center
  TextField "Bottom", ts_crosshair_bottom
}

4.5. Core

4.5.1. Event handler

class ts_EventHandler : StaticEventHandler
{
  override void onRegister()
  {
    (mOptions = new("ts_Options")).initialize();
    (mBlocklist = new("ts_ClassList")).initialize("ts_blocklist");
    (mFollowers = new("ts_ClassList")).initialize("ts_followers");

    (mFader = new("ts_Fader")).initialize();
    (mProjector = new("ts_Projector")).initialize();

    mCurrentTarget = new("ts_TargetSource");
    mDefaultFont   = Font.findFont("NewSmallFont");

    mDisabledFindersCvar = ts_ExistingCvar.find("ts_disabled_finders");
    mDisabledFiltersCvar = ts_ExistingCvar.find("ts_disabled_filters");
    mDisabledGettersCvar = ts_ExistingCvar.find("ts_disabled_getters");
    mDisabledViewsCvar   = ts_ExistingCvar.find("ts_disabled_views");

    (mTextInterpolator  = new("ts_TargetScreenPositionInterpolator")).initialize(mCurrentTarget);

    (mFrameMemory = new("ts_FrameMemory")).initialize(mCurrentTarget);

    collectConfigurations();

    collectClasses();
  }

  override void worldTick()
  {
    if (players[consolePlayer].mo == NULL) return;
    if (level.mapName ~== "titlemap") return;

    mProjector.prepare();
    mFader.tick();

    if (automapactive || !mOptions.isOn())
    {
      mCurrentTarget.target = NULL;
      return;
    }

    findTargets();
    filterTargets();
    chooseCurrentTarget();
    collectInformation();
  }

  override void worldLoaded(WorldEvent event)
  {
    clearInformation();
  }

  override void renderOverlay(RenderEvent event)
  {
    if (players[consolePlayer].mo == NULL) return;

    double alpha = mFader.getAlpha(event.fracTic);

    Array<string> disabledClasses;
    mDisabledViewsCvar.getString().split(disabledClasses, ",", TOK_SkipEmpty);
    applyConfiguration("tsView_", disabledClasses);
    int disabledClassesSize = disabledClasses.size();

    int viewsCount = mViews.size();
    for (int i = 0; i < viewsCount; ++i)
    {
      bool isEnabled = disabledClasses.find(mViewClasses[i]) == disabledClassesSize;
      if (!isEnabled) continue;
      mViews[i].
        call(mInformationPieces, alpha, event, mLastTargetPosition, mLastTargetSize);
    }
  }

  override void networkProcess(ConsoleEvent event)
  {
    if (event.player == consolePlayer && event.name == "ts_read_getters")
      collectGetters();
  }

  static clearscope ts_EventHandler getInstance()
  {
    return (ts_EventHandler)(StaticEventHandler.find("ts_EventHandler"));
  }

  clearscope void getFinders(out Array<string> result) { result.copy(mFinderClasses); }
  clearscope void getFilters(out Array<string> result) { result.copy(mFilterClasses); }
  clearscope void getGetters(out Array<string> result) { result.copy(mGetterClasses); }
  clearscope void getViews(out Array<string> result) { result.copy(mViewClasses); }

  clearscope ts_Options getOptions() { return mOptions; }
  clearscope ts_Projector getProjector() { return mProjector; }

  ts_ClassList getBlocklist() { return mBlocklist; }

  ui vector2 interpolateTextScreenPosition(vector2 position)
  {
    return mTextInterpolator.interpolateScreenPosition(position);
  }

  private void findTargets()
  {
    Array<string> disabledClasses;
    mDisabledFindersCvar.getString().split(disabledClasses, ",", TOK_SkipEmpty);
    applyConfiguration("tsFinder_", disabledClasses);
    int disabledClassesSize = disabledClasses.size();

    int findersCount = mFinders.size();
    for (int i = 0; i < findersCount; ++i)
    {
      bool isEnabled = disabledClasses.find(mFinderClasses[i]) == disabledClassesSize;
      Actor target = isEnabled ? mFinders[i].call() : NULL;
      if (target != NULL && mFollowers.containsParent(target.getClass()))
        target = target.master;

      mTargets[i] = target;
    }
  }

  private void filterTargets()
  {
    int filtersCount = mFilters.size();
    int targetsCount = mTargets.size();

    Array<string> disabledClasses;
    mDisabledFiltersCvar.getString().split(disabledClasses, ",", TOK_SkipEmpty);
    applyConfiguration("tsFilter_", disabledClasses);
    int disabledClassesSize = disabledClasses.size();

    for (int targetIndex = 0; targetIndex < targetsCount; ++targetIndex)
    {
      if (mTargets[targetIndex] == NULL) continue;

      for (int i = 0; i < filtersCount; ++i)
      {
        bool isEnabled = disabledClasses.find(mFilterClasses[i])
          == disabledClassesSize;
        if (!isEnabled) continue;
        if (mFilters[i].call(mTargets[targetIndex])) continue;

        mTargets[targetIndex] = NULL;
        break;
      }
    }
  }

  private void chooseCurrentTarget()
  {
    Actor closestTarget = NULL;
    double minDistance = double.max;
    foreach (target : mTargets)
    {
      if (target == NULL) continue;
      double distance = distanceTo(target);
      if (distance < minDistance)
      {
        closestTarget = target;
        minDistance = distance;
      }
    }

    if (closestTarget != NULL)
    {
      mLastTargetSize = (closestTarget.radius, closestTarget.height);
      mLastTargetPosition = closestTarget.pos;
    }

    mCurrentTarget.target = closestTarget;
  }

  private void collectInformation()
  {
    if (mCurrentTarget.target == NULL)
    {
      if (mFader.getAlpha(1.0) <= 0.0) clearInformation();
      return;
    }

    clearInformation();
    mFader.reset();

    Array<string> disabledClasses;
    mDisabledGettersCvar.getString().split(disabledClasses, ",", TOK_SkipEmpty);
    applyConfiguration("tsGetter_", disabledClasses);
    int disabledClassesSize = disabledClasses.size();

    int gettersCount = mGetters.size();
    for (int i = 0; i < gettersCount; ++i)
    {
      bool isEnabled = disabledClasses.find(mGetterClasses[i]) == disabledClassesSize;
      if (isEnabled)
      {
        mInformationPieces[i].id = mGetterClasses[i];
        [mInformationPieces[i].kind,
         mInformationPieces[i].text,
         mInformationPieces[i].value,
         mInformationPieces[i].max] = mGetters[i].call(mCurrentTarget.target);
        mInformationPieces[i].font = mFontGetters[i]
          ? mFontGetters[i].call(mCurrentTarget.target)
          : mDefaultFont;
      }
    }
  }

  private void clearInformation()
  {
    int gettersCount = mGetters.size();
    for (int i = 0; i < gettersCount; ++i)
      mInformationPieces[i].kind = ts_Kind.Empty;
  }

  private void collectGetters()
  {
    mGetters.clear();
    mGetterClasses.clear();

    Array<string> orderedGetters;
    ts_ExistingCvar.find("ts_getters_order").getString()
      .split(orderedGetters, ",", TOK_SkipEmpty);

    foreach (getter : orderedGetters)
    {
      string className = "tsGetter_" .. getter;
      Class<Actor> aClass = className;
      if (aClass != NULL && !tryAddGetter(aClass))
        Console.printf("ERROR: %s: invalid tsGetter", className);
    }

    foreach (aClass : AllActorClasses)
    {
      string className = aClass.getClassName();
      string shortName = ts_Classes.shorten(className);
      if (mGetterClasses.find(shortName) != mGetterClasses.size()) continue;
      if (className.indexOf("tsGetter_") != -1 && !tryAddGetter(aClass))
        Console.printf("ERROR: %s: invalid tsGetter", className);
    }

    ts_Classes.warnTooLongList(mGetterClasses, "getters");
  }

  private void collectClasses()
  {
    foreach (aClass : AllActorClasses)
    {
      string className = aClass.getClassName();
      if (className.indexOf("tsFinder_") != -1 && !tryAddFinder(aClass))
        Console.printf("ERROR: %s: invalid tsFinder", className);

      if (className.indexOf("tsFilter_") != -1 && !tryAddFilter(aClass))
        Console.printf("ERROR: %s: invalid tsFilter", className);

      if (className.indexOf("tsView_") != -1 && !tryAddView(aClass))
        Console.printf("ERROR: %s: invalid tsView", className);
    }

    ts_Classes.warnTooLongList(mFinderClasses, "finders");
    ts_Classes.warnTooLongList(mFilterClasses, "filters");
    ts_Classes.warnTooLongList(mViewClasses, "views");

    collectGetters();
  }

  private bool tryAddFinder(class<Actor> aClass)
  {
    // This class replaces something: skip, it is handled on replacee side.
    if (Actor.getReplacee(aClass) != aClass) return true;

    // If this class is replaced, use that replacement but keep the original name.
    string className = aClass.getClassName();
    aClass = Actor.getReplacement(aClass);

    let finder = (Function<play Actor()>)(findFunction(aClass, "find"));
    if (finder != NULL)
    {
      mFinders.push(finder);
      mFinderClasses.push(ts_Classes.shorten(className));
      mTargets.push(NULL);
    }
    return finder != NULL;
  }

  private bool tryAddFilter(class<Actor> aClass)
  {
    // This class replaces something: skip, it is handled on replacee side.
    if (Actor.getReplacee(aClass) != aClass) return true;

    // If this class is replaced, use that replacement but keep the original name.
    string className = aClass.getClassName();
    aClass = Actor.getReplacement(aClass);

    let filter = (Function<play bool(Actor)>)(findFunction(aClass, "isAllowed"));
    if (filter != NULL)
    {
      mFilters.push(filter);
      mFilterClasses.push(ts_Classes.shorten(className));
    }
    return filter != NULL;
  }

  private bool tryAddGetter(class<Actor> aClass)
  {
    // This class replaces something: skip, it is handled on replacee side.
    if (Actor.getReplacee(aClass) != aClass) return true;

    // If this class is replaced, use that replacement but keep the original name.
    string className = aClass.getClassName();
    aClass = Actor.getReplacement(aClass);

    let getter = (Function<play int, string, int, int(Actor)>)
      (findFunction(aClass, "makeInformation"));
    if (getter != NULL)
    {
      mGetters.push(getter);
      mGetterClasses.push(ts_Classes.shorten(className));
      mInformationPieces.push(new("ts_InformationPiece"));

      mFontGetters.
        push((Function<play Font(Actor)>)(findFunction(aClass, "getFont")));
    }
    return getter != NULL;
  }

  private bool tryAddView(class<Actor> aClass)
  {
    // This class replaces something: skip, it is handled on replacee side.
    if (Actor.getReplacee(aClass) != aClass) return true;

    // If this class is replaced, use that replacement but keep the original name.
    string className = aClass.getClassName();
    aClass = Actor.getReplacement(aClass);

    let view =
      (Function
       <ui void (Array<ts_InformationPiece>, double, RenderEvent, vector3, vector2)>)
      (findFunction(aClass, "draw"));
    if (view != NULL)
    {
      mViews.push(view);
      mViewClasses.push(ts_Classes.shorten(className));
    }
    return view != NULL;
  }

  private static double distanceTo(Actor anActor)
  {
    return (anActor != NULL)
      ? players[consolePlayer].mo.distance3d(anActor)
      : 0;
  }

  private ts_Options mOptions;
  private Cvar mDisabledFindersCvar;
  private Cvar mDisabledFiltersCvar;
  private Cvar mDisabledGettersCvar;
  private Cvar mDisabledViewsCvar;

  private Array< Function<play Actor()> > mFinders;
  private Array<string> mFinderClasses;
  private Array<Actor> mTargets;

  private ts_TargetSource mCurrentTarget;

  private Array< Function<play bool(Actor)> > mFilters;
  private Array<string> mFilterClasses;

  private vector3 mLastTargetPosition;
  private vector2 mLastTargetSize;

  private Array< Function<play int, string, int, int(Actor)> > mGetters;
  private Array< Function<play Font(Actor)> > mFontGetters;
  private Array<string> mGetterClasses;
  private Array<ts_InformationPiece> mInformationPieces;

  private Array< Function
    <ui void(Array<ts_InformationPiece>, double, RenderEvent, vector3, vector2)>
    > mViews;
  private Array<string> mViewClasses;
  private Font mDefaultFont;

  private ts_ClassList mBlocklist;
  private ts_ClassList mFollowers;
  private ts_Fader mFader;
  private ts_Projector mProjector;

  private ts_TargetScreenPositionInterpolator mTextInterpolator;


  private ts_FrameMemory mFrameMemory;
  clearscope ts_FrameMemory getFrameMemory() { return mFrameMemory; }

  clearscope bool isInConfig(string className)
  {
    return mConfiguration.checkKey(className);
  }

  private void collectConfigurations()
  {
    string lumpName = "ts_config";

    for (int i = Wads.findLump(lumpName);
         i != -1;
         i = Wads.findLump(lumpName, i + 1))
    {
      let configuration = Dictionary.fromString(Wads.readLump(i));
      let iterator      = DictionaryIterator.create(configuration);

      while (iterator.next())
      {
        string key   = iterator.key();
        string value = iterator.value().makeLower();

        if      (value == "on")   mConfiguration.insert(key, 1);
        else if (value == "off")  mConfiguration.insert(key, 0);
        else if (value == "user") mConfiguration.remove(key);
      }
    }
  }

  clearscope void applyConfiguration(string prefix,
                                     out Array<string> disabledClasses)
  {
    foreach (className, isEnabled : mConfiguration)
    {
      if (className.left(prefix.length()) != prefix) continue;

      string shortClassName = ts_Classes.shorten(className);
      int index = disabledClasses.find(shortClassName);

      if (isEnabled)
      {
        if (index != disabledClasses.size())
          disabledClasses.delete(index);
      }
      else
      {
        if (index == disabledClasses.size())
          disabledClasses.push(shortClassName);
      }
    }
  }

  private Map<string, int> mConfiguration;
}

4.5.2. Turn on/off

bool isOn() const { return mIsOn.getInt(); }
private Cvar mIsOn;
mIsOn = ts_ExistingCvar.find("ts_on");
user bool ts_on = true;
AddKeySection "$ts_Keys" ts_Keys

Alias ts_turn_on  "ts_on true"
Alias ts_turn_off "ts_on false"
Alias +ts_hold ts_turn_on
Alias -ts_hold ts_turn_off

AddMenuKey "$ts_Hold" +ts_hold
AddMenuKey "$ts_On"  ts_turn_on
AddMenuKey "$ts_Off" ts_turn_off
ts_Keys = "TargeSpy keys";
ts_Hold = "Hold to turn on";
ts_On   = "Turn on";
ts_Off  = "Turn off";

4.5.3. Information piece

class ts_Kind
{
  enum _
  {
    Empty,
    PlainText,
    Value,
    ValueAndMax,
    Bar,
  }
}

class ts_InformationPiece
{
  string id;    // identifier for the getter that created this piece.
  int    kind;  // see ts_Kind.
  string text;  // defined only for ts_Kind.PlainText.
  int    value; // defined for ts_Kind.Value, ts_Kind.ValueAndMax, and ts_Kind.Bar.
  int    max;   // defined for ts_Kind.ValueAndMax and ts_Kind.Bar.
  Font   font;  // font for this piece.
}

4.5.4. Class list

class ts_ClassList
{
  void initialize(string lumpName)
  {
    for (int i = Wads.findLump(lumpName);
         i != -1;
         i = Wads.findLump(lumpName, i + 1))
    {
      string contents = Wads.readLump(i);
      contents.replace("\r", "");
      Array<string> lines;
      contents.split(lines, "\n", TOK_SkipEmpty);
      int linesCount = lines.size();
      for (int l = 0; l < linesCount; ++l)
      {
        mClasses.insert(lines[l].makeLower(), 0);
      }
    }
  }

  bool contains(string className) const
  {
    return mClasses.checkKey(className.makeLower());
  }

  bool containsParent(class<Object> aClass)
  {
    for (class<Object> parent = aClass;
         parent.getClassName() != "Thinker";
         parent = parent.getParentClass())
    {
      if (contains(parent.getClassName())) return true;
    }
    return false;
  }

  Map<string, int> mClasses;
}

4.5.5. Existing Cvar

TODO: move to a module?

class ts_ExistingCvar
{
  static Cvar find(Name name)
  {
    Cvar result = Cvar.findCvar(name);
    if (result == NULL)
      throwAbortException("ERROR: TargetSpy: %s Cvar not found.", name);
    return result;
  }
}

4.5.6. Utilities

Functions in ts_Utils class.

  1. getActorMaxHealth
    static play int getActorMaxHealth(Actor anActor)
    {
      if (anActor.player != NULL && anActor.player.mo != NULL)
        return anActor.player.mo.getMaxHealth();
    
      int result = anActor.spawnHealth();
    
      string drpgToken = "DRPGMonsterStatsHandler";
      bool isDRPG = (anActor.countInv(drpgToken) > 0);
      if (isDRPG)
        result = anActor.ACS_scriptCall("GetMonsterHealthMax");
    
      string legendaryToken = "LDLegendaryMonsterToken";
      bool isLegenDoom = (anActor.countInv(legendaryToken) > 0);
      if (isLegenDoom && !isDRPG)
        result *= Cvar.getCvar("LD_legendaryHealth").getInt() / 100;
    
      return result;
    }
    
  2. getColor
    static ui int getHealthColor(ts_Options options, Array<ts_InformationPiece> pieces)
    {
      double value;
      double max;
      bool   found = false;
      foreach (piece : pieces)
      {
        if (piece.id == "SilentHealth")
        {
          found = true;
          value = piece.value;
          max   = piece.max;
          break;
        }
      }
    
      if (!found) return Font.CR_Gray;
    
      ts_BarOptions healthBarOptions = options.getBarOptions("HealthBar");
      int percent = int(round(value * 100 / max));
      if (percent <= 0) return healthBarOptions.getEmptyColor(percent);
      return healthBarOptions.getFilledColor(percent);
    }
    

4.5.7. Target screen position interpolator

class ts_TargetSource
{
  Actor target;
}

class ts_TargetScreenPositionInterpolator
{
  void initialize(ts_TargetSource targetSource)
  {
    mTargetSource = targetSource;
  }

  ui vector2 interpolateScreenPosition(vector2 screenPosition)
  {
    Object currentTarget = mTargetSource.target;
    if (mLastInterpolatedTarget != currentTarget)
    {
      mLastInterpolatedTarget = currentTarget;
      mOldScreenPosition = screenPosition;
      return screenPosition;
    }

    vector2 diff = (screenPosition - mOldScreenPosition) / 2;
    mOldScreenPosition.x += round(diff.x);
    mOldScreenPosition.y += round(diff.y);

    return mOldScreenPosition;
  }

  private ts_TargetSource mTargetSource;
  private ui vector2 mOldScreenPosition;
  private ui Object mLastInterpolatedTarget;
}

4.5.8. Class utilities

class ts_Classes
{
  static void warnTooLongList(out Array<string> classes, string listName)
  {
    string disabledClasses = ts_su.join(classes, ",");
    if (disabledClasses.length() > 256)
      Console.printf("WARNING: %s list may be too long (%d) for a user cvar:\n%s",
                     listName,
                     disabledClasses.length(),
                     disabledClasses);
  }

  static string shorten(string className)
  {
    return className.mid(className.indexOf("_") + 1);
  }
}

4.5.9. Follower classes

???

HeadshotTargetZombie
HeadshotTargetEliteZombie
HeadshotTargetImp
HeadshotTargetAngryImp
HeadshotTargetNoble
HeadshotTargetEliteNoble
HeadshotTargetBossNoble
HeadshotTargetArchvile
HeadshotTargetEliteArchvile
HeadshotTargetRevenant
HeadshotTargetEliteRevenant
HeadshotTargetMancubus
HeadshotTargetSoulCommander
HeadshotTargetCacodemon
HeadshotTargetPainElemental
HeadshotTargetDemon
HeadshotTargetCyberdemon

Brutal Doom

HeadshotTargetBase

Venturous

HeadshotHitbox
HeadshotHitboxDouble
HeadshotHitboxHigher
HeadshotHitboxHeavyeadshotHitbox

4.5.10. Projector

class ts_Projector
{
  void initialize()
  {
    mGlProjection = new("ts_GlScreen");
    mSwProjection = new("ts_SwScreen");
    mCvarRenderer = Cvar.findCvar("vid_rendermode");
    if (mCvarRenderer == NULL)
      Console.printf("TargetSpy: WARNING: vid_rendermode CVar not found");
  }

  void prepare()
  {
    // Projection type detection is not quite right here.
    // vid_rendermode contains more complex information.
    // If something breaks, look here.
    if (mCvarRenderer != NULL)
    {
      int renderer = mCvarRenderer.getInt();
      mProjection = (renderer == 0 || renderer == 1)
        ? (ts_ProjScreen)(mSwProjection)
        : (ts_ProjScreen)(mGlProjection);
      return;
    }

    mProjection = mGlProjection;
  }

  ui vector2 makePosition(RenderEvent event, vector3 worldPosition) const
  {
    PlayerInfo player = players[consolePlayer];

    mProjection.cacheResolution();
    mProjection.cacheFov(player.fov);
    mProjection.orientForRenderOverlay(event);
    mProjection.beginProjection();
    mProjection.projectWorldPos(worldPosition);

    ts_Viewport viewport;
    viewport.fromHud();

    return viewport.sceneToWindow(mProjection.projectToNormal());
  }

  private ts_GlScreen   mGlProjection;
  private ts_SwScreen   mSwProjection;
  private CVar          mCvarRenderer;
  private ts_ProjScreen mProjection;
}

4.5.11. View utilities

class ts_ViewUtils ui
{

  static void drawText(Font aFont,
                       string text,
                       double textWidth,
                       vector2 position,
                       double alpha,
                       double scale,
                       int color)
  {
    position.x -= textWidth / 2;
    Screen.drawText(aFont,
                    color,
                    int(round(position.x)),
                    int(round(position.y)),
                    text,
                    DTA_ScaleX, scale,
                    DTA_ScaleY, scale,
                    DTA_Alpha,  alpha);
  }

  static void drawBackground(double textWidth,
                             double textHeight,
                             vector2 position,
                             double alpha)
  {
    position.x -= textWidth / 2;
    Screen.dim(0x000000,
               alpha / 2,
               int(round(position.x)),
               int(round(position.y)),
               int(round(textwidth)),
               int(round(textheight)));
  }

  static string toText(ts_InformationPiece piece, ts_Options options)
  {
    switch (piece.kind)
    {
    case ts_Kind.PlainText:   return piece.text;
    case ts_Kind.Value:       return string.format("%d", piece.value);
    case ts_Kind.ValueAndMax: return string.format("%d/%d", piece.value, piece.max);
    case ts_Kind.Bar:
      return makeBar(piece.value, piece.max, options.getBarOptions(piece.id));
    }
    return "";
  }

  static string makeBar(int value, int max, ts_BarOptions options)
  {
    int length = max(1, int(round(log(max) * 2)));
    int filledCount = 0;

    if (max < 1)
    {
      filledCount = 0;
      max = 1;
    }
    else
    {
      filledCount = clamp(int(ceil(double(value) * length / max)), 0, length);
    }

    int percent = int(round(double(value) * 100 / max));
    int filledColor = options.getFilledColor(percent);
    int emptyColor  = options.getEmptyColor(percent);
    string filled = string.format("%c", options.getFilledCharacter());
    string empty  = string.format("%c", options.getEmptyCharacter());

    return string.format("\c%c%s\c%c%s",
                          SMALL_A + filledColor,
                          ts_su.repeat(filled, filledCount),
                          SMALL_A + emptyColor,
                          ts_su.repeat(empty, length - filledCount));
  }

  const SMALL_A = ts_Ascii.LATIN_SMALL_LETTER_A;
}
  1. drawText
    static void drawText(Font aFont,
                         string text,
                         double textWidth,
                         vector2 position,
                         double alpha,
                         double scale,
                         int color)
    {
      position.x -= textWidth / 2;
      Screen.drawText(aFont,
                      color,
                      int(round(position.x)),
                      int(round(position.y)),
                      text,
                      DTA_ScaleX, scale,
                      DTA_ScaleY, scale,
                      DTA_Alpha,  alpha);
    }
    
  2. drawBackground
    static void drawBackground(double textWidth,
                               double textHeight,
                               vector2 position,
                               double alpha)
    {
      position.x -= textWidth / 2;
      Screen.dim(0x000000,
                 alpha / 2,
                 int(round(position.x)),
                 int(round(position.y)),
                 int(round(textwidth)),
                 int(round(textheight)));
    }
    
    static string toText(ts_InformationPiece piece, ts_Options options)
    {
      switch (piece.kind)
      {
      case ts_Kind.PlainText:   return piece.text;
      case ts_Kind.Value:       return string.format("%d", piece.value);
      case ts_Kind.ValueAndMax: return string.format("%d/%d", piece.value, piece.max);
      case ts_Kind.Bar:
        return makeBar(piece.value, piece.max, options.getBarOptions(piece.id));
      }
      return "";
    }
    
  3. makeBar
    static string makeBar(int value, int max, ts_BarOptions options)
    {
      int length = max(1, int(round(log(max) * 2)));
      int filledCount = 0;
    
      if (max < 1)
      {
        filledCount = 0;
        max = 1;
      }
      else
      {
        filledCount = clamp(int(ceil(double(value) * length / max)), 0, length);
      }
    
      int percent = int(round(double(value) * 100 / max));
      int filledColor = options.getFilledColor(percent);
      int emptyColor  = options.getEmptyColor(percent);
      string filled = string.format("%c", options.getFilledCharacter());
      string empty  = string.format("%c", options.getEmptyCharacter());
    
      return string.format("\c%c%s\c%c%s",
                            SMALL_A + filledColor,
                            ts_su.repeat(filled, filledCount),
                            SMALL_A + emptyColor,
                            ts_su.repeat(empty, length - filledCount));
    }
    
    const SMALL_A = ts_Ascii.LATIN_SMALL_LETTER_A;
    
    class ts_BarOptions
    {
      ts_BarOptions initialize(string tag)
      {
        mFilledColorCvars.push(findCvar(tag, "filled_25_color"));
        mFilledColorCvars.push(findCvar(tag, "filled_50_color"));
        mFilledColorCvars.push(findCvar(tag, "filled_75_color"));
        mFilledColorCvars.push(findCvar(tag, "filled_100_color"));
        mFilledColorCvars.push(findCvar(tag, "filled_101_color"));
    
        mEmptyColorCvars.push(findCvar(tag, "empty_0_color"));
        mEmptyColorCvars.push(findCvar(tag, "empty_25_color"));
        mEmptyColorCvars.push(findCvar(tag, "empty_50_color"));
        mEmptyColorCvars.push(findCvar(tag, "empty_75_color"));
        mEmptyColorCvars.push(findCvar(tag, "empty_100_color"));
    
        mFilledCharacterIndex = findCvar(tag, "filled_character_index");
        mEmptyCharacterIndex  = findCvar(tag, "empty_character_index");
        mCharactersCvar       = findCvar(tag, "characters");
    
        return self;
      }
    
      private Cvar findCvar(string tag, string name)
      {
        let fullName = "ts_" .. tag .. "_" .. name;
        let aCvar = Cvar.findCvar(fullName);
        return (aCvar != NULL)
          ? aCvar
          : ts_ExistingCvar.find("ts_DefaultBar_" .. name);
      }
    
      int getFilledColor(int percent) const
      {
        return mFilledColorCvars[clamp(percent + 24, 25, 125) / 25 - 1].getInt();
      }
      int getEmptyColor(int percent) const
      {
        return mEmptyColorCvars[clamp(percent + 24, 0, 100) / 25].getInt();
      }
      int getFilledCharacter() const
      {
        return ts_su.getCodePointAt(mCharactersCvar.getString(),
                                    max(mFilledCharacterIndex.getInt(), 0));
      }
      int getEmptyCharacter() const
      {
        return ts_su.getCodePointAt(mCharactersCvar.getString(),
                                    max(mEmptyCharacterIndex.getInt(), 0));
      }
    
      private Array<Cvar> mFilledColorCvars;
      private Array<Cvar> mEmptyColorCvars;
      private Cvar mFilledCharacterIndex;
      private Cvar mEmptyCharacterIndex;
      private Cvar mCharactersCvar;
    }
    
    ts_BarOptions getBarOptions(string aClass) { return mBarOptions.get(aClass); }
    
    private static void findGetters(out Array<string> result)
    {
      foreach (aClass : AllActorClasses)
      {
        string className = aClass.getClassName();
        if (className.indexOf("tsGetter_") != -1)
          result.push(ts_Classes.shorten(className));
      }
    }
    
    Map<string, ts_BarOptions> mBarOptions;
    
    mBarOptions.insert("DefaultBar", (new("ts_BarOptions")).initialize("DefaultBar"));
    
    Array<string> getterClasses;
    findGetters(getterClasses);
    foreach (aClass : getterClasses)
      mBarOptions.insert(aClass, (new("ts_BarOptions")).initialize(aClass));
    
    
    
    user int ts_DefaultBar_filled_25_color  = 3; // green
    user int ts_DefaultBar_filled_50_color  = 3;
    user int ts_DefaultBar_filled_75_color  = 3;
    user int ts_DefaultBar_filled_100_color = 3;
    user int ts_DefaultBar_filled_101_color = 3;
    
    user int ts_DefaultBar_empty_0_color    = 6; // red
    user int ts_DefaultBar_empty_25_color   = 6;
    user int ts_DefaultBar_empty_50_color   = 6;
    user int ts_DefaultBar_empty_75_color   = 6;
    user int ts_DefaultBar_empty_100_color  = 6;
    
    user int ts_DefaultBar_filled_character_index = 0;
    user int ts_DefaultBar_empty_character_index  = 0;
    user string ts_DefaultBar_characters = "█▓▒░❤♡☻☹⛧⛤☣✚⛊●☐☒☑⚙";
    
    
    
    user int ts_HealthBar_filled_25_color  = 3; // green
    user int ts_HealthBar_filled_50_color  = 3;
    user int ts_HealthBar_filled_75_color  = 3;
    user int ts_HealthBar_filled_100_color = 3;
    user int ts_HealthBar_filled_101_color = 3;
    
    user int ts_HealthBar_empty_0_color    = 6; // red
    user int ts_HealthBar_empty_25_color   = 6;
    user int ts_HealthBar_empty_50_color   = 6;
    user int ts_HealthBar_empty_75_color   = 6;
    user int ts_HealthBar_empty_100_color  = 6;
    
    user int ts_HealthBar_filled_character_index = 0;
    user int ts_HealthBar_empty_character_index  = 0;
    user string ts_HealthBar_characters = "█▓▒░❤♡☻☹⛧⛤☣✚⛊●☐☒☑⚙";
    
    
    
    user int ts_ArmorBar_filled_25_color  = 3; // green
    user int ts_ArmorBar_filled_50_color  = 3;
    user int ts_ArmorBar_filled_75_color  = 3;
    user int ts_ArmorBar_filled_100_color = 3;
    user int ts_ArmorBar_filled_101_color = 3;
    
    user int ts_ArmorBar_empty_0_color    = 6; // red
    user int ts_ArmorBar_empty_25_color   = 6;
    user int ts_ArmorBar_empty_50_color   = 6;
    user int ts_ArmorBar_empty_75_color   = 6;
    user int ts_ArmorBar_empty_100_color  = 6;
    
    user int ts_ArmorBar_filled_character_index = 0;
    user int ts_ArmorBar_empty_character_index  = 0;
    user string ts_ArmorBar_characters = "█▓▒░❤♡☻☹⛧⛤☣✚⛊●☐☒☑⚙";
    
    
    
    OptionMenu ts_DefaultBarOptions
    {
      Title "DefaultBar options"
    
      Slider "Filled character", ts_DefaultBar_filled_character_index, 0, 99, 1, 0
      ts_HighlightedCharacter    ts_DefaultBar_filled_character_index, DefaultBar
      Slider "Empty character",  ts_DefaultBar_empty_character_index, 0, 99, 1, 0
      ts_HighlightedCharacter    ts_DefaultBar_empty_character_index, DefaultBar
      TextField "Characters",    ts_DefaultBar_characters
    
      StaticText ""
      ts_BarPreview 0,   "DefaultBar"
      ts_BarPreview 25,  "DefaultBar"
      ts_BarPreview 50,  "DefaultBar"
      ts_BarPreview 75,  "DefaultBar"
      ts_BarPreview 100, "DefaultBar"
      ts_BarPreview 101, "DefaultBar"
    
      StaticText ""
      StaticText "Colors for filled characters"
      Option "1-25%" ,  ts_DefaultBar_filled_25_color,  TextColors
      Option "26-50%",  ts_DefaultBar_filled_50_color,  TextColors
      Option "51-75%",  ts_DefaultBar_filled_75_color,  TextColors
      Option "76-100%", ts_DefaultBar_filled_100_color, TextColors
      Option "> 100%",  ts_DefaultBar_filled_101_color, TextColors
    
      StaticText ""
      StaticText "Colors for empty characters"
      Option "≤ 0",      ts_DefaultBar_empty_0_color,   TextColors
      Option "1-25%",   ts_DefaultBar_empty_25_color,  TextColors
      Option "26-50%",  ts_DefaultBar_empty_50_color,  TextColors
      Option "51-75%",  ts_DefaultBar_empty_75_color,  TextColors
      Option "76-100%", ts_DefaultBar_empty_100_color, TextColors
    }
    
    
    OptionMenu ts_HealthBarOptions
    {
      Title "HealthBar options"
    
      Slider "Filled character", ts_HealthBar_filled_character_index, 0, 99, 1, 0
      ts_HighlightedCharacter    ts_HealthBar_filled_character_index, HealthBar
      Slider "Empty character",  ts_HealthBar_empty_character_index, 0, 99, 1, 0
      ts_HighlightedCharacter    ts_HealthBar_empty_character_index, HealthBar
      TextField "Characters",    ts_HealthBar_characters
    
      StaticText ""
      ts_BarPreview 0,   "HealthBar"
      ts_BarPreview 25,  "HealthBar"
      ts_BarPreview 50,  "HealthBar"
      ts_BarPreview 75,  "HealthBar"
      ts_BarPreview 100, "HealthBar"
      ts_BarPreview 101, "HealthBar"
    
      StaticText ""
      StaticText "Colors for filled characters"
      Option "1-25%" ,  ts_HealthBar_filled_25_color,  TextColors
      Option "26-50%",  ts_HealthBar_filled_50_color,  TextColors
      Option "51-75%",  ts_HealthBar_filled_75_color,  TextColors
      Option "76-100%", ts_HealthBar_filled_100_color, TextColors
      Option "> 100%",  ts_HealthBar_filled_101_color, TextColors
    
      StaticText ""
      StaticText "Colors for empty characters"
      Option "≤ 0",      ts_HealthBar_empty_0_color,   TextColors
      Option "1-25%",   ts_HealthBar_empty_25_color,  TextColors
      Option "26-50%",  ts_HealthBar_empty_50_color,  TextColors
      Option "51-75%",  ts_HealthBar_empty_75_color,  TextColors
      Option "76-100%", ts_HealthBar_empty_100_color, TextColors
    }
    
    
    OptionMenu ts_ArmorBarOptions
    {
      Title "ArmorBar options"
    
      Slider "Filled character", ts_ArmorBar_filled_character_index, 0, 99, 1, 0
      ts_HighlightedCharacter    ts_ArmorBar_filled_character_index, ArmorBar
      Slider "Empty character",  ts_ArmorBar_empty_character_index, 0, 99, 1, 0
      ts_HighlightedCharacter    ts_ArmorBar_empty_character_index, ArmorBar
      TextField "Characters",    ts_ArmorBar_characters
    
      StaticText ""
      ts_BarPreview 0,   "ArmorBar"
      ts_BarPreview 25,  "ArmorBar"
      ts_BarPreview 50,  "ArmorBar"
      ts_BarPreview 75,  "ArmorBar"
      ts_BarPreview 100, "ArmorBar"
      ts_BarPreview 101, "ArmorBar"
    
      StaticText ""
      StaticText "Colors for filled characters"
      Option "1-25%" ,  ts_ArmorBar_filled_25_color,  TextColors
      Option "26-50%",  ts_ArmorBar_filled_50_color,  TextColors
      Option "51-75%",  ts_ArmorBar_filled_75_color,  TextColors
      Option "76-100%", ts_ArmorBar_filled_100_color, TextColors
      Option "> 100%",  ts_ArmorBar_filled_101_color, TextColors
    
      StaticText ""
      StaticText "Colors for empty characters"
      Option "≤ 0",      ts_ArmorBar_empty_0_color,   TextColors
      Option "1-25%",   ts_ArmorBar_empty_25_color,  TextColors
      Option "26-50%",  ts_ArmorBar_empty_50_color,  TextColors
      Option "51-75%",  ts_ArmorBar_empty_75_color,  TextColors
      Option "76-100%", ts_ArmorBar_empty_100_color, TextColors
    }
    
    

4.5.12. Menu classes

  1. Toggles
    user string ts_disabled_finders = "NonShootable,Noblockmap";
    user string ts_disabled_filters = "LightLevel,Enemies,Dormant,Active";
    user string ts_disabled_getters = "Health,ArmorBar,Class,Tag,Flags";
    user string ts_disabled_views   = "Frame,Crosshair";
    
    ts_AllOn  = "Activate all";
    ts_AllOff = "Deactivate all";
    
    class ts_ClassToggles : OptionMenu
    {
      void fill(out Array<string> classes,
                string classPrefix,
                string disabledClassesCvarName)
      {
        let eventHandler = ts_EventHandler.getInstance();
    
        mDesc.mItems.clear();
        int classesCount = classes.size();
        for (int i = 0; i < classesCount; ++i)
        {
          if (eventHandler.isInConfig(classPrefix .. classes[i])) continue;
          let toggle = new("ts_ClassToggle");
          toggle.initialize(classes[i], classPrefix, disabledClassesCvarName);
          mDesc.mItems.push(toggle);
        }
    
        mDesc.mItems.push(new("OptionMenuItemStaticText").initDirect("", 0));
        mDesc.mItems.push(new("OptionMenuItemCommand")
                          .init("$ts_AllOn", disabledClassesCvarName .. " \"\""));
        mDesc.mItems.push(new("OptionMenuItemCommand")
                          .init("$ts_AllOff",
                                string.format("%s \"%s\"",
                                              disabledClassesCvarName,
                                              ts_su.join(classes, ","))));
    
        mDesc.mSelectedItem = clamp(mDesc.mSelectedItem, 0, mDesc.mItems.size() - 1);
      }
    }
    
    class ts_ClassToggle : OptionMenuItemOptionBase
    {
      void initialize(string aClass, string classPrefix, string disabledClassesCvarName)
      {
        mClass = aClass;
        mCvar  = ts_ExistingCvar.find(disabledClassesCvarName);
        Super.init("$" .. classPrefix .. aClass, '', "OnOff", NULL, 0);
      }
    
      override int getSelection()
      {
        Array<string> parts;
        mCvar.getString().split(parts, ",", TOK_SkipEmpty);
        return parts.find(mClass) == parts.size();
      }
    
      override void setSelection(int selection)
      {
        Array<string> parts;
        mCvar.getString().split(parts, ",", TOK_SkipEmpty);
        int found = parts.find(mClass);
    
        if (selection == (found == parts.size())) return;
    
        if (selection)
          parts.delete(found);
        else
          parts.push(mClass);
    
        mCvar.setString(ts_su.join(parts, ","));
      }
    
      private Cvar   mCvar;
      private string mClass;
    }
    
    
    class tsFinder_Toggles : ts_ClassToggles
    {
      override void init(Menu parent, OptionMenuDescriptor descriptor)
      {
        Super.init(parent, descriptor);
        Array<string> classes;
        ts_EventHandler.getInstance().getFinders(classes);
        Super.fill(classes, "tsFinder_", "ts_disabled_finders");
      }
    }
    
    class tsFilter_Toggles : ts_ClassToggles
    {
      override void init(Menu parent, OptionMenuDescriptor descriptor)
      {
        Super.init(parent, descriptor);
        Array<string> classes;
        ts_EventHandler.getInstance().getFilters(classes);
        Super.fill(classes, "tsFilter_", "ts_disabled_filters");
      }
    }
    
    class tsGetter_Toggles : ts_ClassToggles
    {
      override void init(Menu parent, OptionMenuDescriptor descriptor)
      {
        Super.init(parent, descriptor);
        Array<string> classes;
        ts_EventHandler.getInstance().getGetters(classes);
        Super.fill(classes, "tsGetter_", "ts_disabled_getters");
      }
    }
    
    class tsView_Toggles : ts_ClassToggles
    {
      override void init(Menu parent, OptionMenuDescriptor descriptor)
      {
        Super.init(parent, descriptor);
        Array<string> classes;
        ts_EventHandler.getInstance().getViews(classes);
        Super.fill(classes, "tsView_", "ts_disabled_views");
      }
    }
    
  2. Highlighted character static text
    class OptionMenuItemts_HighlightedCharacter : OptionMenuItemStaticText
    {
      OptionMenuItemStaticText init(Name command, string tag)
      {
        mCvar = ts_ExistingCvar.find(command);
        mCharactersCvar = ts_ExistingCvar.find(string.format("ts_%s_characters", tag));
        return Super.init("", -1);
      }
    
      override int draw(OptionMenuDescriptor desc, int y, int indent, bool selected)
      {
        string characters = mCharactersCvar.getString();
        mLabel = ts_su.highlight(characters,
                                 clamp(mCvar.getInt(), 0, characters.length() - 1),
                                 Font.CR_Teal);
        drawLabel(indent, y, Font.CR_White);
        return -1;
      }
    
      private Cvar mCvar;
      private Cvar mCharactersCvar;
    }
    
  3. Bar preview
    class OptionMenuItemts_BarPreview : OptionMenuItemStaticText
    {
      OptionMenuItemStaticText init(int percent, string aClass)
      {
        mPercent = percent;
        mClass = aClass;
        return Super.init("", -1);
      }
    
      override int draw(OptionMenuDescriptor desc, int y, int indent, bool selected)
      {
        ts_BarOptions options =
          ts_EventHandler.getInstance().getOptions().getBarOptions(mClass);
        int value = int(round(double(mPercent) * 64 / 100));
        mLabel = string.format("%3d%% ", mPercent)
          .. ts_ViewUtils.makeBar(value, 64, options);
        drawLabel(indent, y, Font.CR_White);
        return -1;
      }
    
      private int mPercent;
      private string mClass;
    }
    
  4. Getters order
    OptionMenu ts_GettersOrder
    {
      Class ts_GettersOrderMenu
      Title "Getters order"
    }
    
    user string ts_getters_order = "";
    
    class ts_GettersOrderMenu : OptionMenu
    {
      override void init(Menu parent, OptionMenuDescriptor descriptor)
      {
        Super.init(parent, descriptor);
    
        ts_EventHandler eventHandler = ts_EventHandler.getInstance();
        eventHandler.getGetters(mClasses);
        eventHandler.applyConfiguration("tsGetter_", mClasses);
    
        mDesc.mItems.clear();
        int classesCount = mClasses.size();
        for (int i = 0; i < classesCount; ++i)
        {
          let entry = new("ts_GettersOrderElement");
          entry.init("$tsGetter_" .. mClasses[i], i, self);
          mDesc.mItems.push(entry);
        }
    
        mDesc.mSelectedItem = clamp(mDesc.mSelectedItem, 0, mDesc.mItems.size() - 1);
        mOrderCvar = ts_ExistingCvar.find("ts_getters_order");
      }
    
      void moveDown(int i)
      {
        if (i + 1 >= mDesc.mItems.size()) return;
    
        {
          let temp = mClasses[i];
          mClasses[i] = mClasses[i + 1];
          mClasses[i + 1] = temp;
        }
        {
          let temp = mDesc.mItems[i];
          mDesc.mItems[i] = mDesc.mItems[i + 1];
          mDesc.mItems[i + 1] = temp;
        }
    
        ++mDesc.mSelectedItem;
        (ts_GettersOrderElement)(mDesc.mItems[i]).setIndex(i);
        (ts_GettersOrderElement)(mDesc.mItems[i + 1]).setIndex(i + 1);
    
        mOrderCvar.setString(ts_su.join(mClasses, ","));
        EventHandler.sendNetworkEvent("ts_read_getters");
      }
    
      Array<string> mClasses;
      Cvar mOrderCvar;
    }
    
    class ts_GettersOrderElement : OptionMenuItemCommand
    {
      ts_GettersOrderElement init(string label, int index, ts_GettersOrderMenu menu)
      {
        Super.init(label, "");
        mIndex = index;
        mMenu = menu;
        return self;
      }
    
      override bool activate()
      {
        mMenu.moveDown(mIndex);
        return true;
      }
    
      void setIndex(int index) { mIndex = index; }
    
      private int mIndex;
      private ts_GettersOrderMenu mMenu;
    }
    

4.5.13. Configuration

Configuration is a way to customize how TargetSpy processes finders, filters, getters and views. By default these classes are enabled or disabled by user choice. Configuration can force them to be on or off. If a class is forced on, it is always active and hidden from option menus. If a class is forced off, it is always disabled and hidden from option menus.

Several configurations may be loaded at once. If more than one configuration configures a class, the last loaded configuration wins.

Configuration lump has name ts_config and is in JSON object format. Key is full class name, value is "off", "on", or "user". "user" can be used to override previous configuration.

Example:

class tsFilter_Abc : Actor
{
  static bool isAllowed(Actor anActor)
  {
    return true;
  }
}

class tsGetter_Def : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    return ts_Kind.PlainText, "Def", 0, 0;
  }
}
{
  "tsFilter_Abc": "on",
  "tsGetter_Def": "off",
  "tsGetter_Health": "user"
}
  1. Implementation
    collectConfigurations();
    
    clearscope bool isInConfig(string className)
    {
      return mConfiguration.checkKey(className);
    }
    
    private void collectConfigurations()
    {
      string lumpName = "ts_config";
    
      for (int i = Wads.findLump(lumpName);
           i != -1;
           i = Wads.findLump(lumpName, i + 1))
      {
        let configuration = Dictionary.fromString(Wads.readLump(i));
        let iterator      = DictionaryIterator.create(configuration);
    
        while (iterator.next())
        {
          string key   = iterator.key();
          string value = iterator.value().makeLower();
    
          if      (value == "on")   mConfiguration.insert(key, 1);
          else if (value == "off")  mConfiguration.insert(key, 0);
          else if (value == "user") mConfiguration.remove(key);
        }
      }
    }
    
    clearscope void applyConfiguration(string prefix,
                                       out Array<string> disabledClasses)
    {
      foreach (className, isEnabled : mConfiguration)
      {
        if (className.left(prefix.length()) != prefix) continue;
    
        string shortClassName = ts_Classes.shorten(className);
        int index = disabledClasses.find(shortClassName);
    
        if (isEnabled)
        {
          if (index != disabledClasses.size())
            disabledClasses.delete(index);
        }
        else
        {
          if (index == disabledClasses.size())
            disabledClasses.push(shortClassName);
        }
      }
    }
    
    private Map<string, int> mConfiguration;
    

4.5.14. Extension replacements

Use either replaces keyword:

class tsGetter_TestClass : Actor replaces tsGetter_Class
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    let [kind, text, value, max] = tsGetter_Class.makeInformation(anActor);
    return kind, text .. " - class test", value, max;
  }
}

Or replace classes with an EventHandler:

class tsGetter_TestTag : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    let [kind, text, value, max] = tsGetter_Tag.makeInformation(anActor);
    return kind, text .. " - name test", value, max;
  }
}

class tst_EventHandler : StaticEventHandler
{
  override void CheckReplacement(ReplaceEvent event)
  {
    if (event.replacee.getClassName() == "tsGetter_Tag")
      event.replacement = "tsGetter_TestTag";
  }
}
GameInfo
{
  EventHandlers = "tst_EventHandler"
}

Optional: replace user-visible class name with a new one:

[enu default]
tsGetter_Class = "Class name test";

5. Project setup

GameInfo
{
  EventHandlers = "ts_EventHandler"
}
version 4.14.3

#include "zscript/ts_common.zs"
#include "zscript/ts_event_handler.zs"
#include "zscript/ts_filters.zs"
#include "zscript/ts_finders.zs"
#include "zscript/ts_getters.zs"
#include "zscript/ts_menus.zs"
#include "zscript/ts_options.zs"
#include "zscript/ts_types.zs"
#include "zscript/ts_views.zs"

#include "zscript/ts_PlainTranslator.zs"
#include "zscript/ts_StringUtils.zs"
#include "zscript/ts_libeye.zs"
version 4.14.3


class tsFilter_Abc : Actor
{
  static bool isAllowed(Actor anActor)
  {
    return true;
  }
}

class tsGetter_Def : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    return ts_Kind.PlainText, "Def", 0, 0;
  }
}

class tsGetter_TestClass : Actor replaces tsGetter_Class
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    let [kind, text, value, max] = tsGetter_Class.makeInformation(anActor);
    return kind, text .. " - class test", value, max;
  }
}

class tsGetter_TestTag : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    let [kind, text, value, max] = tsGetter_Tag.makeInformation(anActor);
    return kind, text .. " - name test", value, max;
  }
}

class tst_EventHandler : StaticEventHandler
{
  override void CheckReplacement(ReplaceEvent event)
  {
    if (event.replacee.getClassName() == "tsGetter_Tag")
      event.replacement = "tsGetter_TestTag";
  }
}

PlainTranslator

Translatable strings:

[enu default]
ts_OptionsTitle = "TargetSpy \cd⌖";
tsFinder_Aim = "Aim target";
tsFinder_Shootable = "Shootable only";
tsFinder_NonShootable = "Non-shootable too";
tsFinder_Noblockmap = "Everything";
tsFilter_Blocklist = "Blocklist";
tsFilter_Monsters = "Monsters only";
tsFilter_LightLevel = "Lit only";
tsFilter_Health = "Positive health only";
tsFilter_Hidden = "Not hidden only";
tsFilter_Enemies = "Enemies only";
tsFilter_Dormant = "Not dormant only";
tsFilter_Active = "Active only";
tsGetter_Health = "Health";
tsGetter_HealthAndMax = "Health and max health";
tsGetter_HealthBar = "Health bar";
tsGetter_ArmorBar = "Armor bar";
tsGetter_Class = "Class name";
tsGetter_Tag = "Tag";
tsGetter_TagAndClass = "Tag (class)";

tsGetter_Flags  = "Flags";

ts_Friendly     = "Friendly";
ts_Invulnerable = "Invulnerable";
ts_Boss         = "Boss";
ts_Dormant      = "Dormant";
ts_Buddha       = "Buddha";
ts_Undamageable = "Undamageable";
ts_NoBlockmap   = "No blockmap";
tsView_Text = "Plain text";
tsView_Frame = "Frame";
tsView_Crosshair = "Crosshair";

ts_Keys = "TargeSpy keys";
ts_Hold = "Hold to turn on";
ts_On   = "Turn on";
ts_Off  = "Turn off";
ts_AllOn  = "Activate all";
ts_AllOff = "Deactivate all";

[ru]
tsGetter_Health = "Здоровье";

ts_Boss = "Босс";
ts_Toggles = "Переключатели";
class ts_Options
{
  void initialize()
  {
    mMinLightLevelCvar = ts_ExistingCvar.find("ts_min_light_level");

    mScaleCvar      = ts_ExistingCvar.find("ts_scale");
    mXPositionCvar  = ts_ExistingCvar.find("ts_x_position");
    mYPositionCvar  = ts_ExistingCvar.find("ts_y_position");
    mPlaceCvar      = ts_ExistingCvar.find("ts_place");
    mBackgroundCvar = ts_ExistingCvar.find("ts_background");
    mHealthColors   = ts_ExistingCvar.find("ts_using_health_bar_colors");

    mCrosshairHeight = ts_ExistingCvar.find("ts_crosshair_height");
    mCrosshairScale  = ts_ExistingCvar.find("ts_crosshair_scale");
    mCrosshairTop    = ts_ExistingCvar.find("ts_crosshair_top");
    mCrosshairCenter = ts_ExistingCvar.find("ts_crosshair_center");
    mCrosshairBottom = ts_ExistingCvar.find("ts_crosshair_bottom");
    mCrosshairAlways = ts_ExistingCvar.find("ts_crosshair_always");
    mCrosshairHealth = ts_ExistingCvar.find("ts_crosshair_health");

    mIsOn = ts_ExistingCvar.find("ts_on");

    mBarOptions.insert("DefaultBar", (new("ts_BarOptions")).initialize("DefaultBar"));

    Array<string> getterClasses;
    findGetters(getterClasses);
    foreach (aClass : getterClasses)
      mBarOptions.insert(aClass, (new("ts_BarOptions")).initialize(aClass));
  }

  int getMinLightLevel() const { return mMinLightLevelCvar.getInt(); }

  double getScale() const { return mScaleCvar.getFloat(); }
  double getXPosition() const { return mXPositionCvar.getFloat(); }
  double getYPosition() const { return mYPositionCvar.getFloat(); }
  enum Places { PlaceFixed, PlaceAboveTarget, PlaceBelowTarget }
  int getPlace() const { return mPlaceCvar.getInt(); }
  bool isBackgroundEnabled() const { return mBackgroundCvar.getInt(); }
  bool isUsingHealthBarColors() const { return mHealthColors.getInt(); }

  double getCrosshairHeight() const { return mCrosshairHeight.getFloat(); }
  double getCrosshairScale()  const { return mCrosshairScale.getFloat(); }
  string getCrosshairTop()    const { return mCrosshairTop   .getString(); }
  string getCrosshairCenter() const { return mCrosshairCenter.getString(); }
  string getCrosshairBottom() const { return mCrosshairBottom.getString(); }
  bool   getCrosshairAlways() const { return mCrosshairAlways.getInt(); }
  bool   getCrosshairHealth() const { return mCrosshairHealth.getInt(); }

  double getCrosshairY() const
  {
    // TODO: cache this Cvar. Cannot be cached like others because
    // it doesn't exist yet if loaded before PreciseCrosshair.
    Cvar crosshairY = Cvar.getCvar("pc_y", players[consolePlayer]);
    if (crosshairY) return crosshairY.getFloat();

    let [x, y, width, height] = Screen.getViewWindow();
    return y + height / 2;
  }

  bool isOn() const { return mIsOn.getInt(); }

  ts_BarOptions getBarOptions(string aClass) { return mBarOptions.get(aClass); }

  private static void findGetters(out Array<string> result)
  {
    foreach (aClass : AllActorClasses)
    {
      string className = aClass.getClassName();
      if (className.indexOf("tsGetter_") != -1)
        result.push(ts_Classes.shorten(className));
    }
  }

  private Cvar mMinLightLevelCvar;

  private Cvar mScaleCvar;
  private Cvar mXPositionCvar;
  private Cvar mYPositionCvar;
  private Cvar mPlaceCvar;
  private Cvar mBackgroundCvar;
  private Cvar mHealthColors;

  private Cvar mCrosshairHeight;
  private Cvar mCrosshairScale;
  private Cvar mCrosshairTop;
  private Cvar mCrosshairCenter;
  private Cvar mCrosshairBottom;
  private Cvar mCrosshairAlways;
  private Cvar mCrosshairHealth;

  private Cvar mIsOn;

  Map<string, ts_BarOptions> mBarOptions;
}

6. Tests

Check that the add-on at least can be loaded and doesn't error out on a target.

wait 2; map map01
wait 4; summon doomimp
wait 8; quit

Created: 2026-06-16 Tue 18:46