Skip to content

Latest commit

 

History

History
436 lines (354 loc) · 12.7 KB

File metadata and controls

436 lines (354 loc) · 12.7 KB

SoundToScreen

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.

Features

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

Implementation Notes

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

Inspiration

License

GPL-3.0-only

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

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

  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
}

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

Code

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 = Noise; 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;
    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.bMissile && anActor.target == player) continue;
        if (!anActor.isActorPlayingSound(CHAN_AUTO)) continue;
        double distance = anActor.distance3D(player);
        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;
    }

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

    for (int i = 0; i < DOTS_COUNT; ++i)
      drawDot(center + makePosition(i, 0.0, radiusX, radiusY), baseColor, dotSize);

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

    for (int type = Noise; 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);
      }
    }

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

    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)
        {
          drawDot(center + makePosition(i, dotLevel, radiusX, radiusY),
                  mColors[type],
                  dotSize);
        }
      }
    }
  }

  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 void drawDot(vector2 position, Color aColor, int dotSize)
  {
    int halfSize = dotSize / 2;
    Screen.dim(aColor, 1.0,
               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);

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

  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 {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 mOnAutomapCvar;
  private Cvar mBaseColorCvar;
  private Cvar mColorCvars[SoundTypesCount];
}

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

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