10.5x

Table of Contents

2019 Cacowards Mini Mod Safari:

Make anything into a slaughtermap, or make nuts.wad not melt your CPU, all from the reach of a single slider.

Required ZScript version:

4.14

Features:

Options:

Anti-features:

Known issues (for multiplier > 1):

Acknowledgments:

10.5x is a part of DoomToolbox.

1. Licenses

2. x5_EventHandler

GameInfo { EventHandlers = "x5_EventHandler" }
server int x5_multiplier = 100;
class x5_EventHandler : EventHandler
{
  // 1. Entry point.
  override void worldLoaded(WorldEvent event)
  {
    if (level.mapName ~== "titlemap")
    {
      destroy();
      return;
    }

    collectSpawnPoints(mSpawnPoints);

    if (mSpawnPoints.size() == 0)
    {
      destroy();
      return;
    }

    mEnemyTypes = collectEnemyTypes(mSpawnPoints);

    if (x5_multiplier == 0)
    {
      // Each enemy type has its own multiplier, ask to fill multipliers.
      mTypeMultipliers = NULL;

      mFreezer = x5_Freezer.init();
      mFreezer.freeze();

      if (consolePlayer == net_arbitrator)
        sendInterfaceEvent(consolePlayer, "x5_menu");
      else
        Console.midPrint(null, StringTable.Localize("$X5_HOST_SELECTS"), true);
    }
    else { mTypeMultipliers = fillTypeMultipliers(mEnemyTypes, x5_multiplier); }
  }

  override void interfaceProcess(ConsoleEvent event)
  {
    if (event.name != "x5_menu") return;

    Menu.setMenu("x5_TypeMultipliers");
    x5_TypeMultipliersMenu(Menu.getCurrentMenu()).setUp(self, mEnemyTypes);
  }

  override void worldTick()
  {
    // wait for type multipliers.
    if (mTypeMultipliers == NULL) return;

    if (mFreezer != NULL)
      mFreezer.thaw();

    if (level.mapTime > TIME_TO_RANDOMIZE)
    {
      multiply();
      mTypeMultipliers = NULL;
    }
  }

  override void networkProcess(ConsoleEvent event)
  {
    if (event.name.left(3) != "x5_") return;

    mTypeMultipliers = Dictionary.fromString(event.name.mid(3));
  }

  override void worldThingSpawned(WorldEvent event)
  {
    Actor thing = event.thing;

    if (thing == NULL || !isCloneable(thing)) return;
    if (thing.bThruActors || thing.checkMove(thing.pos.xy)) return;

    // thing.a_SetRenderStyle(1, STYLE_Stencil); // for debugging.

    thing.bThruActors = true;
    thing.a_GiveInventory('x5_WalkAbilityWatcher', 1);
  }

  private static void collectSpawnPoints(out Array<x5_SpawnPoint> result)
  {
    Actor anActor;
    for (let i = ThinkerIterator.create("Actor"); anActor = Actor(i.next());)
    {
      let replaceeType = Actor.getReplacee(anActor.getClassName());

      if (!isCloneable(getDefaultByType(replaceeType))) continue;

      let spawnPoint          = new ("x5_SpawnPoint");
      spawnPoint.position     = anActor.pos;
      spawnPoint.height       = anActor.height;
      spawnPoint.radius       = anActor.radius;
      spawnPoint.replaceeType = replaceeType;
      spawnPoint.original     = anActor;
      result.push(spawnPoint);
    }
  }

  private static Dictionary collectEnemyTypes(Array<x5_SpawnPoint> spawnPoints)
  {
    let result = Dictionary.create();
    foreach (spawnPoint : spawnPoints)
    {
      result.insert(spawnPoint.replaceeType.getClassName(), "100");
    }
    return result;
  }

  private static Dictionary fillTypeMultipliers(Dictionary enemyTypes,
                                                int multiplier)
  {
    let result              = Dictionary.create();
    let formattedMultiplier = string.format("%d", multiplier);
    for (let i = DictionaryIterator.create(enemyTypes); i.next();)
      result.insert(i.key(), formattedMultiplier);

    return result;
  }

  private void multiply()
  {
    for (let i = DictionaryIterator.create(mTypeMultipliers); i.next();)
    {
      int multiplier = i.value().toInt();
      if (multiplier == 100) continue;

      class<Actor> type = i.key();
      Array<Actor> enemiesByType;
      collectSpawnedEnemiesByType(type, enemiesByType);
      multiplyEnemies(type, enemiesByType, multiplier);
    }
  }

  private void collectSpawnedEnemiesByType(class<Actor> type,
                                           out Array<Actor> enemiesByType)
  {
    foreach (spawnPoint : mSpawnPoints)
    {
      if (spawnPoint.replaceeType != type) continue;

      // If the actor is still present, great!
      // Otherwise, assume the spawned actor isn't far away.
      if (spawnPoint.original != NULL) { enemiesByType.push(spawnPoint.original); }
      else
      {
        let pos    = spawnPoint.position;
        let height = spawnPoint.height;
        let radius = spawnPoint.radius;
        let i      = BlockThingsIterator.createFromPos(pos.x, pos.y, pos.z, height,
                                                       radius, false);

        while (i.next())
        {
          if (((pos.x, pos.y) - i.thing.pos.xy).length() < radius)
          {
            enemiesByType.push(i.thing);
            break;
          }
        }
      }
    }
  }

  private static void
  multiplyEnemies(class<Actor> originalType, Array<Actor> enemies, int multiplier)
  {
    if (multiplier == 100) return;

    // console.printf("multiply time: %d, multiplier: %d", level.time, multiplier);

    int integerMultiplier = multiplier / 100;
    int copiesNumber      = integerMultiplier - 1;
    foreach (enemy : enemies)
    {
      if (multiplier == 0)
      {
        let killer =
            x5_Killer(Actor.spawn("x5_Killer", x5_Killer.makePosition(enemy)));
        killer.init(enemy);
      }
      else
      {
        for (int c = 0; c < copiesNumber; ++c)
          clone(originalType, enemy);
      }
    }

    if (multiplier % 100 == 0) return;

    shuffle(enemies);

    double fractionMultiplier = (multiplier % 100) * 0.01;
    uint enemiesNumber        = enemies.size();
    uint stp                  = uint(round(enemiesNumber * fractionMultiplier));

    if (integerMultiplier >= 1) // add
    {
      for (uint i = 0; i < stp; ++i)
        clone(originalType, enemies[i]);
    }
    else // decimate
    {
      for (uint i = stp; i < enemiesNumber; ++i)
      {
        let killer =
            x5_Killer(Actor.spawn("x5_Killer", x5_Killer.makePosition(enemies[i])));
        killer.init(enemies[i]);
      }
    }
  }

  private static void clone(class<Actor> originalType, Actor enemy)
  {
    let spawned = Actor.spawn(originalType, enemy.pos, ALLOW_REPLACE);

    spawned.bAmbush    = enemy.bAmbush;
    // copied from randomspawner.zs
    spawned.spawnAngle = enemy.spawnAngle;
    spawned.angle      = enemy.angle;
    spawned.pitch      = enemy.pitch;
    spawned.roll       = enemy.roll;
    spawned.spawnPoint = enemy.spawnPoint;
    spawned.special    = enemy.special;
    spawned.args[0]    = enemy.args[0];
    spawned.args[1]    = enemy.args[1];
    spawned.args[2]    = enemy.args[2];
    spawned.args[3]    = enemy.args[3];
    spawned.args[4]    = enemy.args[4];
    spawned.special1   = enemy.special1;
    spawned.special2   = enemy.special2;
    // MTF_SECRET needs special treatment to avoid incrementing the secret
    // counter twice. It had already been processed for the spawner itself.
    spawned.spawnFlags = enemy.spawnFlags & ~MTF_SECRET;
    spawned.handleSpawnFlags();

    spawned.spawnFlags   = enemy.spawnFlags;
    // "Transfer" count secret flag to spawned actor
    spawned.bCountSecret = enemy.spawnFlags & MTF_SECRET;
    spawned.changeTid(enemy.tid);
    spawned.vel    = enemy.vel;
    // For things such as DamageMaster/DamageChildren, transfer mastery.
    spawned.master = enemy.master;
    spawned.target = enemy.target;
    spawned.tracer = enemy.tracer;
    spawned.copyFriendliness(enemy, false);
  }

  // TODO: don't clone non-killable enemies? Find examples (Eviternity II turrets)?
  private static bool isCloneable(readonly<Actor> anActor)
  {
    return anActor.bIsMonster && !anActor.bFriendly && anActor.bCountKill;
  }

  private static void shuffle(out Array<Actor> actors)
  {
    // Fisher-Yates shuffle.
    uint numberOfActors = actors.size();
    for (uint i = numberOfActors - 1; i >= 1; --i)
    {
      int j = random[x105](0, i);

      let temp  = actors[i];
      actors[i] = actors[j];
      actors[j] = temp;
    }
  }

  // There are mods that have randomization that takes a few tics.
  const TIME_TO_RANDOMIZE = 4;

  private Dictionary mEnemyTypes;
  private Dictionary mTypeMultipliers;
  private Array<x5_SpawnPoint> mSpawnPoints;
  private x5_Freezer mFreezer;
} // class x5_EventHandler
class x5_SpawnPoint
{
  vector3 position;
  double height;
  double radius;
  class<Actor> replaceeType;
  Actor original;
}

3. x5_Freezer

class x5_Freezer play
{
  static x5_Freezer init()
  {
    let result             = new ("x5_Freezer");
    result.mWasFrozen      = false;
    result.mWasLevelFrozen = false;
    return result;
  }

  void freeze()
  {
    if (mWasFrozen) return;
    mWasFrozen = true;

    freezeLevel();
    freezePlayer();
  }

  void thaw()
  {
    if (!mWasFrozen) return;
    mWasFrozen = false;

    thawLevel();
    thawPlayer();
  }

  private void freezeLevel()
  {
    mWasLevelFrozen = level.isFrozen();
    level.setFrozen(true);
  }

  private void freezePlayer()
  {
    mWasPlayerFrozen = true;

    PlayerInfo player = players[consolePlayer];

    mCheats   = player.cheats;
    mVelocity = player.mo.vel;
    mGravity  = player.mo.gravity;

    setPlayerFrozen(player.cheats | FROZEN_CHEATS_FLAGS, (0, 0, 0), 0);
  }

  private void thawLevel() const { level.setFrozen(mWasLevelFrozen); }

  private void thawPlayer() const
  {
    if (mWasPlayerFrozen) setPlayerFrozen(mCheats, mVelocity, mGravity);
    mWasPlayerFrozen = false;
  }

  private static void setPlayerFrozen(int cheats, vector3 velocity, double gravity)
  {
    PlayerInfo player = players[consolePlayer];
    if (player.mo == NULL) return;

    player.cheats     = cheats;
    player.vel        = velocity.xy;
    player.mo.vel     = velocity;
    player.mo.gravity = gravity;
  }

  const FROZEN_CHEATS_FLAGS = CF_TotallyFrozen | CF_Frozen;

  private bool mWasFrozen;
  private bool mWasLevelFrozen;
  private bool mWasPlayerFrozen;

  private int mCheats;
  private vector3 mVelocity; // to reset weapon bobbing.
  private double mGravity;

} // class x5_Freezer

4. x5_WalkAbilityWatcher

This inventory item resets bThruActors flag if the actor is able to move without it.

class x5_WalkAbilityWatcher : Inventory
{
  override void tick()
  {
    owner.bThruActors = false;
    bool ownerCanMove = owner.checkMove(owner.pos.xy);

    if (ownerCanMove)
    {
      //owner.a_SetRenderStyle(1, STYLE_Normal); // for debugging.

      owner.removeInventory(self);
      destroy();
      return;
    }
    else { owner.bThruActors = true; }

    Super.tick();
  }
} // class x5_WalkAbilityWatcher

5. x5_Killer

This class kills an enemy when the enemy becomes active. Such an enemy is marked with a floating icon. Whether an enemy killed by x5_Killer can be resurrected is controlled by x5_raise_divided Cvar.

server bool x5_raise_divided = false;
class x5_Killer : Actor
{
  Default
  {
    Height 30;
    FloatBobStrength 0.2;
    RenderStyle 'translucent'; // Change this to 'none' to hide killer marks.
    Alpha 0.3;

    +NoBlockmap;
    +NoGravity;
    +DontSplash;
    +NotOnAutomap;
    +FloatBob;
    +Bright;
  }

  States
  {
  Spawn:
    m8rd A - 1;
    Stop;
  }

  override void tick()
  {
    Super.tick();

    if (mWatched == NULL)
    {
      destroy();
      return;
    }

    setOrigin(makePosition(mWatched), true);

    if (mWatched.health > 0 && mWatched.target == NULL) return;

    mWatched.a_Die();
    mWatched.bCorpse = x5_raise_divided;
    destroy();
  }

  void init(Actor watched) { mWatched = watched; }

  static vector3 makePosition(Actor watched)
  {
    return watched.pos + (0, 0, watched.height * 1.5);
  }

  private Actor mWatched;
} // class x5_Killer

6. language

// SPDX-FileCopyrightText: 2020 Blueberryy

[enu default]
X5_TYPE_MENU_TITLE = "10.5x Enemy Multipliers";
X5_EXIT  = "Exit this menu to start the level.";
X5_000   = "Per enemy type (on level start)";
X5_HOST_SELECTS = "The host selects the multipliers";

[ru]
X5_TYPE_MENU_TITLE = "10.5x Коэффициенты врагов";
X5_EXIT  = "Выйдите из этого меню, чтобы начать уровень.";
X5_000   = "По типу врагов (при старте уровня)";
X5_HOST_SELECTS = "Сервер выбирает коэффициенты";

7. Global multiplier

7.1. OptionMenuItemX5Slider

AddOptionMenu OptionsMenu
{
  X5Slider "", x5_multiplier, 0, 10.5, 0.1, 1
}
class OptionMenuItemX5Slider : OptionMenuItemSlider
{
  OptionMenuItemX5Slider init(string label,
                              name command,
                              double min,
                              double max,
                              double step,
                              int showval = 1)
  {
    Super.init(label, command, min, max, step, showval);
    setLabel(mCvar.getInt());
    return self;
  }

  override double getSliderValue() { return (mCvar.getInt() / 100.0); }

  override void setSliderValue(double val)
  {
    int v = int(round(val * 100));
    mCvar.setInt(v);
    setLabel(v);
  }

  private void setLabel(int val)
  {
    mLabel = (val == 0) ? StringTable.localize("$X5_000").." 10.5x:" : "10.5x:";
  }
} // class OptionMenuItemX5Slider

8. Type multipliers

8.1. x5_TypeMultipliersMenu

OptionMenu "x5_TypeMultipliers"
{
  Class "x5_TypeMultipliersMenu"
  Title "$X5_TYPE_MENU_TITLE"
}
nosave string x5_type_multipliers = "";
class x5_TypeMultipliersMenu : OptionMenu
{
  override bool menuEvent(int mKey, bool fromController)
  {
    if (mKey == MKey_Back) report();

    return Super.menuEvent(mKey, fromController);
  }

  void setUp(EventHandler anEventHandler, Dictionary enemyTypes)
  {
    mEventHandler = anEventHandler;

    mDesc.mItems.clear();
    mDesc.mSelectedItem = 2;

    string description = StringTable.localize("$X5_EXIT");
    mDesc.mItems.push(
        new ("OptionMenuItemStaticText").initDirect(description, Font.CR_Black));
    mDesc.mItems.push(new ("OptionMenuItemStaticText").init(""));

    let savedMultipliers = Dictionary.fromString(x5_type_multipliers);
    for (let i = DictionaryIterator.create(savedMultipliers); i.next();)
    {
      string type = i.key();

      if (enemyTypes.at(type).length() != 0)
      {
        int multiplier = i.value().toInt();
        enemyTypes.insert(type, string.format("%d", multiplier));
      }
    }

    Array<x5_TypeSortElement> types;

    for (let i = DictionaryIterator.create(enemyTypes); i.next();)
    {
      class<Actor> enemyClass = i.key();
      int multiplier          = i.value().toInt();
      let defaultEnemy        = getDefaultByType(enemyClass);

      let element         = new ("x5_TypeSortElement");
      element.mName       = defaultEnemy.getTag();
      element.mHealth     = defaultEnemy.health;
      element.mClass      = enemyClass;
      element.mMultiplier = multiplier;
      types.push(element);
    }

    sortTypes(types);

    foreach (element : types)
    {
      let slider = new ("OptionMenuItemX5TypeSlider");
      slider.init(element.mClass, element.mMultiplier);

      mDesc.mItems.push(slider);
    }
  }

  private void report()
  {
    let savedMultipliers    = Dictionary.fromString(x5_type_multipliers);
    let multipliersToReport = Dictionary.create();

    foreach (menuItem : mDesc.mItems)
    {
      let slider = OptionMenuItemX5TypeSlider(menuItem);
      if (slider == NULL) continue;

      string className  = slider.getEnemyClassName();
      string multiplier = string.format("%d", slider.getValue());

      multipliersToReport.insert(className, multiplier);
      savedMultipliers.insert(className, multiplier);
    }

    Cvar.findCvar("x5_type_multipliers").setString(savedMultipliers.toString());

    string event = string.format("x5_%s", multipliersToReport.toString());
    mEventHandler.sendNetworkEvent(event);
  }

  private void sortTypes(out Array<x5_TypeSortElement> types)
  {
    // Gnome sort (stupid sort): https://en.wikipedia.org/wiki/Gnome_sort

    let pos    = 0;
    let length = types.size();

    while (pos < length)
    {
      if (pos == 0 || isGreaterOrEqual(types[pos], types[pos - 1])) { ++pos; }
      else
      {
        // swap
        let tmp        = types[pos];
        types[pos]     = types[pos - 1];
        types[pos - 1] = tmp;

        --pos;
      }
    }
  }

  private bool isGreaterOrEqual(x5_TypeSortElement lhs, x5_TypeSortElement rhs)
  {
    if (lhs.mHealth > rhs.mHealth) return true;
    if (lhs.mHealth == rhs.mHealth && lhs.mName >= rhs.mName) return true;

    return false;
  }

  private EventHandler mEventHandler;
} // class x5_TypeMultipliersMenu
class x5_TypeSortElement
{
  string mName;
  int mHealth;
  class<Actor> mClass;
  int mMultiplier;
}

8.2. OptionMenuItemX5TypeSlider

class OptionMenuItemX5TypeSlider : OptionMenuItemSlider
{
  void init(class<Actor> enemyClass, int value)
  {
    Super.init(getDefaultByType(enemyClass).getTag(), "", 0, 10.5, 0.1, 1);

    mValue          = value;
    mEnemyClassName = enemyClass.getClassName();
  }

  override double getSliderValue() { return (mValue / 100.0); }

  override void setSliderValue(double value) { mValue = int(round(value * 100)); }

  string getEnemyClassName() { return mEnemyClassName; }

  int getValue() { return mValue; }

  private int mValue;
  private string mEnemyClassName;
}

9. Sprites

sprites/m8rda0.png: m8rda0.png

10. Tests

GameInfo { EventHandlers = "x5t_Test", "x5t_Quoter" }
server string x5t_name  = "";
server string x5t_spawn = "";
class x5t_Clematis : Clematis {}
class x5t_Test : StaticEventHandler
{
  override void onRegister() { setOrder(-1); }

  override void networkProcess(ConsoleEvent event)
  {
    if (event.name == "x5t_begin")
    {
      mTest = new ("x5t_Clematis");
      mTest.describe("10.5x test");
    }
    else if (event.name.left(10) == "x5t_expect")
    {
      let expected = Dictionary.fromString(x5t_Quoter.quote(event.name.mid(10)));
      for (let i = DictionaryIterator.create(expected); i.next();)
        testActorClass(x5t_name, i.value().toInt(), i.key());
    }
    else if (event.name == "x5t_end") { mTest.endDescribe(); }
  }

  override void worldLoaded(WorldEvent event)
  {
    int width  = getDefaultByType('DoomImp').radius * 2;
    int yBegin = -2 * width;
    int yEnd   = 2 * width;
    int x      = 100;
    int y      = yBegin;

    // console.printf("spawn time: %d, x: %d, spawn: %s",
    //                level.time, x5_multiplier, x5t_spawn);
    let spawn = Dictionary.fromString(x5t_Quoter.quote(x5t_spawn));
    for (let i = DictionaryIterator.create(spawn); i.next();)
    {
      int count = i.value().toInt();
      for (int c = 0; c < count; ++c)
      {
        Actor.spawn(i.key(), players[consolePlayer].mo.pos + (x, y, 0),
                    ALLOW_REPLACE);

        y += width;
        if (y > yEnd)
        {
          y = yBegin;
          x += width;
        }
      }
    }
  }

  private void
  testActorClass(string testName, int expectedCount, string actorClassName)
  {
    int aliveCount   = 0;
    int canMoveCount = 0;

    let i = ThinkerIterator.create(actorClassName);
    for (Actor anActor = Actor(i.next()); anActor != NULL; anActor = Actor(i.next()))
    {
      aliveCount += (anActor.health > 0);
      canMoveCount += (anActor.health > 0) && anActor.checkMove(anActor.pos.xy);
    }

    string description = testName..": "..actorClassName;
    mTest.it(description..": alive",
             mTest.assertEval(aliveCount, "==", expectedCount));
    mTest.it(description..": can move",
             mTest.assertEval(canMoveCount, "==", expectedCount));
  }

  private Clematis mTest;

} // class x5t_Test
class x5t_Quoter : EventHandler
{
  static string quote(string input)
  {
    input.replace("'", "\"");
    return input;
  }

  override void NetworkProcess(ConsoleEvent event)
  {
    if (event.name.left(3) == "x5r")
      sendNetworkEvent("x5_"..quote(event.name.mid(3)));
  }
}

Doom monsters with radius 20:

Monster In tests Replacement
Archvile Yes x5t_Archvile via RandomSpawner
DoomImp Yes No
Revenant Yes x5t_Revenant via A_SpawnItemEx
ZombieMan Yes No
ShotgunGuy    
ChaingunGuy    
WolfensteinSS    
// clang-format off
class x5t_Archvile : Archvile {}
class x5t_Revenant : Revenant {}
// clang-format on

class x5t_ArchvileReplacer : RandomSpawner replaces Archvile
{
  Default { DropItem "x5t_Archvile"; }
}

/// Based on switch-based replacements from Brutal Doom v21.
class x5t_RevenantReplacer : Actor replaces Revenant
{
  States
  {
  Spawn:
    TNT1 A 0
    {
      bThruActors = 1;
      bCountKill  = 0;
    }
    TNT1 A 0 a_SpawnItemEx("x5t_Revenant", 0, 0, 0, 0, 0, 0, 0,
                           SXF_NoCheckPosition | SXF_TransferAmbushFlag, 0);
    Stop;
  }
}

TODO: make multiplayer tests, maybe?


x5_multiplier 100
wait 2; map map01
wait 4; netevent x5t_begin

wait 6; x5t_name IntegerMultiplier; x5_multiplier 300
wait 8; x5t_spawn {'DoomImp':'1','ZombieMan':'1'}
wait 10; map map01
wait 19; netevent x5t_expect{'DoomImp':'3','ZombieMan':'3'}

wait 28; x5t_name FractionalMultiplier; x5_multiplier 270
wait 30; x5t_spawn {'DoomImp':'10'}
wait 32; map map01
wait 41; netevent x5t_expect{'DoomImp':'27'}

wait 50; x5t_name Divider; x5_multiplier 70
wait 52; x5t_spawn {'DoomImp':'10'}
wait 54; map map01
wait 63; turn180
wait 72; +attack
wait 81; -attack
wait 90; netevent x5t_expect{'DoomImp':'7'}

wait 99; x5t_name PerClass; x5_multiplier 0
wait 101; x5t_spawn {'DoomImp':'1','ZombieMan':'1'}
wait 103; map map01
wait 112; netevent x5r{'DoomImp':'300','ZombieMan':'500'}; closemenu
wait 121; netevent x5t_expect{'DoomImp':'3','ZombieMan':'5'}

wait 130; x5t_name RandomSpawner; x5_multiplier 200
wait 132; x5t_spawn {'Archvile':'1'}
wait 134; map map01
wait 143; netevent x5t_expect{'x5t_Archvile':'2'}

wait 152; x5t_name A_SpawnItemEx; x5_multiplier 200
wait 154; x5t_spawn {'Revenant':'1'}
wait 156; map map01
wait 165; netevent x5t_expect{'x5t_Revenant':'2'}

wait 174; netevent x5t_end
wait 176; quit

Created: 2026-01-04 Sun 07:06