diff --git a/CREDITS.md b/CREDITS.md index 5d9ea011ce..d4236c31cd 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -930,3 +930,4 @@ This page lists all the individual contributions to the project by their author. - Interop export interface for accessing scenario local/global variables - Add `ClampToScreen` tag for `BannerType` to control whether banner position is clamped to the visible area - **obsidianus** - Automatic conversion based on health +- **Nuke** - Reload speed adjustment on promotion diff --git a/docs/New-or-Enhanced-Logics.md b/docs/New-or-Enhanced-Logics.md index 2842e1fa43..82ab651dc1 100644 --- a/docs/New-or-Enhanced-Logics.md +++ b/docs/New-or-Enhanced-Logics.md @@ -2343,6 +2343,33 @@ RecountBurst=false ; boolean RecountBurst= ; boolean, default to [General] -> RecountBurst ``` +### Reload speed adjustment on promotion + +- Now you can add the following abilities to `VeteranAbilities` and `EliteAbilities` to speed up or slow down their ammo reload. + - `RELOAD` scales the duration of normal reload cycles. + - `EMPTY_RELOAD` scales the duration of the reload cycle that starts when the clip is completely empty, which is only used when the TechnoType has a positive `EmptyReload`. + +```{hint} +- Abilities from `VeteranAbilities` keep working at elite level, and `EliteAbilities` are added on top, matching how vanilla abilities accumulate. +- `VeteranReload` and `VeteranEmptyReload` are multipliers applied to the final reload duration computed by the game, so other reload modifiers such as `ReloadIncrement` are still taken into account. + - Values greater than `1.0` lengthen the reload, values smaller than `1.0` shorten it, and `1.0` (the default) leaves it unchanged. +``` + +In `rulesmd.ini`: +```ini +[General] +VeteranReload=1.0 ; floating point value, multiplier +VeteranEmptyReload= ; floating point value, multiplier, default to [General] -> VeteranReload + +[SOMETECHNO] ; TechnoType, with VeteranAbilities and/or EliteAbilities containing RELOAD or EMPTY_RELOAD. +VeteranReload= ; floating point value, multiplier, default to [General] -> VeteranReload +VeteranEmptyReload= ; floating point value, multiplier, default to [General] -> VeteranEmptyReload +``` + +```{note} +For `EMPTY_RELOAD` to have any effect, the TechnoType must have a positive `EmptyReload` value, otherwise the empty clip uses the regular `Reload` duration instead. +``` + ### Reset MindControl after transformation - After the unit conversion is completed, its mind control can be reset. diff --git a/docs/Whats-New.md b/docs/Whats-New.md index 3f918553c5..de6ef3ec90 100644 --- a/docs/Whats-New.md +++ b/docs/Whats-New.md @@ -664,6 +664,7 @@ HideShakeEffects=false ; boolean - [Allow the unit to keep pursuing the target during ApproachTarget](Fixed-or-Improved-Logics.md#keep-pursuing-the-target-during-approachtarget) (by TaranDahl) - [Customize whether warhead can prevent crew escape from techno](New-or-Enhanced-Logics.md#customize-whether-warhead-can-prevent-crew-escape-from-techno) (by NetsuNegi) - [Disable AlphaImage during Buildup](Fixed-or-Improved-Logics.md#disable-alphaimage-during-buildup) (by Noble_Fish) +- [Reload speed adjustment on promotion](New-or-Enhanced-Logics.md#reload-speed-adjustment-on-promotion) (by Nuke) #### Vanilla fixes: - Fixed sidebar not updating queued unit numbers when adding or removing units when the production is on hold (by CrimRecya) diff --git a/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po b/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po index 23f45b1289..194b5cffee 100644 --- a/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po +++ b/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po @@ -3178,3 +3178,5 @@ msgstr "为 `BannerType` 添加了 `ClampToScreen` 标签以控制横幅位置 msgid "**obsidianus** - Automatic conversion based on health" msgstr "**obsidianus** - 自动根据血量变形" +msgid "**Nuke** - Reload speed adjustment on promotion" +msgstr "**Nuke** - 升级时调整装填速率" diff --git a/docs/locale/zh_CN/LC_MESSAGES/New-or-Enhanced-Logics.po b/docs/locale/zh_CN/LC_MESSAGES/New-or-Enhanced-Logics.po index d073303cea..28f39041c9 100644 --- a/docs/locale/zh_CN/LC_MESSAGES/New-or-Enhanced-Logics.po +++ b/docs/locale/zh_CN/LC_MESSAGES/New-or-Enhanced-Logics.po @@ -5046,6 +5046,52 @@ msgid "" "and 30 frames)." msgstr "现在你可以让单位在更换了开火武器或一段时间(取上一次发射武器的 `ROF` 与 30 帧之间的较大值)内没有目标的情况下重置当前的连发索引。" +msgid "Reload speed adjustment on promotion" +msgstr "升级时调整装填速率" + +msgid "" +"Now you can add the following abilities to `VeteranAbilities` and " +"`EliteAbilities` to speed up or slow down their ammo reload." +msgstr "" +"现在你可以在 `VeteranAbilities` 和 `EliteAbilities` " +"中添加以下能力,以加快或减慢其弹药装填速度。" + +msgid "`RELOAD` scales the duration of normal reload cycles." +msgstr "`RELOAD` 缩放普通装填周期的时长。" + +msgid "" +"`EMPTY_RELOAD` scales the duration of the reload cycle that starts when the " +"clip is completely empty, which is only used when the TechnoType has a " +"positive `EmptyReload`." +msgstr "`EMPTY_RELOAD` 缩放弹夹完全打空后开始的空仓装填周期时长,仅当单位具有正的 `EmptyReload` 时才会使用。" + +msgid "" +"Abilities from `VeteranAbilities` keep working at elite level, and " +"`EliteAbilities` are added on top, matching how vanilla abilities " +"accumulate." +msgstr "" +"`VeteranAbilities` 中的能力在精英等级仍然生效,`EliteAbilities` 则在此基础上额外生效,这与原版能力的叠加方式一致。" + +msgid "" +"`VeteranReload` and `VeteranEmptyReload` are multipliers applied to the " +"final reload duration computed by the game, so other reload modifiers such " +"as `ReloadIncrement` are still taken into account." +msgstr "" +"`VeteranReload` 和 `VeteranEmptyReload` 是对游戏最终计算出的装填时长的倍率,因此 " +"`ReloadIncrement` 等其他装填修正仍然会生效。" + +msgid "" +"Values greater than `1.0` lengthen the reload, values smaller than `1.0` " +"shorten it, and `1.0` (the default) leaves it unchanged." +msgstr "大于 `1.0` 的数值会延长装填时间,小于 `1.0` 的数值会缩短装填时间,`1.0`(默认值)则保持不变。" + +msgid "" +"For `EMPTY_RELOAD` to have any effect, the TechnoType must have a positive " +"`EmptyReload` value, otherwise the empty clip uses the regular `Reload` " +"duration instead." +msgstr "" +"要使 `EMPTY_RELOAD` 生效,单位必须设置正的 `EmptyReload` 值,否则打空弹夹时仍会使用普通的 `Reload` 时长。" + msgid "Reset MindControl after transformation" msgstr "变形后重置心控效果" diff --git a/docs/locale/zh_CN/LC_MESSAGES/Whats-New.po b/docs/locale/zh_CN/LC_MESSAGES/Whats-New.po index 9c8d5f810c..6fb545d815 100644 --- a/docs/locale/zh_CN/LC_MESSAGES/Whats-New.po +++ b/docs/locale/zh_CN/LC_MESSAGES/Whats-New.po @@ -2459,6 +2459,11 @@ msgstr "" "[自定义弹头是否阻止生还者逃出](New-or-Enhanced-Logics.md#customize-whether-warhead-can-" "prevent-crew-escape-from-techno)(by NetsuNegi)" +msgid "" +"[Reload speed adjustment on promotion](New-or-Enhanced-Logics.md#reload-speed-adjustment-on-promotion) (by Nuke)" +msgstr "" +"[升级时调整装填速率](New-or-Enhanced-Logics.md#reload-speed-adjustment-on-promotion)(by Nuke)" + msgid "Vanilla fixes:" msgstr "原版问题修复:" diff --git a/src/Ext/Rules/Body.cpp b/src/Ext/Rules/Body.cpp index cbc1f4f8bc..37bc4860aa 100644 --- a/src/Ext/Rules/Body.cpp +++ b/src/Ext/Rules/Body.cpp @@ -1,5 +1,7 @@ #include "Body.h" +#include + #include #include #include @@ -352,6 +354,24 @@ void RulesExt::ExtData::LoadBeforeTypeData(RulesClass* pThis, CCINIClass* pINI) this->NoRearm_Temporal.Read(exINI, GameStrings::General, "NoRearm.Temporal"); this->NoReload_UnderEMP.Read(exINI, GameStrings::General, "NoReload.UnderEMP"); this->NoReload_Temporal.Read(exINI, GameStrings::General, "NoReload.Temporal"); + + this->VeteranReload.Read(exINI, GameStrings::General, "VeteranReload"); + this->VeteranEmptyReload.Read(exINI, GameStrings::General, "VeteranEmptyReload"); + + const auto validateReloadMultiplier = [](const char* pKey, Valueable& value) + { + if (!std::isfinite(value.Get()) || value.Get() <= 0.0) + { + Debug::INIParseFailed(GameStrings::General, pKey, "", "Expected a finite value greater than 0.0"); + value = 1.0; + } + }; + + validateReloadMultiplier("VeteranReload", this->VeteranReload); + + if (this->VeteranEmptyReload.isset()) + validateReloadMultiplier("VeteranEmptyReload", this->VeteranEmptyReload); + this->NoTurret_TrackTarget.Read(exINI, GameStrings::General, "NoTurret.TrackTarget"); this->GatherWhenMCVDeploy.Read(exINI, GameStrings::General, "GatherWhenMCVDeploy"); @@ -901,6 +921,8 @@ void RulesExt::ExtData::Serialize(T& Stm) .Process(this->NoRearm_Temporal) .Process(this->NoReload_UnderEMP) .Process(this->NoReload_Temporal) + .Process(this->VeteranReload) + .Process(this->VeteranEmptyReload) .Process(this->NoTurret_TrackTarget) .Process(this->GatherWhenMCVDeploy) .Process(this->AIFireSale) diff --git a/src/Ext/Rules/Body.h b/src/Ext/Rules/Body.h index a9321791f4..58db82c07f 100644 --- a/src/Ext/Rules/Body.h +++ b/src/Ext/Rules/Body.h @@ -288,6 +288,8 @@ class RulesExt Valueable NoRearm_Temporal; Valueable NoReload_UnderEMP; Valueable NoReload_Temporal; + Valueable VeteranReload; + Nullable VeteranEmptyReload; Valueable NoTurret_TrackTarget; Valueable GatherWhenMCVDeploy; @@ -771,6 +773,8 @@ class RulesExt , NoRearm_Temporal { false } , NoReload_UnderEMP { false } , NoReload_Temporal { false } + , VeteranReload { 1.0 } + , VeteranEmptyReload {} , NoTurret_TrackTarget { false } , GatherWhenMCVDeploy { true } , AIFireSale { true } diff --git a/src/Ext/Techno/Body.cpp b/src/Ext/Techno/Body.cpp index 7a425db851..1615942f11 100644 --- a/src/Ext/Techno/Body.cpp +++ b/src/Ext/Techno/Body.cpp @@ -173,6 +173,23 @@ void TechnoExt::SyncInvulnerability(TechnoClass* pFrom, TechnoClass* pTo) } } +bool TechnoExt::HasAdditionalAbility(TechnoClass* pThis, AdditionalAbility ability) +{ + if (!pThis || (!pThis->Veterancy.IsVeteran() && !pThis->Veterancy.IsElite())) + return false; + + const auto index = static_cast(ability); + const auto pTypeExt = TechnoExt::Fetch(pThis)->TypeExtData; + + if (pThis->Veterancy.IsElite()) + { + return pTypeExt->AdditionalVeteranAbilities.test(index) + || pTypeExt->AdditionalEliteAbilities.test(index); + } + + return pTypeExt->AdditionalVeteranAbilities.test(index); +} + double TechnoExt::GetCurrentSpeedMultiplier(FootClass* pThis) { double houseMultiplier = 1.0; diff --git a/src/Ext/Techno/Body.h b/src/Ext/Techno/Body.h index 01b6a07674..72c255a142 100644 --- a/src/Ext/Techno/Body.h +++ b/src/Ext/Techno/Body.h @@ -233,6 +233,7 @@ class TechnoExt : public RadioExt, public Detach::Listener static void KillSelf(TechnoClass* pThis, AutoDeathBehavior deathOption, const std::vector& pVanishAnimation, bool isInLimbo = false); static void ObjectKilledBy(TechnoClass* pThis, TechnoClass* pKiller); static void UpdateSharedAmmo(TechnoClass* pThis); + static bool HasAdditionalAbility(TechnoClass* pThis, AdditionalAbility ability); static double GetCurrentSpeedMultiplier(FootClass* pThis); static double GetCurrentFirepowerMultiplier(TechnoClass* pThis); static double GetCurrentArmorMultiplier(TechnoClass* pThis, TechnoTypeClass* pType, HouseClass* pSourceHouse = nullptr, WarheadTypeClass* pWarhead = nullptr); diff --git a/src/Ext/Techno/Hooks.Firing.cpp b/src/Ext/Techno/Hooks.Firing.cpp index b26dff9a85..bf27eaaa3d 100644 --- a/src/Ext/Techno/Hooks.Firing.cpp +++ b/src/Ext/Techno/Hooks.Firing.cpp @@ -4,9 +4,13 @@ #include #include #include +#include #include #include #include +#include + +#include #pragma region TechnoClass_SelectWeapon @@ -1157,6 +1161,42 @@ DEFINE_HOOK(0x6FB086, TechnoClass_Reload_ReloadAmount, 0x8) return 0; } +static inline int ScaleReloadDurationForVeterancy(TechnoClass* pThis, int duration, AdditionalAbility ability) +{ + if (duration <= 0 || !TechnoExt::HasAdditionalAbility(pThis, ability)) + return duration; + + const double multiplier = ability == AdditionalAbility::EmptyReload + ? TechnoExt::Fetch(pThis)->TypeExtData->VeteranEmptyReload.Get(RulesExt::Global()->VeteranEmptyReload.Get(RulesExt::Global()->VeteranReload)) + : TechnoExt::Fetch(pThis)->TypeExtData->VeteranReload.Get(RulesExt::Global()->VeteranReload); + + return Math::max(1, GeneralUtils::SafeMultiply(duration, multiplier)); +} + +// Scale the reload cycle that uses the EmptyReload duration (clip empty and `EmptyReload` is set). +// Hooked at `add esi, 1FCh` in StartReloading: EAX holds the duration, ESI holds `this`. +DEFINE_HOOK(0x6FB0CF, TechnoClass_StartReloading_EmptyReload_Veterancy, 0x6) +{ + GET(TechnoClass* const, pThis, ESI); + GET(const int, duration, EAX); + + R->EAX(ScaleReloadDurationForVeterancy(pThis, duration, AdditionalAbility::EmptyReload)); + + return 0; +} + +// Scale the normal reload cycle duration for veterancy. The duration in EAX is the final +// value computed by the game, including the ReloadIncrement adjustment. +DEFINE_HOOK(0x6FB14C, TechnoClass_StartReloading_Reload_Veterancy, 0x6) +{ + GET(TechnoClass* const, pThis, ESI); + GET(const int, duration, EAX); + + R->EAX(ScaleReloadDurationForVeterancy(pThis, duration, AdditionalAbility::Reload)); + + return 0; +} + // Author: Otamaa DEFINE_HOOK(0x5223B3, InfantryClass_Approach_Target_DeployFireWeapon, 0x6) { diff --git a/src/Ext/TechnoType/Body.cpp b/src/Ext/TechnoType/Body.cpp index 5bc8edfabb..f6ac3f6562 100644 --- a/src/Ext/TechnoType/Body.cpp +++ b/src/Ext/TechnoType/Body.cpp @@ -12,6 +12,43 @@ #include +void ReadAdditionalAbilities( + INI_EX& parser, + const char* section, + const char* key, + std::bitset& result) +{ + std::vector values; + + if (!parser.ParseStringList(values, section, key)) + return; + + // When the key is present, fully replace the previous value with this + // list so that map INIs can override rules values. + result.reset(); + + for (const auto& value : values) + { + if (!_stricmp(value.c_str(), "RELOAD")) + { + result.set(static_cast(AdditionalAbility::Reload)); + } + else if (!_stricmp(value.c_str(), "EMPTY_RELOAD")) + { + result.set(static_cast(AdditionalAbility::EmptyReload)); + } + } +} + +void ValidateReloadMultiplier(const char* pSection, const char* pKey, Nullable& value) +{ + if (value.isset() && (!std::isfinite(value.Get()) || value.Get() <= 0.0)) + { + Debug::INIParseFailed(pSection, pKey, "", "Expected a finite value greater than 0.0"); + value.Reset(); + } +} + bool TechnoTypeExt::SelectWeaponMutex = false; void TechnoTypeExt::ApplyTurretOffset(Matrix3D* mtx, double factor) @@ -1010,6 +1047,15 @@ void TechnoTypeExt::LoadFromINIFile(CCINIClass* const pINI) this->NoReload_UnderEMP.Read(exINI, pSection, "NoReload.UnderEMP"); this->NoReload_Temporal.Read(exINI, pSection, "NoReload.Temporal"); + ReadAdditionalAbilities(exINI, pSection, "VeteranAbilities", this->AdditionalVeteranAbilities); + ReadAdditionalAbilities(exINI, pSection, "EliteAbilities", this->AdditionalEliteAbilities); + + this->VeteranReload.Read(exINI, pSection, "VeteranReload"); + this->VeteranEmptyReload.Read(exINI, pSection, "VeteranEmptyReload"); + + ValidateReloadMultiplier(pSection, "VeteranReload", this->VeteranReload); + ValidateReloadMultiplier(pSection, "VeteranEmptyReload", this->VeteranEmptyReload); + this->Wake.Read(exINI, pSection, "Wake"); this->Wake_Grapple.Read(exINI, pSection, "Wake.Grapple"); this->Wake_Sinking.Read(exINI, pSection, "Wake.Sinking"); @@ -1641,6 +1687,10 @@ void TechnoTypeExt::Serialize(T& Stm) .Process(this->NoRearm_Temporal) .Process(this->NoReload_UnderEMP) .Process(this->NoReload_Temporal) + .Process(this->AdditionalVeteranAbilities) + .Process(this->AdditionalEliteAbilities) + .Process(this->VeteranReload) + .Process(this->VeteranEmptyReload) .Process(this->Wake) .Process(this->Wake_Grapple) diff --git a/src/Ext/TechnoType/Body.h b/src/Ext/TechnoType/Body.h index 5430cf9711..e4ee3ab3e7 100644 --- a/src/Ext/TechnoType/Body.h +++ b/src/Ext/TechnoType/Body.h @@ -294,6 +294,11 @@ class TechnoTypeExt : public ObjectTypeExt Nullable NoReload_UnderEMP; Nullable NoReload_Temporal; + std::bitset AdditionalVeteranAbilities; + std::bitset AdditionalEliteAbilities; + Nullable VeteranReload; + Nullable VeteranEmptyReload; + Nullable Wake; Nullable Wake_Grapple; Nullable Wake_Sinking; @@ -690,6 +695,11 @@ class TechnoTypeExt : public ObjectTypeExt , NoReload_UnderEMP {} , NoReload_Temporal {} + , AdditionalVeteranAbilities {} + , AdditionalEliteAbilities {} + , VeteranReload {} + , VeteranEmptyReload {} + , Wake { } , Wake_Grapple { } , Wake_Sinking { } diff --git a/src/Utilities/Enum.h b/src/Utilities/Enum.h index 9dd1504515..98430c02e5 100644 --- a/src/Utilities/Enum.h +++ b/src/Utilities/Enum.h @@ -32,6 +32,7 @@ #pragma once +#include #include enum class AttachedAnimFlag @@ -440,3 +441,15 @@ enum class EdgeType : BYTE Closest = 1, Random = 2 }; + +// Phobos extension abilities that augment the vanilla veteran/elite ability +// lists (VeteranAbilities / EliteAbilities). Do not extend the vanilla Ability +// enum, whose storage is a fixed-size AbilitiesStruct. +enum class AdditionalAbility : unsigned char +{ + Reload = 0, + EmptyReload = 1, + Count +}; + +constexpr size_t AdditionalAbilityCount = static_cast(AdditionalAbility::Count);