From be956f06084cf28036d5e47a90506774696894ad Mon Sep 17 00:00:00 2001 From: Andrettin <6322423+Andrettin@users.noreply.github.com> Date: Sat, 16 Aug 2025 19:57:22 +0200 Subject: [PATCH] Added Lua Bindings for Adding New Monster Types --- Source/CMakeLists.txt | 2 + Source/loadsave.cpp | 16 +++-- Source/lua/modules/dev/monsters.cpp | 4 +- Source/lua/modules/monsters.cpp | 8 +++ Source/monstdat.cpp | 103 +++++++++++++++++++++------- Source/monstdat.h | 68 +++++++++--------- Source/monster.cpp | 6 +- Source/monster.h | 2 +- Source/monsters/validation.cpp | 17 +++++ Source/monsters/validation.hpp | 1 + assets/lua/devilutionx/events.lua | 4 ++ 11 files changed, 162 insertions(+), 69 deletions(-) diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index bc3a1a594..072e5aec3 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -455,6 +455,7 @@ target_link_dependencies(libdevilutionx_lighting PUBLIC DevilutionX::SDL fmt::fmt tl + unordered_dense::unordered_dense libdevilutionx_vision ) @@ -529,6 +530,7 @@ target_link_dependencies(libdevilutionx_monster magic_enum::magic_enum sol2::sol2 tl + unordered_dense::unordered_dense libdevilutionx_game_mode libdevilutionx_headless_mode libdevilutionx_sound diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index abd8d17ea..5a410752d 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -740,9 +740,9 @@ bool gbSkipSync = false; if (monster.isUnique()) { // check if the unique monster is still valid (it could no longer be valid e.g. because the loaded mods changed and the unique monsters changed as a consequence) - const bool valid = IsUniqueMonsterValid(monster); + const bool valid = IsMonsterValid(monster); if (!valid) { - LogWarn("Unique monster no longer valid, skipping it."); + LogWarn("Monster no longer valid, skipping it."); return false; } } @@ -2507,13 +2507,15 @@ tl::expected LoadGame(bool firstflag) ActiveMonsterCount = tmpNummonsters; ActiveObjectCount = tmpNobjects; - for (int &monstkill : MonsterKillCounts) + for (size_t i = 0; i < MonstersData.size(); ++i) { + int &monstkill = MonsterKillCounts[i]; monstkill = file.NextBE(); + } ankerl::unordered_dense::set removedMonsterIds; // skip ahead for vanilla save compatibility (Related to bugfix where MonsterKillCounts[MaxMonsters] was changed to MonsterKillCounts[NUM_MTYPES] - file.Skip(4 * (MaxMonsters - NUM_MTYPES)); + file.Skip(4 * (MaxMonsters - MonstersData.size())); if (leveltype != DTYPE_TOWN) { LoadMonsters(file, removedMonsterIds, false, nullptr); @@ -2776,10 +2778,12 @@ void SaveGameData(SaveWriter &saveWriter) SaveQuest(&file, i); for (int i = 0; i < MAXPORTAL; i++) SavePortal(&file, i); - for (const int monstkill : MonsterKillCounts) + for (size_t i = 0; i < MonstersData.size(); ++i) { + const int monstkill = MonsterKillCounts[i]; file.WriteBE(monstkill); + } // add padding for vanilla save compatibility (Related to bugfix where MonsterKillCounts[MaxMonsters] was changed to MonsterKillCounts[NUM_MTYPES] - file.Skip(4 * (MaxMonsters - NUM_MTYPES)); + file.Skip(4 * (MaxMonsters - MonstersData.size())); if (leveltype != DTYPE_TOWN) { for (const unsigned monsterId : ActiveMonsters) diff --git a/Source/lua/modules/dev/monsters.cpp b/Source/lua/modules/dev/monsters.cpp index bb5a64a20..c1f8822fa 100644 --- a/Source/lua/modules/dev/monsters.cpp +++ b/Source/lua/modules/dev/monsters.cpp @@ -107,12 +107,12 @@ std::string DebugCmdSpawnMonster(std::string name, std::optional count int mtype = -1; - for (int i = 0; i < NUM_MTYPES; i++) { + for (size_t i = 0; i < MonstersData.size(); i++) { const auto &mondata = MonstersData[i]; const std::string monsterName = AsciiStrToLower(std::string_view(mondata.name)); if (monsterName.find(name) == std::string::npos) continue; - mtype = i; + mtype = static_cast(i); if (monsterName == name) // to support partial name matching but always choose the correct monster if full name is given break; } diff --git a/Source/lua/modules/monsters.cpp b/Source/lua/modules/monsters.cpp index c12593c2c..58d414ae3 100644 --- a/Source/lua/modules/monsters.cpp +++ b/Source/lua/modules/monsters.cpp @@ -5,6 +5,7 @@ #include #include +#include "data/file.hpp" #include "lua/metadoc.hpp" #include "monstdat.h" #include "utils/language.h" @@ -14,6 +15,12 @@ namespace devilution { namespace { +void AddMonsterDataFromTsv(const std::string_view path) +{ + DataFile dataFile = DataFile::loadOrDie(path); + LoadMonstDatFromFile(dataFile, path); +} + void AddUniqueMonsterData(const std::string_view type, const std::string_view name, const std::string_view trn, const uint8_t level, const uint16_t maxHp, const std::string_view ai, const uint8_t intelligence, const uint8_t minDamage, const uint8_t maxDamage, const std::string_view resistance, const std::string_view monsterPack, const std::optional customToHit, const std::optional customArmorClass) { UniqueMonsterData monster; @@ -70,6 +77,7 @@ void AddUniqueMonsterData(const std::string_view type, const std::string_view na sol::table LuaMonstersModule(sol::state_view &lua) { sol::table table = lua.create_table(); + LuaSetDocFn(table, "addMonsterDataFromTsv", "(path: string)", AddMonsterDataFromTsv); LuaSetDocFn(table, "addUniqueMonsterData", "(type: string, name: string, trn: string, level: number, maxHp: number, ai: string, intelligence: number, minDamage: number, maxDamage: number, resistance: string, monsterPack: string, customToHit: number = nil, customArmorClass: number = nil)", AddUniqueMonsterData); return table; } diff --git a/Source/monstdat.cpp b/Source/monstdat.cpp index 74af6b458..7c7bc86e7 100644 --- a/Source/monstdat.cpp +++ b/Source/monstdat.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include "cursor.h" @@ -22,7 +23,7 @@ template <> struct magic_enum::customize::enum_range { static constexpr int min = devilution::MT_INVALID; - static constexpr int max = devilution::NUM_MTYPES; + static constexpr int max = devilution::NUM_DEFAULT_MTYPES; }; namespace devilution { @@ -213,17 +214,37 @@ const _monster_id MonstConvTbl[] = { MT_LRDSAYTR, }; +namespace { + +/** Contains the mapping between monster ID strings and indices, used for parsing additional monster data. */ +ankerl::unordered_dense::map AdditionalMonsterIdStringsToIndices; + +} // namespace + tl::expected<_monster_id, std::string> ParseMonsterId(std::string_view value) { const std::optional<_monster_id> enumValueOpt = magic_enum::enum_cast<_monster_id>(value); if (enumValueOpt.has_value()) { return enumValueOpt.value(); } + const auto findIt = AdditionalMonsterIdStringsToIndices.find(std::string(value)); + if (findIt != AdditionalMonsterIdStringsToIndices.end()) { + return static_cast<_monster_id>(findIt->second); + } return tl::make_unexpected("Unknown enum value"); } namespace { +tl::expected<_monster_id, std::string> ParseMonsterIdIfNotEmpty(std::string_view value) +{ + if (value.empty()) { + return MT_INVALID; + } + + return ParseMonsterId(value); +} + tl::expected ParseMonsterAvailability(std::string_view value) { if (value == "Always") return MonsterAvailability::Always; @@ -331,6 +352,17 @@ tl::expected ParseSelectionRegion(std::string_view return tl::make_unexpected("Unknown enum value"); } +tl::expected ParseMonsterTreasure(std::string_view value) +{ + // TODO: Replace this hack with proper parsing. + + if (value.empty()) return 0; + if (value == "None") return T_NODROP; + if (value == "Uniq(SKCROWN)") return Uniq(UITEM_SKCROWN); + if (value == "Uniq(CLEAVER)") return Uniq(UITEM_CLEAVER); + return tl::make_unexpected("Invalid value. NOTE: Parser is incomplete"); +} + } // namespace tl::expected ParseUniqueMonsterPack(std::string_view value) @@ -341,29 +373,45 @@ tl::expected ParseUniqueMonsterPack(std::string_ return tl::make_unexpected("Unknown enum value"); } -namespace { - -void LoadMonstDat() +void LoadMonstDatFromFile(DataFile &dataFile, const std::string_view filename) { - const std::string_view filename = "txtdata\\monsters\\monstdat.tsv"; - DataFile dataFile = DataFile::loadOrDie(filename); dataFile.skipHeaderOrDie(filename); - MonstersData.clear(); - MonstersData.reserve(dataFile.numRecords()); - ankerl::unordered_dense::map spritePathToId; + MonstersData.reserve(MonstersData.size() + dataFile.numRecords()); + for (DataFileRecord record : dataFile) { + if (MonstersData.size() >= static_cast(NUM_MAX_MTYPES)) { + DisplayFatalErrorAndExit(_("Loading Monster Data Failed"), fmt::format(fmt::runtime(_("Could not add a monster, since the maximum monster type number of {} has already been reached.")), static_cast(NUM_MAX_MTYPES))); + } + RecordReader reader { record, filename }; - MonsterData &monster = MonstersData.emplace_back(); - reader.advance(); // Skip the first column (monster ID). + + std::string monsterId; + reader.readString("_monster_id", monsterId); + const std::optional<_monster_id> monsterIdEnumValueOpt = magic_enum::enum_cast<_monster_id>(monsterId); + + if (!monsterIdEnumValueOpt.has_value()) { + const size_t monsterIndex = MonstersData.size(); + const auto [it, inserted] = AdditionalMonsterIdStringsToIndices.emplace(monsterId, static_cast(monsterIndex)); + if (!inserted) { + DisplayFatalErrorAndExit(_("Loading Monster Data Failed"), fmt::format(fmt::runtime(_("A monster type already exists for ID \"{}\".")), monsterId)); + } + } + + // for hardcoded monsters, use their predetermined slot; for non-hardcoded ones, use the slots after that + MonsterData &monster = monsterIdEnumValueOpt.has_value() ? MonstersData[monsterIdEnumValueOpt.value()] : MonstersData.emplace_back(); + reader.readString("name", monster.name); { std::string assetsSuffix; reader.readString("assetsSuffix", assetsSuffix); - const auto [it, inserted] = spritePathToId.emplace(assetsSuffix, spritePathToId.size()); - if (inserted) - MonsterSpritePaths.push_back(it->first); - monster.spriteId = static_cast(it->second); + const auto findIt = std::find(MonsterSpritePaths.begin(), MonsterSpritePaths.end(), assetsSuffix); + if (findIt != MonsterSpritePaths.end()) { + monster.spriteId = static_cast(findIt - MonsterSpritePaths.begin()); + } else { + monster.spriteId = static_cast(MonsterSpritePaths.size()); + MonsterSpritePaths.push_back(std::string(assetsSuffix)); + } } reader.readString("soundSuffix", monster.soundSuffix); reader.readString("trnFile", monster.trnFile); @@ -395,22 +443,27 @@ void LoadMonstDat() reader.readEnumList("resistance", monster.resistance, ParseMonsterResistance); reader.readEnumList("resistanceHell", monster.resistanceHell, ParseMonsterResistance); reader.readEnumList("selectionRegion", monster.selectionRegion, ParseSelectionRegion); - - // treasure - // TODO: Replace this hack with proper parsing once items have been migrated to data files. - reader.read("treasure", monster.treasure, [](std::string_view value) -> tl::expected { - if (value.empty()) return 0; - if (value == "None") return T_NODROP; - if (value == "Uniq(SKCROWN)") return Uniq(UITEM_SKCROWN); - if (value == "Uniq(CLEAVER)") return Uniq(UITEM_CLEAVER); - return tl::make_unexpected("Invalid value. NOTE: Parser is incomplete"); - }); + reader.read("treasure", monster.treasure, ParseMonsterTreasure); reader.readInt("exp", monster.exp); } MonstersData.shrink_to_fit(); } +namespace { + +void LoadMonstDat() +{ + const std::string_view filename = "txtdata\\monsters\\monstdat.tsv"; + DataFile dataFile = DataFile::loadOrDie(filename); + MonstersData.clear(); + AdditionalMonsterIdStringsToIndices.clear(); + MonstersData.resize(NUM_DEFAULT_MTYPES); // ensure the hardcoded monster type slots are filled + LoadMonstDatFromFile(dataFile, filename); + + LuaEvent("MonsterDataLoaded"); +} + void LoadUniqueMonstDat() { const std::string_view filename = "txtdata\\monsters\\unique_monstdat.tsv"; diff --git a/Source/monstdat.h b/Source/monstdat.h index 06f3bd01a..2545a69a7 100644 --- a/Source/monstdat.h +++ b/Source/monstdat.h @@ -17,6 +17,8 @@ namespace devilution { +class DataFile; + enum class MonsterAIID : int8_t { Zombie, Fat, @@ -97,44 +99,44 @@ struct MonsterData { std::string name; std::string soundSuffix; std::string trnFile; - uint16_t spriteId; - MonsterAvailability availability; - uint16_t width; - uint16_t image; - bool hasSpecial; - bool hasSpecialSound; - int8_t frames[6]; - int8_t rate[6]; - int8_t minDunLvl; - int8_t maxDunLvl; - int8_t level; - uint16_t hitPointsMinimum; - uint16_t hitPointsMaximum; - MonsterAIID ai; + uint16_t spriteId = 0; + MonsterAvailability availability = MonsterAvailability::Never; + uint16_t width = 0; + uint16_t image = 0; + bool hasSpecial = false; + bool hasSpecialSound = false; + int8_t frames[6] {}; + int8_t rate[6] {}; + int8_t minDunLvl = 0; + int8_t maxDunLvl = 0; + int8_t level = 0; + uint16_t hitPointsMinimum = 0; + uint16_t hitPointsMaximum = 0; + MonsterAIID ai = MonsterAIID::Invalid; /** * @brief Denotes monster's abilities defined in @p monster_flag as bitflags * For usage, see @p MonstersData in monstdat.cpp */ - uint16_t abilityFlags; - uint8_t intelligence; - uint8_t toHit; - int8_t animFrameNum; - uint8_t minDamage; - uint8_t maxDamage; - uint8_t toHitSpecial; - int8_t animFrameNumSpecial; - uint8_t minDamageSpecial; - uint8_t maxDamageSpecial; - uint8_t armorClass; - MonsterClass monsterClass; + uint16_t abilityFlags = 0; + uint8_t intelligence = 0; + uint8_t toHit = 0; + int8_t animFrameNum = 0; + uint8_t minDamage = 0; + uint8_t maxDamage = 0; + uint8_t toHitSpecial = 0; + int8_t animFrameNumSpecial = 0; + uint8_t minDamageSpecial = 0; + uint8_t maxDamageSpecial = 0; + uint8_t armorClass = 0; + MonsterClass monsterClass {}; /** Using monster_resistance as bitflags */ - uint8_t resistance; + uint8_t resistance = 0; /** Using monster_resistance as bitflags */ - uint8_t resistanceHell; - SelectionRegion selectionRegion; + uint8_t resistanceHell = 0; + SelectionRegion selectionRegion = SelectionRegion::None; /** Using monster_treasure */ - uint16_t treasure; - uint16_t exp; + uint16_t treasure = 0; + uint16_t exp = 0; [[nodiscard]] const char *spritePath() const; @@ -288,7 +290,8 @@ enum _monster_id : int16_t { MT_FLESTHNG, MT_REAPER, MT_NAKRUL, - NUM_MTYPES, + NUM_DEFAULT_MTYPES, + NUM_MAX_MTYPES = 200, // same as MaxMonsters, for the sake of save game compability MT_INVALID = -1, }; @@ -340,6 +343,7 @@ tl::expected<_monster_id, std::string> ParseMonsterId(std::string_view value); tl::expected ParseAiId(std::string_view value); tl::expected ParseMonsterResistance(std::string_view value); tl::expected ParseUniqueMonsterPack(std::string_view value); +void LoadMonstDatFromFile(DataFile &dataFile, std::string_view filename); void LoadMonsterData(); /** diff --git a/Source/monster.cpp b/Source/monster.cpp index 400ea8d12..711966041 100644 --- a/Source/monster.cpp +++ b/Source/monster.cpp @@ -69,7 +69,7 @@ Monster Monsters[MaxMonsters]; unsigned ActiveMonsters[MaxMonsters]; size_t ActiveMonsterCount; /** Tracks the total number of monsters killed per monster_id. */ -int MonsterKillCounts[NUM_MTYPES]; +int MonsterKillCounts[NUM_MAX_MTYPES]; bool sgbSaveSoundOn; namespace { @@ -3336,7 +3336,7 @@ tl::expected GetLevelMTypes() RETURN_IF_ERROR(AddMonsterType(MT_SKING, PLACE_UNIQUE)); int skeletonTypeCount = 0; - _monster_id skeltypes[NUM_MTYPES]; + _monster_id skeltypes[NUM_MAX_MTYPES]; for (const _monster_id skeletonType : SkeletonTypes) { if (!IsMonsterAvailable(MonstersData[skeletonType])) continue; @@ -3581,7 +3581,7 @@ tl::expected InitMonsters() numplacemonsters = MaxMonsters - 10 - ActiveMonsterCount; totalmonsters = ActiveMonsterCount + numplacemonsters; int numscattypes = 0; - size_t scattertypes[NUM_MTYPES]; + size_t scattertypes[NUM_MAX_MTYPES]; for (size_t i = 0; i < LevelMonsterTypeCount; i++) { if ((LevelMonsterTypes[i].placeFlags & PLACE_SCATTER) != 0) { scattertypes[numscattypes] = i; diff --git a/Source/monster.h b/Source/monster.h index aa0dba879..d83e35208 100644 --- a/Source/monster.h +++ b/Source/monster.h @@ -482,7 +482,7 @@ extern size_t LevelMonsterTypeCount; extern Monster Monsters[MaxMonsters]; extern unsigned ActiveMonsters[MaxMonsters]; extern size_t ActiveMonsterCount; -extern int MonsterKillCounts[NUM_MTYPES]; +extern int MonsterKillCounts[NUM_MAX_MTYPES]; extern bool sgbSaveSoundOn; tl::expected PrepareUniqueMonst(Monster &monster, UniqueMonsterType monsterType, size_t miniontype, int bosspacksize, const UniqueMonsterData &uniqueMonsterData); diff --git a/Source/monsters/validation.cpp b/Source/monsters/validation.cpp index 1818f5d8c..93cde30e1 100644 --- a/Source/monsters/validation.cpp +++ b/Source/monsters/validation.cpp @@ -39,6 +39,23 @@ bool IsEnemyValid(size_t monsterId, size_t enemyId) return IsEnemyValid(enemyId, true); } +bool IsMonsterValid(const Monster &monster) +{ + const CMonster &monsterType = LevelMonsterTypes[monster.levelType]; + const _monster_id monsterId = monsterType.type; + const size_t monsterIndex = static_cast(monsterId); + + if (monsterIndex >= MonstersData.size()) { + return false; + } + + if (monster.isUnique() && !IsUniqueMonsterValid(monster)) { + return false; + } + + return true; +} + bool IsUniqueMonsterValid(const Monster &monster) { assert(monster.isUnique()); diff --git a/Source/monsters/validation.hpp b/Source/monsters/validation.hpp index 75ca9d26a..4dde97a44 100644 --- a/Source/monsters/validation.hpp +++ b/Source/monsters/validation.hpp @@ -13,6 +13,7 @@ struct Monster; bool IsEnemyIdValid(size_t enemyId); bool IsEnemyValid(size_t monsterId, size_t enemyId); +bool IsMonsterValid(const Monster &monster); bool IsUniqueMonsterValid(const Monster &monster); } // namespace devilution diff --git a/assets/lua/devilutionx/events.lua b/assets/lua/devilutionx/events.lua index 70d0b7de9..4df5a9787 100644 --- a/assets/lua/devilutionx/events.lua +++ b/assets/lua/devilutionx/events.lua @@ -44,6 +44,10 @@ local events = { LoadModsComplete = CreateEvent(), __doc_LoadModsComplete = "Called after all mods have been loaded.", + ---Called after the monster data TSV file has been loaded. + MonsterDataLoaded = CreateEvent(), + __doc_MonsterDataLoaded = "Called after the monster data TSV file has been loaded.", + ---Called after the unique monster data TSV file has been loaded. UniqueMonsterDataLoaded = CreateEvent(), __doc_UniqueMonsterDataLoaded = "Called after the unique monster data TSV file has been loaded.",