TargetSpy

Table of Contents

1. About

Features:

  • text crosshair with options and PreciseCrosshair support.

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 = "Target Spy \cd⌖";

TODO: add PlainTranslator?

AddOptionMenu OptionsMenu       { Submenu "$ts_OptionsTitle", ts_Options }
AddOptionMenu OptionsMenuSimple { Submenu "$ts_OptionsTitle", ts_Options }

OptionMenu ts_Options
{
  Title "$ts_OptionsTitle"

  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
}

OptionMenu ts_FinderToggles
{
  Class tsFinder_Toggles
  Title "Finder Toggles"
}

OptionMenu ts_FilterToggles
{
  Class tsFilter_Toggles
  Title "Filter Toggles"
}

OptionMenu ts_GetterToggles
{
  Class tsGetter_Toggles
  Title "Getter Toggles"
}

OptionMenu ts_ViewToggles
{
  Class tsView_Toggles
  Title "View toggles"
}

OptionMenu ts_FilterOptions
{
  Title "Filter options"

  Slider "Minimal light level", ts_min_light_level, 0, 250, 10, 0
}

4. Source

4.1. Extending TargetSpy

4.1.1. Finders, filters, and getters

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 three 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.

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.

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

4.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.

4.2. Common types

4.2.1. Information piece

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

class ts_InformationPiece
{
  string id;
  int kind;
  string text;
  int value;
  int max;
}

4.2.2. 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.2.3. 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.3. Event handler

class ts_EventHandler : StaticEventHandler
{
  override void onRegister()
  {
    (mOptions = new("ts_Options")).initialize();
    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");
    (mFader = new("ts_Fader")).initialize();
    (mBlocklist = new("ts_ClassList")).initialize("ts_blocklist");
    (mFollowers = new("ts_ClassList")).initialize("ts_followers");
    (mProjector = new("ts_Projector")).initialize();
    mTextInterpolator  = new("ts_TargetScreenPositionInterpolator");
    mFrameInterpolator = new("ts_TargetScreenPositionInterpolator");

    collectClasses();
  }

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

    mProjector.prepare();
    findTargets();
    filterTargets();
    chooseCurrentTarget();
    collectInformation();
    mFader.tick();
  }

  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);
    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, mCurrentTarget);
  }

  ui vector2 interpolateFrameScreenPosition(vector2 position)
  {
    return mFrameInterpolator.interpolateScreenPosition(position, mCurrentTarget);
  }

  private ui bool isInformationEmpty() const
  {
    int gettersCount = mGetters.size();
    for (int i = 0; i < gettersCount; ++i)
      if (mInformationPieces[i].kind != ts_Kind.Empty) return false;

    return true;
  }

  private void findTargets()
  {
    Array<string> disabledClasses;
    mDisabledFindersCvar.getString().split(disabledClasses, ",", TOK_SkipEmpty);
    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);
    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 = closestTarget;
  }

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

    clearInformation();
    mFader.reset();

    Array<string> disabledClasses;
    mDisabledGettersCvar.getString().split(disabledClasses, ",", TOK_SkipEmpty);
    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);
      }
    }
  }

  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<Object> 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<Object> aClass)
  {
    let finder = (Function<play Actor()>)(findFunction(aClass, "find"));
    if (finder != NULL)
    {
      mFinders.push(finder);
      mFinderClasses.push(ts_Classes.shorten(aClass.getClassName()));
      mTargets.push(NULL);
    }
    return finder != NULL;
  }

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

  private bool tryAddGetter(class<Object> aClass)
  {
    let getter = (Function<play int, string, int, int(Actor)>)
      (findFunction(aClass, "makeInformation"));
    if (getter != NULL)
    {
      mGetters.push(getter);
      mGetterClasses.push(ts_Classes.shorten(aClass.getClassName()));
      mInformationPieces.push(new("ts_InformationPiece"));
    }
    return getter != NULL;
  }

  private bool tryAddView(class<Object> 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(aClass.getClassName()));
    }
    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 Actor 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<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 ts_ClassList mBlocklist;
  private ts_ClassList mFollowers;
  private ts_Fader mFader;
  private ts_Projector mProjector;

  private ts_TargetScreenPositionInterpolator mTextInterpolator;
  private ts_TargetScreenPositionInterpolator mFrameInterpolator;
}

4.3.1. Target screen position interpolator

class ts_TargetScreenPositionInterpolator
{
  ui vector2 interpolateScreenPosition(vector2 screenPosition, Actor currentTarget)
  {
    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 ui vector2 mOldScreenPosition;
  private ui Actor mLastInterpolatedTarget;
}

4.3.2. 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.3.3. 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.4. Finders

4000.0

4.4.1. Aim targets

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

4.4.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.4.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.4.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.5. Filters

4.5.1. Blocklist

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
tsFilter_Blocklist = "Blocklist";
class tsFilter_Blocklist : Actor
{
  static bool isAllowed(Actor anActor)
  {
    string className = anActor.getClassName();
    return !ts_EventHandler.getInstance().getBlocklist().contains(className);
  }
}

4.5.2. Monsters

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

4.5.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.5.4. Health

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

4.5.5. Hidden

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

4.5.6. Enemies

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

4.5.7. Dormant

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

4.5.8. Active

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

4.6. Getters

4.6.1. Health

tsGetter_Health = "Health";
class tsGetter_Health : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    return ts_Kind.Value, "", anActor.health, 0;
  }
}

4.6.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.6.3. 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.6.4. Armor bar

tsGetter_ArmorBar = "Armor bar";
class tsGetter_ArmorBar : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    PlayerPawn pawn = players[consolePlayer].mo;
    let armor = BasicArmor(pawn.FindInventory("BasicArmor"));

    if (armor == NULL)
      return ts_Kind.Bar, "", 0, 0;

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

4.6.5. 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.6.6. Tag

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

4.6.7. Tag and class

tsGetter_TagAndClass = "Tag (class)";
class tsGetter_TagAndClass : Actor
{
  static int, string, int, int makeInformation(Actor anActor)
  {
    string tag    = 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.7. Views

4.7.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.7.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();
    Font aFont = Font.findFont("NewSmallFont");
    double scale = options.getScale();

    Array<string> lines;
    Array<double> widths;

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

      lines.push(line);
      widths.push(aFont.stringWidth(line) * scale);
    }

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

    double lineHeight = aFont.getHeight() * scale;

    double totalHeight = lineHeight * lines.size();
    double totalWidth = 0;
    foreach (line : lines)
      totalWidth = max(totalWidth, aFont.stringWidth(line));
    // 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);
    position.y = clamp(position.y, 0, screenHeight - totalHeight);

    double additionalWidth = aFont.stringWidth("  ") * scale;
    bool isBackgroundEnabled = options.isBackgroundEnabled();
    for (int i = 0; i < linesCount; ++i)
    {
      if (isBackgroundEnabled)
        drawBackground(widths[i] + additionalWidth, lineHeight, position, alpha);
      ts_ViewUtils.drawText(aFont, lines[i], widths[i], position, alpha, scale);
      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 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(round(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));
  }

  static private ui 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 private ui void drawBackground(double textWidth,
                                        double textHeight,
                                        vector2 position,
                                        double alpha)
  {
    position.x -= int(round(textWidth / 2));
    Screen.dim(0x000000,
               alpha / 2,
               int(round(position.x)),
               int(round(position.y)),
               int(round(textwidth)),
               int(round(textheight)));
  }

  const SMALL_A = ts_Ascii.LATIN_SMALL_LETTER_A;
}
  1. draw
    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();
      Font aFont = Font.findFont("NewSmallFont");
      double scale = options.getScale();
    
      Array<string> lines;
      Array<double> widths;
    
      foreach (piece : informationPieces)
      {
        string line = toText(piece, options);
        if (line.length() == 0) continue;
    
        lines.push(line);
        widths.push(aFont.stringWidth(line) * scale);
      }
    
      int linesCount = lines.size();
      if (linesCount == 0) return;
    
      double lineHeight = aFont.getHeight() * scale;
    
      double totalHeight = lineHeight * lines.size();
      double totalWidth = 0;
      foreach (line : lines)
        totalWidth = max(totalWidth, aFont.stringWidth(line));
      // 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);
      position.y = clamp(position.y, 0, screenHeight - totalHeight);
    
      double additionalWidth = aFont.stringWidth("  ") * scale;
      bool isBackgroundEnabled = options.isBackgroundEnabled();
      for (int i = 0; i < linesCount; ++i)
      {
        if (isBackgroundEnabled)
          drawBackground(widths[i] + additionalWidth, lineHeight, position, alpha);
        ts_ViewUtils.drawText(aFont, lines[i], widths[i], position, alpha, scale);
        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(); }
    
    private Cvar mScaleCvar;
    private Cvar mXPositionCvar;
    private Cvar mYPositionCvar;
    private Cvar mPlaceCvar;
    private Cvar mBackgroundCvar;
    
    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");
    
    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;
    
    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
    
      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"
    }
    
  2. makeBar
    static ui 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(round(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));
    }
    
    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 = "❤♡█▓▒░☻☹⛧⛤☣✚⛊▬●⚰☐☒☑⚙";
    
    

    TODO: tweak these defaults.

    
    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
    }
    
    
  3. Helper functions
    static private ui 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 private ui void drawBackground(double textWidth,
                                          double textHeight,
                                          vector2 position,
                                          double alpha)
    {
      position.x -= int(round(textWidth / 2));
      Screen.dim(0x000000,
                 alpha / 2,
                 int(round(position.x)),
                 int(round(position.y)),
                 int(round(textwidth)),
                 int(round(textheight)));
    }
    
    const SMALL_A = ts_Ascii.LATIN_SMALL_LETTER_A;
    

4.7.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;
    PlayerInfo player = players[consolePlayer];
    ts_EventHandler eventHandler = ts_EventHandler.getInstance();
    ts_Projector projector = eventHandler.getProjector();
    ts_Options options = eventHandler.getOptions();
    int color = options.isFrameUsingHealthBarColors()
      ? ts_Utils.getHealthColor(options, informationPieces)
      : Font.CR_Gray;

    targetPosition.z += targetSize.y * 0.6;
    vector2 rawPosition = projector.makePosition(event, targetPosition);
    vector2 centerPosition = eventHandler
      .interpolateFrameScreenPosition(rawPosition);
    vector2 visibleSize = getVisibleSize(targetPosition, targetSize);

    double  halfWidth  = visibleSize.x;
    double  halfHeight = visibleSize.y * 0.5;
    double  scale      = options.getFrameScale();
    Font    aFont      = Font.findFont("NewSmallFont");

    double left   = centerPosition.x - halfWidth;
    double right  = centerPosition.x + halfWidth;
    double top    = centerPosition.y - halfHeight;
    double bottom = centerPosition.y + halfHeight;

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

    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);
    }
  }

  static ui vector2 getVisibleSize(vector3 targetPosition, vector2 targetSize)
  {
    PlayerInfo player = players[consolePlayer];

    double distance = level.vec3diff(player.mo.pos, targetPosition).length();
    if (distance == 0) return (0, 0);

    double zoomFactor = abs(sin(player.fov));
    if (zoomFactor == 0) return (0, 0);

    return targetSize * 400.0 / distance / zoomFactor;
  }
}
string getFrame(int i) const
{
  if (mCustomFrameEnabled.getInt())
  {
    switch (i)
    {
      case 0: return mCustomFrameTopLeft.getString();
      case 1: return mCustomFrameTopRight.getString();
      case 2: return mCustomFrameBottomLeft.getString();
      case 3: return mCustomFrameBottomRight.getString();
      default: return ".";
    }
  }

  int selectedFrame = clamp(mSelectedFrame.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 getFrameScale() const { return mFrameScale.getFloat(); }
bool isFrameUsingHealthBarColors() const { return mFrameWithHealthColors.getInt(); }
private Cvar mCustomFrameEnabled;
private Cvar mSelectedFrame;
private Cvar mCustomFrameTopLeft;
private Cvar mCustomFrameTopRight;
private Cvar mCustomFrameBottomLeft;
private Cvar mCustomFrameBottomRight;
private Cvar mFrameScale;
private Cvar mFrameWithHealthColors;

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[] = {"↘", "↙", "↗", "↖"};
mCustomFrameEnabled     = ts_ExistingCvar.find("ts_frame_custom_enabled");
mSelectedFrame          = ts_ExistingCvar.find("ts_frame_selected");
mCustomFrameTopLeft     = ts_ExistingCvar.find("ts_frame_custom_top_left");
mCustomFrameTopRight    = ts_ExistingCvar.find("ts_frame_custom_top_right");
mCustomFrameBottomLeft  = ts_ExistingCvar.find("ts_frame_custom_bottom_left");
mCustomFrameBottomRight = ts_ExistingCvar.find("ts_frame_custom_bottom_right");
mFrameScale             = ts_ExistingCvar.find("ts_frame_scale");
mFrameWithHealthColors  = ts_ExistingCvar.find("ts_frame_using_health_bar_colors");
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 "(health bar or health and max health must be enabled)"

  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.7.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;

    vector2 center = (Screen.getWidth() / 2, options.getCrosshairY());
    double  height = options.getCrosshairHeight();
    vector2 positions[3] = { center - (0, height), center, center + (0, height) };
    string  parts[3] =
    {
      options.getCrosshairTop(),
      options.getCrosshairCenter(),
      options.getCrosshairBottom()
    };
    Font   aFont = Font.findFont("NewSmallFont");
    double scale = options.getCrosshairScale();
    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"

  StaticText ""
  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.8. Utilities

Functions in ts_Utils class.

4.8.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;
}

4.8.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.kind == ts_Kind.Bar || piece.kind == ts_Kind.ValueAndMax)
        && piece.id.indexOf("Health") != -1)
    {
      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);
}

5. Projector

class ts_Projector
{
  void initialize()
  {
    mGlProjection = new("ts_GlScreen");
    mSwProjection = new("ts_SwScreen");
    mCvarRenderer = ts_ExistingCvar.find("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;
}

5.0.1. View utilities

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

5.1. Menu classes

5.1.1. Toggles

TODO: disable some classes by default.

user string ts_disabled_finders = "";
user string ts_disabled_filters = "";
user string ts_disabled_getters = "";
user string ts_disabled_views   = "";
ts_AllOn  = "Activate all";
ts_AllOff = "Deactivate all";
class ts_ClassToggles : OptionMenu
{
  void fill(out Array<string> classes,
            string classPrefix,
            string disabledClassesCvarName)
  {
    mDesc.mItems.clear();
    int classesCount = classes.size();
    for (int i = 0; i < classesCount; ++i)
    {
      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");
  }
}

5.1.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;
}

5.1.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)
      .. tsView_Text.makeBar(value, 64, options);
    drawLabel(indent, y, Font.CR_White);
    return -1;
  }

  private int mPercent;
  private string mClass;
}

5.1.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.getInstance().getGetters(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;
}

6. Project setup

GameInfo
{
  EventHandlers = "ts_EventHandler"
}
version 4.14.3

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

#include "zscript/ts_StringUtils.zs"
#include "zscript/ts_libeye.zs"
[enu default]
ts_OptionsTitle = "Target Spy \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)";
tsView_Text = "Plain text";
tsView_Frame = "Frame";
tsView_Crosshair = "Crosshair";
ts_AllOn  = "Activate all";
ts_AllOff = "Deactivate all";
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");

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

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

    mCustomFrameEnabled     = ts_ExistingCvar.find("ts_frame_custom_enabled");
    mSelectedFrame          = ts_ExistingCvar.find("ts_frame_selected");
    mCustomFrameTopLeft     = ts_ExistingCvar.find("ts_frame_custom_top_left");
    mCustomFrameTopRight    = ts_ExistingCvar.find("ts_frame_custom_top_right");
    mCustomFrameBottomLeft  = ts_ExistingCvar.find("ts_frame_custom_bottom_left");
    mCustomFrameBottomRight = ts_ExistingCvar.find("ts_frame_custom_bottom_right");
    mFrameScale             = ts_ExistingCvar.find("ts_frame_scale");
    mFrameWithHealthColors  = ts_ExistingCvar.find("ts_frame_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");
  }

  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(); }

  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));
    }
  }

  string getFrame(int i) const
  {
    if (mCustomFrameEnabled.getInt())
    {
      switch (i)
      {
        case 0: return mCustomFrameTopLeft.getString();
        case 1: return mCustomFrameTopRight.getString();
        case 2: return mCustomFrameBottomLeft.getString();
        case 3: return mCustomFrameBottomRight.getString();
        default: return ".";
      }
    }

    int selectedFrame = clamp(mSelectedFrame.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 getFrameScale() const { return mFrameScale.getFloat(); }
  bool isFrameUsingHealthBarColors() const { return mFrameWithHealthColors.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;
  }

  private Cvar mMinLightLevelCvar;

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

  Map<string, ts_BarOptions> mBarOptions;

  private Cvar mCustomFrameEnabled;
  private Cvar mSelectedFrame;
  private Cvar mCustomFrameTopLeft;
  private Cvar mCustomFrameTopRight;
  private Cvar mCustomFrameBottomLeft;
  private Cvar mCustomFrameBottomRight;
  private Cvar mFrameScale;
  private Cvar mFrameWithHealthColors;

  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[] = {"↘", "↙", "↗", "↖"};

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

7. 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-05-27 Wed 02:54