FinalCustomDoom provides gameplay customization. It can be used to increase or decrease the difficulty in various ways.
FinalCustomDoom is a successor to Ultimate Custom Doom and Custom Doom.
FinalCustomDoom is a part of DoomToolbox.
SPDX-FileCopyrightText: © 2025 Alexander Kromm <mmaulwurff@gmail.com>
SPDX-License-Identifier: GPL-3.0-only
OptionMenu cd_Player
{
<<OptionMenuTitle("Player")>>
<<OptionMenuNote("0 disables the option.")>>
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"
}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);
}
}OptionMenu cd_Actors
{
<<OptionMenuTitle("Actors")>>
<<OptionMenuNote("0 disables the option.")>>
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"
}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;
}
}OptionMenu cd_Powerups
{
<<OptionMenuTitle("Permanent powerups")>>
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
}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;
}
}OptionMenu cd_HealthRegeneration
{
<<OptionMenuTitle("Health Regeneration")>>
<<OptionMenuNote("0 disables the option.")>>
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"
}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"
OptionMenu cd_ArmorRegeneration
{
<<OptionMenuTitle("$Armor Regeneration")>>
<<OptionMenuNote("0 disables the option.")>>
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"
}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"
OptionMenu cd_AmmoRegeneration
{
<<OptionMenuTitle("Ammo Regeneration")>>
<<OptionMenuNote("0 disables the option.")>>
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"
}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"
OptionMenu cd_Commands
{
<<OptionMenuTitle("Commands")>>
<<OptionMenuNote("Resetting and restoring aren't applied if in a game.")>>
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
}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 = "Игрок";AddOptionMenu OptionsMenu { Submenu "$cd_Title", cd_Menu }
AddOptionMenu OptionsMenuSimple { Submenu "$cd_Title", cd_Menu }
OptionMenu cd_Menu protected
{
Class cd_Menu
cd_PlainTranslator
<<OptionMenuTitle("$cd_Title")>>
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 = "FinalCustomDoom \c[CDLightBlue]⚒";
cd_Regeneration = "Regeneration";
cd_Degeneration = "Degeneration";
[ru]
cd_Weapon_damage_multiplier = "Множитель урона от оружия";version 4.14.3
<<tools/scripts.org:include("FinalCustomDoom.org", "build/FinalCustomDoom/\\(zscript/.*zs\\)")>>
#include "zscript/cd_PlainTranslator.zs"Note: cd_PlainTranslator.zs must be installed externally.
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;
GameInfo { EventHandlers = "cd_EventHandler" }
class cd_EventHandler : StaticEventHandler
{
enum EffectTime
{
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("FinalCustomDoom 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;
}
}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);
}
}You can use FinalCustomDoom (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":Classis ZScript class name that contains Function. Attention: class name must start withcd.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 Menus section.
See the example below.
// Note: naming everything related to the Custom Doom extension with "cde" prefix.
OptionMenu cd_Menu
{
Submenu "FinalCustomDoom Extension", cde_Menu
}
OptionMenu cde_Menu
{
Title "FinalCustomDoom 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.3
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.
}wait 2; openmenu cd_menu
wait 2; quit