FinalCustomDoom

Table of Contents

Final Custom Doom provides gameplay customization. It can be used to increase or decrease the difficulty in various ways.

Final Custom Doom is a successor to Ultimate Custom Doom and Custom Doom.

Final Custom Doom is a part of DoomToolbox.

1. License

2. Options

2.1. Player

OptionMenu cd_Player
{
  StaticText "========================================", CDLightBlue
  StaticText "Player"                                  , CDLightBlue
  StaticText "========================================", CDLightBlue
  StaticText ""
  StaticText "0 disables the option.", CDLightBlue
  StaticText ""

  TextField   "Weapon damage multiplier", "cd_Player:weaponDamage:Immediately"
  TextField   "Taken damage multiplier" , "cd_Player:takenDamage:Immediately"
  StaticText  ""
  NumberField "Start health"            , "cd_Player:startHealth:OnPlayerStarted"
  NumberField "Start armor"             , "cd_Player:startArmor:OnPlayerStarted"
  TextField   "Save percent"            , "cd_Player:savePercent"
  StaticText  ""
  NumberField "Max health"              , "cd_Player:maxHealth:Immediately"
  TextField   "Speed multiplier"        , "cd_Player:speedMultiplier:Immediately"
  TextField   "Jump height multiplier"  , "cd_Player:jumpMultiplier:Immediately"
  StaticText  ""
  TextField   "Friction multiplier"     , "cd_Player:friction:Immediately"
  TextField   "Self damage multiplier"  , "cd_Player:selfDamage:Immediately"
}

Details

class cd_Player : cd_EffectsBase
{
  static void takenDamage(string value)
  {
    pawn().damageFactor = defaultPawn().damageFactor * as0to1Multiplier(value);
  }

  static void weaponDamage(string value)
  {
    pawn().damageMultiply = defaultPawn().damageMultiply * as0to1Multiplier(value);
  }

  static void startHealth(string value)
  {
    pawn().a_setHealth(value.toInt());
  }

  static void startArmor(string value)
  {
    pawn().giveInventory('cd_StartArmorBonus', value.toInt());
  }
}

class cd_StartArmorBonus : BasicArmorBonus
{
  Default
  {
    armor.saveAmount    1;
    armor.maxSaveAmount 0x7FFFFFFF;
  }

  override void beginPlay()
  {
    let settings = Dictionary.fromString(cd_settings);
    double value = settings.at("cd_Player:savePercent").toDouble();
    if (value ~== 0) value = 100.0;
    savePercent = value;
  }
}

extend class cd_Player
{
  static void maxHealth(string value)
  {
    let pawn = pawn();
    int newMaxHealth = value.toInt();

    if (newMaxHealth == pawn.maxHealth) return;

    // 1. Update health items healing ability.
    let healthFinder = ThinkerIterator.create("Health", Thinker.STAT_DEFAULT);
    Health healthItem;
    if (newMaxHealth != 0)
    {
      while (healthItem = Health(healthFinder.next()))
      {
        // Zero max amount means no limit, leave it so.
        if (healthItem.maxAmount != 0) continue;

        healthItem.maxAmount = newMaxHealth * 2;
      }
    }
    else
    {
      while (healthItem = Health(healthFinder.next()))
        healthItem.maxAmount = healthItem.default.maxAmount;
    }

    if (newMaxHealth == 0) newMaxHealth = pawn.default.maxHealth;

    // 2. Set max health and update current health accordingly.
    int safeMaxHealth = (pawn.maxHealth == 0) ? pawn.default.health : pawn.maxHealth;
    double relativeHealth = double(pawn.health) / safeMaxHealth;
    pawn.maxHealth = newMaxHealth;
    pawn.a_setHealth(int(round(relativeHealth * newMaxHealth)));
  }

  static void speedMultiplier(string value)
  {
    pawn().speed = defaultPawn().speed * as0to1Multiplier(value);
  }

  static void jumpMultiplier(string value)
  {
    pawn().jumpZ = defaultPawn().jumpZ * as0to1Multiplier(value);
  }

  static void friction(string value)
  {
    pawn().friction = defaultPawn().friction * as0to1Multiplier(value);
  }

  static void selfDamage(string value)
  {
    pawn().selfDamageFactor =
      defaultPawn().selfDamageFactor * as0to1Multiplier(value);
  }
}

2.2. Actors

OptionMenu cd_Actors
{
  StaticText "========================================", CDLightBlue
  StaticText "Actors"                                  , CDLightBlue
  StaticText "========================================", CDLightBlue
  StaticText ""
  StaticText "0 disables the option.", CDLightBlue
  StaticText ""

  StaticText  "Enemies"          , White
  TextField   "Health multiplier", "cd_Actors:enemyHealth:OnActorSpawned"
  NumberField "Health max"       , "cd_Actors:enemyHealthMax:OnActorSpawned"
  TextField   "Speed multiplier" , "cd_Actors:enemySpeed:OnActorSpawned"
  StaticText  ""
  StaticText  "Friends"          , White
  TextField   "Health multiplier", "cd_Actors:friendHealth:OnActorSpawned"
  NumberField "Health max"       , "cd_Actors:friendHealthMax:OnActorSpawned"
  TextField   "Speed multiplier" , "cd_Actors:friendSpeed:OnActorSpawned"
}

Details

class cd_Actors : cd_EffectsBase
{
  static void enemyHealth(string multiplier)
  {
    multiplyHealthIf(
      cd_EventHandler.getLastSpawnedActor(),
      as0to1Multiplier(multiplier),
      getSetting("cd_Actors:enemyHealthMax:OnActorSpawned").toInt(),
      isEnemy);
 }

  static void enemyHealthMax(string max)
  {
    multiplyHealthIf(
      cd_EventHandler.getLastSpawnedActor(),
      as0to1Multiplier(getSetting("cd_Actors:enemyHealth:OnActorSpawned")),
      max.toInt(),
      isEnemy);
  }

  static void enemySpeed(string multiplier)
  {
    multiplySpeedIf(
      cd_EventHandler.getLastSpawnedActor(),
      as0to1Multiplier(multiplier),
      isEnemy);
  }

  static void friendHealth(string multiplier)
  {
    multiplyHealthIf(
      cd_EventHandler.getLastSpawnedActor(),
      as0to1Multiplier(multiplier),
      getSetting("cd_Actors:friendHealthMax:OnActorSpawned").toInt(),
      isFriend);
  }

  static void friendHealthMax(string max)
  {
    multiplyHealthIf(
      cd_EventHandler.getLastSpawnedActor(),
      as0to1Multiplier(getSetting("cd_Actors:friendHealth:OnActorSpawned")),
      max.toInt(),
      isFriend);
  }

  static void friendSpeed(string multiplier)
  {
    multiplySpeedIf(
      cd_EventHandler.getLastSpawnedActor(),
      as0to1Multiplier(multiplier),
      isFriend);
  }

  private static void multiplyHealthIf(Actor lastSpawned,
                                       double multiplier,
                                       int max,
                                       Function<play bool(Actor)> predicate)
  {
    if (lastSpawned == NULL)
    {
      Actor anActor;
      for (let i = ThinkerIterator.create(); anActor = Actor(i.next());)
        if (predicate.call(anActor))
          multiplyHealth(anActor, multiplier, max);
    }
    else if (predicate.call(lastSpawned))
      multiplyHealth(lastSpawned, multiplier, max);
  }

  private static void multiplySpeedIf(Actor lastSpawned,
                                      double multiplier,
                                      Function<play bool(Actor)> predicate)
  {
    if (lastSpawned == NULL)
    {
      Actor anActor;
      for (let i = ThinkerIterator.create(); anActor = Actor(i.next());)
        if (predicate.call(anActor))
          multiplySpeed(anActor, multiplier);
    }
    else if (predicate.call(lastSpawned))
      multiplySpeed(lastSpawned, multiplier);
  }

  private static bool isEnemy(Actor anActor)
  {
    return anActor.bIsMonster && !anActor.bFriendly;
  }

  private static bool isFriend(Actor anActor)
  {
    return anActor.bIsMonster && anActor.bFriendly;
  }

  private static void multiplyHealth(Actor anActor, double multiplier, int max)
  {
    // For LegenDoom Lite compatibility.
    let ldlToken       = "LDLegendaryMonsterToken";
    int ldlMultiplier  = (anActor.countInv(ldlToken) > 0) ? 3 : 1;

    int defaultStartHealth = anActor.default.spawnHealth();
    int oldStartHealth     = anActor.spawnHealth();

    // Some mods have spawn healh as 0???
    if (defaultStartHealth == 0) defaultStartHealth = anActor.health;
    if (oldStartHealth     == 0) oldStartHealth     = anActor.health;
    if (defaultStartHealth == 0 || oldStartHealth == 0) return;

    int oldHealth      = anActor.health;
    let relativeHealth = double(oldHealth) / oldStartHealth;

    int newStartHealth = int(round(defaultStartHealth * multiplier * ldlMultiplier));
    int newHealth      = int(round(newStartHealth * relativeHealth));

    if (max != 0)
    {
      if (newHealth      > max) newHealth      = max;
      if (newStartHealth > max) newStartHealth = max;
    }

    anActor.startHealth = newStartHealth;
    anActor.a_setHealth(newHealth);
  }

  private static void multiplySpeed(Actor anActor, double multiplier)
  {
    anActor.speed = anActor.default.speed * multiplier;
  }
}

2.3. Powerup

OptionMenu cd_Powerups
{
  StaticText "========================================", CDLightBlue
  StaticText "Permanent powerups"                      , CDLightBlue
  StaticText "========================================", CDLightBlue
  StaticText ""

  Option "Buddha"             , "cd_Powerups:buddha:Periodically"           , OnOff
  Option "Damage"             , "cd_Powerups:damage:Periodically"           , OnOff
  Option "Double firing speed", "cd_Powerups:doubleFiringSpeed:Periodically", OnOff
  Option "Drain"              , "cd_Powerups:drain:Periodically"            , OnOff
  Option "Flight"             , "cd_Powerups:flight:Periodically"           , OnOff
  Option "Frightener"         , "cd_Powerups:frightener:Periodically"       , OnOff
  Option "Ghost"              , "cd_Powerups:ghost:Periodically"            , OnOff
  Option "High jump"          , "cd_Powerups:highJump:Periodically"         , OnOff
  Option "Infinite ammo"      , "cd_Powerups:infiniteAmmo:Periodically"     , OnOff
  Option "Invisibility"       , "cd_Powerups:invisibility:Periodically"     , OnOff
  Option "Invulnerability"    , "cd_Powerups:invulnerability:Periodically"  , OnOff
  Option "IronFeet"           , "cd_Powerups:ironFeet:Periodically"         , OnOff
  Option "LightAmp"           , "cd_Powerups:lightAmp:Periodically"         , OnOff
  Option "Mask"               , "cd_Powerups:mask:Periodically"             , OnOff
  Option "Minotaur"           , "cd_Powerups:minotaur:Periodically"         , OnOff
  Option "Morph"              , "cd_Powerups:morph:Periodically"            , OnOff
  Option "Protection"         , "cd_Powerups:protection:Periodically"       , OnOff
  Option "Regeneration"       , "cd_Powerups:regeneration:Periodically"     , OnOff
  Option "Scanner"            , "cd_Powerups:scanner:Periodically"          , OnOff
  Option "Shadow"             , "cd_Powerups:shadow:Periodically"           , OnOff
  Option "Speed"              , "cd_Powerups:speed:Periodically"            , OnOff
  Option "Strength"           , "cd_Powerups:strength:Periodically"         , OnOff
  Option "Targeter"           , "cd_Powerups:targeter:Periodically"         , OnOff
  Option "Time freeze"        , "cd_Powerups:timeFreeze:Periodically"       , OnOff
  Option "Torch"              , "cd_Powerups:torch:Periodically"            , OnOff
  Option "Weapon level 2"     , "cd_Powerups:weaponLevel2:Periodically"     , OnOff
}

Details

class cd_Powerups : cd_EffectsBase
{
  static void buddha           (string value) { prolong("PowerBuddha"           ); }
  static void damage           (string value) { prolong("PowerDamage"           ); }
  static void doubleFiringSpeed(string value) { prolong("PowerDoubleFiringSpeed"); }
  static void drain            (string value) { prolong("PowerDrain"            ); }
  static void flight           (string value) { prolong("PowerFlight"           ); }
  static void frightener       (string value) { prolong("PowerFrightener"       ); }
  static void ghost            (string value) { prolong("PowerGhost"            ); }
  static void highJump         (string value) { prolong("PowerHighJump"         ); }
  static void infiniteAmmo     (string value) { prolong("PowerInfiniteAmmo"     ); }
  static void invisibility     (string value) { prolong("PowerInvisibility"     ); }
  static void invulnerability  (string value) { prolong("PowerInvulnerable"     ); }
  static void ironFeet         (string value) { prolong("PowerIronFeet"         ); }
  static void lightAmp         (string value) { prolong("PowerLightAmp"         ); }
  static void mask             (string value) { prolong("PowerMask"             ); }
  static void minotaur         (string value) { prolongMinotaur(); }
  static void morph            (string value) { prolong("PowerMorph"            ); }
  static void protection       (string value) { prolong("PowerProtection"       ); }
  static void regeneration     (string value) { prolong("PowerRegeneration"     ); }
  static void scanner          (string value) { prolong("PowerScanner"          ); }
  static void shadow           (string value) { prolong("PowerShadow"           ); }
  static void speed            (string value) { prolong("PowerSpeed"            ); }
  static void strength         (string value) { prolong("PowerStrength"         ); }
  static void targeter         (string value) { prolong("PowerTargeter"         ); }
  static void timeFreezer      (string value) { prolong("PowerTimeFreezer"      ); }
  static void torch            (string value) { prolong("PowerTorch"            ); }
  static void weaponLevel2     (string value) { prolong("PowerWeaponLevel2"     ); }

  private static void prolong(string power)
  {
    let powerup = Powerup(pawn().findInventory(power));
    if (powerup == NULL) return;

    if (powerup.effectTics <= Inventory.BLINKTHRESHOLD + TICRATE)
      powerup.effectTics += TICRATE;
  }

  private static void prolongMinotaur()
  {
    prolong("PowerMinotaur");

    MinotaurFriend mo;
    let i = ThinkerIterator.create("MinotaurFriend");
    while ((mo = MinotaurFriend(i.next())) != NULL)
      mo.startTime = level.mapTime;
  }
}

2.4. Health regeneration/degeneration

OptionMenu cd_HealthRegeneration
{
  StaticText "========================================", CDLightBlue
  StaticText "Health Regeneration"                     , CDLightBlue
  StaticText "========================================", CDLightBlue
  StaticText ""
  StaticText "0 disables the option.", CDLightBlue
  StaticText ""

  NumberField "Amount", "cd_HealthRegeneration:amount:Periodically"
  Option      "Type"  , "cd_HealthRegeneration:type", cd_RegenerationType
  NumberField "Period (seconds)", "cd_HealthRegeneration:period"
  StaticText  ""
  NumberField "Min", "cd_HealthRegeneration:min"
  NumberField "Max", "cd_HealthRegeneration:max"
  StaticText  ""
  Textfield   "Sound effect volume"    , "cd_HealthRegeneration:sound"
  TextField   "Visual effect intensity", "cd_HealthRegeneration:visual"
  ColorPicker "Visual effect color"    , "cd_HealthRegeneration:color"
}

Details

class cd_HealthRegeneration : cd_EffectsBase
{
  static void amount(string amount)
  {
    let settings = Dictionary.fromString(cd_settings);

    if (!isMyTime(settings.at("cd_HealthRegeneration:period").toInt())) return;

    int type   = settings.at("cd_HealthRegeneration:type").toInt();
    int min    = settings.at("cd_HealthRegeneration:min").toInt();
    int max    = settings.at("cd_HealthRegeneration:max").toInt();
    int old    = pawn().health;
    int target = old + amount.toInt() * (type == Regeneration ? 1 : -1);
    int new    = getNew(old, target, min, max);

    if (old == new) return;

    pawn().a_setHealth(new);

    playSound("cd_health", settings.at("cd_HealthRegeneration:sound").toDouble());
    flashColor(settings.at("cd_HealthRegeneration:visual").toDouble(),
               settings.at("cd_HealthRegeneration:color").toInt());
  }
}
cd_health = "sounds/540985__magnuswaker__heartbeat-dumpf-dumpf.ogg"

2.5. Armor regeneration/degeneration

OptionMenu cd_ArmorRegeneration
{
  StaticText "========================================", CDLightBlue
  StaticText "$Armor Regeneration"                     , CDLightBlue
  StaticText "========================================", CDLightBlue
  StaticText ""
  StaticText "0 disables the option.", CDLightBlue
  StaticText ""

  NumberField "Amount", "cd_ArmorRegeneration:amount:Periodically"
  Option      "Type"  , "cd_ArmorRegeneration:type", cd_RegenerationType
  NumberField "Period (seconds)", "cd_ArmorRegeneration:period"
  StaticText  ""
  NumberField "Min", "cd_ArmorRegeneration:min"
  NumberField "Max", "cd_ArmorRegeneration:max"
  StaticText  ""
  TextField   "Sound effect volume"    , "cd_ArmorRegeneration:sound"
  TextField   "Visual effect intensity", "cd_ArmorRegeneration:visual"
  ColorPicker "Visual effect color"    , "cd_ArmorRegeneration:color"
}

Details

class cd_ArmorRegeneration : cd_EffectsBase
{
  static void amount(string amount)
  {
    if (pawn().health <= 0) return;

    let settings = Dictionary.fromString(cd_settings);

    if (!isMyTime(settings.at("cd_ArmorRegeneration:period").toInt())) return;

    int type   = settings.at("cd_ArmorRegeneration:type").toInt();
    int min    = settings.at("cd_ArmorRegeneration:min").toInt();
    int max    = settings.at("cd_ArmorRegeneration:max").toInt();
    int old    = pawn().countInv('BasicArmor');
    int target = old + amount.toInt() * (type == Regeneration ? 1 : -1);
    int new    = getNew(old, target, min, max);

    if (old == new) return;

    if (type == Regeneration) pawn().giveInventory('cd_ArmorBonus', new - old);
    else pawn().takeInventory('BasicArmor', old - new);

    playSound("cd_armor", settings.at("cd_ArmorRegeneration:sound").toDouble());
    flashColor(settings.at("cd_ArmorRegeneration:visual").toDouble(),
               settings.at("cd_ArmorRegeneration:color").toInt());
  }
}

class cd_ArmorBonus : BasicArmorBonus
{
  Default
  {
    armor.saveAmount    1;
    armor.maxSaveAmount 0x7FFFFFFF;
  }
}
cd_armor = "sounds/778514__blondpanda__denim_and_cloth_step_foley_12.ogg"

2.6. Ammo regeneration

OptionMenu cd_AmmoRegeneration
{
  StaticText "========================================", CDLightBlue
  StaticText "Ammo Regeneration"                       , CDLightBlue
  StaticText "========================================", CDLightBlue
  StaticText ""
  StaticText "0 disables the option.", CDLightBlue
  StaticText ""

  NumberField "Amount"           , "cd_AmmoRegeneration:amount:Periodically"
  NumberField "Period (seconds)" , "cd_AmmoRegeneration:period"
  Option      "Backpack required", "cd_AmmoRegeneration:backpackRequired", OnOff
  StaticText  ""
  TextField   "Sound effect volume"    , "cd_AmmoRegeneration:sound"
  TextField   "Visual effect intensity", "cd_AmmoRegeneration:visual"
  ColorPicker "Visual effect color"    , "cd_AmmoRegeneration:color"
}

Details

class cd_AmmoRegeneration : cd_EffectsBase
{
  static void amount(string amountString)
  {
    let pawn = pawn();
    if (pawn.health <= 0) return;

    let settings = Dictionary.fromString(cd_settings);

    if (!isMyTime(settings.at("cd_AmmoRegeneration:period").toInt())) return;

    bool isBackpackRequired =
      settings.at("cd_AmmoRegeneration:backpackRequired").toInt();
    if (isBackpackRequired && !isBackpackOwned(pawn)) return;

    int amount = amountString.toInt();
    for (int i = 0; i < amount; ++i)
    {
      let aBackpack = Inventory(Actor.spawn("Backpack", replace: ALLOW_REPLACE));
      aBackpack.clearCounters();
      if (!aBackpack.CallTryPickup(pawn)) aBackpack.destroy();
    }

    playSound("cd_ammo", settings.at("cd_ArmorRegeneration:sound").toDouble());
    flashColor(settings.at("cd_AmmoRegeneration:visual").toDouble(),
               settings.at("cd_AmmoRegeneration:color").toInt());
  }

  private static bool isBackpackOwned(PlayerPawn pawn)
  {
    return pawn.countInv("Backpack")
      || pawn.countInv("BagOfHolding")
      || pawn.countInv("AmmoSatchel");
  }
}
cd_ammo = "sounds/730748__debsound__bullet-shell-falling-on-concrete-surface-024.ogg"

3. Commands

OptionMenu cd_Commands
{
  StaticText "========================================", CDLightBlue
  StaticText "Commands"                                , CDLightBlue
  StaticText "========================================", CDLightBlue
  StaticText ""
  StaticText "Resetting and restoring aren't applied if in a game.", CDLightBlue
  StaticText ""

  SafeCommand "$cd_ResetOptions"   , cd_reset_to_defaults
  StaticText  ""
  SafeCommand "$cd_BackupOptions1" , cd_backup_options1
  SafeCommand "$cd_RestoreOptions1", cd_restore_options1
  StaticText  ""
  SafeCommand "$cd_BackupOptions2" , cd_backup_options2
  SafeCommand "$cd_RestoreOptions2", cd_restore_options2
  StaticText  ""
  SafeCommand "$cd_BackupOptions3" , cd_backup_options3
  SafeCommand "$cd_RestoreOptions3", cd_restore_options3
}

Details

Alias cd_reset_to_defaults "cd_settings \"\""

Alias cd_backup_options1  "cd_settings_profile1 $cd_settings"
Alias cd_restore_options1 "cd_settings $cd_settings_profile1"

Alias cd_backup_options2  "cd_settings_profile2 $cd_settings"
Alias cd_restore_options2 "cd_settings $cd_settings_profile2"

Alias cd_backup_options3  "cd_settings_profile3 $cd_settings"
Alias cd_restore_options3 "cd_settings $cd_settings_profile3"
server string cd_settings_profile1;
server string cd_settings_profile2;
server string cd_settings_profile3;
[enu default]
cd_ResetOptions    = "Reset options to defaults";

cd_BackupOptions1  = "Back up options to Profile 1";
cd_RestoreOptions1 = "Restore options from Profile 1 backup";

cd_BackupOptions2  = "Back up options to Profile 2";
cd_RestoreOptions2 = "Restore options from Profile 2 backup";

cd_BackupOptions3  = "Back up options to Profile 3";
cd_RestoreOptions3 = "Restore options from Profile 3 backup";

[ru]
cd_Player = "Игрок";

4. Acknowledgments

  • Custom Doom base idea: Lud (Accensus),
  • help with developing Custom Doom: JudgeGroovy, Doctrine Dark, Zhs2, Beed28, FaggoStorm, Phantom Allies, FoxBoy, Eruanna.
  • help with developing Ultimate Custom Doom: Beed28, przemko27, DabbingSquidward, drogga (Commado Pen), Nems, HexFlareheart, kondoriyano, Spaceman333, ghost.

5. Implementation details

5.1. Menus

AddOptionMenu OptionsMenu       { Submenu "$cd_Title", cd_Menu }
AddOptionMenu OptionsMenuSimple { Submenu "$cd_Title", cd_Menu }

OptionMenu cd_Menu protected
{
  Class cd_Menu
  cd_PlainTranslator
  StaticText "========================================", CDLightBlue
  StaticText "$cd_Title"                               , CDLightBlue
  StaticText "========================================", CDLightBlue
  StaticText ""

  Submenu    "Player"  , cd_Player
  Submenu    "Actors"  , cd_Actors
  Submenu    "Powerups", cd_Powerups
  StaticText ""
  StaticText "Regeneration/Degeneration", White
  Submenu    "Health"  , cd_HealthRegeneration
  Submenu    "Armor"   , cd_ArmorRegeneration
  Submenu    "Ammo"    , cd_AmmoRegeneration
  StaticText ""
  Submenu    "Commands", cd_Commands
}

OptionValue cd_RegenerationType
{
  0, "$cd_Regeneration"
  1, "$cd_Degeneration"
}
CDLightBlue { #111111 #99CCFF }
// Translation note: most FCD menu items have their strings written in plain English
// and not as $, but are still translatable, for example:
// TextField "Weapon damage multiplier" "cd_something"
// here the string identifier to translate is $cd_Weapon_damage_multiplier.
// Normal $ string identifier can be used too.

[enu default]
cd_Title = "\c[CDLightBlue]⚒\c- Final Custom Doom";
cd_Regeneration = "Regeneration";
cd_Degeneration = "Degeneration";

[ru]
cd_Weapon_damage_multiplier = "Множитель урона от оружия";

5.2. Menu item replacements

class cd_Menu : OptionMenu
{
  override void init(Menu parent, OptionMenuDescriptor descriptor)
  {
    replaceItems(descriptor.mItems);
    Super.init(parent, descriptor);
  }

  private void replaceItems(out Array<OptionMenuItem> items)
  {
    int itemsCount = items.size();
    for (int i = 0; i < itemsCount; ++i)
      items[i] = getReplacement(items[i]);
  }

  private OptionMenuItem getReplacement(OptionMenuItem item)
  {
    let itemClass = item.getClass();

    if (itemClass == 'OptionMenuItemTextField')
      return new("cd_DoubleField").init(item.mLabel, item.getAction());

    if (itemClass == 'OptionMenuItemNumberField')
      return new("cd_IntField").init(item.mLabel, item.getAction());

    if (itemClass == 'OptionMenuItemColorPicker')
      return new("cd_ColorPicker").init(item.mLabel, item.getAction());

    if (itemClass == 'OptionMenuItemOption')
    {
      let option = OptionMenuItemOption(item);
      return new("cd_Option").init(item.mLabel, item.getAction(), option.mValues);
    }

    if (itemClass == 'OptionMenuItemSubmenu')
    {
      let descriptor = MenuDescriptor.getDescriptor(item.getAction());
      replaceItems(OptionMenuDescriptor(descriptor).mItems);

      return item;
    }

    return item;
  }
}

mixin class cd_SettingItem
{
  string mTag;

  private string getSetting() const
  {
    return Dictionary.fromString(cd_settings).at(mTag);
  }

  private void setSetting(string value)
  {
    let settings = Dictionary.fromString(cd_settings);
    string oldValue = settings.at(mTag);

    double doubleValue = value.toDouble();
    if (doubleValue ~== oldValue.toDouble()) return;
    if (doubleValue < 0) return;

    if (doubleValue ~== 0)
      settings.remove(mTag);
    else
      settings.insert(mTag, value);

    Cvar.getCvar('cd_settings', players[consolePlayer]).setString(settings.toString());

    let [_1, _2, _3, when] = cd_EventHandler.parseEffect(mTag);
    if (when == cd_EventHandler.Immediately || when == cd_EventHandler.OnActorSpawned)
      EventHandler.sendNetworkEvent(string.format("%s:%s", mTag, value));
  }
}
server string cd_settings;
class cd_NumberField : OptionMenuItemTextField
{
  mixin cd_SettingItem;
  string mFormat;

  OptionMenuItem init(string label, Name command, int decimalPlaces)
  {
    mTag = command;
    mFormat = string.format("%%.%df", decimalPlaces);

    return Super.init(label, '');
  }

  override bool, string getString(int i)
  {
    if (i != 0) return false, "";

    return true, string.format(mFormat, getSetting().toDouble());
  }

  override bool setString(int i, string aString)
  {
    if (i != 0) return false;

    setSetting(string.format(mFormat, aString.toDouble()));
    return true;
  }

  override string represent()
  {
    return mEnter ? Super.represent()
                  : string.format(mFormat, getSetting().toDouble());
  }
}

class cd_DoubleField : cd_NumberField
{
  OptionMenuItem init(string label, Name command)
  {
    return Super.init(label, command, 2);
  }
}

class cd_IntField : cd_NumberField
{
  OptionMenuItem init(string label, Name command)
  {
    return Super.init(label, command, 0);
  }
}

class cd_Option : OptionMenuItemOptionBase
{
  mixin cd_SettingItem;

  OptionMenuItem init(string label, Name command, Name values)
  {
    mTag = command;
    Super.init(label, '', values, NULL, 0);
    return self;
  }

  override int getSelection()
  {
    int valuesCount = OptionValues.getCount(mValues);
    if (valuesCount <= 0) return -1;

    if (OptionValues.getTextValue(mValues, 0).length() == 0)
    {
      double value = getSetting().toDouble();
      for(int i = 0; i < valuesCount; ++i)
      {
        if (value ~== OptionValues.getValue(mValues, i)) return i;
      }
    }
    else
    {
      string value = getSetting();
      for(int i = 0; i < valuesCount; ++i)
      {
        if (value ~== OptionValues.getTextValue(mValues, i)) return i;
      }
    }

    return -1;
  }

  override void setSelection(int selection)
  {
    if (OptionValues.getCount(mValues) <= 0) return;

    if (OptionValues.getTextValue(mValues, 0).length() == 0)
      setSetting(string.format("%f", OptionValues.getValue(mValues, selection)));
    else
      setSetting(OptionValues.getTextValue(mValues, selection));
  }
}

// Uses a proxy Cvar as a hack just to reuse ColorPickerMenu code.
class cd_ColorPicker : OptionMenuItemColorPicker
{
  mixin cd_SettingItem;
  const CPF_RESET = 0x20001;

  OptionMenuItem init(string label, Name command)
  {
    mTag = command;
    return Super.init(label, 'cd_proxy_color');
  }

  override int draw(OptionMenuDescriptor desc, int y, int indent, bool selected)
  {
    drawLabel(indent, y, selected ? OptionMenuSettings.mFontColorSelection
                                  : OptionMenuSettings.mFontColor, isGrayed());

    int box_x = indent + cursorSpace();
    int box_y = y + CleanYfac_1;
    Screen.clear(box_x,
                 box_y,
                 box_x + CleanXfac_1 * 32,
                 box_y + CleanYfac_1 * OptionMenuSettings.mLinespacing,
                 getSetting().toInt() | 0xff000000);

    return indent;
  }

  override bool setValue(int i, int v)
  {
    if (i != CPF_RESET) return false;

    setSetting("");
    return true;
  }

  override bool activate()
  {
    Menu.menuSound("menu/choose");

    mCvar.setInt(getSetting().toInt());

    let desc = OptionMenuDescriptor(MenuDescriptor.getDescriptor('ColorPickerMenu'));
    let picker = new("cd_ColorPickerMenu");
    picker.mTag = mTag;
    picker.init(Menu.getCurrentMenu(), mLabel, desc, mCvar);
    picker.activateMenu();
    return true;
  }
}

// Uses a proxy Cvar as a hack just to reuse ColorPickerMenu code.
class cd_ColorPickerMenu : ColorPickerMenu
{
  mixin cd_SettingItem;

  override void onDestroy()
  {
    Super.onDestroy();
    setSetting(string.format("%d", Color(int(mRed), int(mGreen), int(mBlue))));
    mCvar.setInt(0);
  }
}
user color cd_proxy_color;

5.3. Event handler

GameInfo { EventHandlers = "cd_EventHandler" }
class cd_EventHandler : StaticEventHandler
{
  enum EffecTime
  {
    Immediately,
    OnPlayerStarted,
    OnActorSpawned,
    Periodically,
    Direct,
  }

  private clearscope static int toEffectTime(string effectTime)
  {
    if (effectTime ~== "Immediately")     return Immediately;
    if (effectTime ~== "OnPlayerStarted") return OnPlayerStarted;
    if (effectTime ~== "OnActorSpawned")  return OnActorSpawned;
    if (effectTime ~== "Periodically")    return Periodically;
    if (effectTime  == "")                return Direct;

    throwAbortException("unknown effect time: %s", effectTime);
    return Direct;
  }

  // Returns class name, function name, value as a string, effect time.
  // Effect string examples:
  // cd_ExampleClass:exampleFunction:onPlayerStarted:3.5
  // cd_ExampleClass:exampleFunction:3.5
  // cd_ExampleClass:exampleFunction:onPlayerStarted
  static clearscope string, string, string, int parseEffect(string input)
  {
    Array<string> parts;
    input.split(parts, ":");

    switch (parts.size())
    {
      case 0:
      case 1: throwAbortException("no class and function in effect description");
      case 2: return parts[0], parts[1], "", Direct;
      case 3: return parts[0], parts[1], parts[2], toEffectTime(parts[2]);
      case 4: return parts[0], parts[1], parts[3], toEffectTime(parts[2]);
      default: throwAbortException("too much parts: %s", input);
    }

    return "", "", "", Direct;
  }

  private static void callByName(string className, string functionName, string value)
  {
    class<Object> aClass = className;
    if (aClass == NULL)
      throwAbortException("class %s not found", className);

    let aFunction = (Function<play void(string)>)(findFunction(aClass, functionName));
    if (aFunction == NULL)
      throwAbortException("function %s.%s not found", className, functionName);

    aFunction.call(value);
  }

  override void networkProcess(ConsoleEvent event)
  {
    if (event.name.left(2) ~== "cd")
    {
      let [className, functionName, value, when] = parseEffect(event.name);
      callByName(className, functionName, value);
    }
  }

  private void applyEffects(int effectTime)
  {
    let settings = Dictionary.fromString(cd_settings);
    for (let i = DictionaryIterator.create(settings); i.next();)
    {
      let [className, functionName, _, when] = parseEffect(i.key());
      if (when == effectTime)
        callByName(className, functionName, i.value());
    }
  }

  override void playerEntered(PlayerEvent event)
  {
    // TODO: support multiplayer?
    if (multiplayer)
      throwAbortException("Final Custom Doom doesn't support multiplayer (yet?).");

    PlayerPawn player = players[event.playerNumber].mo;

    bool isOldGame = (player.findInventory('cd_OldGameMarker') != NULL);
    if (isOldGame) return;

    player.giveInventoryType('cd_OldGameMarker');

    applyEffects(OnPlayerStarted);
    applyEffects(Immediately);
  }

  private Actor mLastSpawnedActor;

  static Actor getLastSpawnedActor()
  {
    return cd_EventHandler(find('cd_EventHandler')).mLastSpawnedActor;
  }

  override void worldThingSpawned(WorldEvent event)
  {
    if (event.thing == NULL) return;

    mLastSpawnedActor = event.thing;
    applyEffects(OnActorSpawned);
    mLastSpawnedActor = NULL;
  }

  override void worldTick()
  {
    if (level.totalTime % TICRATE == 0) applyEffects(Periodically);
  }
}

class cd_OldGameMarker : Inventory
{
  Default
  {
    inventory.maxAmount 1;
    +inventory.untossable;
  }
}

5.4. Effects base

class cd_EffectsBase play
{
  enum GenerationType
  {
    Regeneration,
    Degeneration
  }

  const BLEND_DURATION = TICRATE / 2;

  protected static PlayerPawn pawn()
  {
    return players[consolePlayer].mo;
  }

  protected static readonly<PlayerPawn> defaultPawn()
  {
    return getDefaultByType(pawn().getClass());
  }

  // 0 to 1 multipliers: 0.0 acts as 1.0, both meaning it effectively does nothing.
  protected static double as0to1Multiplier(string stringValue)
  {
    double value = stringValue.toDouble();
    return (value ~== 0.0) ? 1.0 : value;
  }

  protected static bool isMyTime(int period)
  {
    return (period != 0) && ((level.totalTime / TICRATE) % period == 0);
  }

  protected static void playSound(string sound, double volume)
  {
    if (volume != 0.0) pawn().a_startSound(sound, CHAN_AUTO, 0, volume);
  }

  protected static void flashColor(double intensity, int aColor)
  {
    if (intensity != 0.0) pawn().a_setBlend(aColor, intensity, BLEND_DURATION);
  }

  protected static int getNew(int old, int target, int min, int max)
  {
    if (min == 0) min = 1;
    if (max == 0) max = max(old, target);
    if (!(min <= old && old <= max)) return old;

    return clamp(target, min, max);
  }

  protected static string getSetting(string setting)
  {
    return Dictionary.fromString(cd_settings).at(setting);
  }
}

6. Extending Final Custom Doom

You can use Final Custom Doom (FCD) to add your own game settings. To do so, a FCD extension can be created. Basically, such extension consists of two parts: settings definition and settings implementation. Settings definition is contained in menudef lump, where settings are added to cd_Menu, possibly via a submenu. Settings implementation provides in-game effects and is written in ZScript.

Settings defined in a FCD extension don't have an entry in cvarinfo lump. They are stored, reset to defaults, and backed up to profiles together with FCD settings.

Important note: FCD extensions don't depend on FCD code-wise. This means that they can be loaded without errors even without FCD.

Options in cd_Menu and its submenus don't behave like normal options. The differences are:

  • Several item types are transformed into Custom Doom settings:
    • TextField -> double setting,
    • NumberField, Option -> int setting,
    • ColorPicker -> color setting.
  • Instead of a Cvar, a command is specified in format "Class:Function:EffectTime":
    • Class is ZScript class name that contains Function. Attention: class name must start with cd.
    • Function: ZScript function name in Class. It must take a string as a parameter, and have return type void (meaning it returns nothing).
    • EffectTime: one of : Immediately, OnPlayerStarted, OnActorSpawned, Periodically, or left out.
  • Setting labels in some item types are made directly-translatable. See the note in language.txt in 5.1 section.

See the example below.

// Note: naming everything related to the Custom Doom extension with "cde" prefix.
OptionMenu cd_Menu
{
  Submenu "Final Custom Doom Extension", cde_Menu
}

OptionMenu cde_Menu
{
  Title "Final Custom Doom Extension"

  StaticText  "1. Settings types example:", White
  TextField   "Double setting" , "cde_Effects:doubleSetting:Immediately"
  NumberField "Integer setting", "cde_Effects:intSetting:Immediately"
  Option      "Option"         , "cde_Effects:optionSetting:Immediately", cde_Values
  ColorPicker "Color setting"  , "cde_Effects:colorSetting:Immediately"
  StaticText  ""
  StaticText  "2. Settings apply times example:", White
  TextField   "Applied immediately"    , "cde_Effects:setting1:Immediately"
  TextField   "Applied on player start", "cde_Effects:setting2:OnPlayerStarted"
  TextField   "Applied on actor spawn" , "cde_Effects:setting3:OnActorSpawned"
  TextField   "Applied every second"   , "cde_Effects:setting4:Periodically"
  // A setting that isn't applied by itself is used from other settings,
  // see how to get its value in setting1 function.
  TextField   "Isn't applied"          , "cde_Effects:setting5"
}

OptionValue cde_Values
{
  0, "Value 1"
  1, "Value 2"
}
version 4.14.2

class cde_Effects
{
  static void doubleSetting(string value)
  {
    Console.printf("Double setting is set to %f.", value.toDouble());
  }

  static void intSetting(string value)
  {
    Console.printf("Integer setting is set to %d.", value.toInt());
  }

  static void optionSetting(string value)
  {
    Console.printf("Option setting is set to %d.", value.toInt());
  }

  static void colorSetting(string value)
  {
    Console.printf("Color setting is set to %x.", value.toInt());
  }

  static void setting1(string value)
  {
    let settingsCvar = Cvar.getCvar("cd_settings");
    let settings     = Dictionary.fromString(settingsCvar.getString());
    let setting      = settings.at("cde_Effects:setting5").toDouble();

    Console.printf("Setting 1 is applied immediately. Setting 5 is %f.", setting);
  }

  static void setting2(string value)
  {
    Console.printf("Setting 2 is applied on player start.");
  }

  static void setting3(string value)
  {
    Console.printf("Setting 3 is applied on actor spawned.");
  }

  static void setting4(string value)
  {
    Console.printf("Setting 4 is applied periodically.");
  }

  // Setting 5 isn't applied by itself and doesn't need a function.
}

Created: 2026-01-04 Sun 07:06