diff --git a/CREDITS.md b/CREDITS.md index f87c0cf44c..dded5fa53a 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -758,6 +758,7 @@ This page lists all the individual contributions to the project by their author. - Customize whether the unit can be detected by psychic detector - Fix the bug that setting `WalkRate=0` on a TechnoType crashed the game (integer divide-by-zero) the moment an object of that type started moving - Fix the bug where `Ranged=true` causes projectiles using the new Trajectory to ignore settings such as `BounceTimes` + - Customizable infantry sequence rates - **Ollerus**: - Build limit group enhancement - Customizable rocker amplitude diff --git a/docs/New-or-Enhanced-Logics.md b/docs/New-or-Enhanced-Logics.md index 343dae63eb..1711879f7d 100644 --- a/docs/New-or-Enhanced-Logics.md +++ b/docs/New-or-Enhanced-Logics.md @@ -806,6 +806,89 @@ DeployedPrimaryFireFLH= ; integer - Forward,Lateral,Height DeployedSecondaryFireFLH= ; integer - Forward,Lateral,Height ``` +### Customizable infantry sequence rates + +In vanilla, the number of game frames each animation frame of the infantry sequence stays (i.e., its playback rate) is hardcoded. Now it can be customized for each sequence in each infantry's sequence section. +- `Rate` defines the number of game logic frames that 1 animation frame stays; 0 means still, for example, the `Ready` and `Guard` sequences in vanilla are still. +- `Normalized` defines whether the playback rate is affected by game speed, for example, the `Idle1`, `Idle2`, `WetIdle1`, `WetIdle2`, `Hover`, and `Cheer` sequences in vanilla are affected by game speed. + +```{note} +The effective rate of a normalized sequence is `rate` adjusted by the game speed: + +- `rate < 5`: from the table below (`rate` row, `GameSpeed` column). +- `rate >= 5`: `rate * 8 / (GameSpeed + 1)`. + +| Rate \ GameSpeed | 0 | 1 | 2 | 3 | 4 | 5 | 6 | +| ---------------- | - | - | - | - | - | - | - | +| 1 | 2 | 2 | 1 | 1 | 1 | 1 | 1 | +| 2 | 3 | 3 | 3 | 2 | 2 | 2 | 1 | +| 3 | 5 | 4 | 4 | 3 | 3 | 2 | 2 | +| 4 | 7 | 6 | 5 | 4 | 4 | 4 | 3 | + +`GameSpeed` goes from `0` (fastest) to `6` (slowest). +``` + +In `rulesmd.ini`: +```ini +[AudioVisual] +Sequence..DefaultRate=0 ; integer +Sequence..DefaultNormalized=no ; boolean +``` + +```{dropdown} Default value table +| Name | DefaultRate | Normalized | +|-----------------|-------------|------------| +| Ready | 0 | false | +| Guard | 0 | false | +| Prone | 6 | false | +| Walk | 3 | false | +| FireUp | 1 | false | +| Down | 1 | false | +| Crawl | 1 | false | +| Up | 1 | false | +| FireProne | 1 | false | +| Idle1 | 3 | true | +| Idle2 | 3 | true | +| Die1 | 1 | false | +| Die2 | 1 | false | +| Die3 | 1 | false | +| Die4 | 1 | false | +| Die5 | 1 | false | +| Tread | 3 | false | +| Swim | 1 | false | +| WetIdle1 | 3 | true | +| WetIdle2 | 3 | true | +| WetDie1 | 1 | false | +| WetDie2 | 1 | false | +| WetAttack | 1 | false | +| Hover | 2 | true | +| Fly | 1 | false | +| Tumble | 1 | false | +| FireFly | 1 | false | +| Deploy | 1 | false | +| Deployed | 1 | false | +| DeployedFire | 1 | false | +| DeployedIdle | 1 | false | +| Undeploy | 1 | false | +| Cheer | 3 | true | +| Paradrop | 1 | false | +| AirDeathStart | 3 | false | +| AirDeathFalling | 1 | false | +| AirDeathFinish | 3 | false | +| Panic | 4 | false | +| Shovel | 6 | false | +| Carry | 3 | false | +| SecondaryFire | 1 | false | +| SecondaryProne | 1 | false | +``` + +In `artmd.ini`: +```ini +[SEQUENCE] ; Sequence +Rate= ; integer, defaults to [AudioVisual] -> Sequence..DefaultRate +Normalized= ; boolean, defaults to [AudioVisual] -> Sequence..DefaultNormalized +``` + ### Customizable `SlavesFreeSound` - `SlavesFreeSound` can now be set individually for each enslavable infantry type. diff --git a/docs/Whats-New.md b/docs/Whats-New.md index b94e3be3c4..2bcbb40bf6 100644 --- a/docs/Whats-New.md +++ b/docs/Whats-New.md @@ -433,6 +433,7 @@ HideShakeEffects=false ; boolean - [Customize whether the unit can be detected by psychic detector](Fixed-or-Improved-Logics.md#customize-whether-the-unit-can-be-detected-by-psychic-detector) (by Noble_Fish) - Cloak Enhancement (by NetsuNegi) - [`513` Set mission timer properties](AI-Scripting-and-Mapping.md#set-mission-timer-properties) (by Ollerus) +- [Customizable infantry sequence rates](New-or-Enhanced-Logics.md#customizable-infantry-sequence-rates) (by Noble_Fish) #### Vanilla fixes: - Fixed the bug where a building with `Factory=BuildingType` owned by the AI did not play `ProductionAnim` when placing a produced building (by Noble_Fish) diff --git a/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po b/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po index af6dc40f4f..8c54abab96 100644 --- a/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po +++ b/docs/locale/zh_CN/LC_MESSAGES/CREDITS.po @@ -2579,6 +2579,9 @@ msgstr "天窗生产动画" msgid "Customize whether the unit exits from the roof" msgstr "自定义单位是否从天窗退出" +msgid "Customizable infantry sequence rates" +msgstr "自定义步兵序列播放速率" + msgid "**Ollerus**:" msgstr "**Ollerus**:" 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 75693ac6dc..a3aadfdb9c 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 @@ -1928,6 +1928,45 @@ msgstr "" "现在步兵处于匍匐(爪巴)或部署状态的 FLH 可以独立于 `PrimaryFireFLH` 和 `SecondaryFireFLH` " "进行设置。同样对 [Burst 开火坐标](#firing-offsets-for-specific-burst-shots) 生效。" +msgid "Customizable infantry sequence rates" +msgstr "自定义步兵序列播放速率" + +msgid "" +"In vanilla, the number of game frames each animation frame of the " +"infantry sequence stays (i.e., its playback rate) is hardcoded. Now it " +"can be customized for each sequence in each infantry's sequence section." +msgstr "" +"在原版中,步兵序列每个动画帧所停留的游戏帧数(即其播放速率)是硬编码的。现在可以在每个步兵的序列段落中为每个序列自定义。" + +msgid "" +"`Rate` defines the number of game logic frames that 1 animation" +" frame stays; 0 means still, for example, the `Ready` and `Guard` " +"sequences in vanilla are still." +msgstr "" +"`Rate` 用于定义 1 动画帧所停留的游戏逻辑帧数,0 代表静止,例如原版中的 `Ready` 和 `Guard` 序列是静止的。" + +msgid "" +"`Normalized` defines whether the playback rate is affected by " +"game speed, for example, the `Idle1`, `Idle2`, `WetIdle1`, `WetIdle2`, " +"`Hover`, and `Cheer` sequences in vanilla are affected by game speed." +msgstr "" +"`Normalized` 用于定义播放速率是否受游戏速度影响,例如原版中的 `Idle1`、`Idle2`、`WetIdle1`、`WetIdle2`、`Hover` 和 `Cheer` 序列受游戏速度影响。" + +msgid "" +"The effective rate of a normalized sequence is `rate` adjusted by the " +"game speed:" +msgstr "" +"`Normalized` 化序列的有效速率为经过游戏速度调整后的 `rate`:" + +msgid "`rate < 5`: from the table below (`rate` row, `GameSpeed` column)." +msgstr "`rate < 5`:使用下表(`rate` 行,`GameSpeed` 列)。" + +msgid "`rate >= 5`: `rate * 8 / (GameSpeed + 1)`." +msgstr "`rate >= 5`:`rate * 8 / (GameSpeed + 1)`。" + +msgid "`GameSpeed` goes from `0` (fastest) to `6` (slowest)." +msgstr "`GameSpeed` 的取值范围为 `0`(最快)到 `6`(最慢)。" + msgid "Customizable `SlavesFreeSound`" msgstr "自定义 `SlavesFreeSound`" diff --git a/docs/locale/zh_CN/LC_MESSAGES/Whats-New.po b/docs/locale/zh_CN/LC_MESSAGES/Whats-New.po index 508ac00dc4..331d4967e2 100644 --- a/docs/locale/zh_CN/LC_MESSAGES/Whats-New.po +++ b/docs/locale/zh_CN/LC_MESSAGES/Whats-New.po @@ -867,6 +867,12 @@ msgstr "" msgid "Cloak Enhancement (by NetsuNegi)" msgstr "隐形增强(by NetsuNegi)" +msgid "" +"[Customizable infantry sequence rates](New-or-Enhanced-Logics.md" +"#customizable-infantry-sequence-rates) (by Noble_Fish)" +msgstr "" +"[自定义步兵序列播放速率](New-or-Enhanced-Logics.md#customizable-infantry-sequence-rates)(by Noble_Fish)" + msgid "Vanilla fixes:" msgstr "原版问题修复:" diff --git a/src/Ext/Infantry/Hooks.cpp b/src/Ext/Infantry/Hooks.cpp index c454ac6d56..1f20e685fd 100644 --- a/src/Ext/Infantry/Hooks.cpp +++ b/src/Ext/Infantry/Hooks.cpp @@ -1,7 +1,11 @@ #include #include +#include +#include #include +#include +#include DEFINE_HOOK(0x51B2BD, InfantryClass_UpdateTarget_IsControlledByHuman, 0x6) { @@ -209,3 +213,65 @@ DEFINE_HOOK(0x51A002, InfantryClass_UpdatePosition_InfiltrateBuilding, 0x6) return 0; } + +#pragma region CustomInfantrySequenceRates + +// The vanilla hardcoded per-sequence rates used as the final fallback live in SequenceRates::Entries (see Utilities/SequenceRates.h). + +namespace SequenceRateHooks +{ + int GetCustomRate(InfantryClass* pThis, int sequence) + { + if (sequence < 0 || sequence >= static_cast(SequenceRates::Entries.size())) + return -1; + + const int typeRate = InfantryTypeExt::Fetch(pThis->Type)->CustomSequenceRates[sequence]; + const int customRate = typeRate >= 0 ? typeRate : RulesExt::Global()->CustomSequenceRates[sequence]; + + return customRate; + } + + bool IsNormalized(InfantryClass* pThis, int sequence) + { + if (sequence < 0 || sequence >= static_cast(SequenceRates::Entries.size())) + return false; + + const int typeFlag = InfantryTypeExt::Fetch(pThis->Type)->CustomSequenceNormalized[sequence]; + if (typeFlag >= 0) + return typeFlag != 0; + + const int globalFlag = RulesExt::Global()->CustomSequenceNormalized[sequence]; + if (globalFlag >= 0) + return globalFlag != 0; + + return SequenceRates::Entries[sequence].Normalized; + } + + int GetFinalRate(InfantryClass* pThis, int sequence) + { + const int customRate = GetCustomRate(pThis, sequence); + const int rate = customRate >= 0 ? customRate : SequenceRates::Entries[sequence].DefaultRate; + + return IsNormalized(pThis, sequence) + ? GameOptionsClass::Instance.GetAnimSpeed(rate) + : rate; + } +} + +DEFINE_HOOK(0x51DA44, InfantryClass_DoAction_SequenceRate_Store, 0x6) +{ + GET(InfantryClass*, pThis, ESI); + GET(int, sequence, EDI); + + if (sequence >= 0 && sequence < static_cast(SequenceRates::Entries.size())) + { + const int finalRate = SequenceRateHooks::GetFinalRate(pThis, sequence); + + pThis->Animation.Timer.TimeLeft = finalRate; + pThis->Animation.Rate = finalRate; + } + + return 0x51DA4A; +} + +#pragma endregion diff --git a/src/Ext/InfantryType/Body.cpp b/src/Ext/InfantryType/Body.cpp index 48c94999a1..83a25474cf 100644 --- a/src/Ext/InfantryType/Body.cpp +++ b/src/Ext/InfantryType/Body.cpp @@ -1,5 +1,7 @@ #include "Body.h" +#include + InfantryTypeExt::ExtContainer InfantryTypeExt::ExtMap; // ============================= @@ -33,6 +35,24 @@ void InfantryTypeExt::LoadFromINIFile(CCINIClass* const pINI) this->ProneSecondaryFireFLH.Read(exArtINI, pArtSection, "ProneSecondaryFireFLH"); this->DeployedPrimaryFireFLH.Read(exArtINI, pArtSection, "DeployedPrimaryFireFLH"); this->DeployedSecondaryFireFLH.Read(exArtINI, pArtSection, "DeployedSecondaryFireFLH"); + + // Per-sequence animation rates + char sequenceSection[0x20]; + const char* pRateSection = pArtSection; + if (pArtINI->ReadString(pArtSection, "Sequence", "", sequenceSection, sizeof(sequenceSection)) > 0) + pRateSection = sequenceSection; + + for (size_t i = 0; i < SequenceRates::Entries.size(); ++i) + { + char key[64]; + std::snprintf(key, sizeof(key), "%sRate", SequenceRates::Entries[i].Name); + exArtINI.ReadInteger(pRateSection, key, &this->CustomSequenceRates[i]); + + bool normalized; + std::snprintf(key, sizeof(key), "%sNormalized", SequenceRates::Entries[i].Name); + if (exArtINI.ReadBool(pRateSection, key, &normalized)) + this->CustomSequenceNormalized[i] = normalized ? 1 : 0; + } } template @@ -55,6 +75,8 @@ void InfantryTypeExt::Serialize(T& Stm) .Process(this->DeployedWeaponBurstFLHs) .Process(this->EliteDeployedWeaponBurstFLHs) .Process(this->InfantryAutoDeploy) + .Process(this->CustomSequenceRates) + .Process(this->CustomSequenceNormalized) ; } diff --git a/src/Ext/InfantryType/Body.h b/src/Ext/InfantryType/Body.h index 2c38f7afc3..e2ebeb14b7 100644 --- a/src/Ext/InfantryType/Body.h +++ b/src/Ext/InfantryType/Body.h @@ -28,6 +28,12 @@ class InfantryTypeExt final : public TechnoTypeExt std::vector> EliteDeployedWeaponBurstFLHs; Nullable InfantryAutoDeploy; + // Per-sequence animation rates read from the infantry's art section + std::vector CustomSequenceRates; + + // Per-sequence game-speed normalization flags read from the infantry's art section + std::vector CustomSequenceNormalized; + explicit InfantryTypeExt(InfantryTypeClass* const OwnerObject) : TechnoTypeExt(OwnerObject) , Slaved_OwnerWhenMasterKilled { SlaveChangeOwnerType::Killer } , SlavesFreeSound {} @@ -41,6 +47,8 @@ class InfantryTypeExt final : public TechnoTypeExt , DeployedPrimaryFireFLH {} , DeployedSecondaryFireFLH {} , InfantryAutoDeploy {} + , CustomSequenceRates(42, -1) + , CustomSequenceNormalized(42, -1) { } InfantryTypeClass* OwnerObject() const diff --git a/src/Ext/Rules/Body.cpp b/src/Ext/Rules/Body.cpp index bc86659c57..a80aeac4ec 100644 --- a/src/Ext/Rules/Body.cpp +++ b/src/Ext/Rules/Body.cpp @@ -2,6 +2,8 @@ #include +#include + #include #include #include @@ -665,6 +667,20 @@ void RulesExt::ExtData::LoadBeforeTypeData(RulesClass* pThis, CCINIClass* pINI) this->AIScriptsLists.emplace_back(std::move(objectsList)); } + + // Global default per-sequence animation rates for infantry. + for (size_t i = 0; i < SequenceRates::Entries.size(); ++i) + { + char key[64]; + std::snprintf(key, sizeof(key), "Sequence.%s.DefaultRate", SequenceRates::Entries[i].Name); + exINI.ReadInteger(GameStrings::AudioVisual, key, &this->CustomSequenceRates[i]); + + bool normalized; + std::snprintf(key, sizeof(key), "Sequence.%s.DefaultNormalized", SequenceRates::Entries[i].Name); + if (exINI.ReadBool(GameStrings::AudioVisual, key, &normalized)) + this->CustomSequenceNormalized[i] = normalized ? 1 : 0; + } + } // this should load everything that TypeData is not dependant on @@ -1116,6 +1132,8 @@ void RulesExt::ExtData::Serialize(T& Stm) .Process(this->CloakAnims) .Process(this->DecloakAnims) .Process(this->Cloak_KickOutParasite) + .Process(this->CustomSequenceRates) + .Process(this->CustomSequenceNormalized) ; } diff --git a/src/Ext/Rules/Body.h b/src/Ext/Rules/Body.h index 993f1ca9a7..637e089968 100644 --- a/src/Ext/Rules/Body.h +++ b/src/Ext/Rules/Body.h @@ -550,6 +550,12 @@ class RulesExt ValueableVector DecloakAnims; Valueable Cloak_KickOutParasite; + // Global default per-sequence animation rates for infantry + std::vector CustomSequenceRates; + + // Global default per-sequence game-speed normalization flags for infantry + std::vector CustomSequenceNormalized; + ExtData(RulesClass* OwnerObject) : Extension(OwnerObject) , Storage_TiberiumIndex { -1 } , HarvesterDumpAmount { 0.0f } @@ -1036,6 +1042,9 @@ class RulesExt , CloakAnims {} , DecloakAnims {} , Cloak_KickOutParasite { false } + + , CustomSequenceRates(42, -1) + , CustomSequenceNormalized(42, -1) { } virtual ~ExtData() = default; diff --git a/src/Utilities/SequenceRates.h b/src/Utilities/SequenceRates.h new file mode 100644 index 0000000000..bf93844288 --- /dev/null +++ b/src/Utilities/SequenceRates.h @@ -0,0 +1,60 @@ +#pragma once + +#include + +namespace SequenceRates +{ + struct Entry + { + const char* Name; + int DefaultRate; + bool Normalized; + }; + + inline constexpr std::array Entries = {{ + // Name DefaultRate Normalized + // --------------------------------------------- + { "Ready", 0, false }, + { "Guard", 0, false }, + { "Prone", 6, false }, + { "Walk", 3, false }, + { "FireUp", 1, false }, + { "Down", 1, false }, + { "Crawl", 1, false }, + { "Up", 1, false }, + { "FireProne", 1, false }, + { "Idle1", 3, true }, // <-- true in vanilla + { "Idle2", 3, true }, // <-- true in vanilla + { "Die1", 1, false }, + { "Die2", 1, false }, + { "Die3", 1, false }, + { "Die4", 1, false }, + { "Die5", 1, false }, + { "Tread", 3, false }, + { "Swim", 1, false }, + { "WetIdle1", 3, true }, // <-- true in vanilla + { "WetIdle2", 3, true }, // <-- true in vanilla + { "WetDie1", 1, false }, + { "WetDie2", 1, false }, + { "WetAttack", 1, false }, + { "Hover", 2, true }, // <-- true in vanilla + { "Fly", 1, false }, + { "Tumble", 1, false }, + { "FireFly", 1, false }, + { "Deploy", 1, false }, + { "Deployed", 1, false }, + { "DeployedFire", 1, false }, + { "DeployedIdle", 1, false }, + { "Undeploy", 1, false }, + { "Cheer", 3, true }, // <-- true in vanilla + { "Paradrop", 1, false }, + { "AirDeathStart", 3, false }, + { "AirDeathFalling", 1, false }, + { "AirDeathFinish", 3, false }, + { "Panic", 4, false }, + { "Shovel", 6, false }, + { "Carry", 3, false }, + { "SecondaryFire", 1, false }, + { "SecondaryProne", 1, false }, + }}; +}