SoundToScreen

Table of Contents

{
  "old_version": "2.1.0",
  "depends": { "PlainTranslator": "st_" }
}

1. About

SoundToScreen shows where sounds come from. It may be useful for people who play without sound, with mono sound, or people with hearing impairment.

SoundToScreen is a part of DoomToolbox.

1.1. Features

  • Three types of sound: noise (harmless), level (doors, elevators), danger (threats).
  • Compatible with old saves.
  • Options to configure looks.

1.2. Implementation notes

  • SoundToScreen won't work if UZDoom is launched with -nosound.

1.3. Inspiration

2. License

GPL-3.0-only

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

3. Changelog

v2.1.0

  • show player's own sound
  • make dots hug screen edges
  • add alpha (transparency) slider

v2.0.1

  • fix initialization on saves

v2.0.0

  • fix automap option
  • adjust description
  • make compatible with old SoundToScreen
  • adjust options menu, add PlainTranslator
  • reset dots on level change, clean up
  • fix VM abort on titlemaps
  • make boss death always visible
  • ignore player's own projectiles (flying)
  • clean up code
  • calculate max distance automatically
  • add more options
  • fix sound cap
  • better portal awareness for geometry sounds
  • don't show sound when sector is not moving
  • redesign UI
  • check performance
  • add compatibility with saves without SoundToScreen

v1.0.0

  • add an option to disable on automap
  • disable on titlemaps
  • dead enemies sounds are noise
  • add an option to show distance
  • harmless missiles and friendly monsters are noise, not danger
  • add option to disable noise detection

v0.2.0-beta

  • add icon
  • make colors configurable
  • make max distance configurable
  • add implementation notes and inspiration to Readme
  • move background color to language lump
  • detect moving doors and elevators

v0.1.0-beta: initial release

4. Options

OptionMenu st_Menu
{
  st_PlainTranslator

  Title "SoundToScreen"

  StaticText  "Position"
  Slider      "Horizontal", st_x_position, 0.0, 1.0, 0.01, 2
  Slider      "Vertical",   st_y_position, 0.0, 1.0, 0.01, 2

  StaticText  ""
  Slider      "Width",      st_x_radius, 20, 50, 1
  Slider      "Height",     st_y_radius, 20, 50, 1
  Slider      "Dot size",   st_dot_size,  1,  9, 2
  Slider      "Alpha",      st_alpha, 0.1, 1.0, 0.1, 1

  StaticText  ""
  Option      "On automap", st_on_automap, OnOff

  StaticText  ""
  StaticText  "Colors"
  ColorPicker "Base",   st_color_base
  ColorPicker "Noise",  st_color_noise
  ColorPicker "Level",  st_color_geometry
  ColorPicker "Danger", st_color_danger
  ColorPicker "Player", st_color_player
}

AddOptionMenu OptionsMenu       { Submenu "$ST_MENU_NAME", st_Menu }
AddOptionMenu OptionsMenuSimple { Submenu "$ST_MENU_NAME", st_Menu }

PlainTranslator

[enu default]
ST_MENU_NAME  = "SoundToScreen \cj⚞";

[ru]
ST_DANGER = "Опасность";
user float st_x_position = 0.5;
user float st_y_position = 0.3;
user float st_x_radius = 30;
user float st_y_radius = 30;
user int   st_dot_size = 3;
user float st_alpha    = 1.0;
user bool  st_on_automap = false;

user color st_color_base     = "000000"; // black
user color st_color_noise    = "FFFFFF"; // white
user color st_color_geometry = "00FF00"; // green
user color st_color_danger   = "FF0000"; // red
user color st_color_player   = "0000FF"; // blue

5. Code

class st_EventHandler : StaticEventHandler
{
  // Collects sounds by searching for actors that make sounds and doors/elevators.
  override void worldTick()
  {
    if (!mIsInitialized)
    {
      if (level.mapName ~== "TITLEMAP") return;
      initialize();
    }

    if (players[consolePlayer].mo == NULL) return;

    mIterator.reinit();

    for (int type = Own; type < SoundTypesCount; ++type)
      mColors[type] = mColorCvars[type].getString();

    for (int type = Noise; type < SoundTypesCount; ++type)
    {
      for (int i = 0; i < DOTS_COUNT; ++i)
        mDots[type][i] = 0;
    }

    let player = players[consolePlayer].mo;
    double playerSound = player.isActorPlayingSound(CHAN_AUTO) ? 0.25 : 0.0;
    mDots[Own][0] = playerSound;

    Thinker aThinker;
    while (aThinker = mIterator.next())
    {
      let anActor = Actor(aThinker);
      if (anActor != NULL)
      {
        if (anActor == player) continue;
        if (anActor is "Inventory" && Inventory(anActor).owner != NULL) continue;
        if (!anActor.isActorPlayingSound(CHAN_AUTO)) continue;

        double distance = anActor.distance3D(player);
        if (anActor.bMissile && anActor.target == player)
        {
          if (distance < player.radius * 3) mDots[Own][0] = 0.25;
          continue;
        }
        if (anActor.bBoss) distance = min(distance, MAX_DISTANCE / 2);
        if (distance > MAX_DISTANCE) continue;

        let type = ((anActor.bIsMonster && !anActor.bFriendly && anActor.health > 0)
                    || (anActor.bMissile && anActor.damage > 0)) ? Danger : Noise;

        let position = calculateActorScreenPosition(anActor);
        mDots[type][position] += distanceToDotLevel(distance);

        continue;
      }

      let aMover = Mover(aThinker);
      if (aMover != NULL)
      {
        Sector aSector = aMover.getSector();
        if (aSector.flags & Sector.SECF_SilentMove) continue;

        // Important order: ask if moving before remembering.
        bool isMoving = isMoving(aSector);
        remember(aSector);
        if (!isMoving) continue;

        vector3 playerRelative = player.posRelative(aSector);
        vector2 xy = aSector.centerSpot;
        double z = aMover is "MovingCeiling"
          ? aSector.centerCeiling()
          : aSector.centerFloor();
        vector3 soundSource = (xy.x, xy.y, z);
        vector3 diff = level.vec3Diff(soundSource, playerRelative);
        double distance = diff.length();
        if (distance > MAX_DISTANCE) continue;

        let position = calculateSectorScreenPosition(aSector);
        mDots[Geometry][position] += distanceToDotLevel(distance);
      }
    }
  }

  override void renderOverlay(RenderEvent event)
  {
    if (!mIsInitialized) return;
    if (players[consolePlayer].mo == NULL) return;
    if (automapActive && !mOnAutomapCvar.getInt())
    {
      resetUi();
      return;
    }

    double  screenWidth  = Screen.getWidth();
    double  screenHeight = Screen.getHeight();
    vector2 center = (mXPositionCvar.getFloat() * screenWidth,
                      mYPositionCvar.getFloat() * screenHeight);
    double radiusX = mXRadiusCvar.getFloat();
    double radiusY = mYRadiusCvar.getFloat();
    int    dotSize = mDotSizeCvar.getInt();
    Color  baseColor = mBaseColorCvar.getString();
    double alpha   = mAlphaCvar.getFloat();

    for (int i = 0; i < DOTS_COUNT; ++i)
    {
      vector2 position = center + makePosition(i, 0.0, radiusX, radiusY);
      position = (clamp(position.x, 0, screenWidth),
                  clamp(position.y, 0, screenHeight));
      drawDot(position, baseColor, dotSize, alpha);
    }

    if (level.mapTime < 2)
    {
      resetUi();
      return;
    }

    for (int type = Own; type < SoundTypesCount; ++type)
    {
      for (int i = 0; i < DOTS_COUNT; ++i)
      {
        double diff = mDotsInterpolated[type][i] - mDots[type][i];
        mDotsInterpolated[type][i] -= diff * ((diff > 0) ? 0.2 : 0.4);
      }
    }

    // Fake sound level changes.
    double dotsVariance[SoundTypesCount][DOTS_COUNT];
    if (level.time % 3 == 0)
    {
      for (int type = Noise; type < SoundTypesCount; ++type)
      {
        for (int i = 0; i < DOTS_COUNT; ++i)
          dotsVariance[type][i] = frandom[SoundToScreen](-MIN_DOT_LEVEL, MIN_DOT_LEVEL);
      }

      dotsVariance[Own][0] = frandom[SoundToScreen](-MIN_DOT_LEVEL, MIN_DOT_LEVEL);
    }

    // Gaussian blur, sigma 1.
    static const double blurWeights[] =
    {
      0.0613595978134402  / 0.38774041331389975,
      0.24477019552960988 / 0.38774041331389975,
      1.0,
      0.24477019552960988 / 0.38774041331389975,
      0.0613595978134402  / 0.38774041331389975
    };

    double dotsBlurred[SoundTypesCount][DOTS_COUNT];
    for (int type = Noise; type < SoundTypesCount; ++type)
    {
      for (int i = 0; i < DOTS_COUNT; ++i)
      {
        double sum = 0;
        for (int j = -2; j <= 2; ++j)
        {
          int index = (i + j + DOTS_COUNT) % DOTS_COUNT;
          sum += mDotsInterpolated[type][index] * blurWeights[2 + j];

          if (mDotsInterpolated[type][index] > MIN_DOT_LEVEL)
            sum += dotsVariance[type][index];
        }
        dotsBlurred[type][i] = min(1.0, sum);
      }
    }

    double ownDotLevel = mDotsInterpolated[Own][0] + dotsVariance[Own][0];
    if (ownDotLevel > MIN_DOT_LEVEL)
    {
      for (int i = 0; i < DOTS_COUNT; ++i)
      {
          vector2 position = center + makeOwnPosition(i, ownDotLevel, radiusX, radiusY);
          position = (clamp(position.x, 0, screenWidth),
                      clamp(position.y, 0, screenHeight));
          drawDot(position, mColors[Own], dotSize, alpha);
      }
    }

    for (int type = Noise; type < SoundTypesCount; ++type)
    {
      for (int i = 0; i < DOTS_COUNT; ++i)
      {
        double dotLevel = dotsBlurred[type][i];
        if (dotLevel > MIN_DOT_LEVEL)
        {
          vector2 position = center + makePosition(i, dotLevel, radiusX, radiusY);
          position = (clamp(position.x, 0, screenWidth),
                      clamp(position.y, 0, screenHeight));
          drawDot(position, mColors[type], dotSize, alpha);
        }
      }
    }
  }

  private ui void resetUi()
  {
    for (int type = Noise; type < SoundTypesCount; ++type)
      for (int i = 0; i < DOTS_COUNT; ++i) mDotsInterpolated[type][i] = 0;
  }

  private ui vector2 makePosition(int i, double dotLevel, double radiusX, double radiusY)
  {
    double angle = i * (360.0 / DOTS_COUNT);
    return ((1 + dotLevel) * radiusX * sin(angle),
           -(1 + dotLevel) * radiusY * cos(angle));
  }

  private ui vector2 makeOwnPosition(int i,
                                     double dotLevel,
                                     double radiusX,
                                     double radiusY)
  {
    double angle = i * (360.0 / DOTS_COUNT);
    return ((1 - dotLevel) * radiusX * sin(angle),
           -(1 - dotLevel) * radiusY * cos(angle));
  }

  private ui void drawDot(vector2 position, Color aColor, int dotSize, double alpha)
  {
    int halfSize = dotSize / 2;
    Screen.dim(aColor,
               alpha,
               int(round(position.x)) - halfSize,
               int(round(position.y)) - halfSize,
               dotSize,
               dotSize);
  }

  private void initialize()
  {
    mIsInitialized = true;
    mIterator = ThinkerIterator.create("Thinker");

    PlayerInfo player = players[consolePlayer];
    mXPositionCvar    = Cvar.getCvar("st_x_position", player);
    mYPositionCvar    = Cvar.getCvar("st_y_position", player);
    mXRadiusCvar      = Cvar.getCvar("st_x_radius", player);
    mYRadiusCvar      = Cvar.getCvar("st_y_radius", player);
    mDotSizeCvar      = Cvar.getCvar("st_dot_size", player);
    mAlphaCvar        = Cvar.getCvar("st_alpha", player);

    mOnAutomapCvar    = Cvar.getCvar("st_on_automap", player);

    mBaseColorCvar        = Cvar.getCvar("st_color_base", player);
    mColorCvars[Noise]    = Cvar.getCvar("st_color_noise", player);
    mColorCvars[Geometry] = Cvar.getCvar("st_color_geometry", player);
    mColorCvars[Danger]   = Cvar.getCvar("st_color_danger", player);
    mColorCvars[Own]      = Cvar.getCvar("st_color_player", player);
  }

  private static int calculateActorScreenPosition(Actor target)
  {
    PlayerInfo player = players[consolePlayer];
    double angleToTarget = (player.mo.angle - player.mo.angleTo(target)) % 360.0;
    return int(round(angleToTarget / (360.0 / DOTS_COUNT))) % DOTS_COUNT;
  }

  private static int calculateSectorScreenPosition(sector aSector)
  {
    PlayerInfo player = players[consolePlayer];
    vector3 playerRelative = player.mo.posRelative(aSector);
    vector2 diff = aSector.centerSpot - playerRelative.xy;
    double angleToTarget = (player.mo.angle - atan2(diff.y, diff.x)) % 360.0;
    return int(round(angleToTarget / (360.0 / DOTS_COUNT))) % DOTS_COUNT;
  }

  // No way to easily query if a sector is moving, hence this workaround.
  private bool isMoving(Sector aSector)
  {
    let memory = st_SectorMemory(mSectorMemories.getIfExists(aSector.index()));
    return memory != NULL
        && memory.time == level.time - 1
        && (memory.floorHeight != aSector.centerFloor()
            || memory.ceilingHeight != aSector.centerCeiling());
  }

  private void remember(Sector aSector)
  {
    let memory = st_SectorMemory(mSectorMemories.getIfExists(aSector.index()));
    if (memory == NULL)
    {
      memory = new("st_SectorMemory");
      mSectorMemories.insert(aSector.index(), memory);
    }

    memory.time = level.time;
    memory.floorHeight   = aSector.centerFloor();
    memory.ceilingHeight = aSector.centerCeiling();
  }

  // Note: when changing this formula, adjust MAX_DISTANCE (reverse of this).
  private double distanceToDotLevel(double distance)
  {
    return min(1.0, 1.0 / max(distance * distance / DISTANCE_FACTOR, 1.0));
  }

  enum SoundType {Own, Noise, Geometry, Danger, SoundTypesCount}
  const DOTS_COUNT = 32; // Should be divisible by 4 for clear base directions.
  const MIN_DOT_LEVEL = 0.05;
  const DISTANCE_FACTOR = 100000.0;
  const MAX_DISTANCE = sqrt(DISTANCE_FACTOR / MIN_DOT_LEVEL);

  private double mDots[SoundTypesCount][DOTS_COUNT];
  private ui double mDotsInterpolated[SoundTypesCount][DOTS_COUNT];
  private Color mColors[SoundTypesCount];
  private bool mIsInitialized;
  private ThinkerIterator mIterator;
  private Map<int, Object> mSectorMemories;

  private Cvar mXPositionCvar;
  private Cvar mYPositionCvar;
  private CVar mXRadiusCvar;
  private CVar mYRadiusCvar;
  private Cvar mDotSizeCvar;
  private Cvar mAlphaCvar;
  private Cvar mOnAutomapCvar;
  private Cvar mBaseColorCvar;
  private Cvar mColorCvars[SoundTypesCount];
}

class st_SectorMemory
{
  int time;
  double floorHeight;
  double ceilingHeight;
}

6. Project setup

GameInfo { EventHandlers = "st_EventHandler" }
version 4.14.3

#include "zscript/st_PlainTranslator.zs"


class st_EventHandler : StaticEventHandler
{
  // Collects sounds by searching for actors that make sounds and doors/elevators.
  override void worldTick()
  {
    if (!mIsInitialized)
    {
      if (level.mapName ~== "TITLEMAP") return;
      initialize();
    }

    if (players[consolePlayer].mo == NULL) return;

    mIterator.reinit();

    for (int type = Own; type < SoundTypesCount; ++type)
      mColors[type] = mColorCvars[type].getString();

    for (int type = Noise; type < SoundTypesCount; ++type)
    {
      for (int i = 0; i < DOTS_COUNT; ++i)
        mDots[type][i] = 0;
    }

    let player = players[consolePlayer].mo;
    double playerSound = player.isActorPlayingSound(CHAN_AUTO) ? 0.25 : 0.0;
    mDots[Own][0] = playerSound;

    Thinker aThinker;
    while (aThinker = mIterator.next())
    {
      let anActor = Actor(aThinker);
      if (anActor != NULL)
      {
        if (anActor == player) continue;
        if (anActor is "Inventory" && Inventory(anActor).owner != NULL) continue;
        if (!anActor.isActorPlayingSound(CHAN_AUTO)) continue;

        double distance = anActor.distance3D(player);
        if (anActor.bMissile && anActor.target == player)
        {
          if (distance < player.radius * 3) mDots[Own][0] = 0.25;
          continue;
        }
        if (anActor.bBoss) distance = min(distance, MAX_DISTANCE / 2);
        if (distance > MAX_DISTANCE) continue;

        let type = ((anActor.bIsMonster && !anActor.bFriendly && anActor.health > 0)
                    || (anActor.bMissile && anActor.damage > 0)) ? Danger : Noise;

        let position = calculateActorScreenPosition(anActor);
        mDots[type][position] += distanceToDotLevel(distance);

        continue;
      }

      let aMover = Mover(aThinker);
      if (aMover != NULL)
      {
        Sector aSector = aMover.getSector();
        if (aSector.flags & Sector.SECF_SilentMove) continue;

        // Important order: ask if moving before remembering.
        bool isMoving = isMoving(aSector);
        remember(aSector);
        if (!isMoving) continue;

        vector3 playerRelative = player.posRelative(aSector);
        vector2 xy = aSector.centerSpot;
        double z = aMover is "MovingCeiling"
          ? aSector.centerCeiling()
          : aSector.centerFloor();
        vector3 soundSource = (xy.x, xy.y, z);
        vector3 diff = level.vec3Diff(soundSource, playerRelative);
        double distance = diff.length();
        if (distance > MAX_DISTANCE) continue;

        let position = calculateSectorScreenPosition(aSector);
        mDots[Geometry][position] += distanceToDotLevel(distance);
      }
    }
  }

  override void renderOverlay(RenderEvent event)
  {
    if (!mIsInitialized) return;
    if (players[consolePlayer].mo == NULL) return;
    if (automapActive && !mOnAutomapCvar.getInt())
    {
      resetUi();
      return;
    }

    double  screenWidth  = Screen.getWidth();
    double  screenHeight = Screen.getHeight();
    vector2 center = (mXPositionCvar.getFloat() * screenWidth,
                      mYPositionCvar.getFloat() * screenHeight);
    double radiusX = mXRadiusCvar.getFloat();
    double radiusY = mYRadiusCvar.getFloat();
    int    dotSize = mDotSizeCvar.getInt();
    Color  baseColor = mBaseColorCvar.getString();
    double alpha   = mAlphaCvar.getFloat();

    for (int i = 0; i < DOTS_COUNT; ++i)
    {
      vector2 position = center + makePosition(i, 0.0, radiusX, radiusY);
      position = (clamp(position.x, 0, screenWidth),
                  clamp(position.y, 0, screenHeight));
      drawDot(position, baseColor, dotSize, alpha);
    }

    if (level.mapTime < 2)
    {
      resetUi();
      return;
    }

    for (int type = Own; type < SoundTypesCount; ++type)
    {
      for (int i = 0; i < DOTS_COUNT; ++i)
      {
        double diff = mDotsInterpolated[type][i] - mDots[type][i];
        mDotsInterpolated[type][i] -= diff * ((diff > 0) ? 0.2 : 0.4);
      }
    }

    // Fake sound level changes.
    double dotsVariance[SoundTypesCount][DOTS_COUNT];
    if (level.time % 3 == 0)
    {
      for (int type = Noise; type < SoundTypesCount; ++type)
      {
        for (int i = 0; i < DOTS_COUNT; ++i)
          dotsVariance[type][i] = frandom[SoundToScreen](-MIN_DOT_LEVEL, MIN_DOT_LEVEL);
      }

      dotsVariance[Own][0] = frandom[SoundToScreen](-MIN_DOT_LEVEL, MIN_DOT_LEVEL);
    }

    // Gaussian blur, sigma 1.
    static const double blurWeights[] =
    {
      0.0613595978134402  / 0.38774041331389975,
      0.24477019552960988 / 0.38774041331389975,
      1.0,
      0.24477019552960988 / 0.38774041331389975,
      0.0613595978134402  / 0.38774041331389975
    };

    double dotsBlurred[SoundTypesCount][DOTS_COUNT];
    for (int type = Noise; type < SoundTypesCount; ++type)
    {
      for (int i = 0; i < DOTS_COUNT; ++i)
      {
        double sum = 0;
        for (int j = -2; j <= 2; ++j)
        {
          int index = (i + j + DOTS_COUNT) % DOTS_COUNT;
          sum += mDotsInterpolated[type][index] * blurWeights[2 + j];

          if (mDotsInterpolated[type][index] > MIN_DOT_LEVEL)
            sum += dotsVariance[type][index];
        }
        dotsBlurred[type][i] = min(1.0, sum);
      }
    }

    double ownDotLevel = mDotsInterpolated[Own][0] + dotsVariance[Own][0];
    if (ownDotLevel > MIN_DOT_LEVEL)
    {
      for (int i = 0; i < DOTS_COUNT; ++i)
      {
          vector2 position = center + makeOwnPosition(i, ownDotLevel, radiusX, radiusY);
          position = (clamp(position.x, 0, screenWidth),
                      clamp(position.y, 0, screenHeight));
          drawDot(position, mColors[Own], dotSize, alpha);
      }
    }

    for (int type = Noise; type < SoundTypesCount; ++type)
    {
      for (int i = 0; i < DOTS_COUNT; ++i)
      {
        double dotLevel = dotsBlurred[type][i];
        if (dotLevel > MIN_DOT_LEVEL)
        {
          vector2 position = center + makePosition(i, dotLevel, radiusX, radiusY);
          position = (clamp(position.x, 0, screenWidth),
                      clamp(position.y, 0, screenHeight));
          drawDot(position, mColors[type], dotSize, alpha);
        }
      }
    }
  }

  private ui void resetUi()
  {
    for (int type = Noise; type < SoundTypesCount; ++type)
      for (int i = 0; i < DOTS_COUNT; ++i) mDotsInterpolated[type][i] = 0;
  }

  private ui vector2 makePosition(int i, double dotLevel, double radiusX, double radiusY)
  {
    double angle = i * (360.0 / DOTS_COUNT);
    return ((1 + dotLevel) * radiusX * sin(angle),
           -(1 + dotLevel) * radiusY * cos(angle));
  }

  private ui vector2 makeOwnPosition(int i,
                                     double dotLevel,
                                     double radiusX,
                                     double radiusY)
  {
    double angle = i * (360.0 / DOTS_COUNT);
    return ((1 - dotLevel) * radiusX * sin(angle),
           -(1 - dotLevel) * radiusY * cos(angle));
  }

  private ui void drawDot(vector2 position, Color aColor, int dotSize, double alpha)
  {
    int halfSize = dotSize / 2;
    Screen.dim(aColor,
               alpha,
               int(round(position.x)) - halfSize,
               int(round(position.y)) - halfSize,
               dotSize,
               dotSize);
  }

  private void initialize()
  {
    mIsInitialized = true;
    mIterator = ThinkerIterator.create("Thinker");

    PlayerInfo player = players[consolePlayer];
    mXPositionCvar    = Cvar.getCvar("st_x_position", player);
    mYPositionCvar    = Cvar.getCvar("st_y_position", player);
    mXRadiusCvar      = Cvar.getCvar("st_x_radius", player);
    mYRadiusCvar      = Cvar.getCvar("st_y_radius", player);
    mDotSizeCvar      = Cvar.getCvar("st_dot_size", player);
    mAlphaCvar        = Cvar.getCvar("st_alpha", player);

    mOnAutomapCvar    = Cvar.getCvar("st_on_automap", player);

    mBaseColorCvar        = Cvar.getCvar("st_color_base", player);
    mColorCvars[Noise]    = Cvar.getCvar("st_color_noise", player);
    mColorCvars[Geometry] = Cvar.getCvar("st_color_geometry", player);
    mColorCvars[Danger]   = Cvar.getCvar("st_color_danger", player);
    mColorCvars[Own]      = Cvar.getCvar("st_color_player", player);
  }

  private static int calculateActorScreenPosition(Actor target)
  {
    PlayerInfo player = players[consolePlayer];
    double angleToTarget = (player.mo.angle - player.mo.angleTo(target)) % 360.0;
    return int(round(angleToTarget / (360.0 / DOTS_COUNT))) % DOTS_COUNT;
  }

  private static int calculateSectorScreenPosition(sector aSector)
  {
    PlayerInfo player = players[consolePlayer];
    vector3 playerRelative = player.mo.posRelative(aSector);
    vector2 diff = aSector.centerSpot - playerRelative.xy;
    double angleToTarget = (player.mo.angle - atan2(diff.y, diff.x)) % 360.0;
    return int(round(angleToTarget / (360.0 / DOTS_COUNT))) % DOTS_COUNT;
  }

  // No way to easily query if a sector is moving, hence this workaround.
  private bool isMoving(Sector aSector)
  {
    let memory = st_SectorMemory(mSectorMemories.getIfExists(aSector.index()));
    return memory != NULL
        && memory.time == level.time - 1
        && (memory.floorHeight != aSector.centerFloor()
            || memory.ceilingHeight != aSector.centerCeiling());
  }

  private void remember(Sector aSector)
  {
    let memory = st_SectorMemory(mSectorMemories.getIfExists(aSector.index()));
    if (memory == NULL)
    {
      memory = new("st_SectorMemory");
      mSectorMemories.insert(aSector.index(), memory);
    }

    memory.time = level.time;
    memory.floorHeight   = aSector.centerFloor();
    memory.ceilingHeight = aSector.centerCeiling();
  }

  // Note: when changing this formula, adjust MAX_DISTANCE (reverse of this).
  private double distanceToDotLevel(double distance)
  {
    return min(1.0, 1.0 / max(distance * distance / DISTANCE_FACTOR, 1.0));
  }

  enum SoundType {Own, Noise, Geometry, Danger, SoundTypesCount}
  const DOTS_COUNT = 32; // Should be divisible by 4 for clear base directions.
  const MIN_DOT_LEVEL = 0.05;
  const DISTANCE_FACTOR = 100000.0;
  const MAX_DISTANCE = sqrt(DISTANCE_FACTOR / MIN_DOT_LEVEL);

  private double mDots[SoundTypesCount][DOTS_COUNT];
  private ui double mDotsInterpolated[SoundTypesCount][DOTS_COUNT];
  private Color mColors[SoundTypesCount];
  private bool mIsInitialized;
  private ThinkerIterator mIterator;
  private Map<int, Object> mSectorMemories;

  private Cvar mXPositionCvar;
  private Cvar mYPositionCvar;
  private CVar mXRadiusCvar;
  private CVar mYRadiusCvar;
  private Cvar mDotSizeCvar;
  private Cvar mAlphaCvar;
  private Cvar mOnAutomapCvar;
  private Cvar mBaseColorCvar;
  private Cvar mColorCvars[SoundTypesCount];
}

class st_SectorMemory
{
  int time;
  double floorHeight;
  double ceilingHeight;
}

7. Test

GameInfo { EventHandlers = "stt_EventHandler" }
version 4.14.3

class stt_EventHandler : StaticEventHandler
{
  override void networkProcess(ConsoleEvent command)
  {
    if (command.name == "stt_spawn_imps")
    {
      int count = command.args[0];
      Console.printf("Spawning %d imps...", count);
      for (int i = 0; i < count; ++i)
        Actor.spawn("doomimp", players[consolePlayer].mo.pos + (50, 0, 0));
    }
  }
}
wait 2; map map01; wait 2; quit

Created: 2026-08-05 Wed 12:54