PreciseCrosshair

Table of Contents

1. About

PreciseCrosshair makes a crosshair point to where you actually shoot* instead of the center of the screen.

*If your weapons shoots straight and not from an angle.

PreciseCrosshair is a part of DoomToolbox.

1.1. How to use

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

1.2. 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 is not ready;
  • Option to show target health.

*Except "Enable Crosshair". This option will disable only the standard crosshair.

1.3. Credits

  • Color calculation and crosshair code is ripped from GZDoom.

2. Changelog

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 minimalistic 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 is not 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 <mmaulwurff@gmail.com>
SPDX-License-Identifier: GPL-3.0-only

4. Menus

AddOptionMenu OptionsMenu       { Submenu "$PC_TITLE", pc_Menu }
AddOptionMenu OptionsMenuSimple { Submenu "$PC_TITLE", pc_Menu }

OptionMenu pc_Menu
{
  Title      "$PC_TITLE"

  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_DISTANCE_SCALE"  , pc_distance_scale    , YesNo

  StaticText ""
  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_distance_scale = true;

user bool pc_target_health = false;

// Precise External Interface

user float pc_y;
user float pc_scale;

6. Language

[enu default]

// Option menus

PC_TITLE  = "PreciseCrosshair \ck⚀";

PC_ENGINE = "Engine Crosshair Options";
PC_OFF    = "This must be OFF:";
PC_NONE   = "This must be not None:";
PC_OTHER  = "Other crosshair options also apply to PreciseCrosshair:";

PC_OPTIONS = "PreciseCrosshair Options";
PC_ENABLE  = "Enabled";

PC_MIRROR_H = "Mirror horizontally";
PC_MIRROR_V = "Mirror vertically";
PC_SLOT_1   = "Disable on slot 1";
PC_NOT_READY = "Disable when weapon is not ready";
PC_NO_WEAPON = "Disable when there is no weapon";
PC_TARGET_COLOR = "Show target color instead of player";
PC_DISTANCE_SCALE = "Scale crosshair by distance";

// Crosshair names

PC_PIXEL = "Pixel";
PC_BIG   = "Precise Big";
PC_SMALL = "Precise Small";

7. Additional crosshairs

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

8. Source

8.1. Event handler

class pc_EventHandler : EventHandler
{
  override void WorldTick()
  {
    if (!_isInitialized) { initialize(); }
    prepareProjection();
  }

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

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

    _isInitialized = true;
  }

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

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

  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)
       || disableWhenNotReady(player)
       || disableOnNoWeapon(player)
       || event.camera != player.mo
       )
    {
      return;
    }

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

    double size = (crosshairscale > 0.0f)
                ? screenHeight * crosshairscale / 200.0
                : 1.0;

    if (crosshairgrow) { size *= StatusBar.CrosshairSize; }

    Vector2 textureSize = TexMan.GetScaledSize(_crosshairTexture);
    double width  = textureSize.x * size;
    double height = textureSize.y * size;
    if (_settings.isScaledByDistance())
    {
      double distFac = GetDistanceScale(0, 512);
      width *= distFac;
      height *= distFac;
    }

    bool hasHealth;
    int  health, maxHealth;
    [hasHealth, health, maxHealth] = getHealths(player);
    int crossColor = makeCrosshairColor(hasHealth, health, maxHealth);

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

  // Returns noHealth flag, current health, max health.
  private ui bool, int, int getHealths(PlayerInfo player)
  {
    if (_settings.isTargetHealth())
    {
      let aimTarget = getAimTarget(player.mo);
      if (aimTarget)
      {
        return true, aimTarget.health, aimTarget.GetSpawnHealth();
      }
      else
      {
        return false, 0, 0;
      }
    }

    int health     = player.health;
    int maxHealth  = getDefaultHealth(player);

    return true, health, maxHealth;
  }

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

  private static ui int makeCrosshairColor(bool hasHealth, int health, int maxHealth)
  {
    if (!hasHealth)
    {
      return crosshaircolor;
    }

    if (crosshairhealth == 1)
    {
      // "Standard" crosshair health (green-red)
      int health = scale(health, 100, maxHealth);

      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 (red<<16) | (green<<8);
      }
    }
    else if (crosshairhealth == 2)
    {
      // "Enhanced" crosshair health (blue-green-yellow-red)
      int health = clamp(scale(health, 100, maxHealth), 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 (red<<16) | (green<<8) | blue;
    }

    return crosshaircolor;
  }

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

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

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

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

    _externalY.SetFloat(y);
  }

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

    _externalScale.SetFloat(scale);
  }

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

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

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

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

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

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

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

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

    return isNo;
  }

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

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

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

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

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
{

  // public: ///////////////////////////////////////////////////////////////////

  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 isScaledByDistance  () { checkInit(); return _distanceScale    .value(); }

  bool isTargetHealth() { checkInit(); return _targetHealth.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(_targetHealth      = newBoolSetting("pc_target_health"    ));
    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 _targetHealth;
  private pc_BoolSetting _distanceScale;

  private PlayerInfo     _player;
  private transient bool _isInitialized;

}
// This class provides the base functions for various settings classes.
class pc_SettingsBase
{
  // Reset CVars to their default values.
  virtual void resetCvarsToDefaults()
  {
    console.printf("%s: reset is not implemented!", GetClassName());
  }
}
// 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. Project setup

GameInfo { EventHandlers = "pc_EventHandler" }
version 4.14.3

#include "zscript/pc_event_handler.zs"
#include "zscript/pc_puff.zs"
#include "zscript/settings/pc_bool_setting.zs"
#include "zscript/settings/pc_cvar_setting.zs"
#include "zscript/settings/pc_settings.zs"
#include "zscript/settings/pc_settings_base.zs"
#include "zscript/settings/pc_settings_pack.zs"

#include "zscript/pc_libeye.zs"

10. 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-07-11 Sat 08:41