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
- Toby Accessibility Mod by Alando1.
- Making Games Better for the Deaf and Hard of Hearing by Game Maker's Toolkit.
2. License
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 }
[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