PreciseCrosshair

Table of Contents

{
  "old_version": "1.6.0",
  "depends": { "libeye": "pc_" }
}

1. About

PreciseCrosshair tries to put a crosshair where you actually shoot.

PreciseCrosshair is a part of DoomToolbox.

How to use

  1. HUD Options -> Crosshair must be OFF.
  2. HUD Options -> Default crosshair must be not "None".

Features

  • shows where you shoot
  • respects autoaim
  • supports custom crosshairs
  • respects engine crosshair options
  • options to mirror crosshairs
  • options to disable crosshair on slot 1 and when weapon isn't ready
  • option to show target health

Known issues

  • PreciseCrosshair can't be precise for custom weapons that shoot from an angle
  • Crosshair size may be off on UZDoom/LZDoom before v5.0.0
  • "Inverted" crosshair color isn't supported yet

Credits

  • Color calculation and crosshair code is taken from GZDoom/UZDoom.

2. Changelog

  • added option to disable crosshair when there is no target

v1.6.0:

  • moved to the new translation system
  • fixed crosshair size for UZDoom 5.0.0
  • added new color formula

v1.5.0:

  • added writing of result to external cvar
  • fixed GetDistanceScale was missing a return
  • added distance-based crosshair scaling option
  • added options menu icon
  • hide crosshair when looking through a camera
  • fix ready state detection
  • try to respect A_SetCrosshair(-1)

v1.4.1:

  • update Readme
  • fix disabled crosshair on certain weapons
  • move menu strings to language lump
  • add Pixel crosshair
  • update Readme

v1.4:

  • fix weirdness with max distance
  • make crosshair y position change slightly smoother
  • update Readme
  • add option to color crosshair depending on target health
  • port Enhanced crosshair health color from GZDoom
  • fix aim when crouching
  • update build script

v1.3:

  • add option to hide crosshair when no weapon is selected/weapon is holstered
  • add minimalist crosshairs

v1.2.3:

  • fix menu
  • update Readme

v1.2.2:

  • fix ZScript version
  • update Readme
  • add GZDoom version to Readme
  • improve weapon ready detection
  • update menudef
  • update Readme

v1.2.1:

  • bump minimal ZScript version
  • add links to Readme
  • update LibEye
  • fix memory leak on game loading

v1.2:

  • add options to disable precise crosshair on slot 1 and when weapon isn't ready
  • add options to flip the crosshair horizontally and vertically
  • remove debug output
  • update Readme
  • add a cvar with Y position for other mods to read
  • add an on/off switch

v1.1:

  • added prefix to libeye to avoid conflicts with other mods
  • update libeye to support different screen sizes
  • add license file

v1.0: initial release

3. License

GPL-3.0-only

SPDX-FileCopyrightText: © 2019 Alexander Kromm (m8f) <mmaulwurff@gmail.com>
SPDX-License-Identifier: GPL-3.0-only

4. Menus

[enu default]
PC_TITLE  = "PreciseCrosshair \ck⚀";
AddOptionMenu OptionsMenu       { Submenu "$PC_TITLE", pc_Menu }
AddOptionMenu OptionsMenuSimple { Submenu "$PC_TITLE", pc_Menu }

OptionMenu pc_Menu
{
  Title      "PreciseCrosshair"

  StaticText "$PC_ENGINE"          , 1

  StaticText ""
  StaticText "$PC_OFF"
  Option     "$HUDMNU_CROSSHAIRON" , crosshairon, OnOff

  StaticText ""
  StaticText "$PC_NONE"
  Option     "$HUDMNU_CROSSHAIR"   , crosshair, Crosshairs

  StaticText ""
  StaticText "$PC_OTHER"
  Submenu    "$OPTMNU_HUD"         , HUDOptions

  StaticText ""
  StaticText "$PC_OPTIONS"         , 1
  StaticText ""
  Option     "$PC_ENABLE"          , pc_enable            , YesNo

  StaticText ""
  Option     "$PC_MIRROR_H"        , pc_flip_x            , YesNo
  Option     "$PC_MIRROR_V"        , pc_flip_y            , YesNo

  StaticText ""
  Option     "$PC_SLOT_1"          , pc_disable_slot_1    , YesNo
  Option     "$PC_NOT_READY"       , pc_disable_not_ready , YesNo
  Option     "$PC_NO_WEAPON"       , pc_disable_no_weapon , YesNo
  Option     "$PC_NO_TARGET"       , pc_disable_no_target , YesNo
  Option     "$PC_DISTANCE_SCALE"  , pc_distance_scale    , YesNo

  StaticText ""
  Option     "$PC_NEW_COLOR_FORMULA", pc_new_color_formula, YesNo
  Option     "$PC_TARGET_COLOR"    , pc_target_health     , YesNo
}

5. Options

server bool pc_enable = true;

user bool pc_flip_y = false;
user bool pc_flip_x = false;

user bool pc_disable_slot_1    = false;
user bool pc_disable_not_ready = false;
user bool pc_disable_no_weapon = true;
user bool pc_disable_no_target = false;
user bool pc_distance_scale    = true;

user bool pc_target_health = false;
user bool pc_new_color_formula = true;

6. External interface

user float pc_y;
user float pc_scale;

7. Additional crosshairs

97 "$PC_PIXEL"
98 "$PC_BIG"
99 "$PC_SMALL"

8. Source

8.1. Event handler

class pc_EventHandler : EventHandler
{
  private Vector3        _targetPos;
  private double         _distToTarget;
  private bool           _hasTargetPos;

  private ui int         _crosshairNum;
  private ui bool        _isCrossExisting;
  private ui TextureID   _crosshairTexture;

  private transient bool _isInitialized;
  private transient bool _isPrepared;
  private transient Cvar _cvarRenderer;
  private transient Cvar _externalY;
  private transient Cvar _externalScale;

  private pc_ProjScreen  _projection;
  private pc_GlScreen    _glProjection;
  private pc_SwScreen    _swProjection;

  private pc_Settings    _settings;

  private DynamicValueInterpolator _yPositionInterpolator;

  private Cvar _cvarCrosshairColorFull;
  private Cvar _cvarCrosshairColorMax;
}

8.1.1. World tick

override void WorldTick()
{
  if (!_isInitialized) { initialize(); }
  prepareProjection();
}

8.1.2. Render overlay

override void RenderOverlay(RenderEvent event)
{
  if (!_isInitialized || !_isPrepared) { return; }

  PlayerInfo player = players[consolePlayer];

  if (player == NULL) { return; }

  loadTarget(event.viewAngle, event.viewPitch);
  loadCrosshair(player);
  drawCrosshair(player, event);
}

8.1.3. Initialize

private void initialize()
{
  PlayerInfo player = players[consolePlayer];

  _glProjection  = new("pc_GlScreen");
  _swProjection  = new("pc_SwScreen");
  _cvarRenderer  = Cvar.GetCvar("vid_rendermode", player);
  _settings      = new("pc_Settings").init(player);

  _yPositionInterpolator = DynamicValueInterpolator.Create(Screen.GetHeight() / 2,
                                                           0.5,
                                                           1,
                                                           1000000);

  _cvarCrosshairColorFull = Cvar.getCvar("crosshairColorFull", player);
  _cvarCrosshairColorMax  = Cvar.getCvar("crosshairColorMax", player);

  _isInitialized = true;
}

8.1.4. Prepare projection

private void prepareProjection ()
{
  if(_cvarRenderer)
  {
    switch (_cvarRenderer.GetInt())
    {
    default:
      _projection = _glProjection;
      break;

    case 0:
    case 1:
      _projection = _swProjection;
      break;
    }
  }
  else
  {
    Console.printf("WARNING: cannot get render mode");
    _projection = _glProjection;
  }

  _isPrepared = (_projection != NULL);
}

8.1.5. Load target

private void loadTarget(double angle, double pitch) const
{
  PlayerInfo p = players[consolePlayer];
  PlayerPawn a = p.mo;
  pitch = a.AimTarget() ? a.BulletSlope(NULL, ALF_PORTALRESTRICT) : pitch;

  FLineTraceData data;
  double hitHeight = a.height / 2 + a.AttackZOffset * p.crouchFactor;
  _hasTargetPos    = a.LineTrace(angle, 4000.0, pitch, 0, hitHeight, 0, 0, data);
  if (_hasTargetPos)
  {
    _targetPos = data.hitlocation;
    _distToTarget = data.distance;
  }
}

8.1.6. Draw crosshair

private ui void drawCrosshair(PlayerInfo player, RenderEvent event)
{
  Vector2 drawPos = makeDrawPos(player, event);

  setExternalY(player, drawPos.y);

  if (  !_isCrossExisting
     || gamestate == GS_TITLELEVEL
     || player.mo.health <= 0
     || automapactive
     || !pc_enable
     || disabledOnSlot1(player)
     || disabledWhenNotReady(player)
     || disabledOnNoWeapon(player)
     || event.camera != player.mo
     || disabledOnNoTarget(player)
     )
  {
    return;
  }

  int screenWidth  = Screen.GetWidth();
  int screenHeight = Screen.GetHeight();

  int mult = max(screenHeight / 720, 1); // 1080p: 1, 1440p: 2, 4K: 3
  double size = ((crosshairscale > 0.0f) ? crosshairscale : 1.0) * mult;

  if (crosshairgrow) size *= StatusBar.crosshairSize;

  vector2 textureSize = TexMan.getScaledSize(_crosshairTexture) * size;
  double width  = round(textureSize.x);
  double height = round(textureSize.y);

  if (_settings.isScaledByDistance())
  {
    double distFac = GetDistanceScale(0, 512);
    width *= distFac;
    height *= distFac;
  }

  let [hasHealth, health] = getHealths(player);

  bool isNewColorFormula = _settings.isNewColorFormula();
  int crossColor = !hasHealth
    ? crosshaircolor
    : isNewColorFormula
      ? makeNewCrosshairColor(health)
      : makeOldCrosshairColor(health);

  _yPositionInterpolator.Update(int(drawPos.y));

  Screen.DrawTexture( _crosshairTexture
                    , false
                    , screenWidth / 2
                    , _yPositionInterpolator.GetValue()
                    , DTA_DestWidthF    , width
                    , DTA_DestHeightF   , height
                    , DTA_AlphaChannel , true
                    , DTA_KeepRatio    , true
                    , DTA_FillColor    , crossColor & 0xFFFFFF
                    , DTA_FlipX        , _settings.isFlipX()
                    , DTA_FlipY        , _settings.isFlipY()
                    );
}

8.1.7. Get healths

// Returns noHealth flag, current health (scaled to 0-100).
private ui bool, int getHealths(PlayerInfo player)
{
  if (!_settings.isTargetHealth())
    return true, scale(player.health, 100, getDefaultHealth(player));

  let aimTarget = getAimTarget(player.mo);
  if (aimTarget)
    return true, scale(aimTarget.health, 100, aimTarget.getSpawnHealth());
  else
    return false, 0;
}

private ui static int scale(int value, int scaleMax, int valueMax)
{
  return value * scaleMax / valueMax;
}

8.1.8. Get aim target

private play Actor getAimTarget(Actor a) const
{
  return a.AimTarget();
}

8.1.9. Make new crosshair color

// Color functions are adapted from UZDoom/src/utility/colorspace.cpp

private ui Color makeNewCrosshairColor(int health)
{
  if (crosshairhealth == 0) return crosshaircolor;
  if (crosshairhealth == 3) return 0xffffff;
  if (_cvarCrosshairColorFull == NULL || _cvarCrosshairColorMax == NULL)
    return makeOldCrosshairColor(health);

  int health = clamp(health, 0, (crosshairhealth == 1) ? 100 : 200);

  int hi = _cvarCrosshairColorFull.getInt();
  int lo = crosshaircolor;
  double mix = 1.0;

  if (health > 100)
  {
    lo = hi;
    hi = _cvarCrosshairColorMax.getInt();
    mix = (health - 100) / 100.0;
  }
  else if (health <= 85)
  {
    mix = health / 85.0;
  }

  let c = mixColor(lo, hi, mix);

  return Color(c.r, c.g, c.b);
}

private static ui Color mixColor(Color a, Color b, double mix)
{
  if (mix <= 0) return a;
  if (mix >= 1) return b;

  let [al, ac, ah] = rgbToOklch(a);
  let [bl, bc, bh] = rgbToOklch(b);

  double l = lerp(al, bl, mix);
  double c = lerp(ac, bc, mix);
  double h = alerp(ah, bh, mix);

  return oklchToRgb(l, c, h);
}

private static ui double, double, double rgbToOklch(Color a)
{
  let [l, a, b] = rgbToOklab(a);
  let [c, h] = oklabToOklch(a, b);
  return l, c, h;
}

private static ui double, double, double rgbToOklab(Color a)
{
  double r = linear(a.r / 255.0);
  double g = linear(a.g / 255.0);
  double b = linear(a.b / 255.0);
  double l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1.0 / 3);
  double m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1.0 / 3);
  double s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1.0 / 3);

  return
    (l * +0.2104542553 + m * +0.7936177850 + s * -0.0040720468),
    (l * +1.9779984951 + m * -2.4285922050 + s * +0.4505937099),
    (l * +0.0259040371 + m * +0.7827717662 + s * -0.8086757660);
}

private static ui double linear(double c)
{
  return (c >= 0.04045)
    ? (((c + 0.055) / 1.055) ** 2.4)
    : (c / 12.92);
}

private static ui double, double oklabToOklch(double a, double b)
{
  double c = sqrt(a * a + b * b);

  if (c < 0.0001)
    return c, double.NAN;

  double h = atan2(b, a);

  if (h >= 0)
    return c, h;

  return c, h + 360;
}

private static ui Color oklchToRgb(double l, double c, double h)
{
  let [a, b] = oklchToOklab(c, h);
  return oklabToRgb(l, a, b);
}

private static ui double, double oklchToOklab(double c, double h)
{
  double a = (h == double.NAN) ? 0 : c * cos(h);
  double b = (h == double.NAN) ? 0 : c * sin(h);
  return a, b;
}

private static ui Color oklabToRgb(double l, double a, double b)
{
  double l2 = (L + a * +0.3963377774 + b * +0.2158037573) ** 3;
  double m  = (L + a * -0.1055613458 + b * -0.0638541728) ** 3;
  double s  = (L + a * -0.0894841775 + b * -1.2914855480) ** 3;

  double r = gamma(l2 * +4.0767416621 + m * -3.3077115913 + s * +0.2309699292);
  double g = gamma(l2 * -1.2684380046 + m * +2.6097574011 + s * -0.3413193965);
  double b = gamma(l2 * -0.0041960863 + m * -0.7034186147 + s * +1.7076147010);

  return Color(int(255 * r), int(255 * g), int(255 * b));
}

private static ui double gamma(double c)
{
  return clamp(((c >= 0.0031308) ? (1.055 * (c ** (1 / 2.4)) - 0.055) : (12.92 * c)),
               0.0,
               1.0);
}

private static ui double lerp(double a, double b, double t)
{
  // Naive, good enough.
  return a + t * (b - a);
}

private static ui double alerp(double a, double b, double t)
{
  double delta = (b - a) % 360;
  if (delta > 180) delta -= 360;
  if (delta < -180) delta += 360;
  return (a + delta * t + 360) % 360;
}

8.1.10. Make old crosshair color

private static ui Color makeOldCrosshairColor(int health)
{
  if (crosshairhealth == 1)
  {
    // "Standard" crosshair health (green-red)
    if (health >= 85)
    {
      return 0x00ff00;
    }
    else
    {
      int red;
      int green;

      health -= 25;
      if (health <  0) { health = 0; }
      if (health < 30)
      {
        red   = 255;
        green = health * 255 / 30;
      }
      else
      {
        red   = (60 - health) * 255 / 30;
        green = 255;
      }
      return Color(red, green, 0);
    }
  }
  else if (crosshairhealth == 2)
  {
    // "Enhanced" crosshair health (blue-green-yellow-red)
    int health = clamp(health, 0, 200);
    double rr;
    double gg;
    double bb;

    double saturation = health < 150 ? 1.0 : 1.0 - (health - 150) / 100.0;

    HSVtoRGB(rr, gg, bb, health * 1.2f, saturation, 1);
    int red   = int(rr * 255);
    int green = int(gg * 255);
    int blue  = int(bb * 255);

    return Color(red, green, blue);
  }

  return crosshaircolor;
}

8.1.11. Make draw position

private ui Vector2 makeDrawPos(PlayerInfo player, RenderEvent event)
{
  if (!_hasTargetPos)
  {
    int x, y, width, height;
    [x, y, width, height] = Screen.GetViewWindow();
    int screenHeight      = Screen.GetHeight();
    int statusBarHeight   = screenHeight - height - x;
    return (Screen.GetWidth() / 2, (Screen.GetHeight() - statusBarHeight) / 2);
  }

  _projection.CacheResolution();
  _projection.CacheFov(player.fov);
  _projection.OrientForRenderOverlay(event);
  _projection.BeginProjection();

  _projection.ProjectWorldPos(_targetPos);

  pc_Viewport viewport;
  viewport.FromHud();

  Vector2 drawPos = viewport.SceneToWindow(_projection.ProjectToNormal());

  return drawPos;
}

8.1.12. Load crosshair

private ui void loadCrosshair(PlayerInfo player)
{
  int num = 0;

  if (!crosshairforce
     && NULL != player.camera
     && NULL != player.camera.player
     && NULL != player.camera.player.readyWeapon)
  {
    num = player.camera.player.readyWeapon.crosshair;
  }
  if (num == 0)
  {
    num = crosshair;
  }
  if (_crosshairnum == num && _isCrossExisting)
  {
    return;
  }

  if (num <= 0)
  {
    _crosshairNum    = 0;
    _isCrossExisting = false;
    return;
  }
  string size = (Screen.GetWidth() < 640) ? "S" : "B";

  string texName = String.Format("XHAIR%s%d", size, num);
  TextureID texId = checkTexture(texName);
  if (!texId.isValid())
  {
    texName = String.Format("XHAIR%s1", size);
    texId = checkTexture(texName);
    if (!texId.isValid())
    {
      texId = checkTexture("XHAIRS1");
    }
  }

  _crosshairNum     = num;
  _crosshairTexture = texId;
  _isCrossExisting  = true;
}

8.1.13. Check texture

private ui TextureId checkTexture(string name)
{
  return TexMan.checkForTexture(name,
                                TexMan.Type_MiscPatch,
                                TexMan.TryAny | TexMan.ShortNameOnly);
}

8.1.14. Set external y

private play void setExternalY(PlayerInfo player, double y) const
{
  if (_externalY == NULL)
    _externalY = Cvar.GetCVar("pc_y", player);

  _externalY.SetFloat(y);
}

8.1.15. Set external scale

private play void setExternalScale (PlayerInfo player, double scale) const
{
  if (_externalScale == NULL)
    _externalScale = Cvar.GetCVar("pc_scale", player);

  _externalScale.SetFloat(scale);
}

8.1.16. Get default health

private ui static int getDefaultHealth(PlayerInfo player)
{
  class<PlayerPawn> type = player.mo.GetClassName();
  let default = GetDefaultByType(type);
  return default.health;
}

8.1.17. Disabled on slot 1

private play bool disabledOnSlot1(PlayerInfo player) const
{
  return _settings.isDisabledOnSlot1() && isSlot1(player);
}

8.1.18. Disabled when not ready

private play bool disabledWhenNotReady(PlayerInfo player) const
{
  return _settings.isDisabledOnNotReady() && !isWeaponReady(player);
}

8.1.19. Disabled on no weapon

private play bool disabledOnNoWeapon(PlayerInfo player) const
{
  return _settings.isDisabledOnNoWeapon() && isNoWeapon(player);
}

8.1.20. Disabled on no target

private play bool disabledOnNoTarget(PlayerInfo player) const
{
  return _settings.isDisabledOnNoTarget() && (getAimTarget(player.mo) == NULL);
}

8.1.21. Is slot 1?

private static bool isSlot1(PlayerInfo player)
{
  Weapon w = player.readyWeapon;
  if (w == NULL) { return false; }

  int located;
  int slot;
  [located, slot] = player.weapons.LocateWeapon(w.GetClassName());

  bool slot1 = (slot == 1);
  return slot1;
}

8.1.22. Is weapon ready?

private static bool isWeaponReady(PlayerInfo player)
{
  bool isReady
     = player.WeaponState & WF_WEAPONREADY
    || player.WeaponState & WF_WEAPONREADYALT
    || player.WeaponState & WF_WEAPONZOOMOK
    || player.WeaponState & WF_WEAPONSWITCHOK
    || player.cmd.buttons & BT_ATTACK
    || player.cmd.buttons & BT_ALTATTACK;

  return isReady;
}

8.1.23. Is no weapon?

private static bool isNoWeapon(PlayerInfo player)
{
  Weapon w    = player.readyWeapon;
  bool   isNo = (w == NULL) || (w.GetClassName() == "m8f_wm_Holstered");

  return isNo;
}

8.1.24. HSV to RGB

private static ui void HSVtoRGB(out double r,
                                out double g,
                                out double b,
                                double h,
                                double s,
                                double v)
{
  int i;
  double f, p, q, t;

  if (s == 0)
  { // achromatic (grey)
    r = g = b = v;
    return;
  }

  h /= 60;                                    // sector 0 to 5
  i = int(floor(h));
  f = h - i;                                  // factorial part of h
  p = v * (1 - s);
  q = v * (1 - s * f);
  t = v * (1 - s * (1 - f));

  switch (i)
  {
  case 0:     r = v; g = t; b = p; break;
  case 1:     r = q; g = v; b = p; break;
  case 2:     r = p; g = v; b = t; break;
  case 3:     r = p; g = q; b = v; break;
  case 4:     r = t; g = p; b = v; break;
  default:    r = v; g = p; b = q; break;
  }
}

8.1.25. Linear map

private static clearscope double linearMap(double val,
                                           double source_min,
                                           double source_max,
                                           double out_min,
                                           double out_max)
{
  return (val - source_min) * (out_max - out_min) / (source_max - source_min) + out_min;
}

8.1.26. Get distance scale

private ui double getDistanceScale(double minDist = 0,
                                   double maxDist = 1024,
                                   double minScale = 1.0,
                                   double maxScale = 4.0)
{
  double scale = minScale;
  if (_distToTarget < maxDist)
  {
    double dist = Clamp(_distToTarget, minDist, maxDist);
    scale = LinearMap(dist, maxdist, mindist, minscale, maxscale);
  }
  setExternalScale(players[consoleplayer], scale);
  return scale;
}

8.2. Puff

class pc_Puff : Actor
{
  Default
  {
    Mass   0;
    Radius 1;
    Height 2;

    +NOBLOCKMAP
    +NOGRAVITY
    +BLOODLESSIMPACT
    +PUFFONACTORS
    +DONTSPLASH
    +NOTRIGGER
    +NOTONAUTOMAP
    +SKYEXPLODE
  }

  States
  {
    Spawn:
      TNT1 A 0;
      Stop;
  }

}

8.3. Settings

// This class represents a single boolean setting.
class pc_BoolSetting : pc_CvarSetting
{
  bool value() { return variable().GetInt(); }

  pc_BoolSetting init(string cvarName, PlayerInfo p)
  {
    super.init(cvarName, p);
    return self;
  }
}
/// This class represents a single setting.
class pc_CvarSetting : pc_SettingsBase
{
  pc_CvarSetting init(string cvarName, PlayerInfo p)
  {
    _cvar = CVar.GetCvar(cvarName, p);
    return self;
  }

  override void resetCvarsToDefaults()
  {
    _cvar.ResetToDefault();
  }

  protected Cvar variable() { return _cvar; }

  private transient CVar _cvar;
}
class pc_Settings : pc_SettingsPack
{
  pc_Settings init(PlayerInfo player)
  {
    _player = player;
    return self;
  }

  bool isFlipX() { checkInit(); return _flipX.value(); }
  bool isFlipY() { checkInit(); return _flipY.value(); }

  bool isDisabledOnSlot1   () { checkInit(); return _disableOnSlot1   .value(); }
  bool isDisabledOnNotReady() { checkInit(); return _disableOnNotReady.value(); }
  bool isDisabledOnNoWeapon() { checkInit(); return _disableNoWeapon  .value(); }
  bool isDisabledOnNoTarget() { checkInit(); return _disableOnNoTarget.value(); }
  bool isScaledByDistance  () { checkInit(); return _distanceScale    .value(); }

  bool isTargetHealth() { checkInit(); return _targetHealth.value(); }
  bool isNewColorFormula() { checkInit(); return _newColorFormula.value(); }

  private void checkInit()
  {
    if (_isInitialized) { return; }

    clear();

    push(_flipX             = newBoolSetting("pc_flip_x"           ));
    push(_flipY             = newBoolSetting("pc_flip_y"           ));
    push(_disableOnSlot1    = newBoolSetting("pc_disable_slot_1"   ));
    push(_disableOnNotReady = newBoolSetting("pc_disable_not_ready"));
    push(_disableNoWeapon   = newBoolSetting("pc_disable_no_weapon"));
    push(_disableOnNoTarget = newBoolSetting("pc_disable_no_target"));
    push(_targetHealth      = newBoolSetting("pc_target_health"    ));
    push(_newColorFormula   = newBoolSetting("pc_new_color_formula"));
    push(_distanceScale     = newBoolSetting("pc_distance_scale"   ));

    _isInitialized = true;
  }

  private pc_BoolSetting newBoolSetting(String name)
  {
    return new("pc_BoolSetting").init(name, _player);
  }

  private pc_BoolSetting _flipX;
  private pc_BoolSetting _flipY;
  private pc_BoolSetting _disableOnSlot1;
  private pc_BoolSetting _disableOnNotReady;
  private pc_BoolSetting _disableNoWeapon;
  private pc_BoolSetting _disableOnNoTarget;
  private pc_BoolSetting _targetHealth;
  private pc_BoolSetting _newColorFormula;
  private pc_BoolSetting _distanceScale;

  private PlayerInfo     _player;
  private transient bool _isInitialized;

}
// This class provides the base functions for various settings classes.
class pc_SettingsBase abstract
{
  // Reset CVars to their default values.
  abstract void resetCvarsToDefaults();
}
// This class represents a pack of settings.
class pc_SettingsPack : pc_SettingsBase
{
  override void resetCvarsToDefaults()
  {
    int nSettings = _settings.size();
    for (int i = 0; i < nSettings; ++i)
    {
      _settings[i].resetCvarsToDefaults();
    }
  }

  protected void push(pc_SettingsBase setting) { _settings.push(setting); }
  protected void clear()                       { _settings.clear();       }

  private Array<pc_SettingsBase> _settings;
}

9. Tests

version 4.14.3

class TestPistol : Pistol replaces Pistol
{
  States
  {
    Ready:
      PISG A 0 A_SetCrosshair(1);
      PISG A 1 A_WeaponReady();
      Loop;
    Fire:
      PISG B 1 A_SetCrosshair(-1);
      PISG C 1 A_Refire();
      Goto Ready;
  }
}

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-10-15 Thu 16:05