You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
883 lines
25 KiB
883 lines
25 KiB
#include "towners.h" |
|
|
|
#include <algorithm> |
|
#include <cstdint> |
|
#include <unordered_map> |
|
|
|
#include "cursor.h" |
|
#include "engine/clx_sprite.hpp" |
|
#include "engine/load_cel.hpp" |
|
#include "engine/load_file.hpp" |
|
#include "engine/random.hpp" |
|
#include "game_mode.hpp" |
|
#include "inv.h" |
|
#include "minitext.h" |
|
#include "stores.h" |
|
#include "tables/textdat.h" |
|
#include "tables/townerdat.hpp" |
|
#include "utils/is_of.hpp" |
|
#include "utils/language.h" |
|
#include "utils/str_case.hpp" |
|
|
|
namespace devilution { |
|
namespace { |
|
|
|
OptionalOwnedClxSpriteSheet CowSprites; |
|
int CowMsg; |
|
int CowClicks; |
|
|
|
/** Specifies the active sound effect ID for interacting with cows. */ |
|
SfxID CowPlaying = SfxID::None; |
|
|
|
/** Storage for animation order data loaded from TSV (needs stable addresses for span). */ |
|
std::vector<std::vector<uint8_t>> TownerAnimOrderStorage; |
|
|
|
/** |
|
* @brief Defines the behavior (init and talk functions) for each towner type. |
|
* |
|
* The actual data (position, animation, gossip) comes from TSV files. |
|
* This struct only holds the code that can't be data-driven. |
|
*/ |
|
struct TownerData { |
|
_talker_id type; |
|
/** Custom initialization function, or nullptr to use the default InitTownerFromData. */ |
|
void (*init)(Towner &towner, const TownerDataEntry &entry); |
|
/** Function called when the player talks to this towner. */ |
|
void (*talk)(Player &player, Towner &towner); |
|
}; |
|
|
|
/** |
|
* @brief Lookup table from towner type to its behavior data. |
|
* |
|
* Populated during InitTowners() from the TownersData array. |
|
*/ |
|
std::unordered_map<_talker_id, const TownerData *> TownerBehaviors; |
|
|
|
/** |
|
* @brief Default towner initialization using TSV data. |
|
* |
|
* Sets up animation, gossip texts, and other properties from the TSV entry. |
|
* Used for most towners; special cases (cows, cow farmer) have custom init functions. |
|
*/ |
|
void InitTownerFromData(Towner &towner, const TownerDataEntry &entry); |
|
|
|
#ifdef _DEBUG |
|
/** |
|
* @brief Finds the towner data entry from TSV for a given type. |
|
*/ |
|
const TownerDataEntry *FindTownerDataEntry(_talker_id type, Point position = {}) |
|
{ |
|
for (const auto &entry : TownersDataEntries) { |
|
if (entry.type == type) { |
|
// For types with multiple instances (like cows), match by position |
|
if (position != Point {} && entry.position != position) |
|
continue; |
|
return &entry; |
|
} |
|
} |
|
return nullptr; |
|
} |
|
#endif |
|
|
|
void NewTownerAnim(Towner &towner, ClxSpriteList sprites, uint8_t numFrames, int delay) |
|
{ |
|
towner.anim.emplace(sprites); |
|
towner._tAnimLen = numFrames; |
|
towner._tAnimFrame = 0; |
|
towner._tAnimCnt = 0; |
|
towner._tAnimDelay = delay; |
|
} |
|
|
|
void InitTownerInfo(Towner &towner, const TownerData &townerData, const TownerDataEntry &entry) |
|
{ |
|
towner._ttype = townerData.type; |
|
auto nameIt = TownerLongNames.find(townerData.type); |
|
towner.name = nameIt != TownerLongNames.end() ? _(nameIt->second.c_str()) : std::string_view(entry.name); |
|
towner.position = entry.position; |
|
towner.talk = townerData.talk; |
|
|
|
if (townerData.init != nullptr) { |
|
townerData.init(towner, entry); |
|
} else { |
|
InitTownerFromData(towner, entry); |
|
} |
|
} |
|
|
|
void LoadTownerAnimations(Towner &towner, const char *path, int frames, int delay) |
|
{ |
|
towner.ownedAnim = std::nullopt; |
|
towner.ownedAnim = LoadCel(path, towner._tAnimWidth); |
|
NewTownerAnim(towner, *towner.ownedAnim, frames, delay); |
|
} |
|
|
|
/** |
|
* @brief Default towner initialization using TSV data. |
|
*/ |
|
void InitTownerFromData(Towner &towner, const TownerDataEntry &entry) |
|
{ |
|
towner._tAnimWidth = entry.animWidth; |
|
|
|
// Store animation order and set the span |
|
if (!entry.animOrder.empty()) { |
|
TownerAnimOrderStorage.push_back(entry.animOrder); |
|
towner.animOrder = { TownerAnimOrderStorage.back() }; |
|
} else { |
|
towner.animOrder = {}; |
|
} |
|
|
|
if (!entry.animPath.empty()) { |
|
LoadTownerAnimations(towner, entry.animPath.c_str(), entry.animFrames, entry.animDelay); |
|
} |
|
|
|
// Set gossip from TSV data |
|
if (!entry.gossipTexts.empty()) { |
|
const auto index = std::max<int32_t>(GenerateRnd(static_cast<int32_t>(entry.gossipTexts.size())), 0); |
|
towner.gossip = entry.gossipTexts[index]; |
|
} |
|
} |
|
|
|
/** |
|
* @brief Special initialization for cows. |
|
* |
|
* Cows differ from other towners: |
|
* - They share a sprite sheet (CowSprites) instead of loading individual animations |
|
* - They occupy multiple tiles (4 tiles for collision purposes) |
|
* - Animation frame is randomized on spawn |
|
*/ |
|
void InitCows(Towner &towner, const TownerDataEntry &entry) |
|
{ |
|
// Cows use a shared sprite sheet and need special handling |
|
towner._tAnimWidth = entry.animWidth; |
|
towner.animOrder = {}; |
|
|
|
NewTownerAnim(towner, (*CowSprites)[static_cast<size_t>(entry.direction)], 12, 3); |
|
towner._tAnimFrame = GenerateRnd(11); |
|
|
|
const Point position = entry.position; |
|
const int16_t cowId = dMonster[position.x][position.y]; |
|
|
|
// Cows are large sprites so take up multiple tiles. Vanilla Diablo/Hellfire allowed the player to stand adjacent |
|
// to a cow facing an ordinal direction (the two top-right cows) which leads to visual clipping. It's easier to |
|
// treat all cows as 4 tile sprites since this works for all facings. |
|
// The active tile is always the south tile as this is closest to the camera, we mark the other 3 tiles as occupied |
|
// using -id to match the convention used for moving/large monsters and players. |
|
Point offset = position + Direction::NorthWest; |
|
dMonster[offset.x][offset.y] = -cowId; |
|
offset = position + Direction::NorthEast; |
|
dMonster[offset.x][offset.y] = -cowId; |
|
offset = position + Direction::North; |
|
dMonster[offset.x][offset.y] = -cowId; |
|
} |
|
|
|
/** |
|
* @brief Special initialization for the cow farmer (Complete Nut). |
|
* |
|
* Uses different sprites depending on whether the Jersey quest is complete. |
|
*/ |
|
void InitCowFarmer(Towner &towner, const TownerDataEntry &entry) |
|
{ |
|
towner._tAnimWidth = entry.animWidth; |
|
towner.animOrder = {}; |
|
|
|
// CowFarmer has special logic for quest state |
|
const char *celPath = "towners\\farmer\\cfrmrn2"; |
|
if (Quests[Q_JERSEY]._qactive == QUEST_DONE) { |
|
celPath = "towners\\farmer\\mfrmrn2"; |
|
} |
|
LoadTownerAnimations(towner, celPath, 15, 3); |
|
} |
|
|
|
void TownDead(Towner &towner) |
|
{ |
|
if (qtextflag) { |
|
if (Quests[Q_BUTCHER]._qvar1 == 1) |
|
towner._tAnimCnt = 0; // Freeze while speaking |
|
return; |
|
} |
|
|
|
if ((Quests[Q_BUTCHER]._qactive == QUEST_DONE || Quests[Q_BUTCHER]._qvar1 == 1) && towner._tAnimLen != 1) { |
|
towner._tAnimLen = 1; |
|
towner.name = _("Slain Townsman"); |
|
} |
|
} |
|
|
|
void TownerTalk(_speech_id message) |
|
{ |
|
CowClicks = 0; |
|
CowMsg = 0; |
|
InitQTextMsg(message); |
|
} |
|
|
|
void TalkToBarOwner(Player &player, Towner &barOwner) |
|
{ |
|
if (!player._pLvlVisited[0]) { |
|
InitQTextMsg(TEXT_INTRO); |
|
return; |
|
} |
|
|
|
auto &kingQuest = Quests[Q_SKELKING]; |
|
if (kingQuest._qactive != QUEST_NOTAVAIL) { |
|
if (player._pLvlVisited[2] || player._pLvlVisited[4]) { |
|
if (kingQuest._qvar2 == 0) { |
|
kingQuest._qvar2 = 1; |
|
kingQuest._qlog = true; |
|
if (kingQuest._qactive == QUEST_INIT) { |
|
kingQuest._qactive = QUEST_ACTIVE; |
|
kingQuest._qvar1 = 1; |
|
} |
|
InitQTextMsg(TEXT_KING2); |
|
NetSendCmdQuest(true, kingQuest); |
|
return; |
|
} |
|
if (kingQuest._qactive == QUEST_DONE && kingQuest._qvar2 == 1) { |
|
kingQuest._qvar2 = 2; |
|
kingQuest._qvar1 = 2; |
|
InitQTextMsg(TEXT_KING4); |
|
NetSendCmdQuest(true, kingQuest); |
|
return; |
|
} |
|
} |
|
} |
|
|
|
auto &bannerQuest = Quests[Q_LTBANNER]; |
|
if (bannerQuest._qactive != QUEST_NOTAVAIL) { |
|
if ((player._pLvlVisited[3] || player._pLvlVisited[4]) && bannerQuest._qactive != QUEST_DONE) { |
|
if (bannerQuest._qvar2 == 0) { |
|
bannerQuest._qvar2 = 1; |
|
if (bannerQuest._qactive == QUEST_INIT) { |
|
bannerQuest._qvar1 = 1; |
|
bannerQuest._qactive = QUEST_ACTIVE; |
|
} |
|
bannerQuest._qlog = true; |
|
NetSendCmdQuest(true, bannerQuest); |
|
InitQTextMsg(TEXT_BANNER2); |
|
return; |
|
} |
|
|
|
if (bannerQuest._qvar2 == 1 && RemoveInventoryItemById(player, IDI_BANNER)) { |
|
bannerQuest._qactive = QUEST_DONE; |
|
bannerQuest._qvar1 = 3; |
|
NetSendCmdQuest(true, bannerQuest); |
|
SpawnUnique(UITEM_HARCREST, barOwner.position + Direction::SouthWest, bannerQuest._qlevel); |
|
InitQTextMsg(TEXT_BANNER3); |
|
return; |
|
} |
|
} |
|
} |
|
|
|
TownerTalk(TEXT_OGDEN1); |
|
StartStore(TalkID::Tavern); |
|
} |
|
|
|
void TalkToDeadguy(Player &player, Towner & /*deadguy*/) |
|
{ |
|
auto &quest = Quests[Q_BUTCHER]; |
|
if (quest._qactive == QUEST_DONE) |
|
return; |
|
|
|
if (quest._qvar1 == 1) { |
|
player.SaySpecific(HeroSpeech::YourDeathWillBeAvenged); |
|
return; |
|
} |
|
|
|
quest._qactive = QUEST_ACTIVE; |
|
quest._qlog = true; |
|
quest._qmsg = TEXT_BUTCH9; |
|
quest._qvar1 = 1; |
|
InitQTextMsg(TEXT_BUTCH9); |
|
NetSendCmdQuest(true, quest); |
|
} |
|
|
|
void TalkToBlackSmith(Player &player, Towner &blackSmith) |
|
{ |
|
if (Quests[Q_ROCK]._qactive != QUEST_NOTAVAIL) { |
|
if ((player._pLvlVisited[4] || player._pLvlVisited[5]) && Quests[Q_ROCK]._qactive != QUEST_DONE) { |
|
if (Quests[Q_ROCK]._qvar2 == 0) { |
|
Quests[Q_ROCK]._qvar2 = 1; |
|
Quests[Q_ROCK]._qlog = true; |
|
if (Quests[Q_ROCK]._qactive == QUEST_INIT) { |
|
Quests[Q_ROCK]._qactive = QUEST_ACTIVE; |
|
} |
|
NetSendCmdQuest(true, Quests[Q_ROCK]); |
|
InitQTextMsg(TEXT_INFRA5); |
|
return; |
|
} |
|
|
|
if (Quests[Q_ROCK]._qvar2 == 1 && RemoveInventoryItemById(player, IDI_ROCK)) { |
|
Quests[Q_ROCK]._qactive = QUEST_DONE; |
|
NetSendCmdQuest(true, Quests[Q_ROCK]); |
|
SpawnUnique(UITEM_INFRARING, blackSmith.position + Direction::SouthWest, Quests[Q_ROCK]._qlevel); |
|
InitQTextMsg(TEXT_INFRA7); |
|
return; |
|
} |
|
} |
|
} |
|
if (IsNoneOf(Quests[Q_ANVIL]._qactive, QUEST_NOTAVAIL, QUEST_DONE)) { |
|
if ((player._pLvlVisited[9] || player._pLvlVisited[10]) && Quests[Q_ANVIL]._qvar2 == 0) { |
|
Quests[Q_ANVIL]._qvar2 = 1; |
|
Quests[Q_ANVIL]._qlog = true; |
|
if (Quests[Q_ANVIL]._qactive == QUEST_INIT) { |
|
Quests[Q_ANVIL]._qactive = QUEST_ACTIVE; |
|
} |
|
NetSendCmdQuest(true, Quests[Q_ANVIL]); |
|
InitQTextMsg(TEXT_ANVIL5); |
|
return; |
|
} |
|
|
|
if (Quests[Q_ANVIL]._qvar2 == 1 && RemoveInventoryItemById(player, IDI_ANVIL)) { |
|
Quests[Q_ANVIL]._qactive = QUEST_DONE; |
|
NetSendCmdQuest(true, Quests[Q_ANVIL]); |
|
SpawnUnique(UITEM_GRISWOLD, blackSmith.position + Direction::SouthWest, Quests[Q_ANVIL]._qlevel); |
|
InitQTextMsg(TEXT_ANVIL7); |
|
return; |
|
} |
|
} |
|
|
|
TownerTalk(TEXT_GRISWOLD1); |
|
StartStore(TalkID::Smith); |
|
} |
|
|
|
void TalkToWitch(Player &player, Towner & /*witch*/) |
|
{ |
|
if (Quests[Q_MUSHROOM]._qactive != QUEST_NOTAVAIL) { |
|
if (Quests[Q_MUSHROOM]._qactive == QUEST_INIT && RemoveInventoryItemById(player, IDI_FUNGALTM)) { |
|
Quests[Q_MUSHROOM]._qactive = QUEST_ACTIVE; |
|
Quests[Q_MUSHROOM]._qlog = true; |
|
Quests[Q_MUSHROOM]._qvar1 = QS_TOMEGIVEN; |
|
NetSendCmdQuest(true, Quests[Q_MUSHROOM]); |
|
InitQTextMsg(TEXT_MUSH8); |
|
return; |
|
} |
|
if (Quests[Q_MUSHROOM]._qactive == QUEST_ACTIVE) { |
|
if (Quests[Q_MUSHROOM]._qvar1 >= QS_TOMEGIVEN && Quests[Q_MUSHROOM]._qvar1 < QS_MUSHGIVEN) { |
|
if (RemoveInventoryItemById(player, IDI_MUSHROOM)) { |
|
Quests[Q_MUSHROOM]._qvar1 = QS_MUSHGIVEN; |
|
SetTownerQuestDialog(TOWN_HEALER, Q_MUSHROOM, TEXT_MUSH3); |
|
SetTownerQuestDialog(TOWN_WITCH, Q_MUSHROOM, TEXT_NONE); |
|
Quests[Q_MUSHROOM]._qmsg = TEXT_MUSH10; |
|
NetSendCmdQuest(true, Quests[Q_MUSHROOM]); |
|
InitQTextMsg(TEXT_MUSH10); |
|
return; |
|
} |
|
if (Quests[Q_MUSHROOM]._qmsg != TEXT_MUSH9) { |
|
Quests[Q_MUSHROOM]._qmsg = TEXT_MUSH9; |
|
NetSendCmdQuest(true, Quests[Q_MUSHROOM]); |
|
InitQTextMsg(TEXT_MUSH9); |
|
return; |
|
} |
|
} |
|
if (Quests[Q_MUSHROOM]._qvar1 >= QS_MUSHGIVEN) { |
|
if (HasInventoryItemWithId(player, IDI_BRAIN) && Quests[Q_MUSHROOM]._qvar2 != TEXT_MUSH11) { |
|
Quests[Q_MUSHROOM]._qmsg = TEXT_MUSH11; |
|
Quests[Q_MUSHROOM]._qvar2 = TEXT_MUSH11; |
|
NetSendCmdQuest(true, Quests[Q_MUSHROOM]); |
|
InitQTextMsg(TEXT_MUSH11); |
|
return; |
|
} |
|
if (HasInventoryOrBeltItemWithId(player, IDI_SPECELIX)) { |
|
Quests[Q_MUSHROOM]._qactive = QUEST_DONE; |
|
NetSendCmdQuest(true, Quests[Q_MUSHROOM]); |
|
InitQTextMsg(TEXT_MUSH12); |
|
return; |
|
} |
|
} |
|
} |
|
} |
|
|
|
TownerTalk(TEXT_ADRIA1); |
|
StartStore(TalkID::Witch); |
|
} |
|
|
|
void TalkToBarmaid(Player &player, Towner & /*barmaid*/) |
|
{ |
|
if (!player._pLvlVisited[21] && HasInventoryItemWithId(player, IDI_MAPOFDOOM) && Quests[Q_GRAVE]._qmsg != TEXT_GRAVE8) { |
|
Quests[Q_GRAVE]._qactive = QUEST_ACTIVE; |
|
Quests[Q_GRAVE]._qlog = true; |
|
Quests[Q_GRAVE]._qmsg = TEXT_GRAVE8; |
|
NetSendCmdQuest(true, Quests[Q_GRAVE]); |
|
InitQTextMsg(TEXT_GRAVE8); |
|
return; |
|
} |
|
|
|
TownerTalk(TEXT_GILLIAN1); |
|
StartStore(TalkID::Barmaid); |
|
} |
|
|
|
void TalkToDrunk(Player & /*player*/, Towner & /*drunk*/) |
|
{ |
|
TownerTalk(TEXT_FARNHAM1); |
|
StartStore(TalkID::Drunk); |
|
} |
|
|
|
void TalkToHealer(Player &player, Towner &healer) |
|
{ |
|
Quest &poisonWater = Quests[Q_PWATER]; |
|
if (poisonWater._qactive != QUEST_NOTAVAIL) { |
|
if ((poisonWater._qactive == QUEST_INIT && (player._pLvlVisited[1] || player._pLvlVisited[5])) || (poisonWater._qactive == QUEST_ACTIVE && !poisonWater._qlog)) { |
|
// Play the dialog and make the quest visible in the log if the player has not started the quest but has |
|
// visited the dungeon at least once, or if they've found the poison water cave before speaking to Pepin |
|
poisonWater._qactive = QUEST_ACTIVE; |
|
poisonWater._qlog = true; |
|
poisonWater._qmsg = TEXT_POISON3; |
|
InitQTextMsg(TEXT_POISON3); |
|
NetSendCmdQuest(true, poisonWater); |
|
return; |
|
} |
|
if (poisonWater._qactive == QUEST_DONE && poisonWater._qvar1 != 2) { |
|
poisonWater._qvar1 = 2; |
|
InitQTextMsg(TEXT_POISON5); |
|
SpawnUnique(UITEM_TRING, healer.position + Direction::SouthWest, poisonWater._qlevel); |
|
NetSendCmdQuest(true, poisonWater); |
|
return; |
|
} |
|
} |
|
Quest &blackMushroom = Quests[Q_MUSHROOM]; |
|
if (blackMushroom._qactive == QUEST_ACTIVE) { |
|
if (blackMushroom._qvar1 >= QS_MUSHGIVEN && blackMushroom._qvar1 < QS_BRAINGIVEN && RemoveInventoryItemById(player, IDI_BRAIN)) { |
|
SpawnQuestItem(IDI_SPECELIX, healer.position + Displacement { 0, 1 }, 0, SelectionRegion::None, true); |
|
InitQTextMsg(TEXT_MUSH4); |
|
blackMushroom._qvar1 = QS_BRAINGIVEN; |
|
SetTownerQuestDialog(TOWN_HEALER, Q_MUSHROOM, TEXT_NONE); |
|
NetSendCmdQuest(true, blackMushroom); |
|
return; |
|
} |
|
} |
|
|
|
TownerTalk(TEXT_PEPIN1); |
|
StartStore(TalkID::Healer); |
|
} |
|
|
|
void TalkToBoy(Player & /*player*/, Towner & /*boy*/) |
|
{ |
|
TownerTalk(TEXT_WIRT1); |
|
StartStore(TalkID::Boy); |
|
} |
|
|
|
void TalkToStoryteller(Player &player, Towner & /*storyteller*/) |
|
{ |
|
auto &betrayerQuest = Quests[Q_BETRAYER]; |
|
if (!UseMultiplayerQuests()) { |
|
if (betrayerQuest._qactive == QUEST_INIT && RemoveInventoryItemById(player, IDI_LAZSTAFF)) { |
|
InitQTextMsg(TEXT_VILE1); |
|
betrayerQuest._qlog = true; |
|
betrayerQuest._qactive = QUEST_ACTIVE; |
|
betrayerQuest._qvar1 = 2; |
|
NetSendCmdQuest(true, betrayerQuest); |
|
return; |
|
} |
|
} else { |
|
if (betrayerQuest._qactive == QUEST_ACTIVE && !betrayerQuest._qlog) { |
|
InitQTextMsg(TEXT_VILE1); |
|
betrayerQuest._qlog = true; |
|
NetSendCmdQuest(true, betrayerQuest); |
|
return; |
|
} |
|
} |
|
if (betrayerQuest._qactive == QUEST_DONE && betrayerQuest._qvar1 == 7) { |
|
betrayerQuest._qvar1 = 8; |
|
InitQTextMsg(TEXT_VILE3); |
|
auto &diabloQuest = Quests[Q_DIABLO]; |
|
diabloQuest._qlog = true; |
|
if (gbIsMultiplayer) { |
|
NetSendCmdQuest(true, betrayerQuest); |
|
NetSendCmdQuest(true, diabloQuest); |
|
} |
|
return; |
|
} |
|
|
|
TownerTalk(TEXT_STORY1); |
|
StartStore(TalkID::Storyteller); |
|
} |
|
|
|
void TalkToCow(Player &player, Towner &cow) |
|
{ |
|
if (CowPlaying != SfxID::None && effect_is_playing(CowPlaying)) |
|
return; |
|
|
|
CowClicks++; |
|
|
|
CowPlaying = SfxID::Cow1; |
|
if (CowClicks == 4) { |
|
if (gbIsSpawn) |
|
CowClicks = 0; |
|
|
|
CowPlaying = SfxID::Cow2; |
|
} else if (CowClicks >= 8 && !gbIsSpawn) { |
|
CowClicks = 4; |
|
|
|
static const HeroSpeech SnSfx[3] = { |
|
HeroSpeech::YepThatsACowAlright, |
|
HeroSpeech::ImNotThirsty, |
|
HeroSpeech::ImNoMilkmaid, |
|
}; |
|
player.SaySpecific(SnSfx[CowMsg]); |
|
CowMsg++; |
|
if (CowMsg >= 3) |
|
CowMsg = 0; |
|
} |
|
|
|
PlaySfxLoc(CowPlaying, cow.position); |
|
} |
|
|
|
void TalkToFarmer(Player &player, Towner &farmer) |
|
{ |
|
auto &quest = Quests[Q_FARMER]; |
|
switch (quest._qactive) { |
|
case QUEST_NOTAVAIL: |
|
case QUEST_INIT: |
|
if (HasInventoryItemWithId(player, IDI_RUNEBOMB)) { |
|
InitQTextMsg(TEXT_FARMER2); |
|
quest._qactive = QUEST_ACTIVE; |
|
quest._qvar1 = 1; |
|
quest._qmsg = TEXT_FARMER1; |
|
quest._qlog = true; |
|
if (gbIsMultiplayer) |
|
NetSendCmdQuest(true, quest); |
|
break; |
|
} |
|
|
|
if (!player._pLvlVisited[9] && player.getCharacterLevel() < 15) { |
|
_speech_id qt = TEXT_FARMER8; |
|
if (player._pLvlVisited[2]) |
|
qt = TEXT_FARMER5; |
|
if (player._pLvlVisited[5]) |
|
qt = TEXT_FARMER7; |
|
if (player._pLvlVisited[7]) |
|
qt = TEXT_FARMER9; |
|
InitQTextMsg(qt); |
|
break; |
|
} |
|
|
|
InitQTextMsg(TEXT_FARMER1); |
|
quest._qactive = QUEST_ACTIVE; |
|
quest._qvar1 = 1; |
|
quest._qlog = true; |
|
quest._qmsg = TEXT_FARMER1; |
|
SpawnRuneBomb(farmer.position + Displacement { 1, 0 }, true); |
|
if (gbIsMultiplayer) |
|
NetSendCmdQuest(true, quest); |
|
break; |
|
case QUEST_ACTIVE: |
|
InitQTextMsg(HasInventoryItemWithId(player, IDI_RUNEBOMB) ? TEXT_FARMER2 : TEXT_FARMER3); |
|
break; |
|
case QUEST_DONE: |
|
InitQTextMsg(TEXT_FARMER4); |
|
SpawnRewardItem(IDI_AURIC, farmer.position + Displacement { 1, 0 }, true); |
|
quest._qactive = QUEST_HIVE_DONE; |
|
if (gbIsMultiplayer) |
|
NetSendCmdQuest(true, quest); |
|
break; |
|
case QUEST_HIVE_DONE: |
|
break; |
|
default: |
|
InitQTextMsg(TEXT_FARMER4); |
|
break; |
|
} |
|
} |
|
|
|
void TalkToCowFarmer(Player &player, Towner &cowFarmer) |
|
{ |
|
if (RemoveInventoryItemById(player, IDI_GREYSUIT)) { |
|
InitQTextMsg(TEXT_JERSEY7); |
|
return; |
|
} |
|
|
|
auto &quest = Quests[Q_JERSEY]; |
|
|
|
if (RemoveInventoryItemById(player, IDI_BROWNSUIT)) { |
|
SpawnUnique(UITEM_BOVINE, cowFarmer.position + Direction::SouthEast, quest._qlevel); |
|
InitQTextMsg(TEXT_JERSEY8); |
|
quest._qactive = QUEST_DONE; |
|
UpdateCowFarmerAnimAfterQuestComplete(); |
|
NetSendCmdQuest(true, quest); |
|
return; |
|
} |
|
|
|
if (HasInventoryItemWithId(player, IDI_RUNEBOMB)) { |
|
InitQTextMsg(TEXT_JERSEY5); |
|
quest._qactive = QUEST_ACTIVE; |
|
quest._qvar1 = 1; |
|
quest._qmsg = TEXT_JERSEY4; |
|
quest._qlog = true; |
|
NetSendCmdQuest(true, quest); |
|
return; |
|
} |
|
|
|
switch (quest._qactive) { |
|
case QUEST_NOTAVAIL: |
|
case QUEST_INIT: |
|
InitQTextMsg(TEXT_JERSEY1); |
|
quest._qactive = QUEST_HIVE_TEASE1; |
|
if (gbIsMultiplayer) |
|
NetSendCmdQuest(true, quest); |
|
break; |
|
case QUEST_DONE: |
|
InitQTextMsg(TEXT_JERSEY1); |
|
break; |
|
case QUEST_HIVE_TEASE1: |
|
InitQTextMsg(TEXT_JERSEY2); |
|
quest._qactive = QUEST_HIVE_TEASE2; |
|
if (gbIsMultiplayer) |
|
NetSendCmdQuest(true, quest); |
|
break; |
|
case QUEST_HIVE_TEASE2: |
|
InitQTextMsg(TEXT_JERSEY3); |
|
quest._qactive = QUEST_HIVE_ACTIVE; |
|
if (gbIsMultiplayer) |
|
NetSendCmdQuest(true, quest); |
|
break; |
|
case QUEST_HIVE_ACTIVE: |
|
if (!player._pLvlVisited[9] && player.getCharacterLevel() < 15) { |
|
InitQTextMsg(PickRandomlyAmong({ TEXT_JERSEY9, TEXT_JERSEY10, TEXT_JERSEY11, TEXT_JERSEY12 })); |
|
break; |
|
} |
|
|
|
InitQTextMsg(TEXT_JERSEY4); |
|
quest._qactive = QUEST_ACTIVE; |
|
quest._qvar1 = 1; |
|
quest._qmsg = TEXT_JERSEY4; |
|
quest._qlog = true; |
|
SpawnRuneBomb(cowFarmer.position + Displacement { 1, 0 }, true); |
|
if (gbIsMultiplayer) |
|
NetSendCmdQuest(true, quest); |
|
break; |
|
default: |
|
InitQTextMsg(TEXT_JERSEY5); |
|
break; |
|
} |
|
} |
|
|
|
void TalkToGirl(Player &player, Towner &girl) |
|
{ |
|
auto &quest = Quests[Q_GIRL]; |
|
|
|
if (quest._qactive != QUEST_DONE && RemoveInventoryItemById(player, IDI_THEODORE)) { |
|
InitQTextMsg(TEXT_GIRL4); |
|
CreateAmulet(girl.position, 13, false, false, true); |
|
quest._qactive = QUEST_DONE; |
|
UpdateGirlAnimAfterQuestComplete(); |
|
if (gbIsMultiplayer) |
|
NetSendCmdQuest(true, quest); |
|
return; |
|
} |
|
|
|
switch (quest._qactive) { |
|
case QUEST_NOTAVAIL: |
|
case QUEST_INIT: |
|
InitQTextMsg(TEXT_GIRL2); |
|
quest._qactive = QUEST_ACTIVE; |
|
quest._qvar1 = 1; |
|
quest._qlog = true; |
|
quest._qmsg = TEXT_GIRL2; |
|
if (gbIsMultiplayer) |
|
NetSendCmdQuest(true, quest); |
|
return; |
|
case QUEST_ACTIVE: |
|
InitQTextMsg(TEXT_GIRL3); |
|
return; |
|
default: |
|
return; |
|
} |
|
} |
|
|
|
const TownerData TownersData[] = { |
|
// clang-format off |
|
// type init (nullptr = default) talk |
|
{ TOWN_SMITH, nullptr, TalkToBlackSmith }, |
|
{ TOWN_HEALER, nullptr, TalkToHealer }, |
|
{ TOWN_DEADGUY, nullptr, TalkToDeadguy }, |
|
{ TOWN_TAVERN, nullptr, TalkToBarOwner }, |
|
{ TOWN_STORY, nullptr, TalkToStoryteller }, |
|
{ TOWN_DRUNK, nullptr, TalkToDrunk }, |
|
{ TOWN_WITCH, nullptr, TalkToWitch }, |
|
{ TOWN_BMAID, nullptr, TalkToBarmaid }, |
|
{ TOWN_PEGBOY, nullptr, TalkToBoy }, |
|
{ TOWN_COW, InitCows, TalkToCow }, |
|
{ TOWN_COWFARM, InitCowFarmer, TalkToCowFarmer }, |
|
{ TOWN_FARMER, nullptr, TalkToFarmer }, |
|
{ TOWN_GIRL, nullptr, TalkToGirl }, |
|
// clang-format on |
|
}; |
|
|
|
} // namespace |
|
|
|
std::vector<Towner> Towners; |
|
|
|
std::unordered_map<_talker_id, std::string> TownerLongNames; |
|
|
|
size_t GetNumTownerTypes() |
|
{ |
|
return TownerLongNames.size(); |
|
} |
|
|
|
size_t GetNumTowners() |
|
{ |
|
return Towners.size(); |
|
} |
|
|
|
bool IsTownerPresent(_talker_id npc) |
|
{ |
|
switch (npc) { |
|
case TOWN_DEADGUY: |
|
return Quests[Q_BUTCHER]._qactive != QUEST_NOTAVAIL && Quests[Q_BUTCHER]._qactive != QUEST_DONE; |
|
case TOWN_FARMER: |
|
return gbIsHellfire && sgGameInitInfo.bCowQuest == 0 && Quests[Q_FARMER]._qactive != QUEST_HIVE_DONE; |
|
case TOWN_COWFARM: |
|
return gbIsHellfire && sgGameInitInfo.bCowQuest != 0; |
|
case TOWN_GIRL: |
|
return gbIsHellfire && sgGameInitInfo.bTheoQuest != 0 && MyPlayer->_pLvlVisited[17] && Quests[Q_GIRL]._qactive != QUEST_DONE; |
|
default: |
|
return true; |
|
} |
|
} |
|
|
|
Towner *GetTowner(_talker_id type) |
|
{ |
|
for (Towner &towner : Towners) { |
|
if (towner._ttype == type) |
|
return &towner; |
|
} |
|
return nullptr; |
|
} |
|
|
|
void InitTowners() |
|
{ |
|
assert(!CowSprites); |
|
|
|
// Load towner data from TSV files |
|
LoadTownerData(); |
|
TownerAnimOrderStorage.clear(); |
|
|
|
// Build lookup table for towner behaviors |
|
TownerBehaviors.clear(); |
|
for (const auto &behavior : TownersData) { |
|
TownerBehaviors[behavior.type] = &behavior; |
|
} |
|
|
|
// Build TownerLongNames from TSV data (first occurrence of each type wins) |
|
TownerLongNames.clear(); |
|
for (const auto &entry : TownersDataEntries) { |
|
TownerLongNames.try_emplace(entry.type, entry.name); |
|
} |
|
|
|
CowSprites.emplace(LoadCelSheet("towners\\animals\\cow", 128)); |
|
|
|
Towners.clear(); |
|
Towners.reserve(TownersDataEntries.size()); |
|
int16_t i = 0; |
|
for (const auto &entry : TownersDataEntries) { |
|
if (!IsTownerPresent(entry.type)) |
|
continue; |
|
|
|
auto behaviorIt = TownerBehaviors.find(entry.type); |
|
if (behaviorIt == TownerBehaviors.end() || behaviorIt->second == nullptr) |
|
continue; |
|
|
|
// It's necessary to assign this before invoking townerData.init() |
|
// specifically for the cows that need to read this value to fill adjacent tiles |
|
dMonster[entry.position.x][entry.position.y] = i + 1; |
|
|
|
Towners.emplace_back(); |
|
InitTownerInfo(Towners.back(), *behaviorIt->second, entry); |
|
i++; |
|
} |
|
} |
|
|
|
void FreeTownerGFX() |
|
{ |
|
for (Towner &towner : Towners) { |
|
towner.ownedAnim = std::nullopt; |
|
} |
|
CowSprites = std::nullopt; |
|
} |
|
|
|
void ProcessTowners() |
|
{ |
|
// BUGFIX: should be `i < numtowners`, was `i < NUM_TOWNERS` |
|
for (auto &towner : Towners) { |
|
if (towner._ttype == TOWN_DEADGUY) { |
|
TownDead(towner); |
|
} |
|
|
|
towner._tAnimCnt++; |
|
if (towner._tAnimCnt < towner._tAnimDelay) { |
|
continue; |
|
} |
|
|
|
towner._tAnimCnt = 0; |
|
|
|
if (!towner.animOrder.empty()) { |
|
towner._tAnimFrameCnt++; |
|
if (towner._tAnimFrameCnt > towner.animOrder.size() - 1) |
|
towner._tAnimFrameCnt = 0; |
|
|
|
towner._tAnimFrame = towner.animOrder[towner._tAnimFrameCnt]; |
|
continue; |
|
} |
|
|
|
towner._tAnimFrame++; |
|
if (towner._tAnimFrame >= towner._tAnimLen) |
|
towner._tAnimFrame = 0; |
|
} |
|
} |
|
|
|
void TalkToTowner(Player &player, int t) |
|
{ |
|
auto &towner = Towners[t]; |
|
|
|
if (player.position.tile.WalkingDistance(towner.position) >= 2) |
|
return; |
|
|
|
if (!player.HoldItem.isEmpty()) { |
|
return; |
|
} |
|
|
|
towner.talk(player, towner); |
|
} |
|
|
|
void UpdateGirlAnimAfterQuestComplete() |
|
{ |
|
Towner *girl = GetTowner(TOWN_GIRL); |
|
if (girl == nullptr || !girl->ownedAnim) |
|
return; // Girl is not spawned in town yet |
|
auto curFrame = girl->_tAnimFrame; |
|
LoadTownerAnimations(*girl, "towners\\girl\\girls1", 20, 6); |
|
girl->_tAnimFrame = std::min<uint8_t>(curFrame, girl->_tAnimLen - 1); |
|
} |
|
|
|
void UpdateCowFarmerAnimAfterQuestComplete() |
|
{ |
|
Towner *cowFarmer = GetTowner(TOWN_COWFARM); |
|
auto curFrame = cowFarmer->_tAnimFrame; |
|
LoadTownerAnimations(*cowFarmer, "towners\\farmer\\mfrmrn2", 15, 3); |
|
cowFarmer->_tAnimFrame = std::min<uint8_t>(curFrame, cowFarmer->_tAnimLen - 1); |
|
} |
|
|
|
#ifdef _DEBUG |
|
bool DebugTalkToTowner(_talker_id type) |
|
{ |
|
if (!IsTownerPresent(type)) |
|
return false; |
|
// cows have an init function that differs from the rest and isn't compatible with this code, skip them :( |
|
if (type == TOWN_COW) |
|
return false; |
|
|
|
const TownerData *behavior = TownerBehaviors[type]; |
|
if (behavior == nullptr) |
|
return false; |
|
|
|
const TownerDataEntry *entry = FindTownerDataEntry(type); |
|
if (entry == nullptr) |
|
return false; |
|
|
|
SetupTownStores(); |
|
Player &myPlayer = *MyPlayer; |
|
Towner fakeTowner; |
|
InitTownerInfo(fakeTowner, *behavior, *entry); |
|
fakeTowner.position = myPlayer.position.tile; |
|
behavior->talk(myPlayer, fakeTowner); |
|
return true; |
|
} |
|
#endif |
|
|
|
} // namespace devilution
|
|
|