SoundToScreen
Table of Contents
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.
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
- Toby Accessibility Mod by Alando1.
- Making Games Better for the Deaf and Hard of Hearing by Game Maker's Toolkit.
2. License
3. 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 }
[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
4. Code
GameInfo { EventHandlers = "st_EventHandler" }
version 4.13.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; }
5. Test
GameInfo { EventHandlers = "stt_EventHandler" }
version 4.13.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