Add Lua towns.register with role-based per-town NPCs (#8774)

This commit is contained in:
Yuri Pourre authored and GitHub committed 2026-11-07 18:41:05 +00:00
1 parent 8a6b857427
commit 74ce5cdb15
20 files changed
+745 -69

No files matched your search

+1
View File
@@ -118,6 +118,7 @@ set(libdevilutionx_SRCS
lua/modules/render.cpp
lua/modules/system.cpp
lua/modules/towners.cpp
lua/modules/towns.cpp
lua/repl.cpp
monsters/validation.cpp
+19
View File
@@ -48,6 +48,23 @@ struct TownEntryPoint {
int warpFromLevel; // Source level for ENTRY_TWARPUP (-1 for any)
};
/**
* @brief An NPC placed in a town.
*
* `role` selects the built-in behavior (see ParseTownerRole in towners.h). Optional fields that are
* left unset fall back to the Tristram towners.tsv row for that role.
*/
struct TownNpc {
std::string role; // e.g. "healer", "smith", "storyteller"
Point position;
std::optional<std::string> name;
std::optional<std::string> animPath;
std::optional<uint8_t> animFrames;
std::optional<int16_t> animDelay;
/** Speech ids by enum name, e.g. "TEXT_PEPIN2". */
std::optional<std::vector<std::string>> gossip;
};
/**
* @brief Dungeon entrance / town warp trigger (see InitTownTriggers)
*/
@@ -127,6 +144,8 @@ struct TownConfig {
std::vector<TownSector> sectors;
std::vector<TownEntryPoint> entries;
std::vector<TownTrigger> triggers;
/** When empty, the default towners.tsv NPCs are used. */
std::vector<TownNpc> npcs;
std::array<Point, NumTownPortalSlots> portalPositions = DefaultTristramPortalPositions;
/**
+2
View File
@@ -37,6 +37,7 @@
#include "lua/modules/render.hpp"
#include "lua/modules/system.hpp"
#include "lua/modules/towners.hpp"
#include "lua/modules/towns.hpp"
#include "options.h"
#include "plrmsg.h"
#include "stores.h"
@@ -314,6 +315,7 @@ void LuaInitialize()
"devilutionx.player", LuaPlayerModule(lua),
"devilutionx.render", LuaRenderModule(lua),
"devilutionx.towners", LuaTownersModule(lua),
"devilutionx.towns", LuaTownsModule(lua),
"devilutionx.hellfire", LuaHellfireModule(lua),
"devilutionx.system", LuaSystemModule(lua),
"devilutionx.floatingnumbers", LuaFloatingNumbersModule(lua),
+239
View File
@@ -0,0 +1,239 @@
#include "lua/modules/towns.hpp"
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <sol/sol.hpp>
#include "interfac.h"
#include "levels/gendung_defs.hpp"
#include "levels/town_data.h"
#include "lua/metadoc.hpp"
#include "towners.h"
#include "utils/log.hpp"
namespace devilution {
namespace {
std::optional<dungeon_type> ParseWarpGateString(std::string_view w)
{
if (w == "catacombs")
return DTYPE_CATACOMBS;
if (w == "caves")
return DTYPE_CAVES;
if (w == "hell")
return DTYPE_HELL;
if (w == "nest")
return DTYPE_NEST;
if (w == "crypt")
return DTYPE_CRYPT;
return std::nullopt;
}
std::string LuaRegisterTown(std::string_view townId, const sol::table &config)
{
TownConfig townConfig;
sol::optional<std::string> name = config["name"];
townConfig.name = name.has_value() ? *name : std::string(townId);
sol::optional<int> saveId = config["saveId"];
townConfig.saveId = saveId.has_value() ? static_cast<uint8_t>(*saveId) : 0;
if (sol::optional<sol::table> assets = config["assets"]) {
sol::table t = *assets;
if (sol::optional<std::string> v = t["dungeonCel"])
townConfig.assets.dungeonCelPath = *v;
if (sol::optional<std::string> v = t["megaTile"])
townConfig.assets.megaTilePath = *v;
if (sol::optional<std::string> v = t["min"])
townConfig.assets.pieceMinPath = *v;
if (sol::optional<std::string> v = t["specialCels"])
townConfig.assets.specialCelsPath = *v;
if (sol::optional<std::string> v = t["sol"])
townConfig.assets.solPath = *v;
if (sol::optional<std::string> v = t["palette"])
townConfig.assets.palettePath = *v;
if (sol::optional<int> v = t["microTileLen"])
townConfig.assets.microTileLen = static_cast<uint_fast8_t>(*v);
}
if (sol::optional<sol::table> bounds = config["bounds"]) {
if (sol::optional<sol::table> min = (*bounds)["min"]) {
sol::optional<int> minX = (*min)["x"];
sol::optional<int> minY = (*min)["y"];
townConfig.dminPosition = { minX.value_or(10), minY.value_or(10) };
}
if (sol::optional<sol::table> max = (*bounds)["max"]) {
sol::optional<int> maxX = (*max)["x"];
sol::optional<int> maxY = (*max)["y"];
townConfig.dmaxPosition = { maxX.value_or(84), maxY.value_or(84) };
}
}
if (sol::optional<sol::table> sectors = config["sectors"]) {
for (const auto &kv : *sectors) {
sol::table sector = kv.second.as<sol::table>();
TownSector s;
sol::optional<std::string> path = sector["path"];
s.filePath = path.value_or("");
sol::optional<int> sx = sector["x"];
sol::optional<int> sy = sector["y"];
s.x = sx.value_or(0);
s.y = sy.value_or(0);
townConfig.sectors.push_back(s);
}
}
if (sol::optional<sol::table> entries = config["entries"]) {
for (const auto &kv : *entries) {
sol::table entry = kv.second.as<sol::table>();
TownEntryPoint ep;
sol::optional<std::string> typeStr = entry["type"];
std::string type = typeStr.value_or("main");
if (type == "prev")
ep.entryType = ENTRY_PREV;
else if (type == "twarpdn")
ep.entryType = ENTRY_TWARPDN;
else if (type == "twarpup")
ep.entryType = ENTRY_TWARPUP;
else
ep.entryType = ENTRY_MAIN;
sol::optional<int> ex = entry["x"];
sol::optional<int> ey = entry["y"];
sol::optional<int> warpFrom = entry["warpFrom"];
ep.viewPosition = { ex.value_or(75), ey.value_or(68) };
ep.warpFromLevel = warpFrom.value_or(-1);
townConfig.entries.push_back(ep);
}
}
if (sol::optional<sol::table> npcs = config["npcs"]) {
for (const auto &kv : *npcs) {
sol::table t = kv.second.as<sol::table>();
TownNpc npc;
sol::optional<std::string> role = t["role"];
if (!role.has_value() || !ParseTownerRole(*role).has_value()) {
LogError("registerTown: npcs[] has unknown or missing role '{}'", role.value_or(""));
continue;
}
npc.role = *role;
sol::optional<int> nx = t["x"];
sol::optional<int> ny = t["y"];
npc.position = { nx.value_or(0), ny.value_or(0) };
if (sol::optional<std::string> v = t["name"])
npc.name = *v;
if (sol::optional<std::string> v = t["anim"])
npc.animPath = *v;
if (sol::optional<int> v = t["frames"])
npc.animFrames = static_cast<uint8_t>(*v);
if (sol::optional<int> v = t["delay"])
npc.animDelay = static_cast<int16_t>(*v);
if (sol::optional<sol::table> gossip = t["gossip"]) {
std::vector<std::string> ids;
for (const auto &g : *gossip)
ids.push_back(g.second.as<std::string>());
npc.gossip = std::move(ids);
}
townConfig.npcs.push_back(std::move(npc));
}
}
if (sol::optional<sol::table> triggerList = config["triggers"]) {
for (const auto &kv : *triggerList) {
sol::table t = kv.second.as<sol::table>();
TownTrigger tr;
sol::optional<int> tx = t["x"];
sol::optional<int> ty = t["y"];
tr.position = { tx.value_or(0), ty.value_or(0) };
sol::optional<std::string> kindStr = t["kind"];
const std::string kind = kindStr.value_or("nextlevel");
if (kind == "townwarp") {
tr.msg = WM_DIABTOWNWARP;
sol::optional<int> lvl = t["level"];
tr.level = lvl.value_or(0);
sol::optional<std::string> warpStr = t["warp"];
if (warpStr.has_value() && !warpStr->empty()) {
std::optional<dungeon_type> gate = ParseWarpGateString(*warpStr);
if (!gate.has_value()) {
LogError("registerTown: unknown triggers[].warp '{}'", *warpStr);
continue;
}
tr.warpGate = gate;
}
} else if (kind == "nextlevel") {
tr.msg = WM_DIABNEXTLVL;
tr.level = 0;
} else {
LogError("registerTown: unknown triggers[].kind '{}', expected nextlevel or townwarp", kind);
continue;
}
townConfig.triggers.push_back(tr);
}
}
if (sol::optional<sol::table> portals = config["portals"]) {
size_t slot = 0;
for (const auto &kv : *portals) {
if (slot >= NumTownPortalSlots) {
LogWarn("registerTown: portals list has more than {} entries; ignoring extras", NumTownPortalSlots);
break;
}
sol::table t = kv.second.as<sol::table>();
sol::optional<int> px = t["x"];
sol::optional<int> py = t["y"];
townConfig.portalPositions[slot] = { px.value_or(0), py.value_or(0) };
++slot;
}
}
std::string townIdStr(townId);
GetTownRegistry().RegisterTown(townIdStr, townConfig);
return townIdStr;
}
std::string LuaGetCurrentTown()
{
return GetTownRegistry().GetCurrentTown();
}
bool LuaHasTown(std::string_view townId)
{
return GetTownRegistry().HasTown(std::string(townId));
}
} // namespace
sol::table LuaTownsModule(sol::state_view &lua)
{
sol::table table = lua.create_table();
LuaSetDocFn(table, "register", "(townId: string, config: table) -> string",
"Registers a new town from a config table. Returns town ID.\n"
"Optional assets: { dungeonCel, megaTile, min, specialCels, sol, palette, microTileLen } — paths for the tile gfx/palette.\n"
" microTileLen: sub-tile count per mega-tile (16 for town.cel, 10 for L1/L2/L3/L6 CEL, 12 for L4). Default: 16.\n"
" Omitted path keys keep the Tristram default. On load failure the engine falls back through Tristram's full path chain.\n"
" Omitting palette uses levels\\towndata\\town.pal regardless of other assets.\n"
"Optional triggers: array of tables with x, y, kind (\"nextlevel\" or \"townwarp\").\n"
"For townwarp, set level (dungeon level) and warp (\"catacombs\", \"caves\", \"hell\", \"nest\", \"crypt\") for IsWarpOpen gating.\n"
"Optional portals: up to four { x, y } tables for town portal spell positions (defaults match Tristram).\n"
"Optional npcs: array of tables { role, x, y, name?, anim?, frames?, delay?, gossip? }.\n"
" role selects the behavior: smith, healer, deadguy, tavern, storyteller, drunk, witch, barmaid, boy, cow, farmer, girl, cowfarmer.\n"
" Omitted fields fall back to Tristram's data for that role. gossip is a list of speech ids such as \"TEXT_PEPIN2\".\n"
" A renamed NPC is addressed by its name in menus (\"Talk to ...\"). Without npcs, the default Tristram NPCs are used.",
LuaRegisterTown);
LuaSetDocFn(table, "current", "() -> string",
"Returns the current town ID.",
LuaGetCurrentTown);
LuaSetDocFn(table, "exists", "(townId: string) -> boolean",
"Checks if a town is registered.",
LuaHasTown);
return table;
}
} // namespace devilution
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <sol/sol.hpp>
namespace devilution {
sol::table LuaTownsModule(sol::state_view &lua);
} // namespace devilution
+1 -1
View File
@@ -574,7 +574,7 @@ bool InitMulti(GameData *gameData)
} // namespace
DVL_API_FOR_TEST std::string DescribeLeaveReason(leaveinfo_t leaveReason)
std::string DescribeLeaveReason(leaveinfo_t leaveReason)
{
switch (leaveReason) {
case leaveinfo_t::LEAVE_EXIT:
+1 -3
View File
@@ -13,8 +13,6 @@
#include "dvlnet/leaveinfo.hpp"
#include "game_mode.hpp"
#include "msg.h"
#include "utils/attributes.h"
namespace devilution {
using net::leaveinfo_t;
@@ -47,7 +45,7 @@ extern uint32_t player_state[MAX_PLRS];
extern bool IsLoopback;
void SwapGameDataLE(GameData &gameData);
DVL_API_FOR_TEST std::string DescribeLeaveReason(leaveinfo_t leaveReason);
std::string DescribeLeaveReason(leaveinfo_t leaveReason);
std::string FormatGameSeed(const uint32_t gameSeed[4]);
void InitGameInfo();
+9 -2
View File
@@ -17,6 +17,7 @@
#include "levels/dun_tile_data.hpp"
#include "levels/gendung.h"
#include "levels/town.h"
#include "levels/town_data.h"
#include "levels/trigs.h"
#include "missiles.h"
#include "monster.h"
@@ -318,8 +319,14 @@ Point GetMapReturnPosition()
return Quests[Q_PWATER].position + Direction::SouthWest;
case SL_VILEBETRAYER:
return Quests[Q_BETRAYER].position + Direction::South;
default:
return GetTowner(TOWN_DRUNK)->position + Direction::SouthEast;
default: {
if (const Towner *drunk = GetTowner(TOWN_DRUNK); drunk != nullptr)
return drunk->position + Direction::SouthEast;
// Custom towns may not have a drunk; use the town's main entry point instead.
if (const TownConfig *town = GetCurrentTownConfig(); town != nullptr)
return town->GetEntryPoint(ENTRY_MAIN);
return ViewPosition;
}
}
}
+15 -28
View File
@@ -198,19 +198,6 @@ int8_t CountdownScrollUp;
/** Countdown for the push state of the scroll down button */
int8_t CountdownScrollDown;
/** Maps from towner IDs to NPC names. */
const char *const TownerNames[] = {
N_("Griswold"),
N_("Pepin"),
"",
N_("Ogden"),
N_("Cain"),
N_("Farnham"),
N_("Adria"),
N_("Gillian"),
N_("Wirt"),
};
constexpr int PaddingTop = 32;
// For most languages, line height is always 12.
@@ -474,7 +461,7 @@ void StartSmith()
AddSText(0, 1, _("Welcome to the"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 3, _("Blacksmith's shop"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 7, _("Would you like to:"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddTalkMenuEntry(10, _("Talk to Griswold"));
AddTalkMenuEntry(10, FormatRuntime(_("Talk to {:s}"), GetTownerShortName(TOWN_SMITH)));
if (*GetOptions().Gameplay.storeUi == StoreUi::VisualGrid) {
AddMenuEntry(12, _("Trade / Repair"));
AddMenuEntry(14, _("Leave the shop"));
@@ -731,7 +718,7 @@ void StartWitch()
HasScrollbar = false;
AddSText(0, 2, _("Witch's shack"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 9, _("Would you like to:"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddTalkMenuEntry(12, _("Talk to Adria"));
AddTalkMenuEntry(12, FormatRuntime(_("Talk to {:s}"), GetTownerShortName(TOWN_WITCH)));
if (*GetOptions().Gameplay.storeUi == StoreUi::VisualGrid) {
AddMenuEntry(14, _("Buy / Sell"));
AddMenuEntry(16, _("Recharge staves"));
@@ -1010,17 +997,17 @@ void StartBoy()
{
IsTextFullSize = false;
HasScrollbar = false;
AddSText(0, 2, _("Wirt the Peg-legged boy"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 2, GetTownerFullName(TOWN_PEGBOY), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSLine(5);
if (!BoyItem.isEmpty()) {
AddTalkMenuEntry(8, _("Talk to Wirt"));
AddTalkMenuEntry(8, FormatRuntime(_("Talk to {:s}"), GetTownerShortName(TOWN_PEGBOY)));
AddMenuLabel(12, _("I have something for sale,"));
AddMenuLabel(14, _("but it will cost 50 gold"));
AddMenuLabel(16, _("just to take a look. "));
AddMenuEntry(18, _("What have you got?"));
AddMenuEntry(20, _("Say goodbye"));
} else {
AddTalkMenuEntry(12, _("Talk to Wirt"));
AddTalkMenuEntry(12, FormatRuntime(_("Talk to {:s}"), GetTownerShortName(TOWN_PEGBOY)));
AddMenuEntry(18, _("Say goodbye"));
}
}
@@ -1073,7 +1060,7 @@ void StartHealer()
AddSText(0, 1, _("Welcome to the"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 3, _("Healer's home"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 9, _("Would you like to:"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddTalkMenuEntry(12, _("Talk to Pepin"));
AddTalkMenuEntry(12, FormatRuntime(_("Talk to {:s}"), GetTownerShortName(TOWN_HEALER)));
AddMenuEntry(14, _("Buy items"));
AddMenuEntry(18, _("Leave Healer's home"));
AddSLine(5);
@@ -1113,7 +1100,7 @@ void StartStoryteller()
HasScrollbar = false;
AddSText(0, 2, _("The Town Elder"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 9, _("Would you like to:"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddTalkMenuEntry(12, _("Talk to Cain"));
AddTalkMenuEntry(12, FormatRuntime(_("Talk to {:s}"), GetTownerShortName(TOWN_STORY)));
AddMenuEntry(14, _("Identify an item"));
AddMenuEntry(18, _("Say goodbye"));
AddSLine(5);
@@ -1245,10 +1232,10 @@ void StartTalk()
IsTextFullSize = false;
HasScrollbar = false;
AddSText(0, 2, FormatRuntime(_("Talk to {:s}"), _(TownerNames[TownerId])), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 2, FormatRuntime(_("Talk to {:s}"), GetTownerShortName(TownerId)), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSLine(5);
if (gbIsSpawn) {
AddSText(0, 10, FormatRuntime(_("Talking to {:s}"), _(TownerNames[TownerId])), UiFlags::ColorWhite | UiFlags::AlignCenter, false);
AddSText(0, 10, FormatRuntime(_("Talking to {:s}"), GetTownerShortName(TownerId)), UiFlags::ColorWhite | UiFlags::AlignCenter, false);
AddSText(0, 12, _("is not available"), UiFlags::ColorWhite | UiFlags::AlignCenter, false);
AddSText(0, 14, _("in the shareware"), UiFlags::ColorWhite | UiFlags::AlignCenter, false);
AddSText(0, 16, _("version"), UiFlags::ColorWhite | UiFlags::AlignCenter, false);
@@ -1289,7 +1276,7 @@ void StartTavern()
AddSText(0, 1, _("Welcome to the"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 3, _("Rising Sun"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 9, _("Would you like to:"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddTalkMenuEntry(12, _("Talk to Ogden"));
AddTalkMenuEntry(12, FormatRuntime(_("Talk to {:s}"), GetTownerShortName(TOWN_TAVERN)));
AddMenuEntry(18, _("Leave the tavern"));
AddSLine(5);
CurrentItemIndex = 20;
@@ -1299,9 +1286,9 @@ void StartBarmaid()
{
IsTextFullSize = false;
HasScrollbar = false;
AddSText(0, 2, _("Gillian"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 2, GetTownerShortName(TOWN_BMAID), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 9, _("Would you like to:"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddTalkMenuEntry(12, _("Talk to Gillian"));
AddTalkMenuEntry(12, FormatRuntime(_("Talk to {:s}"), GetTownerShortName(TOWN_BMAID)));
AddMenuEntry(14, _("Access Storage"));
AddMenuEntry(18, _("Say goodbye"));
AddSLine(5);
@@ -1312,9 +1299,9 @@ void StartDrunk()
{
IsTextFullSize = false;
HasScrollbar = false;
AddSText(0, 2, _("Farnham the Drunk"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 2, GetTownerFullName(TOWN_DRUNK), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddSText(0, 9, _("Would you like to:"), UiFlags::ColorWhitegold | UiFlags::AlignCenter, false);
AddTalkMenuEntry(12, _("Talk to Farnham"));
AddTalkMenuEntry(12, FormatRuntime(_("Talk to {:s}"), GetTownerShortName(TOWN_DRUNK)));
AddMenuEntry(18, _("Say Goodbye"));
AddSLine(5);
CurrentItemIndex = 20;
@@ -2025,7 +2012,7 @@ void TalkEnter()
}
if (CurrentTextLine == sn - 2) {
Towner *target = GetTowner(TownerId);
Towner *target = GetTalkingTowner(TownerId);
assert(target != nullptr);
InitQTextMsg(target->gossip);
return;
+1 -1
View File
@@ -125,7 +125,7 @@ extern DVL_API_FOR_TEST std::vector<std::pair<std::string, std::vector<TownerDia
* Only maps top-level store entries (Smith, Witch, Boy, Healer, Storyteller, Tavern, Drunk, Barmaid).
* Sub-pages (SmithBuy, WitchSell, etc.) return nullptr.
*/
DVL_API_FOR_TEST const char *TownerNameForTalkID(TalkID s);
const char *TownerNameForTalkID(TalkID s);
/**
* @brief Registers a dynamic dialog option for a towner's talk menu.
+1
View File
@@ -110,6 +110,7 @@ void LoadTownersFromFile()
reader.read("type", entry.type, ParseEnum<_talker_id>);
reader.readString("name", entry.name);
reader.readString("shortName", entry.shortName);
reader.readInt("position_x", entry.position.x);
reader.readInt("position_y", entry.position.y);
reader.read("direction", entry.direction, ParseEnum<Direction>);
+2
View File
@@ -25,6 +25,8 @@ namespace devilution {
struct TownerDataEntry {
_talker_id type; // Parsed from TSV using magic_enum
std::string name;
/** Name used in menus such as "Talk to ..."; empty means use `name`. */
std::string shortName;
Point position;
Direction direction;
uint16_t animWidth;
+142 -4
View File
@@ -2,7 +2,9 @@
#include <algorithm>
#include <cstdint>
#include <string_view>
#include <unordered_map>
#include <utility>
#include "cursor.h"
#include "cursor_defs.hpp"
@@ -12,12 +14,14 @@
#include "engine/random.hpp"
#include "game_mode.hpp"
#include "inv.h"
#include "levels/town_data.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/log.hpp"
#include "utils/str_case.hpp"
namespace devilution {
@@ -30,6 +34,9 @@ int CowClicks;
/** Specifies the active sound effect ID for interacting with cows. */
SfxID CowPlaying = SfxID::None;
/** Towner entries for the currently loaded town (kept alive for string_view/span users). */
std::vector<TownerDataEntry> ActiveTownerEntries;
/** Storage for animation order data loaded from TSV (needs stable addresses for span). */
std::vector<std::vector<uint8_t>> TownerAnimOrderStorage;
@@ -92,8 +99,8 @@ void NewTownerAnim(Towner &towner, ClxSpriteList sprites, uint8_t numFrames, int
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.name = _(entry.name.c_str());
towner.shortName = entry.shortName.empty() ? towner.name : _(entry.shortName.c_str());
towner.position = entry.position;
towner.talk = townerData.talk;
@@ -748,6 +755,129 @@ bool IsTownerPresent(_talker_id npc)
}
}
namespace {
constexpr std::pair<std::string_view, _talker_id> TownerRoles[] = {
{ "smith", TOWN_SMITH },
{ "healer", TOWN_HEALER },
{ "deadguy", TOWN_DEADGUY },
{ "tavern", TOWN_TAVERN },
{ "storyteller", TOWN_STORY },
{ "drunk", TOWN_DRUNK },
{ "witch", TOWN_WITCH },
{ "barmaid", TOWN_BMAID },
{ "boy", TOWN_PEGBOY },
{ "cow", TOWN_COW },
{ "farmer", TOWN_FARMER },
{ "girl", TOWN_GIRL },
{ "cowfarmer", TOWN_COWFARM },
};
} // namespace
std::optional<_talker_id> ParseTownerRole(std::string_view role)
{
for (const auto &[name, type] : TownerRoles) {
if (name == role)
return type;
}
return std::nullopt;
}
namespace {
std::string_view GetTownerFullNameDefault(_talker_id type)
{
const auto nameIt = TownerLongNames.find(type);
return nameIt != TownerLongNames.end() ? _(nameIt->second.c_str()) : std::string_view {};
}
} // namespace
static std::optional<size_t> TalkingTownerIndex;
void SetTalkingTowner(std::optional<size_t> index)
{
TalkingTownerIndex = index;
}
Towner *GetTalkingTowner(_talker_id type)
{
if (TalkingTownerIndex.has_value() && *TalkingTownerIndex < Towners.size() && Towners[*TalkingTownerIndex]._ttype == type)
return &Towners[*TalkingTownerIndex];
return GetTowner(type);
}
std::string_view GetTownerShortName(_talker_id type)
{
if (const Towner *towner = GetTalkingTowner(type); towner != nullptr)
return towner->shortName;
for (const TownerDataEntry &entry : TownersDataEntries) {
if (entry.type == type && !entry.shortName.empty())
return _(entry.shortName.c_str());
}
return GetTownerFullNameDefault(type);
}
std::string_view GetTownerFullName(_talker_id type)
{
if (const Towner *towner = GetTalkingTowner(type); towner != nullptr && !towner->name.empty())
return towner->name;
return GetTownerFullNameDefault(type);
}
std::vector<TownerDataEntry> ResolveTownerEntries(const TownConfig *config)
{
if (config == nullptr || config->npcs.empty())
return TownersDataEntries;
std::vector<TownerDataEntry> result;
result.reserve(config->npcs.size());
for (const TownNpc &npc : config->npcs) {
const std::optional<_talker_id> type = ParseTownerRole(npc.role);
if (!type.has_value()) {
LogError("Town NPC: unknown role '{}', skipping", npc.role);
continue;
}
const auto baseIt = std::find_if(TownersDataEntries.begin(), TownersDataEntries.end(),
[&](const TownerDataEntry &e) { return e.type == *type; });
if (baseIt == TownersDataEntries.end()) {
LogError("Town NPC: no base data for role '{}', skipping", npc.role);
continue;
}
TownerDataEntry entry = *baseIt;
entry.position = npc.position;
if (npc.name.has_value()) {
entry.name = *npc.name;
// The base entry's short name belongs to the original NPC.
entry.shortName.clear();
}
if (npc.animPath.has_value()) {
entry.animPath = *npc.animPath;
// The base entry's frame order belongs to its own sprite.
entry.animOrder.clear();
}
if (npc.animFrames.has_value())
entry.animFrames = *npc.animFrames;
if (npc.animDelay.has_value())
entry.animDelay = *npc.animDelay;
if (npc.gossip.has_value()) {
entry.gossipTexts.clear();
for (const std::string &speechName : *npc.gossip) {
const auto speech = ParseSpeechId(speechName);
if (!speech.has_value()) {
LogError("Town NPC: unknown gossip id '{}', skipping", speechName);
continue;
}
entry.gossipTexts.push_back(*speech);
}
}
result.push_back(std::move(entry));
}
return result;
}
Towner *GetTowner(_talker_id type)
{
for (Towner &towner : Towners) {
@@ -779,10 +909,14 @@ void InitTowners()
CowSprites.emplace(LoadCelSheet("towners\\animals\\cow", 128));
// Entries must outlive the towners: Towner::name may point into them.
ActiveTownerEntries = ResolveTownerEntries(GetCurrentTownConfig());
SetTalkingTowner(std::nullopt);
Towners.clear();
Towners.reserve(TownersDataEntries.size());
Towners.reserve(ActiveTownerEntries.size());
int16_t i = 0;
for (const auto &entry : TownersDataEntries) {
for (const auto &entry : ActiveTownerEntries) {
if (!IsTownerPresent(entry.type))
continue;
@@ -849,6 +983,7 @@ void TalkToTowner(Player &player, int t)
return;
}
SetTalkingTowner(static_cast<size_t>(t));
towner.talk(player, towner);
}
@@ -865,6 +1000,8 @@ void UpdateGirlAnimAfterQuestComplete()
void UpdateCowFarmerAnimAfterQuestComplete()
{
Towner *cowFarmer = GetTowner(TOWN_COWFARM);
if (cowFarmer == nullptr)
return;
auto curFrame = cowFarmer->_tAnimFrame;
LoadTownerAnimations(*cowFarmer, "towners\\farmer\\mfrmrn2", 15, 3);
cowFarmer->_tAnimFrame = std::min<uint8_t>(curFrame, cowFarmer->_tAnimLen - 1);
@@ -887,6 +1024,7 @@ bool DebugTalkToTowner(_talker_id type)
if (entry == nullptr)
return false;
SetTalkingTowner(std::nullopt);
SetupTownStores();
Player &myPlayer = *MyPlayer;
Towner fakeTowner;
+42 -1
View File
@@ -8,6 +8,7 @@
#include <cstddef>
#include <cstdint>
#include <memory>
#include <optional>
#include <span>
#include <string>
#include <string_view>
@@ -45,6 +46,39 @@ enum _talker_id : uint8_t {
extern DVL_API_FOR_TEST std::unordered_map<_talker_id, std::string> TownerLongNames; // Maps towner type enum to display name
extern const std::unordered_map<_talker_id, const char *> TownerShortNames; // Maps towner type enum to Lua/mod short name
struct TownConfig;
struct TownerDataEntry;
/**
* @brief Parses a role name ("healer", "smith", "storyteller", ...) used by town NPC definitions.
* @return The matching towner type, or nullopt for an unknown role.
*/
std::optional<_talker_id> ParseTownerRole(std::string_view role);
/**
* @brief Builds the list of towner entries to spawn for a town.
*
* Returns the default towners.tsv entries when `config` is null or defines no NPCs. Otherwise returns one
* entry per NPC definition, based on the first TSV entry of the NPC's role with the provided fields applied.
* Unknown roles and unknown gossip ids are logged and skipped.
*/
std::vector<TownerDataEntry> ResolveTownerEntries(const TownConfig *config);
/**
* @brief Translated menu name ("Talk to ...") of the towner with this role, or the towners.tsv default if not spawned.
*/
std::string_view GetTownerShortName(_talker_id type);
/**
* @brief Translated full name of the spawned towner with this role, or the role's default long name if not spawned.
*/
std::string_view GetTownerFullName(_talker_id type);
/**
* @brief Remembers which towner (index into Towners) the player is talking to, or clears it.
*/
void SetTalkingTowner(std::optional<size_t> index);
struct Towner {
OptionalOwnedClxSpriteList ownedAnim;
OptionalClxSpriteList anim;
@@ -53,6 +87,8 @@ struct Towner {
void (*talk)(Player &player, Towner &towner);
std::string_view name;
/** Name used in menus such as "Talk to ..."; same as `name` when the data has no short name. */
std::string_view shortName;
/** Tile position of NPC */
Point position;
@@ -80,7 +116,7 @@ struct Towner {
}
};
extern std::vector<Towner> Towners;
extern DVL_API_FOR_TEST std::vector<Towner> Towners;
/**
* @brief Returns the number of unique towner types found in TSV data.
@@ -102,6 +138,11 @@ bool IsTownerPresent(_talker_id npc);
*/
Towner *GetTowner(_talker_id type);
/**
* @brief Like GetTowner, but prefers the towner the player is currently talking to when it has this role.
*/
Towner *GetTalkingTowner(_talker_id type);
void InitTowners();
void FreeTownerGFX();
void ProcessTowners();
+22
View File
@@ -681,6 +681,24 @@ const char *SPELL_TELEKINESIS_NAME = P_("spell", "Telekinesis");
const char *SPELL_HEAL_OTHER_NAME = P_("spell", "Heal Other");
const char *SPELL_BLOOD_STAR_NAME = P_("spell", "Blood Star");
const char *SPELL_BONE_SPIRIT_NAME = P_("spell", "Bone Spirit");
const char *TOWN_SMITH_NAME = N_("Griswold the Blacksmith");
const char *TOWN_SMITH_SHORT_NAME = N_("Griswold");
const char *TOWN_HEALER_NAME = N_("Pepin the Healer");
const char *TOWN_HEALER_SHORT_NAME = N_("Pepin");
const char *TOWN_DEADGUY_NAME = N_("Wounded Townsman");
const char *TOWN_TAVERN_NAME = N_("Ogden the Tavern owner");
const char *TOWN_TAVERN_SHORT_NAME = N_("Ogden");
const char *TOWN_STORY_NAME = N_("Cain the Elder");
const char *TOWN_STORY_SHORT_NAME = N_("Cain");
const char *TOWN_DRUNK_NAME = N_("Farnham the Drunk");
const char *TOWN_DRUNK_SHORT_NAME = N_("Farnham");
const char *TOWN_WITCH_NAME = N_("Adria the Witch");
const char *TOWN_WITCH_SHORT_NAME = N_("Adria");
const char *TOWN_BMAID_NAME = N_("Gillian the Barmaid");
const char *TOWN_BMAID_SHORT_NAME = N_("Gillian");
const char *TOWN_PEGBOY_NAME = N_("Wirt the Peg-legged boy");
const char *TOWN_PEGBOY_SHORT_NAME = N_("Wirt");
const char *TOWN_COW_NAME = N_("Cow");
const char *TEXT_0 = N_(" Ahh, the story of our King, is it? The tragic fall of Leoric was a harsh blow to this land. The people always loved the King, and now they live in mortal fear of him. The question that I keep asking myself is how he could have fallen so far from the Light, as Leoric had always been the holiest of men. Only the vilest powers of Hell could so utterly destroy a man from within...");
const char *TEXT_1 = N_("The village needs your help, good master! Some months ago King Leoric's son, Prince Albrecht, was kidnapped. The King went into a rage and scoured the village for his missing child. With each passing day, Leoric seemed to slip deeper into madness. He sought to blame innocent townsfolk for the boy's disappearance and had them brutally executed. Less than half of us survived his insanity...\n \nThe King's Knights and Priests tried to placate him, but he turned against them and sadly, they were forced to kill him. With his dying breath the King called down a terrible curse upon his former followers. He vowed that they would serve him in darkness forever...\n \nThis is where things take an even darker twist than I thought possible! Our former King has risen from his eternal sleep and now commands a legion of undead minions within the Labyrinth. His body was buried in a tomb three levels beneath the Cathedral. Please, good master, put his soul at ease by destroying his now cursed form...");
const char *TEXT_2 = N_("As I told you, good master, the King was entombed three levels below. He's down there, waiting in the putrid darkness for his chance to destroy this land...");
@@ -1041,5 +1059,9 @@ const char *SPELL_RUNE_OF_LIGHT_NAME = P_("spell", "Rune of Light");
const char *SPELL_RUNE_OF_NOVA_NAME = P_("spell", "Rune of Nova");
const char *SPELL_RUNE_OF_IMMOLATION_NAME = P_("spell", "Rune of Immolation");
const char *SPELL_RUNE_OF_STONE_NAME = P_("spell", "Rune of Stone");
const char *TOWN_COWFARM_NAME = N_("Complete Nut");
const char *TOWN_FARMER_NAME = N_("Lester the Farmer");
const char *TOWN_FARMER_SHORT_NAME = N_("Lester");
const char *TOWN_GIRL_NAME = N_("Celia");
} // namespace
+13 -13
View File
@@ -1,13 +1,13 @@
type name position_x position_y direction animWidth animPath animFrames animDelay gossipTexts animOrder
TOWN_SMITH Griswold the Blacksmith 62 63 SouthWest 96 towners\smith\smithn 16 3 TEXT_GRISWOLD2,TEXT_GRISWOLD3,TEXT_GRISWOLD4,TEXT_GRISWOLD5,TEXT_GRISWOLD6,TEXT_GRISWOLD7,TEXT_GRISWOLD8,TEXT_GRISWOLD9,TEXT_GRISWOLD10,TEXT_GRISWOLD12,TEXT_GRISWOLD13 4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,14,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3
TOWN_HEALER Pepin the Healer 55 79 SouthEast 96 towners\healer\healer 20 6 TEXT_PEPIN2,TEXT_PEPIN3,TEXT_PEPIN4,TEXT_PEPIN5,TEXT_PEPIN6,TEXT_PEPIN7,TEXT_PEPIN9,TEXT_PEPIN10,TEXT_PEPIN11 0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19
TOWN_DEADGUY Wounded Townsman 24 32 North 96 towners\butch\deadguy 8 6
TOWN_TAVERN Ogden the Tavern owner 55 62 SouthWest 96 towners\twnf\twnfn 16 3 TEXT_OGDEN2,TEXT_OGDEN3,TEXT_OGDEN4,TEXT_OGDEN5,TEXT_OGDEN6,TEXT_OGDEN8,TEXT_OGDEN9,TEXT_OGDEN10 0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,1,0,15,14,13,13,14,15,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
TOWN_STORY Cain the Elder 62 71 South 96 towners\strytell\strytell 25 3 TEXT_STORY2,TEXT_STORY3,TEXT_STORY4,TEXT_STORY5,TEXT_STORY6,TEXT_STORY7,TEXT_STORY9,TEXT_STORY10,TEXT_STORY11 0,0,24,24,23,22,21,20,19,18,17,16,15,14,15,16,17,18,19,20,21,22,23,24,24,24,0,0,0,24,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0
TOWN_DRUNK Farnham the Drunk 71 84 South 96 towners\drunk\twndrunk 18 3 TEXT_FARNHAM2,TEXT_FARNHAM3,TEXT_FARNHAM4,TEXT_FARNHAM5,TEXT_FARNHAM6,TEXT_FARNHAM8,TEXT_FARNHAM9,TEXT_FARNHAM10,TEXT_FARNHAM11,TEXT_FARNHAM12,TEXT_FARNHAM13 0,0,0,1,2,3,4,5,6,7,8,9,10,10,10,10,11,12,13,14,15,16,17,17,0,0,0,17,16,15,14,13,12,11,10,9,10,11,12,13,14,15,16,17,0,1,2,3,4,4,4,3,2,1
TOWN_WITCH Adria the Witch 80 20 South 96 towners\townwmn1\witch 19 6 TEXT_ADRIA2,TEXT_ADRIA3,TEXT_ADRIA4,TEXT_ADRIA5,TEXT_ADRIA6,TEXT_ADRIA7,TEXT_ADRIA8,TEXT_ADRIA9,TEXT_ADRIA10,TEXT_ADRIA12,TEXT_ADRIA13 3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,2,1,0,18,17,18,0,1,0,18,17,18,0,1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,14,14,13,12,12,12,12,13,14,14,14,13,12,11,11,11,10,9,9,9,8,7,8,9,9,10,11,12,13,14,15,16,17,18,0,1,0,18,17,18,0,1,0,1,2
TOWN_BMAID Gillian the Barmaid 43 66 South 96 towners\townwmn1\wmnn 18 6 TEXT_GILLIAN2,TEXT_GILLIAN3,TEXT_GILLIAN4,TEXT_GILLIAN5,TEXT_GILLIAN6,TEXT_GILLIAN7,TEXT_GILLIAN9,TEXT_GILLIAN10
TOWN_PEGBOY Wirt the Peg-legged boy 11 53 South 96 towners\townboy\pegkid1 20 6 TEXT_WIRT2,TEXT_WIRT3,TEXT_WIRT4,TEXT_WIRT5,TEXT_WIRT6,TEXT_WIRT7,TEXT_WIRT8,TEXT_WIRT9,TEXT_WIRT11,TEXT_WIRT12
TOWN_COW Cow 58 16 SouthWest 128 12 3
TOWN_COW Cow 56 14 NorthWest 128 12 3
TOWN_COW Cow 59 20 North 128 12 3
type name shortName position_x position_y direction animWidth animPath animFrames animDelay gossipTexts animOrder
TOWN_SMITH Griswold the Blacksmith Griswold 62 63 SouthWest 96 towners\smith\smithn 16 3 TEXT_GRISWOLD2,TEXT_GRISWOLD3,TEXT_GRISWOLD4,TEXT_GRISWOLD5,TEXT_GRISWOLD6,TEXT_GRISWOLD7,TEXT_GRISWOLD8,TEXT_GRISWOLD9,TEXT_GRISWOLD10,TEXT_GRISWOLD12,TEXT_GRISWOLD13 4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,14,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3
TOWN_HEALER Pepin the Healer Pepin 55 79 SouthEast 96 towners\healer\healer 20 6 TEXT_PEPIN2,TEXT_PEPIN3,TEXT_PEPIN4,TEXT_PEPIN5,TEXT_PEPIN6,TEXT_PEPIN7,TEXT_PEPIN9,TEXT_PEPIN10,TEXT_PEPIN11 0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19
TOWN_DEADGUY Wounded Townsman 24 32 North 96 towners\butch\deadguy 8 6
TOWN_TAVERN Ogden the Tavern owner Ogden 55 62 SouthWest 96 towners\twnf\twnfn 16 3 TEXT_OGDEN2,TEXT_OGDEN3,TEXT_OGDEN4,TEXT_OGDEN5,TEXT_OGDEN6,TEXT_OGDEN8,TEXT_OGDEN9,TEXT_OGDEN10 0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,1,0,15,14,13,13,14,15,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
TOWN_STORY Cain the Elder Cain 62 71 South 96 towners\strytell\strytell 25 3 TEXT_STORY2,TEXT_STORY3,TEXT_STORY4,TEXT_STORY5,TEXT_STORY6,TEXT_STORY7,TEXT_STORY9,TEXT_STORY10,TEXT_STORY11 0,0,24,24,23,22,21,20,19,18,17,16,15,14,15,16,17,18,19,20,21,22,23,24,24,24,0,0,0,24,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0
TOWN_DRUNK Farnham the Drunk Farnham 71 84 South 96 towners\drunk\twndrunk 18 3 TEXT_FARNHAM2,TEXT_FARNHAM3,TEXT_FARNHAM4,TEXT_FARNHAM5,TEXT_FARNHAM6,TEXT_FARNHAM8,TEXT_FARNHAM9,TEXT_FARNHAM10,TEXT_FARNHAM11,TEXT_FARNHAM12,TEXT_FARNHAM13 0,0,0,1,2,3,4,5,6,7,8,9,10,10,10,10,11,12,13,14,15,16,17,17,0,0,0,17,16,15,14,13,12,11,10,9,10,11,12,13,14,15,16,17,0,1,2,3,4,4,4,3,2,1
TOWN_WITCH Adria the Witch Adria 80 20 South 96 towners\townwmn1\witch 19 6 TEXT_ADRIA2,TEXT_ADRIA3,TEXT_ADRIA4,TEXT_ADRIA5,TEXT_ADRIA6,TEXT_ADRIA7,TEXT_ADRIA8,TEXT_ADRIA9,TEXT_ADRIA10,TEXT_ADRIA12,TEXT_ADRIA13 3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,2,1,0,18,17,18,0,1,0,18,17,18,0,1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,14,14,13,12,12,12,12,13,14,14,14,13,12,11,11,11,10,9,9,9,8,7,8,9,9,10,11,12,13,14,15,16,17,18,0,1,0,18,17,18,0,1,0,1,2
TOWN_BMAID Gillian the Barmaid Gillian 43 66 South 96 towners\townwmn1\wmnn 18 6 TEXT_GILLIAN2,TEXT_GILLIAN3,TEXT_GILLIAN4,TEXT_GILLIAN5,TEXT_GILLIAN6,TEXT_GILLIAN7,TEXT_GILLIAN9,TEXT_GILLIAN10
TOWN_PEGBOY Wirt the Peg-legged boy Wirt 11 53 South 96 towners\townboy\pegkid1 20 6 TEXT_WIRT2,TEXT_WIRT3,TEXT_WIRT4,TEXT_WIRT5,TEXT_WIRT6,TEXT_WIRT7,TEXT_WIRT8,TEXT_WIRT9,TEXT_WIRT11,TEXT_WIRT12
TOWN_COW Cow 58 16 SouthWest 128 12 3
TOWN_COW Cow 56 14 NorthWest 128 12 3
TOWN_COW Cow 59 20 North 128 12 3
1 type name shortName position_x position_y direction animWidth animPath animFrames animDelay gossipTexts animOrder
2 TOWN_SMITH Griswold the Blacksmith Griswold 62 63 SouthWest 96 towners\smith\smithn 16 3 TEXT_GRISWOLD2,TEXT_GRISWOLD3,TEXT_GRISWOLD4,TEXT_GRISWOLD5,TEXT_GRISWOLD6,TEXT_GRISWOLD7,TEXT_GRISWOLD8,TEXT_GRISWOLD9,TEXT_GRISWOLD10,TEXT_GRISWOLD12,TEXT_GRISWOLD13 4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,14,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3
3 TOWN_HEALER Pepin the Healer Pepin 55 79 SouthEast 96 towners\healer\healer 20 6 TEXT_PEPIN2,TEXT_PEPIN3,TEXT_PEPIN4,TEXT_PEPIN5,TEXT_PEPIN6,TEXT_PEPIN7,TEXT_PEPIN9,TEXT_PEPIN10,TEXT_PEPIN11 0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19
4 TOWN_DEADGUY Wounded Townsman 24 32 North 96 towners\butch\deadguy 8 6
5 TOWN_TAVERN Ogden the Tavern owner Ogden 55 62 SouthWest 96 towners\twnf\twnfn 16 3 TEXT_OGDEN2,TEXT_OGDEN3,TEXT_OGDEN4,TEXT_OGDEN5,TEXT_OGDEN6,TEXT_OGDEN8,TEXT_OGDEN9,TEXT_OGDEN10 0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,1,0,15,14,13,13,14,15,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
6 TOWN_STORY Cain the Elder Cain 62 71 South 96 towners\strytell\strytell 25 3 TEXT_STORY2,TEXT_STORY3,TEXT_STORY4,TEXT_STORY5,TEXT_STORY6,TEXT_STORY7,TEXT_STORY9,TEXT_STORY10,TEXT_STORY11 0,0,24,24,23,22,21,20,19,18,17,16,15,14,15,16,17,18,19,20,21,22,23,24,24,24,0,0,0,24,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0
7 TOWN_DRUNK Farnham the Drunk Farnham 71 84 South 96 towners\drunk\twndrunk 18 3 TEXT_FARNHAM2,TEXT_FARNHAM3,TEXT_FARNHAM4,TEXT_FARNHAM5,TEXT_FARNHAM6,TEXT_FARNHAM8,TEXT_FARNHAM9,TEXT_FARNHAM10,TEXT_FARNHAM11,TEXT_FARNHAM12,TEXT_FARNHAM13 0,0,0,1,2,3,4,5,6,7,8,9,10,10,10,10,11,12,13,14,15,16,17,17,0,0,0,17,16,15,14,13,12,11,10,9,10,11,12,13,14,15,16,17,0,1,2,3,4,4,4,3,2,1
8 TOWN_WITCH Adria the Witch Adria 80 20 South 96 towners\townwmn1\witch 19 6 TEXT_ADRIA2,TEXT_ADRIA3,TEXT_ADRIA4,TEXT_ADRIA5,TEXT_ADRIA6,TEXT_ADRIA7,TEXT_ADRIA8,TEXT_ADRIA9,TEXT_ADRIA10,TEXT_ADRIA12,TEXT_ADRIA13 3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,2,1,0,18,17,18,0,1,0,18,17,18,0,1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,14,14,13,12,12,12,12,13,14,14,14,13,12,11,11,11,10,9,9,9,8,7,8,9,9,10,11,12,13,14,15,16,17,18,0,1,0,18,17,18,0,1,0,1,2
9 TOWN_BMAID Gillian the Barmaid Gillian 43 66 South 96 towners\townwmn1\wmnn 18 6 TEXT_GILLIAN2,TEXT_GILLIAN3,TEXT_GILLIAN4,TEXT_GILLIAN5,TEXT_GILLIAN6,TEXT_GILLIAN7,TEXT_GILLIAN9,TEXT_GILLIAN10
10 TOWN_PEGBOY Wirt the Peg-legged boy Wirt 11 53 South 96 towners\townboy\pegkid1 20 6 TEXT_WIRT2,TEXT_WIRT3,TEXT_WIRT4,TEXT_WIRT5,TEXT_WIRT6,TEXT_WIRT7,TEXT_WIRT8,TEXT_WIRT9,TEXT_WIRT11,TEXT_WIRT12
11 TOWN_COW Cow 58 16 SouthWest 128 12 3
12 TOWN_COW Cow 56 14 NorthWest 128 12 3
13 TOWN_COW Cow 59 20 North 128 12 3
+16 -16
View File
@@ -1,16 +1,16 @@
type name position_x position_y direction animWidth animPath animFrames animDelay gossipTexts animOrder
TOWN_SMITH Griswold the Blacksmith 62 63 SouthWest 96 towners\smith\smithn 16 3 TEXT_GRISWOLD2,TEXT_GRISWOLD3,TEXT_GRISWOLD4,TEXT_GRISWOLD5,TEXT_GRISWOLD6,TEXT_GRISWOLD7,TEXT_GRISWOLD8,TEXT_GRISWOLD9,TEXT_GRISWOLD10,TEXT_GRISWOLD12,TEXT_GRISWOLD13 4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,14,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3
TOWN_HEALER Pepin the Healer 55 79 SouthEast 96 towners\healer\healer 20 6 TEXT_PEPIN2,TEXT_PEPIN3,TEXT_PEPIN4,TEXT_PEPIN5,TEXT_PEPIN6,TEXT_PEPIN7,TEXT_PEPIN9,TEXT_PEPIN10,TEXT_PEPIN11 0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19
TOWN_DEADGUY Wounded Townsman 24 32 North 96 towners\butch\deadguy 8 6
TOWN_TAVERN Ogden the Tavern owner 55 62 SouthWest 96 towners\twnf\twnfn 16 3 TEXT_OGDEN2,TEXT_OGDEN3,TEXT_OGDEN4,TEXT_OGDEN5,TEXT_OGDEN6,TEXT_OGDEN8,TEXT_OGDEN9,TEXT_OGDEN10 0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,1,0,15,14,13,13,14,15,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
TOWN_STORY Cain the Elder 62 71 South 96 towners\strytell\strytell 25 3 TEXT_STORY2,TEXT_STORY3,TEXT_STORY4,TEXT_STORY5,TEXT_STORY6,TEXT_STORY7,TEXT_STORY9,TEXT_STORY10,TEXT_STORY11 0,0,24,24,23,22,21,20,19,18,17,16,15,14,15,16,17,18,19,20,21,22,23,24,24,24,0,0,0,24,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0
TOWN_DRUNK Farnham the Drunk 71 84 South 96 towners\drunk\twndrunk 18 3 TEXT_FARNHAM2,TEXT_FARNHAM3,TEXT_FARNHAM4,TEXT_FARNHAM5,TEXT_FARNHAM6,TEXT_FARNHAM8,TEXT_FARNHAM9,TEXT_FARNHAM10,TEXT_FARNHAM11,TEXT_FARNHAM12,TEXT_FARNHAM13 0,0,0,1,2,3,4,5,6,7,8,9,10,10,10,10,11,12,13,14,15,16,17,17,0,0,0,17,16,15,14,13,12,11,10,9,10,11,12,13,14,15,16,17,0,1,2,3,4,4,4,3,2,1
TOWN_WITCH Adria the Witch 80 20 South 96 towners\townwmn1\witch 19 6 TEXT_ADRIA2,TEXT_ADRIA3,TEXT_ADRIA4,TEXT_ADRIA5,TEXT_ADRIA6,TEXT_ADRIA7,TEXT_ADRIA8,TEXT_ADRIA9,TEXT_ADRIA10,TEXT_ADRIA12,TEXT_ADRIA13 3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,2,1,0,18,17,18,0,1,0,18,17,18,0,1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,14,14,13,12,12,12,12,13,14,14,14,13,12,11,11,11,10,9,9,9,8,7,8,9,9,10,11,12,13,14,15,16,17,18,0,1,0,18,17,18,0,1,0,1,2
TOWN_BMAID Gillian the Barmaid 43 66 South 96 towners\townwmn1\wmnn 18 6 TEXT_GILLIAN2,TEXT_GILLIAN3,TEXT_GILLIAN4,TEXT_GILLIAN5,TEXT_GILLIAN6,TEXT_GILLIAN7,TEXT_GILLIAN9,TEXT_GILLIAN10
TOWN_PEGBOY Wirt the Peg-legged boy 11 53 South 96 towners\townboy\pegkid1 20 6 TEXT_WIRT2,TEXT_WIRT3,TEXT_WIRT4,TEXT_WIRT5,TEXT_WIRT6,TEXT_WIRT7,TEXT_WIRT8,TEXT_WIRT9,TEXT_WIRT11,TEXT_WIRT12
TOWN_COW Cow 58 16 SouthWest 128 12 3
TOWN_COW Cow 56 14 NorthWest 128 12 3
TOWN_COW Cow 59 20 North 128 12 3
TOWN_COWFARM Complete Nut 61 22 SouthWest 96 towners\farmer\cfrmrn2 15 3
TOWN_FARMER Lester the farmer 62 16 South 96 towners\farmer\farmrn2 15 3
TOWN_GIRL Celia 77 43 South 96 towners\girl\girlw1 20 6
type name shortName position_x position_y direction animWidth animPath animFrames animDelay gossipTexts animOrder
TOWN_SMITH Griswold the Blacksmith Griswold 62 63 SouthWest 96 towners\smith\smithn 16 3 TEXT_GRISWOLD2,TEXT_GRISWOLD3,TEXT_GRISWOLD4,TEXT_GRISWOLD5,TEXT_GRISWOLD6,TEXT_GRISWOLD7,TEXT_GRISWOLD8,TEXT_GRISWOLD9,TEXT_GRISWOLD10,TEXT_GRISWOLD12,TEXT_GRISWOLD13 4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,14,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3
TOWN_HEALER Pepin the Healer Pepin 55 79 SouthEast 96 towners\healer\healer 20 6 TEXT_PEPIN2,TEXT_PEPIN3,TEXT_PEPIN4,TEXT_PEPIN5,TEXT_PEPIN6,TEXT_PEPIN7,TEXT_PEPIN9,TEXT_PEPIN10,TEXT_PEPIN11 0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19
TOWN_DEADGUY Wounded Townsman 24 32 North 96 towners\butch\deadguy 8 6
TOWN_TAVERN Ogden the Tavern owner Ogden 55 62 SouthWest 96 towners\twnf\twnfn 16 3 TEXT_OGDEN2,TEXT_OGDEN3,TEXT_OGDEN4,TEXT_OGDEN5,TEXT_OGDEN6,TEXT_OGDEN8,TEXT_OGDEN9,TEXT_OGDEN10 0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,1,0,15,14,13,13,14,15,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
TOWN_STORY Cain the Elder Cain 62 71 South 96 towners\strytell\strytell 25 3 TEXT_STORY2,TEXT_STORY3,TEXT_STORY4,TEXT_STORY5,TEXT_STORY6,TEXT_STORY7,TEXT_STORY9,TEXT_STORY10,TEXT_STORY11 0,0,24,24,23,22,21,20,19,18,17,16,15,14,15,16,17,18,19,20,21,22,23,24,24,24,0,0,0,24,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0
TOWN_DRUNK Farnham the Drunk Farnham 71 84 South 96 towners\drunk\twndrunk 18 3 TEXT_FARNHAM2,TEXT_FARNHAM3,TEXT_FARNHAM4,TEXT_FARNHAM5,TEXT_FARNHAM6,TEXT_FARNHAM8,TEXT_FARNHAM9,TEXT_FARNHAM10,TEXT_FARNHAM11,TEXT_FARNHAM12,TEXT_FARNHAM13 0,0,0,1,2,3,4,5,6,7,8,9,10,10,10,10,11,12,13,14,15,16,17,17,0,0,0,17,16,15,14,13,12,11,10,9,10,11,12,13,14,15,16,17,0,1,2,3,4,4,4,3,2,1
TOWN_WITCH Adria the Witch Adria 80 20 South 96 towners\townwmn1\witch 19 6 TEXT_ADRIA2,TEXT_ADRIA3,TEXT_ADRIA4,TEXT_ADRIA5,TEXT_ADRIA6,TEXT_ADRIA7,TEXT_ADRIA8,TEXT_ADRIA9,TEXT_ADRIA10,TEXT_ADRIA12,TEXT_ADRIA13 3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,2,1,0,18,17,18,0,1,0,18,17,18,0,1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,14,14,13,12,12,12,12,13,14,14,14,13,12,11,11,11,10,9,9,9,8,7,8,9,9,10,11,12,13,14,15,16,17,18,0,1,0,18,17,18,0,1,0,1,2
TOWN_BMAID Gillian the Barmaid Gillian 43 66 South 96 towners\townwmn1\wmnn 18 6 TEXT_GILLIAN2,TEXT_GILLIAN3,TEXT_GILLIAN4,TEXT_GILLIAN5,TEXT_GILLIAN6,TEXT_GILLIAN7,TEXT_GILLIAN9,TEXT_GILLIAN10
TOWN_PEGBOY Wirt the Peg-legged boy Wirt 11 53 South 96 towners\townboy\pegkid1 20 6 TEXT_WIRT2,TEXT_WIRT3,TEXT_WIRT4,TEXT_WIRT5,TEXT_WIRT6,TEXT_WIRT7,TEXT_WIRT8,TEXT_WIRT9,TEXT_WIRT11,TEXT_WIRT12
TOWN_COW Cow 58 16 SouthWest 128 12 3
TOWN_COW Cow 56 14 NorthWest 128 12 3
TOWN_COW Cow 59 20 North 128 12 3
TOWN_COWFARM Complete Nut 61 22 SouthWest 96 towners\farmer\cfrmrn2 15 3
TOWN_FARMER Lester the Farmer Lester 62 16 South 96 towners\farmer\farmrn2 15 3
TOWN_GIRL Celia 77 43 South 96 towners\girl\girlw1 20 6
1 type name shortName position_x position_y direction animWidth animPath animFrames animDelay gossipTexts animOrder
2 TOWN_SMITH Griswold the Blacksmith Griswold 62 63 SouthWest 96 towners\smith\smithn 16 3 TEXT_GRISWOLD2,TEXT_GRISWOLD3,TEXT_GRISWOLD4,TEXT_GRISWOLD5,TEXT_GRISWOLD6,TEXT_GRISWOLD7,TEXT_GRISWOLD8,TEXT_GRISWOLD9,TEXT_GRISWOLD10,TEXT_GRISWOLD12,TEXT_GRISWOLD13 4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,14,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3
3 TOWN_HEALER Pepin the Healer Pepin 55 79 SouthEast 96 towners\healer\healer 20 6 TEXT_PEPIN2,TEXT_PEPIN3,TEXT_PEPIN4,TEXT_PEPIN5,TEXT_PEPIN6,TEXT_PEPIN7,TEXT_PEPIN9,TEXT_PEPIN10,TEXT_PEPIN11 0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19
4 TOWN_DEADGUY Wounded Townsman 24 32 North 96 towners\butch\deadguy 8 6
5 TOWN_TAVERN Ogden the Tavern owner Ogden 55 62 SouthWest 96 towners\twnf\twnfn 16 3 TEXT_OGDEN2,TEXT_OGDEN3,TEXT_OGDEN4,TEXT_OGDEN5,TEXT_OGDEN6,TEXT_OGDEN8,TEXT_OGDEN9,TEXT_OGDEN10 0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,1,0,15,14,13,13,14,15,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15
6 TOWN_STORY Cain the Elder Cain 62 71 South 96 towners\strytell\strytell 25 3 TEXT_STORY2,TEXT_STORY3,TEXT_STORY4,TEXT_STORY5,TEXT_STORY6,TEXT_STORY7,TEXT_STORY9,TEXT_STORY10,TEXT_STORY11 0,0,24,24,23,22,21,20,19,18,17,16,15,14,15,16,17,18,19,20,21,22,23,24,24,24,0,0,0,24,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0
7 TOWN_DRUNK Farnham the Drunk Farnham 71 84 South 96 towners\drunk\twndrunk 18 3 TEXT_FARNHAM2,TEXT_FARNHAM3,TEXT_FARNHAM4,TEXT_FARNHAM5,TEXT_FARNHAM6,TEXT_FARNHAM8,TEXT_FARNHAM9,TEXT_FARNHAM10,TEXT_FARNHAM11,TEXT_FARNHAM12,TEXT_FARNHAM13 0,0,0,1,2,3,4,5,6,7,8,9,10,10,10,10,11,12,13,14,15,16,17,17,0,0,0,17,16,15,14,13,12,11,10,9,10,11,12,13,14,15,16,17,0,1,2,3,4,4,4,3,2,1
8 TOWN_WITCH Adria the Witch Adria 80 20 South 96 towners\townwmn1\witch 19 6 TEXT_ADRIA2,TEXT_ADRIA3,TEXT_ADRIA4,TEXT_ADRIA5,TEXT_ADRIA6,TEXT_ADRIA7,TEXT_ADRIA8,TEXT_ADRIA9,TEXT_ADRIA10,TEXT_ADRIA12,TEXT_ADRIA13 3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,2,1,0,18,17,18,0,1,0,18,17,18,0,1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,14,14,13,12,12,12,12,13,14,14,14,13,12,11,11,11,10,9,9,9,8,7,8,9,9,10,11,12,13,14,15,16,17,18,0,1,0,18,17,18,0,1,0,1,2
9 TOWN_BMAID Gillian the Barmaid Gillian 43 66 South 96 towners\townwmn1\wmnn 18 6 TEXT_GILLIAN2,TEXT_GILLIAN3,TEXT_GILLIAN4,TEXT_GILLIAN5,TEXT_GILLIAN6,TEXT_GILLIAN7,TEXT_GILLIAN9,TEXT_GILLIAN10
10 TOWN_PEGBOY Wirt the Peg-legged boy Wirt 11 53 South 96 towners\townboy\pegkid1 20 6 TEXT_WIRT2,TEXT_WIRT3,TEXT_WIRT4,TEXT_WIRT5,TEXT_WIRT6,TEXT_WIRT7,TEXT_WIRT8,TEXT_WIRT9,TEXT_WIRT11,TEXT_WIRT12
11 TOWN_COW Cow 58 16 SouthWest 128 12 3
12 TOWN_COW Cow 56 14 NorthWest 128 12 3
13 TOWN_COW Cow 59 20 North 128 12 3
14 TOWN_COWFARM Complete Nut 61 22 SouthWest 96 towners\farmer\cfrmrn2 15 3
15 TOWN_FARMER Lester the farmer Lester the Farmer Lester 62 16 South 96 towners\farmer\farmrn2 15 3
16 TOWN_GIRL Celia 77 43 South 96 towners\girl\girlw1 20 6
+199
View File
@@ -6,9 +6,18 @@
#include <gtest/gtest.h>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
#include "levels/town_data.h"
#include "tables/textdat.h"
#include "tables/townerdat.hpp"
#include "towners.h"
using namespace devilution;
@@ -235,4 +244,194 @@ TEST(InitializeTristram, PreservesValidCurrentTownOnRepeatCall)
reg.SetCurrentTown(TristramTownId);
}
class TownNpcTest : public ::testing::Test {
protected:
void SetUp() override
{
saved_ = TownersDataEntries;
TownersDataEntries.clear();
TownerDataEntry healer;
healer.type = TOWN_HEALER;
healer.name = "Pepin the Healer";
healer.shortName = "Pepin";
healer.position = { 55, 79 };
healer.direction = Direction::SouthEast;
healer.animWidth = 96;
healer.animPath = "towners\\healer\\healer";
healer.animFrames = 20;
healer.animDelay = 6;
healer.gossipTexts = { TEXT_PEPIN2 };
TownersDataEntries.push_back(healer);
}
void TearDown() override { TownersDataEntries = saved_; }
private:
std::vector<TownerDataEntry> saved_;
};
TEST_F(TownNpcTest, NoNpcs_UsesDefaultEntries)
{
TownConfig cfg;
const std::vector<TownerDataEntry> entries = ResolveTownerEntries(&cfg);
ASSERT_EQ(entries.size(), TownersDataEntries.size());
EXPECT_EQ(entries[0].position, TownersDataEntries[0].position);
EXPECT_EQ(ResolveTownerEntries(nullptr).size(), TownersDataEntries.size());
}
TEST_F(TownNpcTest, PositionOnly_KeepsTristramData)
{
TownConfig cfg;
TownNpc npc;
npc.role = "healer";
npc.position = { 40, 52 };
cfg.npcs.push_back(npc);
const std::vector<TownerDataEntry> entries = ResolveTownerEntries(&cfg);
ASSERT_EQ(entries.size(), 1U);
EXPECT_EQ(entries[0].type, TOWN_HEALER);
EXPECT_EQ(entries[0].position, (Point { 40, 52 }));
EXPECT_EQ(entries[0].animPath, TownersDataEntries[0].animPath);
EXPECT_EQ(entries[0].gossipTexts, TownersDataEntries[0].gossipTexts);
EXPECT_EQ(entries[0].shortName, "Pepin");
}
TEST_F(TownNpcTest, CustomFields_AreApplied)
{
TownConfig cfg;
TownNpc npc;
npc.role = "healer";
npc.position = { 10, 11 };
npc.name = "Tremain the Priest";
npc.animPath = "towners\\priest\\priest8";
npc.gossip = std::vector<std::string> { "TEXT_PEPIN3" };
cfg.npcs.push_back(npc);
const std::vector<TownerDataEntry> entries = ResolveTownerEntries(&cfg);
ASSERT_EQ(entries.size(), 1U);
EXPECT_EQ(entries[0].name, "Tremain the Priest");
EXPECT_TRUE(entries[0].shortName.empty());
EXPECT_EQ(entries[0].animPath, "towners\\priest\\priest8");
ASSERT_EQ(entries[0].gossipTexts.size(), 1U);
EXPECT_EQ(entries[0].gossipTexts[0], TEXT_PEPIN3);
}
TEST_F(TownNpcTest, UnknownRoleAndGossip_AreSkipped)
{
TownConfig cfg;
TownNpc badRole;
badRole.role = "jester";
cfg.npcs.push_back(badRole);
TownNpc badGossip;
badGossip.role = "healer";
badGossip.gossip = std::vector<std::string> { "NOT_A_SPEECH", "TEXT_PEPIN3" };
cfg.npcs.push_back(badGossip);
const std::vector<TownerDataEntry> entries = ResolveTownerEntries(&cfg);
ASSERT_EQ(entries.size(), 1U);
ASSERT_EQ(entries[0].gossipTexts.size(), 1U);
EXPECT_EQ(entries[0].gossipTexts[0], TEXT_PEPIN3);
}
TEST(TownerNames, ParseRole)
{
EXPECT_EQ(ParseTownerRole("healer"), std::optional<_talker_id> { TOWN_HEALER });
EXPECT_EQ(ParseTownerRole("storyteller"), std::optional<_talker_id> { TOWN_STORY });
EXPECT_FALSE(ParseTownerRole("jester").has_value());
}
class TownerNameLookupTest : public ::testing::Test {
protected:
void SetUp() override
{
savedTowners_ = std::move(Towners);
savedEntries_ = TownersDataEntries;
savedLongNames_ = TownerLongNames;
Towners.clear();
TownersDataEntries.clear();
TownerDataEntry smith;
smith.type = TOWN_SMITH;
smith.name = "Griswold the Blacksmith";
smith.shortName = "Griswold";
TownersDataEntries.push_back(smith);
TownerLongNames.clear();
TownerLongNames[TOWN_HEALER] = "Pepin the Healer";
SetTalkingTowner(std::nullopt);
}
void TearDown() override
{
SetTalkingTowner(std::nullopt);
Towners = std::move(savedTowners_);
TownersDataEntries = savedEntries_;
TownerLongNames = savedLongNames_;
}
/** Mirrors InitTownerInfo: an empty short name falls back to the full name. */
static void AddTowner(_talker_id type, std::string_view name, std::string_view shortName = {})
{
Towner &towner = Towners.emplace_back();
towner._ttype = type;
towner.name = name;
towner.shortName = shortName.empty() ? name : shortName;
}
private:
std::vector<Towner> savedTowners_;
std::vector<TownerDataEntry> savedEntries_;
std::unordered_map<_talker_id, std::string> savedLongNames_;
};
TEST_F(TownerNameLookupTest, DefaultHealer_UsesTristramNames)
{
AddTowner(TOWN_HEALER, "Pepin the Healer", "Pepin");
EXPECT_EQ(GetTownerShortName(TOWN_HEALER), "Pepin");
EXPECT_EQ(GetTownerFullName(TOWN_HEALER), "Pepin the Healer");
}
TEST_F(TownerNameLookupTest, RenamedHealer_UsesCustomName)
{
AddTowner(TOWN_HEALER, "Tremain the Priest");
EXPECT_EQ(GetTownerShortName(TOWN_HEALER), "Tremain the Priest");
EXPECT_EQ(GetTownerFullName(TOWN_HEALER), "Tremain the Priest");
}
TEST_F(TownerNameLookupTest, DuplicateHealers_UseTheOneTalkedTo)
{
AddTowner(TOWN_HEALER, "Akara the Healer");
AddTowner(TOWN_HEALER, "Tremain the Priest");
SetTalkingTowner(1);
EXPECT_EQ(GetTownerShortName(TOWN_HEALER), "Tremain the Priest");
SetTalkingTowner(0);
EXPECT_EQ(GetTownerShortName(TOWN_HEALER), "Akara the Healer");
SetTalkingTowner(std::nullopt);
EXPECT_EQ(GetTownerShortName(TOWN_HEALER), "Akara the Healer");
EXPECT_EQ(GetTalkingTowner(TOWN_HEALER), &Towners[0]);
}
TEST_F(TownerNameLookupTest, TalkingToOtherRole_DoesNotAffectLookup)
{
AddTowner(TOWN_SMITH, "Griswold the Blacksmith", "Griswold");
AddTowner(TOWN_HEALER, "Pepin the Healer", "Pepin");
SetTalkingTowner(0);
EXPECT_EQ(GetTownerFullName(TOWN_HEALER), "Pepin the Healer");
EXPECT_EQ(GetTalkingTowner(TOWN_HEALER), &Towners[1]);
}
TEST_F(TownerNameLookupTest, StaleTalkingIndex_IsIgnored)
{
AddTowner(TOWN_HEALER, "Pepin the Healer");
SetTalkingTowner(5);
EXPECT_EQ(GetTalkingTowner(TOWN_HEALER), &Towners[0]);
}
TEST_F(TownerNameLookupTest, UnspawnedRole_FallsBackToDefaults)
{
EXPECT_EQ(GetTownerShortName(TOWN_SMITH), "Griswold");
EXPECT_EQ(GetTownerFullName(TOWN_HEALER), "Pepin the Healer");
EXPECT_EQ(GetTalkingTowner(TOWN_SMITH), nullptr);
}
} // namespace
+1
View File
@@ -89,6 +89,7 @@ TEST(TownerDat, LoadTownerData)
ASSERT_NE(healer, nullptr) << "Should find TOWN_HEALER data";
EXPECT_EQ(healer->type, TOWN_HEALER);
EXPECT_EQ(healer->name, "Pepin the Healer");
EXPECT_EQ(healer->shortName, "Pepin");
EXPECT_EQ(healer->position.x, 55);
EXPECT_EQ(healer->position.y, 79);
EXPECT_EQ(healer->direction, Direction::SouthEast);
+10
View File
@@ -16,6 +16,7 @@ base_paths = {
"questdat": root.joinpath("assets/txtdata/quests/questdat.tsv"),
"spelldat": root.joinpath("assets/txtdata/spells/spelldat.tsv"),
"textdat": root.joinpath("assets/txtdata/text/textdat.tsv"),
"towners": root.joinpath("assets/txtdata/towners/towners.tsv"),
}
hf_paths = {
@@ -24,6 +25,7 @@ hf_paths = {
"item_prefixes": root.joinpath("mods/hf/txtdata/items/item_prefixes.tsv"),
"item_suffixes": root.joinpath("mods/hf/txtdata/items/item_suffixes.tsv"),
"spelldat": root.joinpath("mods/hf/txtdata/spells/spelldat.tsv"),
"towners": root.joinpath("mods/hf/txtdata/towners/towners.tsv"),
}
seen_pairs = set()
@@ -120,6 +122,14 @@ def process_files(paths, temp_source):
var_name = create_identifier(row['name'], 'SPELL_', '_NAME')
write_entry(temp_source, var_name, "spell", row['name'], True)
# Towners
if "towners" in paths:
with open(paths["towners"], 'r') as tsv:
reader = csv.DictReader(tsv, delimiter='\t')
for row in reader:
write_entry(temp_source, create_identifier(row['type'], '', '_NAME'), "default", row['name'], False)
write_entry(temp_source, create_identifier(row['type'], '', '_SHORT_NAME'), "default", row['shortName'], False)
# Text/Speeches
if "textdat" in paths:
with open(paths["textdat"], 'r') as tsv: