最终整理版

This commit is contained in:
2026-06-03 17:04:06 +08:00
commit 959055ce90
1240 changed files with 80570 additions and 0 deletions
+7327
View File
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
#pragma once
#include <filesystem>
#include <algorithm>
#include <cctype>
#include <string>
#include <utility>
#include <vector>
inline std::filesystem::path MonsterSprite(const std::filesystem::path& root, const std::string& file)
{
return root / "assets/sprites/monsters" / file;
}
inline std::filesystem::path NpcSprite(const std::filesystem::path& root, const std::string& file)
{
return root / "assets/sprites/npcs" / file;
}
inline std::filesystem::path GuiSprite(const std::filesystem::path& root, const std::string& file)
{
return root / "assets/ui" / file;
}
inline std::filesystem::path PlayerSprite(const std::filesystem::path& root)
{
return root / "assets/sprites/players/player-male.png";
}
inline std::string CompactAssetName(std::string name)
{
std::string out;
for (unsigned char ch : name) {
if (std::isalnum(ch)) {
out.push_back(static_cast<char>(std::tolower(ch)));
}
}
return out;
}
inline std::filesystem::path ExistingOrEmpty(const std::filesystem::path& path)
{
return std::filesystem::exists(path) ? path : std::filesystem::path{};
}
inline std::filesystem::path MonsterSpriteForName(const std::filesystem::path& root, const std::string& name)
{
const std::vector<std::pair<std::string, std::string>> aliases = {
{"Fire Goblin", "firegoblin.png"},
{"Giant Maggot", "maggot-giant.png"},
{"Evil Mushroom", "mushroom-evil.png"},
{"Spiky Mushroom", "mushroom-spiky.png"},
{"Plushroom Field", "mushroom-spiky.png"},
{"Little Blub", "slime-drain.png"},
{"Salt Slime", "slime-salt.png"},
{"Sludge Slime", "slime-sludge.png"},
{"Drain Slime", "slime-drain.png"},
{"Fire Skull", "skull-fire.png"},
{"Sand Snake", "snake.png"},
{"Desert Snake", "snake.png"},
{"Mister Prickel", "peyote.png"},
{"Little Blub", "slime-drain.png"},
{"Lizandras", "lizandra.png"},
{"Piousse", "piou.png"},
{"Pikpik", "bird.png"},
};
for (const auto& [entityName, file] : aliases) {
if (entityName == name) {
return ExistingOrEmpty(MonsterSprite(root, file));
}
}
return ExistingOrEmpty(MonsterSprite(root, CompactAssetName(name) + ".png"));
}
inline std::filesystem::path NpcSpriteForName(const std::filesystem::path& root, const std::string& name)
{
const std::vector<std::pair<std::string, std::string>> aliases = {
{"Soul Menhir", "soulmenhir.png"},
{"Flour Barrel", "flour-barrel.png"},
{"Old Chest", "chest-small.png"},
{"Thiefs Chest", "chest-large.png"},
{"Letter Stash", "chest-small.png"},
};
for (const auto& [entityName, file] : aliases) {
if (entityName == name) {
return ExistingOrEmpty(NpcSprite(root, file));
}
}
return ExistingOrEmpty(NpcSprite(root, CompactAssetName(name) + ".png"));
}
+522
View File
@@ -0,0 +1,522 @@
#include "EntityPreset.h"
#include <cmath>
#include <fstream>
#include <regex>
#include <set>
#include <sstream>
#include <stdexcept>
#include <mutex>
namespace {
std::string ReadTextFile(const std::filesystem::path& path)
{
std::ifstream in(path);
if (!in) {
return {};
}
std::ostringstream buffer;
buffer << in.rdbuf();
return buffer.str();
}
std::filesystem::path ResolveResPath(const std::filesystem::path& root, const std::string& resPath)
{
constexpr const char* prefix = "res://";
std::string relative = resPath;
if (relative.rfind(prefix, 0) == 0) {
relative = relative.substr(std::char_traits<char>::length(prefix));
}
if (relative.rfind("data/graphics/items/", 0) == 0) {
return root / "assets/icon/items" / relative.substr(std::string("data/graphics/items/").size());
}
if (relative.rfind("data/graphics/gui/", 0) == 0) {
return root / "assets/ui" / relative.substr(std::string("data/graphics/gui/").size());
}
if (relative.rfind("data/graphics/sprites/", 0) == 0) {
return root / "assets/sprites" / relative.substr(std::string("data/graphics/sprites/").size());
}
if (relative.rfind("data/graphics/effects/", 0) == 0) {
return root / "assets/effects" / relative.substr(std::string("data/graphics/effects/").size());
}
if (relative.rfind("data/graphics/fonts/", 0) == 0) {
return root / "assets/fonts" / relative.substr(std::string("data/graphics/fonts/").size());
}
if (relative.rfind("presets/", 0) == 0) {
return root / "assets/presets" / relative.substr(std::string("presets/").size());
}
if (relative.rfind("sources/scripts/", 0) == 0) {
return root / "assets/scripts" / relative.substr(std::string("sources/scripts/").size());
}
return root / relative;
}
std::map<std::filesystem::path, EntityPreset>& EntityPresetCache()
{
static std::map<std::filesystem::path, EntityPreset> cache;
return cache;
}
std::mutex& EntityPresetCacheMutex()
{
static std::mutex mutex;
return mutex;
}
bool FindCachedEntityPreset(const std::filesystem::path& path, EntityPreset& preset)
{
std::lock_guard<std::mutex> lock(EntityPresetCacheMutex());
const auto cached = EntityPresetCache().find(path);
if (cached == EntityPresetCache().end()) {
return false;
}
preset = cached->second;
return true;
}
void StoreCachedEntityPreset(const std::filesystem::path& path, const EntityPreset& preset)
{
std::lock_guard<std::mutex> lock(EntityPresetCacheMutex());
EntityPresetCache()[path] = preset;
}
std::map<std::string, std::string> ParseExtResources(const std::string& text)
{
std::map<std::string, std::string> resources;
std::istringstream lines(text);
std::string line;
const std::regex idRegex(R"(\bid=\"([^\"]+)\")");
const std::regex pathRegex(R"(path=\"([^\"]+)\")");
std::smatch idMatch;
std::smatch pathMatch;
while (std::getline(lines, line)) {
if (line.rfind("[ext_resource", 0) != 0) {
continue;
}
if (std::regex_search(line, idMatch, idRegex) && std::regex_search(line, pathMatch, pathRegex)) {
resources[idMatch[1].str()] = pathMatch[1].str();
}
}
return resources;
}
std::string ParseQuotedAssignment(const std::string& text, const std::string& key)
{
const std::regex regex(key + R"(\s*=\s*\"([^\"]*)\")");
std::smatch match;
return std::regex_search(text, match, regex) ? match[1].str() : std::string{};
}
int ParseIntAssignment(const std::string& text, const std::string& key, int fallback)
{
const std::regex regex(key + R"(\s*=\s*(-?[0-9]+))");
std::smatch match;
return std::regex_search(text, match, regex) ? std::stoi(match[1].str()) : fallback;
}
std::string ParseExtResourceAssignment(const std::string& text, const std::string& key)
{
const std::regex regex(key + R"(\s*=\s*ExtResource\(\"([^\"]+)\"\))");
std::smatch match;
return std::regex_search(text, match, regex) ? match[1].str() : std::string{};
}
std::map<std::string, float> ParseStats(const std::string& text)
{
std::map<std::string, float> stats;
const std::string marker = "_stats = {";
const std::size_t start = text.find(marker);
if (start == std::string::npos) {
return stats;
}
const std::size_t bodyStart = start + marker.size();
const std::size_t bodyEnd = text.find('}', bodyStart);
if (bodyEnd == std::string::npos) {
return stats;
}
const std::string body = text.substr(bodyStart, bodyEnd - bodyStart);
const std::regex statRegex(R"(\"([^\"]+)\"\s*:\s*(-?[0-9]+(?:\.[0-9]+)?))");
auto begin = std::sregex_iterator(body.begin(), body.end(), statRegex);
auto end = std::sregex_iterator();
for (auto it = begin; it != end; ++it) {
stats[(*it)[1].str()] = std::stof((*it)[2].str());
}
return stats;
}
std::map<std::string, std::string> ParseStringStats(const std::string& text)
{
std::map<std::string, std::string> stats;
const std::string marker = "_stats = {";
const std::size_t start = text.find(marker);
if (start == std::string::npos) {
return stats;
}
const std::size_t bodyStart = start + marker.size();
const std::size_t bodyEnd = text.find('}', bodyStart);
if (bodyEnd == std::string::npos) {
return stats;
}
const std::string body = text.substr(bodyStart, bodyEnd - bodyStart);
const std::regex statRegex(R"(\"([^\"]+)\"\s*:\s*\"([^\"]*)\")");
auto begin = std::sregex_iterator(body.begin(), body.end(), statRegex);
auto end = std::sregex_iterator();
for (auto it = begin; it != end; ++it) {
stats[(*it)[1].str()] = (*it)[2].str();
}
return stats;
}
int GenderTextureIndex(const std::string& gender)
{
std::string normalized;
for (unsigned char ch : gender) {
if (std::isalnum(ch)) {
normalized.push_back(static_cast<char>(std::tolower(ch)));
}
}
if (normalized == "female") {
return 1;
}
if (normalized == "nonbinary") {
return 2;
}
return 0;
}
std::filesystem::path TextureFromItemCell(
const std::filesystem::path& root,
const std::filesystem::path& path,
const std::string& gender)
{
const std::string text = ReadTextFile(path);
if (text.empty()) {
return {};
}
const std::map<std::string, std::string> resources = ParseExtResources(text);
const std::regex texturesRegex(R"(textures\s*=\s*Array\[Texture2D\]\(\[([^\]]*)\]\))");
const std::regex resourceRegex(R"(ExtResource\(\"([^\"]+)\"\))");
std::smatch texturesMatch;
if (!std::regex_search(text, texturesMatch, texturesRegex)) {
return {};
}
const std::string body = texturesMatch[1].str();
std::vector<std::string> textureIds;
for (auto it = std::sregex_iterator(body.begin(), body.end(), resourceRegex); it != std::sregex_iterator(); ++it) {
textureIds.push_back((*it)[1].str());
}
if (textureIds.empty()) {
return {};
}
const int index = std::clamp(GenderTextureIndex(gender), 0, static_cast<int>(textureIds.size()) - 1);
const auto resource = resources.find(textureIds[static_cast<std::size_t>(index)]);
if (resource == resources.end()) {
return {};
}
return ResolveResPath(root, resource->second);
}
std::filesystem::path PaletteFromItemCell(const std::filesystem::path& root, const std::filesystem::path& path)
{
const std::string text = ReadTextFile(path);
if (text.empty()) {
return {};
}
const std::map<std::string, std::string> resources = ParseExtResources(text);
const std::string shaderId = ParseExtResourceAssignment(text, "shader");
if (shaderId.empty()) {
return {};
}
const auto resource = resources.find(shaderId);
if (resource == resources.end()) {
return {};
}
return ResolveResPath(root, resource->second);
}
struct EquipmentVisualAsset {
std::filesystem::path texture;
std::filesystem::path palette;
};
std::map<int, EquipmentVisualAsset> ParseEquipmentVisualAssets(
const std::filesystem::path& root,
const std::string& text,
const std::map<std::string, std::string>& resources,
const std::string& gender)
{
std::map<int, EquipmentVisualAsset> assets;
const std::regex equipmentRegex(R"(_equipment\s*=\s*Array\[[^\]]+\]\(\[([^\]]*)\]\))");
const std::regex resourceRegex(R"(ExtResource\(\"([^\"]+)\"\)|null)");
std::smatch equipmentMatch;
if (!std::regex_search(text, equipmentMatch, equipmentRegex)) {
return assets;
}
const std::string body = equipmentMatch[1].str();
auto begin = std::sregex_iterator(body.begin(), body.end(), resourceRegex);
auto end = std::sregex_iterator();
int slot = 0;
for (auto it = begin; it != end; ++it, ++slot) {
if ((*it)[0].str() == "null") {
continue;
}
const auto resource = resources.find((*it)[1].str());
if (resource == resources.end()) {
continue;
}
const std::filesystem::path itemPath = ResolveResPath(root, resource->second);
EquipmentVisualAsset asset;
asset.texture = TextureFromItemCell(root, itemPath, gender);
asset.palette = PaletteFromItemCell(root, itemPath);
if (!asset.texture.empty()) {
assets[slot] = asset;
}
}
return assets;
}
std::string CompactKey(std::string value)
{
std::string out;
for (unsigned char ch : value) {
if (std::isalnum(ch)) {
out.push_back(static_cast<char>(std::tolower(ch)));
}
}
return out;
}
std::string KebabKey(std::string value)
{
std::string out;
bool pendingDash = false;
for (unsigned char ch : value) {
if (std::isalnum(ch)) {
if (pendingDash && !out.empty()) {
out.push_back('-');
}
out.push_back(static_cast<char>(std::tolower(ch)));
pendingDash = false;
} else {
pendingDash = true;
}
}
return out;
}
std::filesystem::path FindHairstyleTexture(const std::filesystem::path& root, const std::string& name)
{
if (name.empty()) {
return {};
}
const std::filesystem::path hairstyleRoot = root / "assets/presets/cells/hairstyles";
if (!std::filesystem::exists(hairstyleRoot)) {
return {};
}
const std::string target = CompactKey(name);
for (const auto& entry : std::filesystem::recursive_directory_iterator(hairstyleRoot)) {
if (!entry.is_regular_file() || entry.path().extension() != ".tres") {
continue;
}
const std::string text = ReadTextFile(entry.path());
if (CompactKey(ParseQuotedAssignment(text, "_name")) != target) {
continue;
}
const std::string relative = ParseQuotedAssignment(text, "_path");
if (!relative.empty()) {
if (relative.rfind("sprites/", 0) == 0) {
return root / "assets/sprites" / relative.substr(std::string("sprites/").size());
}
return root / "assets/sprites" / relative;
}
}
return {};
}
std::filesystem::path PlayerBodyTexture(const std::filesystem::path& root, const std::string& gender)
{
const std::string normalized = CompactKey(gender);
const std::string file = normalized == "female" ? "player-female.png" : "player-male.png";
const std::filesystem::path path = root / "assets/sprites/players" / file;
return std::filesystem::exists(path) ? path : std::filesystem::path{};
}
std::filesystem::path PlayerFaceTexture(
const std::filesystem::path& root,
const std::string& race,
const std::string& gender)
{
const std::string raceKey = CompactKey(race.empty() ? "Human" : race);
const std::string genderKey = CompactKey(gender) == "female" ? "female" : "male";
const std::filesystem::path path = root / "assets/sprites/players/faces" / (raceKey + "-" + genderKey + ".png");
return std::filesystem::exists(path) ? path : std::filesystem::path{};
}
std::filesystem::path SkinPalettePath(
const std::filesystem::path& root,
const std::string& race,
const std::string& skintone)
{
const std::string raceKey = KebabKey(race.empty() ? "Human" : race);
const std::string skinKey = KebabKey(skintone.empty() ? "Medium" : skintone);
const std::filesystem::path path = root / "assets/presets/palettes/races" / (raceKey + "-skin-" + skinKey + ".tres");
return std::filesystem::exists(path) ? path : std::filesystem::path{};
}
std::filesystem::path HairPalettePath(const std::filesystem::path& root, const std::string& haircolor)
{
if (haircolor.empty()) {
return {};
}
const std::filesystem::path path = root / "assets/presets/palettes/haircolors" / ("hair-" + KebabKey(haircolor) + ".tres");
return std::filesystem::exists(path) ? path : std::filesystem::path{};
}
std::filesystem::path TexturePathFromSpritePreset(const std::filesystem::path& root, const std::string& spritePreset)
{
if (spritePreset.empty()) {
return {};
}
const std::filesystem::path scenePath = root / "assets/presets/entities/sprites" / (spritePreset + ".tscn");
const std::string text = ReadTextFile(scenePath);
if (text.empty()) {
return {};
}
std::istringstream lines(text);
std::string line;
const std::regex pathRegex(R"(path=\"(res://data/graphics/sprites/[^\"]+\.png)\")");
std::smatch pathMatch;
while (std::getline(lines, line)) {
if (line.rfind("[ext_resource", 0) != 0 || line.find("Texture2D") == std::string::npos) {
continue;
}
if (std::regex_search(line, pathMatch, pathRegex)) {
return ResolveResPath(root, pathMatch[1].str());
}
}
return {};
}
EntityPreset LoadEntityPresetFile(
const std::filesystem::path& root,
const std::filesystem::path& path,
std::set<std::filesystem::path>& loading)
{
const std::filesystem::path canonical = std::filesystem::weakly_canonical(path);
EntityPreset cached;
if (FindCachedEntityPreset(canonical, cached)) {
return cached;
}
if (loading.contains(canonical)) {
throw std::runtime_error("Circular entity preset parent: " + canonical.string());
}
const std::string text = ReadTextFile(canonical);
if (text.empty()) {
throw std::runtime_error("Unable to read entity preset: " + canonical.string());
}
loading.insert(canonical);
const std::map<std::string, std::string> resources = ParseExtResources(text);
EntityPreset preset;
const std::string parentId = ParseExtResourceAssignment(text, "_parent");
if (!parentId.empty()) {
const auto parentPath = resources.find(parentId);
if (parentPath != resources.end()) {
preset = LoadEntityPresetFile(root, ResolveResPath(root, parentPath->second), loading);
}
}
const std::string name = ParseQuotedAssignment(text, "_name");
if (!name.empty()) {
preset.name = name;
}
const std::string spritePreset = ParseQuotedAssignment(text, "_spritePreset");
if (!spritePreset.empty()) {
preset.spritePreset = spritePreset;
}
preset.radius = ParseIntAssignment(text, "_radius", preset.radius);
for (const auto& [key, value] : ParseStats(text)) {
preset.stats[key] = value;
}
for (const auto& [key, value] : ParseStringStats(text)) {
preset.stringStats[key] = value;
}
const std::string customTextureId = ParseExtResourceAssignment(text, "_customTexture");
if (!customTextureId.empty()) {
const auto texture = resources.find(customTextureId);
if (texture != resources.end()) {
preset.texturePath = ResolveResPath(root, texture->second);
}
}
if (preset.texturePath.empty()) {
preset.texturePath = TexturePathFromSpritePreset(root, preset.spritePreset);
}
if (preset.spritePreset == "Player") {
preset.bodyTexturePath = PlayerBodyTexture(root, preset.StatString("gender"));
}
if (preset.spritePreset == "Player" || preset.spritePreset == "StaticNPC") {
preset.faceTexturePath = PlayerFaceTexture(root, preset.StatString("race"), preset.StatString("gender"));
preset.skinPalettePath = SkinPalettePath(root, preset.StatString("race"), preset.StatString("skintone"));
}
for (const auto& [slot, asset] : ParseEquipmentVisualAssets(root, text, resources, preset.StatString("gender"))) {
preset.equipmentTexturePaths[slot] = asset.texture;
if (!asset.palette.empty()) {
preset.equipmentPalettePaths[slot] = asset.palette;
}
}
if (const std::filesystem::path hair = FindHairstyleTexture(root, preset.StatString("hairstyle")); !hair.empty()) {
preset.hairTexturePath = hair;
preset.hairPalettePath = HairPalettePath(root, preset.StatString("haircolor"));
}
loading.erase(canonical);
StoreCachedEntityPreset(canonical, preset);
return preset;
}
} // namespace
bool EntityPreset::HasStat(const std::string& key) const
{
return stats.find(key) != stats.end();
}
float EntityPreset::StatFloat(const std::string& key, float fallback) const
{
const auto it = stats.find(key);
return it == stats.end() ? fallback : it->second;
}
int EntityPreset::StatInt(const std::string& key, int fallback) const
{
const auto it = stats.find(key);
return it == stats.end() ? fallback : static_cast<int>(std::lround(it->second));
}
std::string EntityPreset::StatString(const std::string& key, const std::string& fallback) const
{
const auto it = stringStats.find(key);
return it == stringStats.end() ? fallback : it->second;
}
EntityPreset LoadEntityPreset(const std::filesystem::path& root, const std::string& name)
{
std::set<std::filesystem::path> loading;
return LoadEntityPresetFile(root, root / "assets/presets/entities" / (name + ".tres"), loading);
}
void ClearEntityPresetCache()
{
std::lock_guard<std::mutex> lock(EntityPresetCacheMutex());
EntityPresetCache().clear();
}
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <filesystem>
#include <map>
#include <string>
struct EntityPreset {
std::string name;
std::string spritePreset;
int radius = 0;
std::map<std::string, float> stats;
std::map<std::string, std::string> stringStats;
std::filesystem::path texturePath;
std::filesystem::path bodyTexturePath;
std::filesystem::path faceTexturePath;
std::filesystem::path skinPalettePath;
std::filesystem::path hairPalettePath;
std::map<int, std::filesystem::path> equipmentTexturePaths;
std::map<int, std::filesystem::path> equipmentPalettePaths;
std::filesystem::path hairTexturePath;
bool HasStat(const std::string& key) const;
float StatFloat(const std::string& key, float fallback) const;
int StatInt(const std::string& key, int fallback) const;
std::string StatString(const std::string& key, const std::string& fallback = {}) const;
};
EntityPreset LoadEntityPreset(const std::filesystem::path& root, const std::string& name);
void ClearEntityPresetCache();
+355
View File
@@ -0,0 +1,355 @@
#include "ItemIconCatalog.h"
#include "TonoriItems.h"
#include <algorithm>
#include <cctype>
#include <fstream>
#include <regex>
#include <sstream>
namespace {
std::string NormalizeKey(const std::string& value)
{
std::string normalized;
normalized.reserve(value.size());
for (unsigned char ch : value) {
if (std::isalnum(ch)) {
normalized.push_back(static_cast<char>(std::tolower(ch)));
}
}
return normalized;
}
void AddIcon(ItemIconCatalog& catalog, const std::string& key, const std::filesystem::path& path)
{
if (key.empty() || path.empty() || !std::filesystem::exists(path)) {
return;
}
const std::string normalized = NormalizeKey(key);
if (normalized.empty()) {
return;
}
catalog.iconsByKey.try_emplace(normalized, path);
}
std::string ReadTextFile(const std::filesystem::path& path)
{
std::ifstream in(path);
if (!in) {
return {};
}
std::ostringstream buffer;
buffer << in.rdbuf();
return buffer.str();
}
std::filesystem::path ResolveResPath(const std::filesystem::path& root, const std::string& resPath)
{
constexpr const char* prefix = "res://";
std::string relative = resPath;
if (relative.rfind(prefix, 0) == 0) {
relative = relative.substr(std::char_traits<char>::length(prefix));
}
if (relative.rfind("data/graphics/items/", 0) == 0) {
return root / "assets/icon/items" / relative.substr(std::string("data/graphics/items/").size());
}
if (relative.rfind("data/graphics/gui/", 0) == 0) {
return root / "assets/ui" / relative.substr(std::string("data/graphics/gui/").size());
}
if (relative.rfind("data/graphics/sprites/", 0) == 0) {
return root / "assets/sprites" / relative.substr(std::string("data/graphics/sprites/").size());
}
if (relative.rfind("data/graphics/effects/", 0) == 0) {
return root / "assets/effects" / relative.substr(std::string("data/graphics/effects/").size());
}
if (relative.rfind("data/graphics/fonts/", 0) == 0) {
return root / "assets/fonts" / relative.substr(std::string("data/graphics/fonts/").size());
}
if (relative.rfind("presets/", 0) == 0) {
return root / "assets/presets" / relative.substr(std::string("presets/").size());
}
if (relative.rfind("sources/scripts/", 0) == 0) {
return root / "assets/scripts" / relative.substr(std::string("sources/scripts/").size());
}
return root / relative;
}
std::map<std::string, std::string> ParseExtResources(const std::string& text)
{
std::map<std::string, std::string> resources;
std::istringstream lines(text);
std::string line;
const std::regex idRegex(R"(\bid=\"([^\"]+)\")");
const std::regex pathRegex(R"(path=\"([^\"]+)\")");
std::smatch idMatch;
std::smatch pathMatch;
while (std::getline(lines, line)) {
if (line.rfind("[ext_resource", 0) != 0) {
continue;
}
if (std::regex_search(line, idMatch, idRegex) && std::regex_search(line, pathMatch, pathRegex)) {
resources[idMatch[1].str()] = pathMatch[1].str();
}
}
return resources;
}
std::string ParseQuotedAssignment(const std::string& text, const std::string& key)
{
const std::regex regex(key + R"(\s*=\s*\"([^\"]*)\")");
std::smatch match;
return std::regex_search(text, match, regex) ? match[1].str() : std::string{};
}
std::string ParseExtResourceAssignment(const std::string& text, const std::string& key)
{
const std::regex regex(key + R"(\s*=\s*ExtResource\(\"([^\"]+)\"\))");
std::smatch match;
return std::regex_search(text, match, regex) ? match[1].str() : std::string{};
}
int ParseIntAssignment(const std::string& text, const std::string& key, int fallback)
{
const std::regex regex(key + R"(\s*=\s*(-?[0-9]+))");
std::smatch match;
return std::regex_search(text, match, regex) ? std::stoi(match[1].str()) : fallback;
}
float ParseFloatAssignment(const std::string& text, const std::string& key, float fallback)
{
const std::regex regex(key + R"(\s*=\s*(-?[0-9]+(?:\.[0-9]+)?))");
std::smatch match;
return std::regex_search(text, match, regex) ? std::stof(match[1].str()) : fallback;
}
bool ParseBoolAssignment(const std::string& text, const std::string& key, bool fallback)
{
const std::regex regex(key + R"(\s*=\s*(true|false))");
std::smatch match;
if (!std::regex_search(text, match, regex)) {
return fallback;
}
return match[1].str() == "true";
}
std::filesystem::path ParseIconPath(
const std::filesystem::path& root,
const std::string& text,
const std::map<std::string, std::string>& resources)
{
const std::string iconId = ParseExtResourceAssignment(text, "icon");
const auto icon = resources.find(iconId);
if (icon == resources.end()) {
return {};
}
return ResolveResPath(root, icon->second);
}
void AddMetadata(ItemIconCatalog& catalog, const ItemCatalogEntry& entry, const std::string& fallbackKey)
{
if (entry.name.empty()) {
return;
}
catalog.itemsByKey[NormalizeKey(entry.name)] = entry;
AddIcon(catalog, entry.name, entry.iconPath);
if (!fallbackKey.empty()) {
catalog.itemsByKey.try_emplace(NormalizeKey(fallbackKey), entry);
AddIcon(catalog, fallbackKey, entry.iconPath);
}
}
void ScanItemMetadata(ItemIconCatalog& catalog, const std::filesystem::path& root, const std::filesystem::path& directory)
{
if (!std::filesystem::exists(directory) || !std::filesystem::is_directory(directory)) {
return;
}
for (const std::filesystem::directory_entry& entry : std::filesystem::recursive_directory_iterator(directory)) {
if (!entry.is_regular_file() || entry.path().extension() != ".tres") {
continue;
}
const std::string text = ReadTextFile(entry.path());
if (text.empty() || text.find("script_class=\"ItemCell\"") == std::string::npos) {
continue;
}
const std::map<std::string, std::string> resources = ParseExtResources(text);
ItemCatalogEntry item;
item.name = ParseQuotedAssignment(text, "name");
item.description = ParseQuotedAssignment(text, "description");
item.iconPath = ParseIconPath(root, text, resources);
item.slot = ParseIntAssignment(text, "slot", -1);
item.weight = ParseFloatAssignment(text, "weight", 0.0f);
item.stackable = ParseBoolAssignment(text, "stackable", false);
item.usable = ParseBoolAssignment(text, "usable", false);
AddMetadata(catalog, item, entry.path().stem().string());
}
}
void ScanPngIcons(ItemIconCatalog& catalog, const std::filesystem::path& directory)
{
if (!std::filesystem::exists(directory) || !std::filesystem::is_directory(directory)) {
return;
}
for (const std::filesystem::directory_entry& entry : std::filesystem::recursive_directory_iterator(directory)) {
if (!entry.is_regular_file() || entry.path().extension() != ".png") {
continue;
}
AddIcon(catalog, entry.path().stem().string(), entry.path());
}
}
std::filesystem::path FirstExisting(const std::initializer_list<std::filesystem::path>& paths)
{
for (const std::filesystem::path& path : paths) {
if (std::filesystem::exists(path)) {
return path;
}
}
return {};
}
std::filesystem::path ResolveTonoriIconPath(const std::filesystem::path& root, const std::string& iconFile)
{
const std::filesystem::path extraItems = root / "assets/icon/tuxemon";
const std::filesystem::path iconPath(iconFile);
if (iconFile.rfind("data/", 0) == 0) {
return FirstExisting({ResolveResPath(root, "res://" + iconFile), extraItems / iconPath.filename()});
}
return FirstExisting({
extraItems / iconPath,
root / "assets/icon/items/usable" / iconPath,
root / "assets/icon/items/common" / iconPath,
root / "assets/icon/items/skill" / iconPath,
});
}
void AddTonoriAliases(ItemIconCatalog& catalog, const std::filesystem::path& root)
{
for (const TonoriItemDefinition& item : TonoriItemCatalog()) {
const std::filesystem::path icon = ResolveTonoriIconPath(root, item.iconFile);
AddIcon(catalog, item.id, icon);
AddIcon(catalog, item.name, icon);
if (!item.iconFile.empty()) {
AddIcon(catalog, std::filesystem::path(item.iconFile).stem().string(), icon);
}
}
}
void AddKnownAliases(ItemIconCatalog& catalog, const std::filesystem::path& root)
{
const std::filesystem::path items = root / "assets/icon/items";
const std::filesystem::path gui = root / "assets/ui";
catalog.genericIcon = FirstExisting({gui / "item.png"});
AddIcon(catalog, "item", catalog.genericIcon);
AddIcon(catalog, "generic", catalog.genericIcon);
const std::filesystem::path key = FirstExisting({items / "common/treasure-key.png", gui / "key.png"});
AddIcon(catalog, "key", key);
AddIcon(catalog, "chest mine key", key);
AddIcon(catalog, "sandstorm mine key", key);
const std::filesystem::path letter = FirstExisting({items / "quest/letter.png", items / "quest/envelope.png"});
AddIcon(catalog, "letter", letter);
AddIcon(catalog, "letters", letter);
AddIcon(catalog, "sealed letters", letter);
AddIcon(catalog, "letter stash", letter);
const std::filesystem::path water = FirstExisting({items / "usable/bottle-water.png", items / "common/bottle-empty.png"});
AddIcon(catalog, "water", water);
AddIcon(catalog, "clean water", water);
AddIcon(catalog, "well", water);
AddIcon(catalog, "pond", water);
AddIcon(catalog, "apple", items / "usable/apple.png");
const std::filesystem::path sword = FirstExisting({items / "weapon/shortsword.png", items / "weapon/gladius.png"});
AddIcon(catalog, "sword", sword);
AddIcon(catalog, "short sword", sword);
AddIcon(catalog, "shortsword", sword);
AddIcon(catalog, "chest", FirstExisting({gui / "inventory/slots/chest.png", catalog.genericIcon}));
AddIcon(catalog, "old chest", FirstExisting({gui / "inventory/slots/chest.png", catalog.genericIcon}));
AddIcon(catalog, "thiefs chest", FirstExisting({gui / "inventory/slots/chest.png", catalog.genericIcon}));
}
std::filesystem::path FindByContains(const ItemIconCatalog& catalog, const std::string& normalized)
{
const std::pair<const char*, const char*> aliases[] = {
{"sandstormminekey", "sandstormminekey"},
{"chestminekey", "chestminekey"},
{"key", "key"},
{"letter", "letter"},
{"envelope", "letter"},
{"water", "water"},
{"well", "well"},
{"pond", "pond"},
{"apple", "apple"},
{"shortsword", "shortsword"},
{"sword", "sword"},
{"chest", "chest"},
};
for (const auto& [needle, key] : aliases) {
if (normalized.find(needle) == std::string::npos) {
continue;
}
const auto it = catalog.iconsByKey.find(key);
if (it != catalog.iconsByKey.end()) {
return it->second;
}
}
return {};
}
} // namespace
ItemIconCatalog BuildItemIconCatalog(const std::filesystem::path& root)
{
ItemIconCatalog catalog;
ScanPngIcons(catalog, root / "assets/icon/items");
ScanPngIcons(catalog, root / "assets/ui");
ScanPngIcons(catalog, root / "assets/presets");
ScanItemMetadata(catalog, root, root / "assets/presets/cells/items");
ScanPngIcons(catalog, root / "assets/icon/tuxemon");
AddKnownAliases(catalog, root);
AddTonoriAliases(catalog, root);
return catalog;
}
std::filesystem::path FindItemIcon(const ItemIconCatalog& catalog, const std::string& itemName)
{
const std::string normalized = NormalizeKey(itemName);
if (normalized.empty()) {
return catalog.genericIcon;
}
const auto metadata = catalog.itemsByKey.find(normalized);
if (metadata != catalog.itemsByKey.end() && !metadata->second.iconPath.empty()) {
return metadata->second.iconPath;
}
const auto exact = catalog.iconsByKey.find(normalized);
if (exact != catalog.iconsByKey.end()) {
return exact->second;
}
const std::filesystem::path alias = FindByContains(catalog, normalized);
if (!alias.empty()) {
return alias;
}
return catalog.genericIcon;
}
const ItemCatalogEntry* FindItemMetadata(const ItemIconCatalog& catalog, const std::string& itemName)
{
const std::string normalized = NormalizeKey(itemName);
if (normalized.empty()) {
return nullptr;
}
const auto exact = catalog.itemsByKey.find(normalized);
return exact == catalog.itemsByKey.end() ? nullptr : &exact->second;
}
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <filesystem>
#include <map>
#include <string>
struct ItemCatalogEntry {
std::string name;
std::string description;
std::filesystem::path iconPath;
int slot = -1;
float weight = 0.0f;
bool stackable = false;
bool usable = false;
};
struct ItemIconCatalog {
std::map<std::string, std::filesystem::path> iconsByKey;
std::map<std::string, ItemCatalogEntry> itemsByKey;
std::filesystem::path genericIcon;
};
ItemIconCatalog BuildItemIconCatalog(const std::filesystem::path& root);
std::filesystem::path FindItemIcon(const ItemIconCatalog& catalog, const std::string& itemName);
const ItemCatalogEntry* FindItemMetadata(const ItemIconCatalog& catalog, const std::string& itemName);
+72
View File
@@ -0,0 +1,72 @@
#include "MinimapAsset.h"
#include "TmxWorld.h"
#include <filesystem>
namespace {
std::filesystem::path MinimapRoot(const std::filesystem::path& root)
{
return root / "assets/minimap";
}
} // namespace
std::filesystem::path FindMinimapAsset(const std::filesystem::path& root, const TmxMap& map)
{
const std::string filename = MapDisplayName(map) + ".png";
const std::filesystem::path mapsRoot = root / "assets/maps";
const std::filesystem::path minimapRoot = MinimapRoot(root);
std::error_code ec;
const std::filesystem::path relativeMap = std::filesystem::relative(map.sourcePath, mapsRoot, ec);
if (!ec && !relativeMap.empty()) {
const std::filesystem::path candidate = minimapRoot / relativeMap.parent_path() / filename;
if (std::filesystem::exists(candidate)) {
return candidate;
}
}
if (!std::filesystem::exists(minimapRoot)) {
return {};
}
for (const auto& entry : std::filesystem::recursive_directory_iterator(minimapRoot)) {
if (entry.is_regular_file() && entry.path().filename() == filename) {
return entry.path();
}
}
return {};
}
std::map<std::string, std::filesystem::path> BuildMinimapCatalog(
const std::filesystem::path& root,
const TmxWorldIndex& world)
{
std::map<std::string, std::filesystem::path> catalog;
for (const auto& [name, path] : world.mapsByName) {
const TmxMap& map = LoadTmxMapCached(path);
const std::filesystem::path minimap = FindMinimapAsset(root, map);
if (!minimap.empty()) {
catalog[name] = minimap;
}
}
return catalog;
}
MinimapCoverage BuildMinimapCoverage(
const std::filesystem::path& root,
const TmxWorldIndex& world)
{
MinimapCoverage coverage;
coverage.totalMaps = static_cast<int>(world.mapsByName.size());
for (const auto& [name, path] : world.mapsByName) {
const TmxMap& map = LoadTmxMapCached(path);
if (!FindMinimapAsset(root, map).empty()) {
++coverage.mapsWithMinimap;
} else {
coverage.missingMaps.push_back(name);
}
}
return coverage;
}
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include "TmxMap.h"
#include "TmxWorld.h"
#include <filesystem>
#include <map>
#include <string>
#include <vector>
struct MinimapCoverage {
int totalMaps = 0;
int mapsWithMinimap = 0;
std::vector<std::string> missingMaps;
};
std::filesystem::path FindMinimapAsset(const std::filesystem::path& root, const TmxMap& map);
std::map<std::string, std::filesystem::path> BuildMinimapCatalog(
const std::filesystem::path& root,
const TmxWorldIndex& world);
MinimapCoverage BuildMinimapCoverage(
const std::filesystem::path& root,
const TmxWorldIndex& world);
+54
View File
@@ -0,0 +1,54 @@
#include "MusicAssets.h"
#include <fstream>
#include <regex>
#include <sstream>
namespace {
std::string ReadTextFile(const std::filesystem::path& path)
{
std::ifstream in(path);
if (!in) {
return {};
}
std::ostringstream buffer;
buffer << in.rdbuf();
return buffer.str();
}
} // namespace
MusicCatalog LoadMusicCatalog(const std::filesystem::path& root)
{
MusicCatalog catalog;
const std::string text = ReadTextFile(root / "assets/db/music.json");
if (text.empty()) {
return catalog;
}
const std::regex entryRegex("\\\"([^\\\"]+)\\\"\\s*:\\s*\\{[^{}]*\\\"Path\\\"\\s*:\\s*\\\"([^\\\"]+)\\\"");
for (auto it = std::sregex_iterator(text.begin(), text.end(), entryRegex); it != std::sregex_iterator(); ++it) {
MusicTrack track;
track.name = (*it)[1].str();
track.filename = (*it)[2].str();
track.path = root / "assets/music" / track.filename;
catalog.tracks[track.name] = std::move(track);
}
return catalog;
}
MusicTrack ResolveMapMusic(const std::filesystem::path& root, const TmxMap& map, const MusicCatalog& catalog)
{
const auto music = map.properties.find("music");
if (music == map.properties.end() || music->second.empty()) {
return {};
}
const auto track = catalog.tracks.find(music->second);
if (track != catalog.tracks.end()) {
return track->second;
}
return {music->second, music->second + ".ogg", root / "assets/music" / (music->second + ".ogg")};
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "TmxMap.h"
#include <filesystem>
#include <map>
#include <string>
struct MusicTrack {
std::string name;
std::string filename;
std::filesystem::path path;
};
struct MusicCatalog {
std::map<std::string, MusicTrack> tracks;
};
MusicCatalog LoadMusicCatalog(const std::filesystem::path& root);
MusicTrack ResolveMapMusic(const std::filesystem::path& root, const TmxMap& map, const MusicCatalog& catalog);
+94
View File
@@ -0,0 +1,94 @@
#include "SoundAssets.h"
#include <algorithm>
#include <cctype>
namespace {
std::string NormalizeKey(const std::string& value)
{
std::string normalized;
normalized.reserve(value.size());
for (unsigned char ch : value) {
if (std::isalnum(ch)) {
normalized.push_back(static_cast<char>(std::tolower(ch)));
}
}
return normalized;
}
bool IsSoundFile(const std::filesystem::path& path)
{
const std::string ext = path.extension().string();
return ext == ".ogg" || ext == ".wav";
}
void ScanSounds(SoundCatalog& catalog, const std::filesystem::path& directory)
{
if (!std::filesystem::exists(directory) || !std::filesystem::is_directory(directory)) {
return;
}
for (const std::filesystem::directory_entry& entry : std::filesystem::recursive_directory_iterator(directory)) {
if (!entry.is_regular_file() || !IsSoundFile(entry.path())) {
continue;
}
const std::string key = NormalizeKey(entry.path().parent_path().filename().string() + " " + entry.path().stem().string());
catalog.assets.push_back({key, entry.path()});
}
std::sort(catalog.assets.begin(), catalog.assets.end(), [](const SoundAsset& a, const SoundAsset& b) {
return a.path.string() < b.path.string();
});
}
std::filesystem::path FirstMatching(const SoundCatalog& catalog, const std::initializer_list<std::string>& needles)
{
for (const std::string& needleText : needles) {
const std::string needle = NormalizeKey(needleText);
for (const SoundAsset& asset : catalog.assets) {
if (asset.key.find(needle) != std::string::npos) {
return asset.path;
}
}
}
return {};
}
} // namespace
SoundCatalog LoadSoundCatalog(const std::filesystem::path& root)
{
SoundCatalog catalog;
ScanSounds(catalog, root / "assets/sounds");
ScanSounds(catalog, root / "assets/music");
return catalog;
}
std::filesystem::path ResolveSoundPurpose(const SoundCatalog& catalog, const std::string& purpose)
{
const std::string key = NormalizeKey(purpose);
if (key == "hit" || key == "battle" || key == "attack") {
return FirstMatching(catalog, {"alteration hit", "hit"});
}
if (key == "levelup" || key == "level") {
return FirstMatching(catalog, {"alteration levelup", "levelup"});
}
if (key == "exp" || key == "experience") {
return FirstMatching(catalog, {"alteration exp"});
}
if (key == "quest" || key == "questupdate") {
return FirstMatching(catalog, {"alteration quest update", "quest update"});
}
if (key == "questdone") {
return FirstMatching(catalog, {"alteration quest done", "quest done"});
}
if (key == "chest" || key == "open") {
return FirstMatching(catalog, {"chest open"});
}
if (key == "heal") {
return FirstMatching(catalog, {"alteration heal"});
}
if (key == "dodge") {
return FirstMatching(catalog, {"alteration dodge"});
}
return {};
}
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <filesystem>
#include <string>
#include <vector>
struct SoundAsset {
std::string key;
std::filesystem::path path;
};
struct SoundCatalog {
std::vector<SoundAsset> assets;
};
SoundCatalog LoadSoundCatalog(const std::filesystem::path& root);
std::filesystem::path ResolveSoundPurpose(const SoundCatalog& catalog, const std::string& purpose);
+568
View File
@@ -0,0 +1,568 @@
#include "SpriteAnimation.h"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <fstream>
#include <regex>
#include <sstream>
#include <utility>
namespace {
std::string ReadTextFile(const std::filesystem::path& path)
{
std::ifstream in(path);
if (!in) {
return {};
}
std::ostringstream buffer;
buffer << in.rdbuf();
return buffer.str();
}
std::filesystem::path ResolveResPath(const std::filesystem::path& root, const std::string& resPath)
{
constexpr const char* prefix = "res://";
std::string relative = resPath;
if (relative.rfind(prefix, 0) == 0) {
relative = relative.substr(std::char_traits<char>::length(prefix));
}
if (relative.rfind("data/graphics/items/", 0) == 0) {
return root / "assets/icon/items" / relative.substr(std::string("data/graphics/items/").size());
}
if (relative.rfind("data/graphics/gui/", 0) == 0) {
return root / "assets/ui" / relative.substr(std::string("data/graphics/gui/").size());
}
if (relative.rfind("data/graphics/sprites/", 0) == 0) {
return root / "assets/sprites" / relative.substr(std::string("data/graphics/sprites/").size());
}
if (relative.rfind("data/graphics/effects/", 0) == 0) {
return root / "assets/effects" / relative.substr(std::string("data/graphics/effects/").size());
}
if (relative.rfind("data/graphics/fonts/", 0) == 0) {
return root / "assets/fonts" / relative.substr(std::string("data/graphics/fonts/").size());
}
if (relative.rfind("presets/", 0) == 0) {
return root / "assets/presets" / relative.substr(std::string("presets/").size());
}
if (relative.rfind("sources/scripts/", 0) == 0) {
return root / "assets/scripts" / relative.substr(std::string("sources/scripts/").size());
}
return root / relative;
}
std::map<std::string, std::string> ParseExtResources(const std::string& text)
{
std::map<std::string, std::string> resources;
std::istringstream lines(text);
std::string line;
const std::regex idRegex(R"(\bid=\"([^\"]+)\")");
const std::regex pathRegex(R"(path=\"([^\"]+)\")");
std::smatch idMatch;
std::smatch pathMatch;
while (std::getline(lines, line)) {
if (line.rfind("[ext_resource", 0) != 0) {
continue;
}
if (std::regex_search(line, idMatch, idRegex) && std::regex_search(line, pathMatch, pathRegex)) {
resources[idMatch[1].str()] = pathMatch[1].str();
}
}
return resources;
}
int ParseIntAssignment(const std::string& text, const std::string& key, int fallback)
{
const std::regex regex(key + R"(\s*=\s*(-?[0-9]+))");
std::smatch match;
return std::regex_search(text, match, regex) ? std::stoi(match[1].str()) : fallback;
}
double ParseDoubleAssignment(const std::string& text, const std::string& key, double fallback)
{
const std::regex regex(key + R"(\s*=\s*(-?[0-9]+(?:\.[0-9]+)?))");
std::smatch match;
return std::regex_search(text, match, regex) ? std::stod(match[1].str()) : fallback;
}
std::string ParseQuotedAssignment(const std::string& text, const std::string& key)
{
const std::regex regex(key + R"(\s*=\s*\"([^\"]*)\")");
std::smatch match;
return std::regex_search(text, match, regex) ? match[1].str() : std::string{};
}
std::string ParseExtResourceAssignment(const std::string& text, const std::string& key)
{
const std::regex regex(key + R"(\s*=\s*ExtResource\(\"([^\"]+)\"\))");
std::smatch match;
return std::regex_search(text, match, regex) ? match[1].str() : std::string{};
}
std::pair<float, float> ParseVector2Assignment(const std::string& text, const std::string& key, std::pair<float, float> fallback)
{
const std::regex regex(key + R"(\s*=\s*Vector2\((-?[0-9]+(?:\.[0-9]+)?),\s*(-?[0-9]+(?:\.[0-9]+)?)\))");
std::smatch match;
if (!std::regex_search(text, match, regex)) {
return fallback;
}
return {std::stof(match[1].str()), std::stof(match[2].str())};
}
std::vector<int> ParseIntList(const std::string& text)
{
std::vector<int> values;
const std::regex numberRegex(R"(-?[0-9]+)");
auto begin = std::sregex_iterator(text.begin(), text.end(), numberRegex);
auto end = std::sregex_iterator();
for (auto it = begin; it != end; ++it) {
values.push_back(std::stoi((*it)[0].str()));
}
return values;
}
std::vector<double> ParseDoubleList(const std::string& text)
{
std::vector<double> values;
const std::regex numberRegex(R"(-?[0-9]+(?:\.[0-9]+)?)");
auto begin = std::sregex_iterator(text.begin(), text.end(), numberRegex);
auto end = std::sregex_iterator();
for (auto it = begin; it != end; ++it) {
values.push_back(std::stod((*it)[0].str()));
}
return values;
}
std::vector<SpriteVector2Value> ParseVector2List(const std::string& text)
{
std::vector<SpriteVector2Value> values;
const std::regex vectorRegex(R"(Vector2\((-?[0-9]+(?:\.[0-9]+)?),\s*(-?[0-9]+(?:\.[0-9]+)?)\))");
auto begin = std::sregex_iterator(text.begin(), text.end(), vectorRegex);
auto end = std::sregex_iterator();
for (auto it = begin; it != end; ++it) {
values.push_back({std::stof((*it)[1].str()), std::stof((*it)[2].str())});
}
return values;
}
std::vector<double> ParseTrackTimes(const std::string& trackBlock)
{
const std::regex timesRegex(R"("times"\s*:\s*PackedFloat32Array\(([^\)]*)\))");
std::smatch match;
return std::regex_search(trackBlock, match, timesRegex) ? ParseDoubleList(match[1].str()) : std::vector<double>{};
}
std::string FirstNodeBlock(const std::string& text, const std::string& nodeName)
{
const std::string marker = "[node name=\"" + nodeName + "\"";
const std::size_t start = text.find(marker);
if (start == std::string::npos) {
return {};
}
std::size_t end = text.find("\n[", start + 1);
return text.substr(start, end == std::string::npos ? std::string::npos : end - start);
}
void ParseAnimationBlock(const std::string& block, SpriteAnimationSet& set)
{
const std::string name = ParseQuotedAssignment(block, "resource_name");
if (name.empty()) {
return;
}
SpriteAnimation animation;
animation.name = name;
animation.length = ParseDoubleAssignment(block, "length", 0.1);
std::size_t cursor = 0;
while (true) {
const std::size_t pathPos = block.find("path = NodePath(\"", cursor);
if (pathPos == std::string::npos) {
break;
}
const std::size_t pathStart = pathPos + std::string("path = NodePath(\"").size();
const std::size_t pathEnd = block.find("\")", pathStart);
if (pathEnd == std::string::npos) {
break;
}
const std::string path = block.substr(pathStart, pathEnd - pathStart);
std::size_t trackEnd = std::string::npos;
const std::size_t trackMarker = block.rfind("tracks/", pathPos);
if (trackMarker != std::string::npos) {
const std::size_t numberStart = trackMarker + std::string("tracks/").size();
const std::size_t numberEnd = block.find('/', numberStart);
if (numberEnd != std::string::npos) {
const int trackIndex = std::stoi(block.substr(numberStart, numberEnd - numberStart));
trackEnd = block.find("\ntracks/" + std::to_string(trackIndex + 1) + "/type", pathEnd);
}
}
const std::string trackBlock = block.substr(pathPos, trackEnd == std::string::npos ? std::string::npos : trackEnd - pathPos);
cursor = trackEnd == std::string::npos ? block.size() : trackEnd + 1;
const std::size_t valuesPos = trackBlock.find("\"values\": [");
if (valuesPos == std::string::npos) {
continue;
}
const std::size_t listStart = trackBlock.find('[', valuesPos);
const std::size_t listEnd = trackBlock.find(']', listStart);
if (listStart == std::string::npos || listEnd == std::string::npos || listEnd <= listStart) {
continue;
}
const std::string values = trackBlock.substr(listStart + 1, listEnd - listStart - 1);
const std::vector<double> times = ParseTrackTimes(trackBlock);
if (path == ".:frame") {
animation.frames = ParseIntList(values);
animation.frameTimes = times;
continue;
}
constexpr const char* layerPrefix = "../";
const std::size_t propertySeparator = path.find(':');
if (path.rfind(layerPrefix, 0) != 0 || propertySeparator == std::string::npos) {
continue;
}
const std::string layerName = path.substr(std::char_traits<char>::length(layerPrefix), propertySeparator - std::char_traits<char>::length(layerPrefix));
const std::string property = path.substr(propertySeparator + 1);
if (property == "frame") {
animation.layerFrameTracks[layerName] = {times, ParseIntList(values)};
} else if (property == "offset") {
animation.layerOffsetTracks[layerName] = {times, ParseVector2List(values)};
}
}
if (!animation.frames.empty() || !animation.layerFrameTracks.empty() || !animation.layerOffsetTracks.empty()) {
set.animations[name] = std::move(animation);
}
}
std::string ActionPrefix(SpriteAction action)
{
switch (action) {
case SpriteAction::Idle:
return "Idle";
case SpriteAction::Walk:
return "Walk";
case SpriteAction::Sit:
return "Sit";
case SpriteAction::Attack:
return "Attack";
case SpriteAction::Death:
return "Death";
}
return "Idle";
}
std::string DirectionSuffix(SpriteFacing facing)
{
switch (facing) {
case SpriteFacing::Down:
return "Down";
case SpriteFacing::LeftDown:
return "LeftDown";
case SpriteFacing::RightDown:
return "RightDown";
case SpriteFacing::Left:
return "Left";
case SpriteFacing::Right:
return "Right";
case SpriteFacing::LeftUp:
return "LeftUp";
case SpriteFacing::RightUp:
return "RightUp";
case SpriteFacing::Up:
return "Up";
}
return "Down";
}
std::string AnimationName(SpriteFacing facing, SpriteAction action)
{
return ActionPrefix(action) + DirectionSuffix(facing);
}
const SpriteAnimation* FindAnimation(const SpriteAnimationSet& set, SpriteFacing facing, SpriteAction action)
{
const std::string wanted = AnimationName(facing, action);
if (const auto it = set.animations.find(wanted); it != set.animations.end()) {
return &it->second;
}
switch (facing) {
case SpriteFacing::LeftDown:
case SpriteFacing::LeftUp:
if (const SpriteAnimation* fallback = FindAnimation(set, SpriteFacing::Left, action)) {
return fallback;
}
break;
case SpriteFacing::RightDown:
case SpriteFacing::RightUp:
if (const SpriteAnimation* fallback = FindAnimation(set, SpriteFacing::Right, action)) {
return fallback;
}
break;
case SpriteFacing::Down:
case SpriteFacing::Left:
case SpriteFacing::Right:
case SpriteFacing::Up:
break;
}
if (const auto base = set.animations.find(ActionPrefix(action)); base != set.animations.end()) {
return &base->second;
}
if (action != SpriteAction::Idle) {
return FindAnimation(set, facing, SpriteAction::Idle);
}
if (const auto it = set.animations.find("IdleDown"); it != set.animations.end()) {
return &it->second;
}
if (const auto it = set.animations.find("Idle"); it != set.animations.end()) {
return &it->second;
}
return nullptr;
}
std::size_t SelectTrackIndex(
const std::vector<double>& times,
std::size_t valueCount,
double length,
double elapsedSeconds)
{
if (valueCount <= 1) {
return 0;
}
const double wrapped = length > 0.0 ? std::fmod(std::max(0.0, elapsedSeconds), length) : 0.0;
if (times.size() == valueCount) {
std::size_t selected = 0;
for (std::size_t i = 0; i < times.size(); ++i) {
if (wrapped + 0.000001 < times[i]) {
break;
}
selected = i;
}
return std::min(selected, valueCount - 1);
}
if (length <= 0.0) {
return 0;
}
return std::min<std::size_t>(
static_cast<std::size_t>((wrapped / length) * static_cast<double>(valueCount)),
valueCount - 1);
}
int SelectIntTrackValue(
const std::vector<double>& times,
const std::vector<int>& values,
double length,
double elapsedSeconds,
int fallback)
{
if (values.empty()) {
return fallback;
}
return values[SelectTrackIndex(times, values.size(), length, elapsedSeconds)];
}
SpriteVector2Value SelectVector2TrackValue(
const std::vector<double>& times,
const std::vector<SpriteVector2Value>& values,
double length,
double elapsedSeconds,
SpriteVector2Value fallback)
{
if (values.empty()) {
return fallback;
}
return values[SelectTrackIndex(times, values.size(), length, elapsedSeconds)];
}
} // namespace
SpriteFacing FacingFromDirectionName(const std::string& direction, SpriteFacing fallback)
{
std::string normalized = direction;
std::transform(normalized.begin(), normalized.end(), normalized.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
if (normalized == "down") {
return SpriteFacing::Down;
}
if (normalized == "leftdown" || normalized == "left_down" || normalized == "left-down") {
return SpriteFacing::LeftDown;
}
if (normalized == "rightdown" || normalized == "right_down" || normalized == "right-down") {
return SpriteFacing::RightDown;
}
if (normalized == "left") {
return SpriteFacing::Left;
}
if (normalized == "right") {
return SpriteFacing::Right;
}
if (normalized == "leftup" || normalized == "left_up" || normalized == "left-up") {
return SpriteFacing::LeftUp;
}
if (normalized == "rightup" || normalized == "right_up" || normalized == "right-up") {
return SpriteFacing::RightUp;
}
if (normalized == "up") {
return SpriteFacing::Up;
}
return fallback;
}
SpriteAction SpriteActionFromStateName(const std::string& state)
{
std::string normalized = state;
std::transform(normalized.begin(), normalized.end(), normalized.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
if (normalized == "walk") {
return SpriteAction::Walk;
}
if (normalized == "sit") {
return SpriteAction::Sit;
}
if (normalized == "attack") {
return SpriteAction::Attack;
}
if (normalized == "death") {
return SpriteAction::Death;
}
return SpriteAction::Idle;
}
SpriteAnimationSet LoadSpriteAnimationSet(const std::filesystem::path& root, const std::string& spritePreset)
{
SpriteAnimationSet set;
if (spritePreset.empty()) {
return set;
}
const std::filesystem::path scenePath = root / "assets/presets/entities/sprites" / (spritePreset + ".tscn");
const std::string text = ReadTextFile(scenePath);
if (text.empty()) {
return set;
}
const std::map<std::string, std::string> resources = ParseExtResources(text);
const std::string body = FirstNodeBlock(text, "Body");
const std::string textureId = ParseExtResourceAssignment(body, "texture");
if (!textureId.empty()) {
const auto texture = resources.find(textureId);
if (texture != resources.end()) {
set.texturePath = ResolveResPath(root, texture->second);
}
}
if (set.texturePath.empty()) {
for (const auto& [_, path] : resources) {
if (path.find("res://data/graphics/sprites/") == 0 && path.ends_with(".png")) {
set.texturePath = ResolveResPath(root, path);
break;
}
}
}
set.hframes = std::max(1, ParseIntAssignment(body, "hframes", 1));
set.vframes = std::max(1, ParseIntAssignment(body, "vframes", 1));
set.defaultFrame = std::max(0, ParseIntAssignment(body, "frame", 0));
const auto [offsetX, offsetY] = ParseVector2Assignment(body, "offset", {0.0f, 0.0f});
set.offsetX = offsetX;
set.offsetY = offsetY;
std::size_t start = 0;
while ((start = text.find("[sub_resource type=\"Animation\"", start)) != std::string::npos) {
std::size_t end = text.find("\n[", start + 1);
ParseAnimationBlock(text.substr(start, end == std::string::npos ? std::string::npos : end - start), set);
if (end == std::string::npos) {
break;
}
start = end + 1;
}
return set;
}
SpriteFrameRect SpriteFrameSourceRect(int textureWidth, int textureHeight, int hframes, int vframes, int frame)
{
hframes = std::max(1, hframes);
vframes = std::max(1, vframes);
const int frameWidth = textureWidth / hframes;
const int frameHeight = textureHeight / vframes;
const int maxFrame = std::max(0, hframes * vframes - 1);
frame = std::clamp(frame, 0, maxFrame);
return {
(frame % hframes) * frameWidth,
(frame / hframes) * frameHeight,
frameWidth,
frameHeight,
};
}
SpriteFrameRect DirectionalLayerSourceRect(int textureWidth, int textureHeight, int frame)
{
int hframes = 1;
if (textureWidth > 0 && textureWidth % 5 == 0) {
hframes = 5;
} else if (textureHeight > 0) {
hframes = std::max(1, textureWidth / textureHeight);
}
return SpriteFrameSourceRect(textureWidth, textureHeight, hframes, 1, frame);
}
int SelectSpriteFrame(const SpriteAnimationSet& set, SpriteFacing facing, bool moving, double elapsedSeconds)
{
return SelectSpriteFrameForAction(set, facing, moving ? SpriteAction::Walk : SpriteAction::Idle, moving, elapsedSeconds);
}
int SelectSpriteFrameForAction(
const SpriteAnimationSet& set,
SpriteFacing facing,
SpriteAction action,
bool moving,
double elapsedSeconds)
{
if (moving && action == SpriteAction::Idle) {
action = SpriteAction::Walk;
}
const SpriteAnimation* animation = FindAnimation(set, facing, action);
if (!animation || animation->frames.empty()) {
return set.defaultFrame;
}
return SelectIntTrackValue(animation->frameTimes, animation->frames, animation->length, elapsedSeconds, set.defaultFrame);
}
SpriteLayerPose SelectSpriteLayerPoseForAction(
const SpriteAnimationSet& set,
SpriteFacing facing,
SpriteAction action,
bool moving,
double elapsedSeconds,
const std::string& layerName,
int fallbackFrame,
float fallbackOffsetX,
float fallbackOffsetY)
{
if (moving && action == SpriteAction::Idle) {
action = SpriteAction::Walk;
}
SpriteLayerPose pose{fallbackFrame, fallbackOffsetX, fallbackOffsetY};
const SpriteAnimation* animation = FindAnimation(set, facing, action);
if (!animation) {
return pose;
}
if (const auto frameIt = animation->layerFrameTracks.find(layerName); frameIt != animation->layerFrameTracks.end()) {
pose.frame = SelectIntTrackValue(frameIt->second.times, frameIt->second.values, animation->length, elapsedSeconds, pose.frame);
}
if (const auto offsetIt = animation->layerOffsetTracks.find(layerName); offsetIt != animation->layerOffsetTracks.end()) {
const SpriteVector2Value offset = SelectVector2TrackValue(
offsetIt->second.times,
offsetIt->second.values,
animation->length,
elapsedSeconds,
{pose.offsetX, pose.offsetY});
pose.offsetX = offset.x;
pose.offsetY = offset.y;
}
return pose;
}
+95
View File
@@ -0,0 +1,95 @@
#pragma once
#include <filesystem>
#include <map>
#include <string>
#include <vector>
struct SpriteFrameRect {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
};
struct SpriteIntTrack {
std::vector<double> times;
std::vector<int> values;
};
struct SpriteVector2Value {
float x = 0.0f;
float y = 0.0f;
};
struct SpriteVector2Track {
std::vector<double> times;
std::vector<SpriteVector2Value> values;
};
struct SpriteLayerPose {
int frame = 0;
float offsetX = 0.0f;
float offsetY = 0.0f;
};
struct SpriteAnimation {
std::string name;
double length = 0.1;
std::vector<double> frameTimes;
std::vector<int> frames;
std::map<std::string, SpriteIntTrack> layerFrameTracks;
std::map<std::string, SpriteVector2Track> layerOffsetTracks;
};
struct SpriteAnimationSet {
std::filesystem::path texturePath;
int hframes = 1;
int vframes = 1;
int defaultFrame = 0;
float offsetX = 0.0f;
float offsetY = 0.0f;
std::map<std::string, SpriteAnimation> animations;
};
enum class SpriteFacing {
Down,
LeftDown,
RightDown,
Left,
Right,
LeftUp,
RightUp,
Up
};
enum class SpriteAction {
Idle,
Walk,
Sit,
Attack,
Death
};
SpriteAnimationSet LoadSpriteAnimationSet(const std::filesystem::path& root, const std::string& spritePreset);
SpriteFrameRect SpriteFrameSourceRect(int textureWidth, int textureHeight, int hframes, int vframes, int frame);
SpriteFrameRect DirectionalLayerSourceRect(int textureWidth, int textureHeight, int frame);
int SelectSpriteFrame(const SpriteAnimationSet& set, SpriteFacing facing, bool moving, double elapsedSeconds);
int SelectSpriteFrameForAction(
const SpriteAnimationSet& set,
SpriteFacing facing,
SpriteAction action,
bool moving,
double elapsedSeconds);
SpriteLayerPose SelectSpriteLayerPoseForAction(
const SpriteAnimationSet& set,
SpriteFacing facing,
SpriteAction action,
bool moving,
double elapsedSeconds,
const std::string& layerName,
int fallbackFrame,
float fallbackOffsetX,
float fallbackOffsetY);
SpriteFacing FacingFromDirectionName(const std::string& direction, SpriteFacing fallback);
SpriteAction SpriteActionFromStateName(const std::string& state);
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <filesystem>
struct HealthBarAssets {
std::filesystem::path under;
std::filesystem::path progress;
};
inline HealthBarAssets ResolveHealthBarAssets(const std::filesystem::path& root)
{
return {
root / "assets/ui/smallbar.png",
root / "assets/ui/smallbarprogress.png",
};
}
+200
View File
@@ -0,0 +1,200 @@
#include "BattleAssets.h"
#include "Assets.h"
#include "EntityPreset.h"
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <exception>
#include <string>
namespace {
std::string LowerAscii(std::string value)
{
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return value;
}
std::filesystem::path Existing(const std::filesystem::path& path)
{
return std::filesystem::exists(path) ? path : std::filesystem::path{};
}
std::filesystem::path FindTonoriMinimap(const std::filesystem::path& root, const std::string& mapName)
{
if (mapName.empty()) {
return {};
}
const std::string wanted = LowerAscii(mapName);
const std::filesystem::path minimapRoot = root / "assets/minimap/tonori";
if (!std::filesystem::exists(minimapRoot)) {
return {};
}
for (const std::filesystem::directory_entry& entry : std::filesystem::recursive_directory_iterator(minimapRoot)) {
if (!entry.is_regular_file() || entry.path().extension() != ".png") {
continue;
}
if (LowerAscii(entry.path().stem().string()) == wanted) {
return entry.path();
}
}
return {};
}
} // namespace
BattleEnvironment ResolveBattleEnvironment(const std::string& mapName)
{
const std::string lower = LowerAscii(mapName);
if (lower.find("cave") != std::string::npos || lower.find("mine") != std::string::npos || lower.find("pit") != std::string::npos) {
return BattleEnvironment::Cave;
}
if (lower.find("beach") != std::string::npos || lower.find("bay") != std::string::npos || lower.find("water") != std::string::npos) {
return BattleEnvironment::Beach;
}
if (lower.find("castle") != std::string::npos || lower.find("corridor") != std::string::npos || lower.find("chamber") != std::string::npos) {
return BattleEnvironment::Indoor;
}
if (lower.find("tulimshar") != std::string::npos || lower.find("manayir") != std::string::npos) {
return BattleEnvironment::Town;
}
return BattleEnvironment::Desert;
}
BattleBackgroundAssets ResolveBattleBackgroundAssets(
const std::filesystem::path& root,
const std::string& mapName,
const std::filesystem::path& currentMinimap)
{
BattleBackgroundAssets assets;
assets.environment = ResolveBattleEnvironment(mapName);
assets.battleBackdrop = Existing(root / "assets/battle/tuxemon/desert_background.png");
if (assets.battleBackdrop.empty()) {
assets.battleBackdrop = Existing(root / "assets/press/readme/combat.png");
}
assets.minimap = Existing(currentMinimap);
if (assets.minimap.empty()) {
assets.minimap = FindTonoriMinimap(root, mapName);
}
switch (assets.environment) {
case BattleEnvironment::Cave:
assets.primaryTile = Existing(root / "assets/tilesets/tonori/cave/cave.png");
assets.secondaryTile = Existing(root / "assets/tilesets/tonori/cave/cave-x1x3.png");
assets.accentTile = Existing(root / "assets/tilesets/tonori/cave/torch-x3x2.png");
break;
case BattleEnvironment::Beach:
assets.primaryTile = Existing(root / "assets/tilesets/tonori/ground.png");
assets.secondaryTile = Existing(root / "assets/tilesets/tonori/water.png");
assets.accentTile = Existing(root / "assets/tilesets/tonori/waves.png");
break;
case BattleEnvironment::Indoor:
assets.primaryTile = Existing(root / "assets/tilesets/tonori/indoor/castle-indoor.png");
assets.secondaryTile = Existing(root / "assets/tilesets/tonori/indoor/carpet.png");
assets.accentTile = Existing(root / "assets/tilesets/tonori/indoor/sunlight-x4-x4.png");
break;
case BattleEnvironment::Town:
assets.primaryTile = Existing(root / "assets/tilesets/tonori/ground.png");
assets.secondaryTile = Existing(root / "assets/tilesets/tonori/house.png");
assets.accentTile = Existing(root / "assets/tilesets/tonori/palm-tree-x5.png");
break;
case BattleEnvironment::Desert:
assets.primaryTile = Existing(root / "assets/tilesets/tonori/ground.png");
assets.secondaryTile = Existing(root / "assets/tilesets/overworld/desert.png");
assets.accentTile = Existing(root / "assets/tilesets/tonori/ground-x1x3.png");
break;
}
return assets;
}
BattleHudAssets ResolveBattleHudAssets(const std::filesystem::path& root)
{
return {
Existing(root / "assets/ui/window/window_bg_soft_title.png"),
Existing(root / "assets/ui/window/window_bg_soft.png"),
Existing(root / "assets/ui/button/button.png"),
Existing(root / "assets/ui/button/button_hover.png"),
Existing(root / "assets/ui/button/button_focus.png"),
Existing(root / "assets/ui/button/button_pressed.png"),
Existing(root / "assets/ui/button/button_disabled.png"),
Existing(root / "assets/ui/messagebox.png"),
Existing(root / "assets/ui/notification.png"),
Existing(root / "assets/ui/smallbar.png"),
Existing(root / "assets/ui/smallbarprogress.png"),
Existing(root / "assets/ui/stat/healthbar.png"),
Existing(root / "assets/ui/icon/skills.png"),
Existing(root / "assets/ui/icon/inventory.png"),
Existing(root / "assets/ui/item.png"),
Existing(root / "assets/icon/items/skill/melee.png"),
Existing(root / "assets/ui/icon/inventory.png"),
Existing(root / "assets/ui/icon/social.png"),
Existing(root / "assets/icon/items/skill/run.png"),
};
}
BattleEffectAssets ResolveBattleEffectAssets(const std::filesystem::path& root, const std::string& element)
{
const std::string lower = LowerAscii(element);
std::filesystem::path icon;
if (lower == "") {
icon = root / "assets/effects/particles/fire.png";
} else if (lower == "") {
icon = root / "assets/effects/particles/orb.png";
} else if (lower == "") {
icon = root / "assets/effects/particles/leaf.png";
} else if (lower == "") {
icon = root / "assets/effects/particles/fog.png";
} else if (lower == "") {
icon = root / "assets/effects/sonic-wave.png";
} else if (lower == "") {
icon = root / "assets/effects/particles/smoke.png";
} else if (lower == "") {
icon = root / "assets/tilesets/tonori/ground.png";
} else if (lower == "普通") {
icon = root / "assets/effects/arrow.png";
}
return {
Existing(icon),
Existing(root / "assets/ui/icon/skills.png"),
};
}
BattleSpriteView ResolveBattleSpriteForPet(const std::filesystem::path& root, const Pet& pet)
{
EntityPreset preset;
try {
preset = LoadEntityPreset(root, pet.name);
} catch (const std::exception&) {
preset.name = pet.name;
}
BattleSpriteView view;
view.texturePath = preset.texturePath;
view.spritePreset = preset.spritePreset;
if (view.texturePath.empty()) {
view.texturePath = MonsterSpriteForName(root, pet.name);
}
if (view.texturePath.empty()) {
view.texturePath = MonsterSprite(root, "lulea.png");
}
if (view.spritePreset.empty()) {
view.spritePreset = "Slime";
}
const std::string name = LowerAscii(pet.name);
if (name.find("bat") != std::string::npos || name.find("bird") != std::string::npos || name.find("piou") != std::string::npos) {
view.scale = 3.2f;
} else if (name.find("maggot") != std::string::npos || name.find("slime") != std::string::npos) {
view.scale = 3.9f;
} else if (name.find("croc") != std::string::npos || name.find("turtle") != std::string::npos) {
view.scale = 3.4f;
} else {
view.scale = 3.55f;
}
return view;
}
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include "GameCore.h"
#include <filesystem>
#include <string>
enum class BattleEnvironment {
Desert,
Cave,
Beach,
Indoor,
Town
};
struct BattleBackgroundAssets {
BattleEnvironment environment = BattleEnvironment::Desert;
std::filesystem::path battleBackdrop;
std::filesystem::path minimap;
std::filesystem::path primaryTile;
std::filesystem::path secondaryTile;
std::filesystem::path accentTile;
};
struct BattleHudAssets {
std::filesystem::path window;
std::filesystem::path softWindow;
std::filesystem::path button;
std::filesystem::path buttonHover;
std::filesystem::path buttonFocus;
std::filesystem::path buttonPressed;
std::filesystem::path buttonDisabled;
std::filesystem::path messageBox;
std::filesystem::path notification;
std::filesystem::path smallBar;
std::filesystem::path smallBarProgress;
std::filesystem::path healthBar;
std::filesystem::path skillIcon;
std::filesystem::path inventoryIcon;
std::filesystem::path itemIcon;
std::filesystem::path commandBattleIcon;
std::filesystem::path commandItemIcon;
std::filesystem::path commandPetIcon;
std::filesystem::path commandEscapeIcon;
};
struct BattleEffectAssets {
std::filesystem::path icon;
std::filesystem::path fallbackIcon;
};
struct BattleSpriteView {
std::filesystem::path texturePath;
std::string spritePreset;
float scale = 3.45f;
};
BattleEnvironment ResolveBattleEnvironment(const std::string& mapName);
BattleBackgroundAssets ResolveBattleBackgroundAssets(
const std::filesystem::path& root,
const std::string& mapName,
const std::filesystem::path& currentMinimap);
BattleHudAssets ResolveBattleHudAssets(const std::filesystem::path& root);
BattleEffectAssets ResolveBattleEffectAssets(const std::filesystem::path& root, const std::string& element);
BattleSpriteView ResolveBattleSpriteForPet(const std::filesystem::path& root, const Pet& pet);
+35
View File
@@ -0,0 +1,35 @@
#include "BattleLayout.h"
BattleLayout MakeBattleLayout(float width, float height)
{
const float sx = width / 1280.0f;
const float sy = height / 720.0f;
const auto rect = [&](float x, float y, float w, float h) {
return Rectangle{x * sx, y * sy, w * sx, h * sy};
};
const auto vec = [&](float x, float y) {
return Vector2{x * sx, y * sy};
};
BattleLayout layout;
layout.screen = rect(0.0f, 0.0f, 1280.0f, 720.0f);
layout.playerHud = rect(104.0f, 374.0f, 374.0f, 104.0f);
layout.wildHud = rect(774.0f, 76.0f, 374.0f, 104.0f);
layout.playerParty = rect(122.0f, 484.0f, 270.0f, 16.0f);
layout.wildParty = rect(856.0f, 184.0f, 270.0f, 16.0f);
layout.commandPanel = rect(70.0f, 532.0f, 676.0f, 150.0f);
layout.textPanel = rect(766.0f, 532.0f, 444.0f, 150.0f);
layout.playerFoot = vec(330.0f, 380.0f);
layout.wildFoot = vec(930.0f, 292.0f);
layout.playerPlatform = rect(176.0f, 344.0f, 352.0f, 92.0f);
layout.wildPlatform = rect(784.0f, 262.0f, 306.0f, 72.0f);
for (int i = 0; i < 4; ++i) {
const float x = 96.0f + static_cast<float>(i % 2) * 326.0f;
const float y = 562.0f + static_cast<float>(i / 2) * 58.0f;
layout.commandButtons[static_cast<std::size_t>(i)] = rect(x, y, 292.0f, 50.0f);
layout.skillButtons[static_cast<std::size_t>(i)] = rect(x, y, 292.0f, 50.0f);
}
return layout;
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <array>
#include <raylib.h>
struct BattleLayout {
Rectangle screen{};
Rectangle playerHud{};
Rectangle wildHud{};
Rectangle playerParty{};
Rectangle wildParty{};
Rectangle textPanel{};
Rectangle commandPanel{};
std::array<Rectangle, 4> commandButtons{};
std::array<Rectangle, 4> skillButtons{};
Vector2 playerFoot{};
Vector2 wildFoot{};
Rectangle playerPlatform{};
Rectangle wildPlatform{};
};
BattleLayout MakeBattleLayout(float width = 1280.0f, float height = 720.0f);
+989
View File
@@ -0,0 +1,989 @@
#include "BattleScene.h"
#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <filesystem>
#include <string>
#include <vector>
namespace {
constexpr Color kGold{232, 196, 104, 255};
constexpr Color kIvory{255, 248, 218, 255};
constexpr Color kInk{35, 35, 42, 255};
constexpr Color kDimText{196, 204, 206, 255};
constexpr Color kThemeText{238, 216, 161, 255};
constexpr Color kTextShadow{15, 16, 18, 190};
void DrawTextCn(Font font, const std::string& text, Vector2 pos, float size, Color color)
{
DrawTextEx(font, text.c_str(), pos, size, 1.0f, color);
}
void DrawTextCnOutlined(Font font, const std::string& text, Vector2 pos, float size, Color color)
{
DrawTextCn(font, text, {pos.x + 1.0f, pos.y}, size, kTextShadow);
DrawTextCn(font, text, {pos.x - 1.0f, pos.y}, size, kTextShadow);
DrawTextCn(font, text, {pos.x, pos.y + 1.0f}, size, kTextShadow);
DrawTextCn(font, text, {pos.x, pos.y - 1.0f}, size, kTextShadow);
DrawTextCn(font, text, pos, size, color);
}
std::size_t Utf8CharLength(unsigned char ch)
{
if ((ch & 0x80) == 0) {
return 1;
}
if ((ch & 0xE0) == 0xC0) {
return 2;
}
if ((ch & 0xF0) == 0xE0) {
return 3;
}
if ((ch & 0xF8) == 0xF0) {
return 4;
}
return 1;
}
std::vector<std::string> WrapTextTokens(const std::string& text)
{
std::vector<std::string> tokens;
for (std::size_t i = 0; i < text.size();) {
const unsigned char ch = static_cast<unsigned char>(text[i]);
if (text[i] == '\n') {
tokens.push_back("\n");
++i;
continue;
}
if (std::isspace(ch)) {
while (i < text.size() && text[i] != '\n' && std::isspace(static_cast<unsigned char>(text[i]))) {
++i;
}
tokens.push_back(" ");
continue;
}
if (ch < 0x80) {
std::size_t end = i + 1;
while (end < text.size()) {
const unsigned char next = static_cast<unsigned char>(text[end]);
if (next >= 0x80 || std::isspace(next)) {
break;
}
++end;
}
tokens.push_back(text.substr(i, end - i));
i = end;
continue;
}
const std::size_t len = std::min(Utf8CharLength(ch), text.size() - i);
tokens.push_back(text.substr(i, len));
i += len;
}
return tokens;
}
void DrawWrappedTextCn(Font font, const std::string& text, Vector2 pos, float size, float maxWidth, Color color, int maxLines = -1)
{
std::string line;
float y = pos.y;
int linesDrawn = 0;
const auto drawLine = [&]() -> bool {
if (line.empty()) {
return true;
}
if (maxLines >= 0 && linesDrawn >= maxLines) {
return false;
}
DrawTextCn(font, line, {pos.x, y}, size, color);
++linesDrawn;
y += size + 7.0f;
line.clear();
return maxLines < 0 || linesDrawn < maxLines;
};
for (const std::string& token : WrapTextTokens(text)) {
if (token == "\n") {
if (!drawLine()) {
return;
}
continue;
}
if (token == " " && line.empty()) {
continue;
}
const std::string candidate = line + token;
if (!line.empty() && token != " " && MeasureTextEx(font, candidate.c_str(), size, 1.0f).x > maxWidth) {
if (!drawLine()) {
return;
}
line = token;
} else {
line = candidate;
}
}
drawLine();
}
Color HpColor(float ratio)
{
if (ratio <= 0.25f) {
return Color{218, 78, 62, 255};
}
if (ratio <= 0.55f) {
return Color{230, 178, 64, 255};
}
return Color{72, 204, 116, 255};
}
Color ElementColor(const std::string& element)
{
if (element == "") {
return Color{216, 91, 55, 255};
}
if (element == "") {
return Color{76, 146, 215, 255};
}
if (element == "") {
return Color{82, 171, 94, 255};
}
if (element == "") {
return Color{154, 92, 185, 255};
}
if (element == "") {
return Color{86, 185, 196, 255};
}
if (element == "") {
return Color{80, 78, 100, 255};
}
if (element == "") {
return Color{177, 126, 70, 255};
}
return Color{152, 157, 164, 255};
}
float Clamp01(float value)
{
return std::clamp(value, 0.0f, 1.0f);
}
float EaseOutCubic(float t)
{
t = Clamp01(t);
const float inv = 1.0f - t;
return 1.0f - inv * inv * inv;
}
Vector2 LerpVector(Vector2 a, Vector2 b, float t)
{
t = Clamp01(t);
return {a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t};
}
Vector2 NormalizeOrZero(Vector2 value)
{
const float length = std::sqrt(value.x * value.x + value.y * value.y);
if (length <= 0.001f) {
return {};
}
return {value.x / length, value.y / length};
}
Vector2 Add(Vector2 a, Vector2 b)
{
return {a.x + b.x, a.y + b.y};
}
Vector2 Scale(Vector2 value, float scale)
{
return {value.x * scale, value.y * scale};
}
void DrawTextureCover(Texture2D& texture, Rectangle dst, Color tint = WHITE)
{
const float textureW = static_cast<float>(std::max(1, texture.width));
const float textureH = static_cast<float>(std::max(1, texture.height));
const float sourceRatio = textureW / textureH;
const float dstRatio = dst.width / dst.height;
Rectangle src{0.0f, 0.0f, textureW, textureH};
if (sourceRatio > dstRatio) {
src.width = textureH * dstRatio;
src.x = (textureW - src.width) * 0.5f;
} else {
src.height = textureW / dstRatio;
src.y = (textureH - src.height) * 0.5f;
}
DrawTexturePro(texture, src, dst, {0.0f, 0.0f}, 0.0f, tint);
}
void DrawTextureContain(Texture2D& texture, Rectangle dst, Color tint = WHITE)
{
const float textureW = static_cast<float>(std::max(1, texture.width));
const float textureH = static_cast<float>(std::max(1, texture.height));
const float scale = std::min(dst.width / textureW, dst.height / textureH);
const Rectangle target{
dst.x + (dst.width - textureW * scale) * 0.5f,
dst.y + (dst.height - textureH * scale) * 0.5f,
textureW * scale,
textureH * scale,
};
DrawTexturePro(texture, {0.0f, 0.0f, textureW, textureH}, target, {0.0f, 0.0f}, 0.0f, tint);
}
void DrawNinePatch(Texture2D& texture, Rectangle bounds, Color tint = WHITE, int left = 6, int top = 8, int right = 6, int bottom = 6)
{
const NPatchInfo patch{
{0.0f, 0.0f, static_cast<float>(texture.width), static_cast<float>(texture.height)},
left,
top,
right,
bottom,
NPATCH_NINE_PATCH,
};
DrawTextureNPatch(texture, patch, bounds, {0.0f, 0.0f}, 0.0f, tint);
}
void DrawFramedHudPanel(const BattleHudAssets& assets, const BattleSceneDrawHooks& hooks, Rectangle bounds)
{
if (!assets.softWindow.empty()) {
Texture2D& panel = hooks.loadTexture(assets.softWindow);
DrawNinePatch(panel, bounds, WHITE, 6, 8, 6, 6);
DrawRectangleRec({bounds.x + 8.0f, bounds.y + 8.0f, bounds.width - 16.0f, bounds.height - 16.0f}, Color{10, 13, 16, 150});
return;
}
DrawRectangleRec(bounds, Color{10, 13, 16, 210});
DrawRectangleLinesEx(bounds, 2.0f, Color{232, 196, 104, 220});
}
void DrawButtonPanel(
const BattleHudAssets& assets,
const BattleSceneDrawHooks& hooks,
Rectangle bounds,
bool selected,
bool disabled)
{
std::filesystem::path path = disabled && !assets.buttonDisabled.empty() ? assets.buttonDisabled : selected ? assets.buttonFocus : assets.button;
if (path.empty()) {
path = assets.softWindow;
}
if (!path.empty()) {
Texture2D& button = hooks.loadTexture(path);
DrawNinePatch(button, bounds, disabled && assets.buttonDisabled.empty() ? Color{150, 150, 150, 180} : WHITE, 5, 4, 6, 5);
} else {
DrawRectangleRec(bounds, selected ? Color{74, 70, 58, 238} : Color{27, 31, 39, 224});
DrawRectangleLinesEx(bounds, selected ? 2.0f : 1.0f, selected ? kGold : Color{98, 85, 52, 190});
}
}
void DrawTexturedBar(
const BattleHudAssets& assets,
const BattleSceneDrawHooks& hooks,
Rectangle bounds,
float ratio,
Color tint)
{
ratio = std::clamp(ratio, 0.0f, 1.0f);
if (!assets.smallBar.empty() && !assets.smallBarProgress.empty()) {
Texture2D& under = hooks.loadTexture(assets.smallBar);
Texture2D& progress = hooks.loadTexture(assets.smallBarProgress);
DrawNinePatch(under, bounds, WHITE, 1, 1, 1, 1);
if (ratio > 0.0f) {
Rectangle fill{bounds.x, bounds.y, std::max(2.0f, bounds.width * ratio), bounds.height};
DrawNinePatch(progress, fill, tint, 1, 1, 1, 1);
}
} else {
DrawRectangleRec(bounds, Color{47, 50, 55, 255});
DrawRectangle(static_cast<int>(bounds.x), static_cast<int>(bounds.y), static_cast<int>(bounds.width * ratio), static_cast<int>(bounds.height), tint);
}
DrawRectangleLinesEx(bounds, 1.0f, Color{24, 24, 26, 180});
}
void DrawBattleBackground(const BattleBackgroundAssets& assets, const BattleSceneDrawHooks& hooks, Rectangle screen)
{
if (!assets.battleBackdrop.empty()) {
Texture2D& backdrop = hooks.loadTexture(assets.battleBackdrop);
DrawTextureCover(backdrop, screen, WHITE);
DrawRectangle(0, 0, static_cast<int>(screen.width), static_cast<int>(screen.height), Color{0, 0, 0, 28});
return;
}
switch (assets.environment) {
case BattleEnvironment::Cave:
DrawRectangleGradientV(0, 0, static_cast<int>(screen.width), static_cast<int>(screen.height), Color{47, 42, 54, 255}, Color{23, 24, 31, 255});
break;
case BattleEnvironment::Beach:
DrawRectangleGradientV(0, 0, static_cast<int>(screen.width), static_cast<int>(screen.height), Color{102, 172, 190, 255}, Color{212, 184, 116, 255});
break;
case BattleEnvironment::Indoor:
DrawRectangleGradientV(0, 0, static_cast<int>(screen.width), static_cast<int>(screen.height), Color{104, 82, 73, 255}, Color{54, 44, 50, 255});
break;
case BattleEnvironment::Town:
DrawRectangleGradientV(0, 0, static_cast<int>(screen.width), static_cast<int>(screen.height), Color{130, 168, 128, 255}, Color{193, 166, 102, 255});
break;
case BattleEnvironment::Desert:
DrawRectangleGradientV(0, 0, static_cast<int>(screen.width), static_cast<int>(screen.height), Color{204, 173, 101, 255}, Color{104, 93, 75, 255});
break;
}
if (!assets.minimap.empty()) {
Texture2D& minimap = hooks.loadTexture(assets.minimap);
DrawTextureCover(minimap, screen, Color{255, 255, 255, 92});
}
if (!assets.primaryTile.empty()) {
Texture2D& tile = hooks.loadTexture(assets.primaryTile);
DrawTextureCover(tile, {0.0f, 318.0f, screen.width, 214.0f}, Color{255, 255, 255, 92});
}
if (!assets.secondaryTile.empty()) {
Texture2D& tile = hooks.loadTexture(assets.secondaryTile);
DrawTextureCover(tile, {0.0f, 0.0f, screen.width, 178.0f}, Color{255, 255, 255, 56});
}
DrawRectangle(0, 0, static_cast<int>(screen.width), static_cast<int>(screen.height), Color{0, 0, 0, 38});
}
void DrawPlatform(Rectangle bounds, BattleEnvironment environment)
{
Color base = Color{112, 88, 58, 168};
Color line = Color{239, 210, 131, 118};
if (environment == BattleEnvironment::Cave) {
base = Color{72, 62, 75, 190};
line = Color{162, 142, 165, 120};
} else if (environment == BattleEnvironment::Beach) {
base = Color{202, 170, 103, 180};
line = Color{255, 238, 172, 130};
} else if (environment == BattleEnvironment::Indoor) {
base = Color{95, 62, 60, 185};
line = Color{212, 171, 111, 130};
} else if (environment == BattleEnvironment::Town) {
base = Color{99, 126, 78, 165};
line = Color{221, 203, 127, 120};
}
DrawEllipse(
static_cast<int>(bounds.x + bounds.width * 0.5f),
static_cast<int>(bounds.y + bounds.height * 0.5f),
bounds.width * 0.5f,
bounds.height * 0.5f,
base);
DrawEllipseLines(
static_cast<int>(bounds.x + bounds.width * 0.5f),
static_cast<int>(bounds.y + bounds.height * 0.5f),
bounds.width * 0.5f,
bounds.height * 0.5f,
line);
}
void DrawPartyTray(const BattleHudAssets& assets, const BattleSceneDrawHooks& hooks, Rectangle bounds, const Team& team, bool enemy)
{
constexpr int slotCount = 6;
const float gap = 7.0f;
const float slotW = (bounds.width - gap * static_cast<float>(slotCount - 1)) / static_cast<float>(slotCount);
for (int i = 0; i < slotCount; ++i) {
const Rectangle slot{bounds.x + static_cast<float>(i) * (slotW + gap), bounds.y, slotW, bounds.height};
DrawRectangleRec(slot, Color{19, 22, 27, 154});
DrawRectangleLinesEx(slot, 1.0f, Color{216, 187, 112, 130});
if (enemy) {
if (i == 0) {
DrawRectangleRec({slot.x + 3.0f, slot.y + 4.0f, slot.width - 6.0f, slot.height - 8.0f}, Color{205, 82, 72, 220});
}
continue;
}
if (i >= static_cast<int>(team.pets.size())) {
continue;
}
const Pet& pet = team.pets[static_cast<std::size_t>(i)];
const float ratio = static_cast<float>(std::max(0, pet.hp)) / static_cast<float>(std::max(1, pet.maxHp));
DrawTexturedBar(assets, hooks, {slot.x + 3.0f, slot.y + 4.0f, slot.width - 6.0f, slot.height - 8.0f}, ratio, HpColor(ratio));
}
}
void DrawStatusHud(
const BattleHudAssets& assets,
const BattleSceneDrawHooks& hooks,
Font font,
Rectangle bounds,
const Pet& pet,
bool alignRight,
float displayedHp)
{
const std::string name = PetDisplayName(pet.name);
const std::string level = "等级 " + std::to_string(pet.level);
const std::string primaryElement = SpeciesPrimaryElement(pet.name);
const std::string secondaryElement = SpeciesSecondaryElement(pet.name);
const Vector2 levelSize = MeasureTextEx(font, level.c_str(), 18.0f, 1.0f);
const Rectangle contentBack{
bounds.x,
bounds.y,
bounds.width,
bounds.height,
};
DrawFramedHudPanel(assets, hooks, contentBack);
const Rectangle titleBack{
contentBack.x + 6.0f,
contentBack.y + 5.0f,
contentBack.width - levelSize.x - 32.0f,
29.0f,
};
const float nameX = titleBack.x + 10.0f;
DrawTextCnOutlined(font, name, {nameX, bounds.y + 12.0f}, 23.0f, kIvory);
float badgeX = nameX + MeasureTextEx(font, name.c_str(), 23.0f, 1.0f).x + 10.0f;
const auto drawElementBadge = [&](const std::string& element) {
if (element.empty() || badgeX + 34.0f > titleBack.x + titleBack.width - 4.0f) {
return;
}
const Rectangle badge{badgeX, bounds.y + 15.0f, 31.0f, 18.0f};
DrawRectangleRounded(badge, 0.25f, 8, ElementColor(element));
DrawRectangleLinesEx(badge, 1.0f, Color{255, 255, 255, 92});
DrawTextCn(font, element, {badge.x + 5.0f, badge.y + 1.0f}, 13.0f, WHITE);
badgeX += badge.width + 5.0f;
};
drawElementBadge(primaryElement);
drawElementBadge(secondaryElement);
DrawTextCnOutlined(font, level, {bounds.x + bounds.width - levelSize.x - 18.0f, bounds.y + 17.0f}, 18.0f, kGold);
const float hpForBar = displayedHp >= 0.0f ? displayedHp : static_cast<float>(pet.hp);
const float ratio = hpForBar / static_cast<float>(std::max(1, pet.maxHp));
DrawTexturedBar(assets, hooks, {bounds.x + 20.0f, bounds.y + 56.0f, bounds.width - 40.0f, 15.0f}, ratio, HpColor(ratio));
const std::string hp = std::to_string(std::max(0, pet.hp)) + " / " + std::to_string(std::max(1, pet.maxHp));
const Vector2 hpSize = MeasureTextEx(font, hp.c_str(), 16.0f, 1.0f);
const float hpX = alignRight ? bounds.x + 22.0f : bounds.x + bounds.width - hpSize.x - 22.0f;
DrawTextCnOutlined(font, hp, {hpX, bounds.y + 78.0f}, 16.0f, Color{224, 224, 214, 255});
}
Vector2 BattleActorBodyCenter(const BattleLayout& layout, bool player)
{
const Vector2 foot = player ? layout.playerFoot : layout.wildFoot;
return player ? Vector2{foot.x + 18.0f, foot.y - 118.0f} : Vector2{foot.x - 14.0f, foot.y - 104.0f};
}
Vector2 BattleActorFootOffset(const BattleLayout& layout, const BattlePresentationState& presentation, bool player)
{
if (!presentation.hasEvent) {
return {};
}
const BattleVisualEvent& event = presentation.event;
const bool attacker = event.attackerIsPlayer == player;
const bool target = event.attackerIsPlayer != player;
const float elapsed = presentation.eventElapsed;
Vector2 offset{};
if (attacker && elapsed <= 0.44f) {
const Vector2 self = player ? layout.playerFoot : layout.wildFoot;
const Vector2 opponent = player ? layout.wildFoot : layout.playerFoot;
const Vector2 direction = NormalizeOrZero({opponent.x - self.x, opponent.y - self.y});
const float lunge = std::sin(Clamp01(elapsed / 0.44f) * PI) * 46.0f;
offset = Add(offset, Scale(direction, lunge));
}
if (target && event.hit && elapsed >= 0.28f && elapsed <= 0.58f) {
const float shake = std::sin(elapsed * 92.0f) * (1.0f - Clamp01((elapsed - 0.28f) / 0.30f));
offset.x += shake * 13.0f;
offset.y += std::sin(elapsed * 57.0f) * 5.0f;
}
if (target && event.hit && elapsed >= 0.58f && elapsed <= 0.78f) {
offset.y += EaseOutCubic((elapsed - 0.58f) / 0.20f) * 10.0f;
}
return offset;
}
Color BattleActorTint(const BattlePresentationState& presentation, bool player)
{
if (!presentation.hasEvent || !presentation.event.hit || presentation.event.attackerIsPlayer == player) {
return WHITE;
}
const float elapsed = presentation.eventElapsed;
if (elapsed < 0.30f || elapsed > 0.56f) {
return WHITE;
}
const unsigned char flash = static_cast<unsigned char>(std::sin(elapsed * 70.0f) > 0.0f ? 255 : 185);
return Color{255, flash, flash, 255};
}
SpriteAction BattleActorAction(const BattlePresentationState& presentation, const Pet& pet, bool player)
{
if (pet.hp <= 0) {
if (presentation.hasEvent && presentation.event.attackerIsPlayer != player && presentation.eventElapsed < 0.46f) {
return SpriteAction::Idle;
}
return SpriteAction::Death;
}
if (presentation.hasEvent && presentation.event.attackerIsPlayer == player && presentation.eventElapsed <= 0.58f) {
return SpriteAction::Attack;
}
return SpriteAction::Idle;
}
void DrawBattleImpactEffect(
const BattleSceneView& view,
const BattleSceneDrawHooks& hooks,
Font font,
const BattleLayout& layout)
{
if (!view.presentation.hasEvent) {
return;
}
const BattleVisualEvent& event = view.presentation.event;
const float elapsed = view.presentation.eventElapsed;
const Vector2 attacker = BattleActorBodyCenter(layout, event.attackerIsPlayer);
const Vector2 target = BattleActorBodyCenter(layout, !event.attackerIsPlayer);
const BattleEffectAssets effect = ResolveBattleEffectAssets(view.root, event.element);
const std::filesystem::path projectilePath = !effect.icon.empty() ? effect.icon : effect.fallbackIcon;
const Color element = ElementColor(event.element);
if (elapsed >= 0.10f && elapsed <= 0.38f) {
const float t = EaseOutCubic((elapsed - 0.10f) / 0.28f);
const Vector2 pos = LerpVector(attacker, target, t);
if (!projectilePath.empty()) {
Texture2D& texture = hooks.loadTexture(projectilePath);
const float size = 34.0f + std::sin(t * PI) * 13.0f;
DrawTextureContain(texture, {pos.x - size * 0.5f, pos.y - size * 0.5f, size, size}, Fade(WHITE, 0.95f));
} else {
DrawCircleV(pos, 16.0f + std::sin(t * PI) * 7.0f, Fade(element, 0.88f));
}
DrawCircleV(pos, 5.0f, Fade(WHITE, 0.75f));
}
if (event.hit && elapsed >= 0.30f && elapsed <= 0.64f) {
const float t = Clamp01((elapsed - 0.30f) / 0.34f);
const float alpha = 1.0f - t;
DrawCircleLines(
static_cast<int>(target.x),
static_cast<int>(target.y),
18.0f + t * 54.0f,
Fade(element, 0.85f * alpha));
DrawCircleV(target, 26.0f + t * 20.0f, Fade(element, 0.18f * alpha));
DrawLineEx({target.x - 42.0f, target.y - 10.0f}, {target.x + 42.0f, target.y + 10.0f}, 4.0f, Fade(WHITE, 0.45f * alpha));
DrawLineEx({target.x - 22.0f, target.y + 32.0f}, {target.x + 28.0f, target.y - 36.0f}, 3.0f, Fade(WHITE, 0.35f * alpha));
}
if (!event.hit && elapsed >= 0.34f && elapsed <= 0.76f) {
const float t = Clamp01((elapsed - 0.34f) / 0.42f);
DrawTextCnOutlined(font, "落空", {target.x - 22.0f, target.y - 64.0f - t * 18.0f}, 23.0f, Fade(kDimText, 1.0f - t));
}
if (event.hit && event.damage > 0 && elapsed >= 0.36f && elapsed <= 0.86f) {
const float t = Clamp01((elapsed - 0.36f) / 0.50f);
const std::string text = "-" + std::to_string(event.damage);
const Vector2 size = MeasureTextEx(font, text.c_str(), 27.0f, 1.0f);
DrawTextCnOutlined(
font,
text,
{target.x - size.x * 0.5f, target.y - 86.0f - EaseOutCubic(t) * 34.0f},
27.0f,
Fade(Color{255, 230, 174, 255}, 1.0f - t * 0.55f));
}
}
void DrawCommandButton(
const BattleHudAssets& assets,
const BattleSceneDrawHooks& hooks,
Font font,
Rectangle bounds,
const std::string& label,
const std::filesystem::path& iconPath,
bool selected,
bool disabled = false)
{
DrawButtonPanel(assets, hooks, bounds, selected, disabled);
const Rectangle iconSlot{bounds.x + 24.0f, bounds.y + (bounds.height - 28.0f) * 0.5f, 28.0f, 28.0f};
if (!iconPath.empty()) {
Texture2D& icon = hooks.loadTexture(iconPath);
DrawTextureContain(icon, iconSlot, disabled ? Color{160, 160, 160, 170} : WHITE);
}
const float textSize = 22.0f;
const Vector2 textMeasure = MeasureTextEx(font, label.c_str(), textSize, 1.0f);
const Color text = disabled ? Color{158, 158, 158, 235} : kThemeText;
DrawTextCnOutlined(font, label, {bounds.x + 74.0f, bounds.y + (bounds.height - textMeasure.y) * 0.5f - 1.0f}, textSize, text);
}
void DrawSkillButton(
const BattleHudAssets& assets,
const BattleSceneDrawHooks& hooks,
Font font,
Rectangle bounds,
const BattleSkill* skill,
int index,
bool selected,
const std::filesystem::path& root)
{
DrawButtonPanel(assets, hooks, bounds, selected, skill == nullptr);
const std::string hotkey = std::to_string(index + 1);
DrawTextCnOutlined(font, hotkey, {bounds.x + 12.0f, bounds.y + 10.0f}, 18.0f, kGold);
if (!skill) {
DrawTextCnOutlined(font, "空技能", {bounds.x + 48.0f, bounds.y + 10.0f}, 19.0f, Color{145, 145, 145, 210});
return;
}
const BattleEffectAssets effect = ResolveBattleEffectAssets(root, skill->element);
const std::filesystem::path iconPath = !effect.icon.empty() ? effect.icon : effect.fallbackIcon;
if (!iconPath.empty()) {
Texture2D& icon = hooks.loadTexture(iconPath);
DrawTextureContain(icon, {bounds.x + 36.0f, bounds.y + 9.0f, 24.0f, 24.0f});
}
DrawTextCnOutlined(font, skill->name, {bounds.x + 70.0f, bounds.y + 3.0f}, 17.0f, kIvory);
const Rectangle tag{bounds.x + 70.0f, bounds.y + 26.0f, 30.0f, 16.0f};
DrawRectangleRounded(tag, 0.25f, 8, ElementColor(skill->element));
DrawTextCnOutlined(font, skill->element, {tag.x + 5.0f, tag.y + 0.0f}, 13.0f, WHITE);
const std::string meta = "" + std::to_string(skill->power) + "" + std::to_string(skill->accuracy);
DrawTextCnOutlined(font, meta, {bounds.x + 108.0f, bounds.y + 26.0f}, 14.0f, Color{230, 220, 186, 255});
}
} // namespace
BattleUiCommand ReadBattleUiCommand(BattleMenuMode& mode, int& selectedIndex, int skillCount, int itemChoiceCount, int petChoiceCount)
{
const auto isItemChoiceMode = [](BattleMenuMode mode) {
return mode == BattleMenuMode::CaptureItems
|| mode == BattleMenuMode::HealingItems
|| mode == BattleMenuMode::SkillBookItems;
};
const auto isPagedChoiceMode = [&](BattleMenuMode mode) {
return isItemChoiceMode(mode) || mode == BattleMenuMode::Pets;
};
const auto logicalMousePosition = []() {
const Vector2 mouse = GetMousePosition();
const float screenW = static_cast<float>(std::max(1, GetScreenWidth()));
const float screenH = static_cast<float>(std::max(1, GetScreenHeight()));
return Vector2{mouse.x * 1280.0f / screenW, mouse.y * 720.0f / screenH};
};
const auto panelCellAt = [](Rectangle panel, Vector2 point) {
if (!CheckCollisionPointRec(point, panel)) {
return -1;
}
const int column = point.x < panel.x + panel.width * 0.5f ? 0 : 1;
const int row = point.y < panel.y + panel.height * 0.5f ? 0 : 1;
return row * 2 + column;
};
auto moveSelection = [&](int dx, int dy, int count) {
if (count <= 0) {
selectedIndex = 0;
return;
}
const int columns = 2;
int column = selectedIndex % columns;
int row = selectedIndex / columns;
column = std::clamp(column + dx, 0, columns - 1);
row = std::clamp(row + dy, 0, std::max(0, (count - 1) / columns));
selectedIndex = std::clamp(row * columns + column, 0, count - 1);
};
const int itemCount = mode == BattleMenuMode::Skills
? std::max(1, std::min(4, skillCount))
: isItemChoiceMode(mode)
? std::max(1, itemChoiceCount)
: mode == BattleMenuMode::Pets
? std::max(1, petChoiceCount)
: mode == BattleMenuMode::Items
? 3
: 4;
selectedIndex = std::clamp(selectedIndex, 0, itemCount - 1);
const auto commandForSelected = [&]() -> BattleUiCommand {
if (mode == BattleMenuMode::Skills) {
return {BattleUiCommandType::Skill, selectedIndex};
}
if (isItemChoiceMode(mode)) {
if (itemChoiceCount <= 0) {
return {};
}
return {BattleUiCommandType::UseItem, selectedIndex};
}
if (mode == BattleMenuMode::Pets) {
if (petChoiceCount <= 0) {
return {};
}
return {BattleUiCommandType::SwitchPet, selectedIndex};
}
if (mode == BattleMenuMode::Items) {
if (selectedIndex == 0) {
mode = BattleMenuMode::CaptureItems;
selectedIndex = 0;
return {};
}
if (selectedIndex == 1) {
mode = BattleMenuMode::HealingItems;
selectedIndex = 0;
return {};
}
if (selectedIndex == 2) {
mode = BattleMenuMode::Main;
selectedIndex = 1;
}
if (selectedIndex == 3) {
mode = BattleMenuMode::Main;
selectedIndex = 1;
}
return {};
}
if (selectedIndex == 0) {
mode = BattleMenuMode::Skills;
selectedIndex = 0;
return {};
}
if (selectedIndex == 1) {
mode = BattleMenuMode::Items;
selectedIndex = 0;
return {};
}
if (selectedIndex == 2) {
mode = BattleMenuMode::Pets;
selectedIndex = 0;
return {};
}
return {BattleUiCommandType::Escape, 0};
};
if (IsKeyPressed(KEY_LEFT) || IsKeyPressed(KEY_A)) {
moveSelection(-1, 0, itemCount);
} else if (IsKeyPressed(KEY_RIGHT) || IsKeyPressed(KEY_D)) {
moveSelection(1, 0, itemCount);
} else if (IsKeyPressed(KEY_UP) || IsKeyPressed(KEY_W)) {
moveSelection(0, -1, itemCount);
} else if (IsKeyPressed(KEY_DOWN) || IsKeyPressed(KEY_S)) {
moveSelection(0, 1, itemCount);
}
const BattleLayout layout = MakeBattleLayout(1280.0f, 720.0f);
const Vector2 mouse = logicalMousePosition();
const std::array<Rectangle, 4>& activeButtons = mode == BattleMenuMode::Skills ? layout.skillButtons : layout.commandButtons;
const int pageStart = isPagedChoiceMode(mode) ? (selectedIndex / 4) * 4 : 0;
const int visibleButtonCount = isPagedChoiceMode(mode) ? std::min(4, itemCount - pageStart) : std::min(4, itemCount);
for (int i = 0; i < visibleButtonCount && i < 4; ++i) {
if (CheckCollisionPointRec(mouse, activeButtons[static_cast<std::size_t>(i)])) {
selectedIndex = pageStart + i;
break;
}
}
int numericSkill = -1;
if (IsKeyPressed(KEY_ONE)) {
numericSkill = 0;
} else if (IsKeyPressed(KEY_TWO)) {
numericSkill = 1;
} else if (IsKeyPressed(KEY_THREE)) {
numericSkill = 2;
} else if (IsKeyPressed(KEY_FOUR)) {
numericSkill = 3;
}
if (numericSkill >= 0) {
if (mode == BattleMenuMode::Skills) {
return {BattleUiCommandType::Skill, numericSkill};
}
if (numericSkill < visibleButtonCount) {
selectedIndex = pageStart + numericSkill;
return commandForSelected();
}
return {};
}
if (IsKeyPressed(KEY_C)) {
mode = BattleMenuMode::CaptureItems;
selectedIndex = 0;
return {};
}
if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) {
for (int i = 0; i < visibleButtonCount && i < 4; ++i) {
if (CheckCollisionPointRec(mouse, activeButtons[static_cast<std::size_t>(i)])) {
selectedIndex = pageStart + i;
return commandForSelected();
}
}
if (mode != BattleMenuMode::Skills && !isPagedChoiceMode(mode)) {
const int panelCell = panelCellAt(layout.commandPanel, mouse);
if (panelCell >= 0 && panelCell < itemCount) {
selectedIndex = panelCell;
return commandForSelected();
}
}
}
if (IsKeyPressed(KEY_ESCAPE)) {
if (mode == BattleMenuMode::Main) {
return {BattleUiCommandType::Escape, 0};
}
mode = isItemChoiceMode(mode) ? BattleMenuMode::Items : BattleMenuMode::Main;
selectedIndex = 0;
return {};
}
if (!IsKeyPressed(KEY_ENTER) && !IsKeyPressed(KEY_SPACE)) {
return {};
}
return commandForSelected();
}
void DrawBattleScene(const BattleSceneView& view, Font font, const BattleSceneDrawHooks& hooks)
{
if (!hooks.loadTexture || !hooks.drawSprite) {
return;
}
const BattleLayout layout = MakeBattleLayout(1280.0f, 720.0f);
const BattleBackgroundAssets background = ResolveBattleBackgroundAssets(view.root, view.mapName, view.minimapPath);
const BattleHudAssets hud = ResolveBattleHudAssets(view.root);
ClearBackground(kInk);
DrawBattleBackground(background, hooks, layout.screen);
DrawPlatform(layout.wildPlatform, background.environment);
DrawPlatform(layout.playerPlatform, background.environment);
if (!view.wildSprite.texturePath.empty() && std::filesystem::exists(view.wildSprite.texturePath)) {
Texture2D& wildTexture = hooks.loadTexture(view.wildSprite.texturePath);
hooks.drawSprite({
wildTexture,
Add(layout.wildFoot, BattleActorFootOffset(layout, view.presentation, false)),
view.wildSprite.spritePreset,
SpriteFacing::LeftDown,
BattleActorAction(view.presentation, view.battle.wild, false),
view.wildSprite.scale,
BattleActorTint(view.presentation, false),
});
}
if (!view.playerSprite.texturePath.empty() && std::filesystem::exists(view.playerSprite.texturePath)) {
Texture2D& playerTexture = hooks.loadTexture(view.playerSprite.texturePath);
hooks.drawSprite({
playerTexture,
Add(layout.playerFoot, BattleActorFootOffset(layout, view.presentation, true)),
view.playerSprite.spritePreset,
SpriteFacing::RightUp,
BattleActorAction(view.presentation, view.battle.player, true),
view.playerSprite.scale + 0.22f,
BattleActorTint(view.presentation, true),
});
}
DrawBattleImpactEffect(view, hooks, font, layout);
DrawStatusHud(hud, hooks, font, layout.playerHud, view.battle.player, false, view.presentation.playerDisplayedHp);
DrawStatusHud(hud, hooks, font, layout.wildHud, view.battle.wild, true, view.presentation.wildDisplayedHp);
DrawPartyTray(hud, hooks, layout.playerParty, view.team, false);
DrawPartyTray(hud, hooks, layout.wildParty, view.team, true);
DrawFramedHudPanel(hud, hooks, layout.textPanel);
DrawTextCnOutlined(font, "日志", {layout.textPanel.x + 22.0f, layout.textPanel.y + 14.0f}, 19.0f, kGold);
DrawWrappedTextCn(
font,
view.battle.message.empty() ? "请选择行动" : view.battle.message,
{layout.textPanel.x + 22.0f, layout.textPanel.y + 46.0f},
18.0f,
layout.textPanel.width - 44.0f,
kIvory,
3);
const std::string shortcutHint = view.menuMode == BattleMenuMode::Skills
? "1-4出招 取消键返回"
: view.menuMode == BattleMenuMode::Items
? "1捕捉 2恢复 3返回 取消键返回"
: view.menuMode == BattleMenuMode::Pets
? "1-4选择宠物 方向键翻页 取消键返回"
: (view.menuMode == BattleMenuMode::CaptureItems
|| view.menuMode == BattleMenuMode::HealingItems
|| view.menuMode == BattleMenuMode::SkillBookItems)
? "1-4选择具体道具 方向键翻页 取消键返回"
: "1战斗 2道具 3宠物 4逃跑";
DrawTextCnOutlined(font, shortcutHint, {layout.textPanel.x + 22.0f, layout.textPanel.y + 120.0f}, 14.0f, Color{218, 202, 142, 255});
DrawFramedHudPanel(hud, hooks, layout.commandPanel);
if (view.menuMode == BattleMenuMode::Skills) {
DrawTextCnOutlined(font, "选择技能", {layout.commandPanel.x + 24.0f, layout.commandPanel.y + 12.0f}, 18.0f, kGold);
for (int i = 0; i < 4; ++i) {
const BattleSkill* skill = i < static_cast<int>(view.skills.size()) ? &view.skills[static_cast<std::size_t>(i)] : nullptr;
DrawSkillButton(hud, hooks, font, layout.skillButtons[static_cast<std::size_t>(i)], skill, i, view.selectedIndex == i, view.root);
}
} else if (view.menuMode == BattleMenuMode::Items) {
DrawTextCnOutlined(font, "道具", {layout.commandPanel.x + 24.0f, layout.commandPanel.y + 12.0f}, 18.0f, kGold);
const std::array<std::string, 3> labels = {"捕捉符", "恢复药", "返回"};
for (int i = 0; i < 3; ++i) {
const bool disabled = false;
DrawCommandButton(
hud,
hooks,
font,
layout.commandButtons[static_cast<std::size_t>(i)],
labels[static_cast<std::size_t>(i)],
i == 0 ? hud.inventoryIcon : hud.itemIcon,
view.selectedIndex == i,
disabled);
}
if (!view.hasCaptureItem) {
DrawTextCnOutlined(font, "背包中没有捕捉符", {layout.commandPanel.x + 244.0f, layout.commandPanel.y + 126.0f}, 15.0f, Color{230, 168, 126, 255});
}
} else if (view.menuMode == BattleMenuMode::CaptureItems
|| view.menuMode == BattleMenuMode::HealingItems
|| view.menuMode == BattleMenuMode::SkillBookItems) {
const std::string title = view.menuMode == BattleMenuMode::CaptureItems
? "选择捕捉符"
: view.menuMode == BattleMenuMode::HealingItems
? "选择恢复药"
: "选择技能书";
DrawTextCnOutlined(font, title, {layout.commandPanel.x + 24.0f, layout.commandPanel.y + 12.0f}, 18.0f, kGold);
if (view.itemChoices.empty()) {
DrawTextCnOutlined(font, "当前没有可用道具", {layout.commandPanel.x + 44.0f, layout.commandPanel.y + 74.0f}, 18.0f, Color{230, 168, 126, 255});
}
const int pageStart = view.itemChoices.empty() ? 0 : (view.selectedIndex / 4) * 4;
const int pageEnd = std::min(pageStart + 4, static_cast<int>(view.itemChoices.size()));
for (int index = pageStart; index < pageEnd; ++index) {
const int local = index - pageStart;
const BattleItemChoice& item = view.itemChoices[static_cast<std::size_t>(index)];
const std::string label = item.name + " x" + std::to_string(item.count);
DrawCommandButton(
hud,
hooks,
font,
layout.commandButtons[static_cast<std::size_t>(local)],
label,
item.iconPath.empty() ? hud.itemIcon : item.iconPath,
view.selectedIndex == index,
false);
}
if (view.itemChoices.size() > 4) {
const std::string page = "" + std::to_string(pageStart / 4 + 1) + "/" + std::to_string((static_cast<int>(view.itemChoices.size()) + 3) / 4) + "";
DrawTextCnOutlined(font, page, {layout.commandPanel.x + 244.0f, layout.commandPanel.y + 126.0f}, 15.0f, Color{218, 202, 142, 255});
}
} else if (view.menuMode == BattleMenuMode::Pets) {
DrawTextCnOutlined(font, "选择出战宠物", {layout.commandPanel.x + 24.0f, layout.commandPanel.y + 12.0f}, 18.0f, kGold);
if (view.team.pets.empty()) {
DrawTextCnOutlined(font, "队伍里没有宠物", {layout.commandPanel.x + 44.0f, layout.commandPanel.y + 74.0f}, 18.0f, Color{230, 168, 126, 255});
}
const int pageStart = view.team.pets.empty() ? 0 : (view.selectedIndex / 4) * 4;
const int pageEnd = std::min(pageStart + 4, static_cast<int>(view.team.pets.size()));
for (int index = pageStart; index < pageEnd; ++index) {
const int local = index - pageStart;
const Pet& pet = view.team.pets[static_cast<std::size_t>(index)];
std::string label = std::to_string(index + 1) + " " + PetDisplayName(pet.name) + " Lv" + std::to_string(pet.level);
label += " " + std::to_string(std::max(0, pet.hp)) + "/" + std::to_string(std::max(1, pet.maxHp));
const bool disabled = index == 0 || pet.hp <= 0;
DrawCommandButton(
hud,
hooks,
font,
layout.commandButtons[static_cast<std::size_t>(local)],
label,
hud.commandPetIcon,
view.selectedIndex == index,
disabled);
}
if (view.team.pets.size() > 4) {
const std::string page = "" + std::to_string(pageStart / 4 + 1) + "/" + std::to_string((static_cast<int>(view.team.pets.size()) + 3) / 4) + "";
DrawTextCnOutlined(font, page, {layout.commandPanel.x + 244.0f, layout.commandPanel.y + 126.0f}, 15.0f, Color{218, 202, 142, 255});
}
} else {
DrawTextCnOutlined(font, "行动", {layout.commandPanel.x + 24.0f, layout.commandPanel.y + 12.0f}, 18.0f, kGold);
const std::array<std::string, 4> labels = {"战斗", "道具", "宠物", "逃跑"};
const std::array<std::filesystem::path, 4> icons = {
hud.commandBattleIcon,
hud.commandItemIcon,
hud.commandPetIcon,
hud.commandEscapeIcon,
};
for (int i = 0; i < 4; ++i) {
DrawCommandButton(hud, hooks, font, layout.commandButtons[static_cast<std::size_t>(i)], labels[static_cast<std::size_t>(i)], icons[static_cast<std::size_t>(i)], view.selectedIndex == i);
}
}
}
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include "BattleAssets.h"
#include "BattleLayout.h"
#include "GameCore.h"
#include "SpriteAnimation.h"
#include <filesystem>
#include <functional>
#include <raylib.h>
#include <string>
#include <vector>
enum class BattleMenuMode {
Main,
Skills,
Items,
CaptureItems,
HealingItems,
SkillBookItems,
Pets
};
enum class BattleUiCommandType {
None,
Skill,
Capture,
UseItem,
SwitchPet,
Escape,
PetMenu
};
struct BattleUiCommand {
BattleUiCommandType type = BattleUiCommandType::None;
int index = -1;
};
struct BattleSpriteDraw {
Texture2D texture{};
Vector2 foot{};
std::string spritePreset;
SpriteFacing facing = SpriteFacing::Down;
SpriteAction action = SpriteAction::Idle;
float scale = 1.0f;
Color tint = WHITE;
};
struct BattleVisualEvent {
bool attackerIsPlayer = true;
bool hit = false;
int damage = 0;
std::string element;
};
struct BattlePresentationState {
bool hasEvent = false;
BattleVisualEvent event;
float eventElapsed = 0.0f;
float playerDisplayedHp = -1.0f;
float wildDisplayedHp = -1.0f;
};
struct BattleItemChoice {
std::string itemKey;
std::string name;
int count = 0;
std::filesystem::path iconPath;
};
struct BattleSceneView {
std::filesystem::path root;
std::string mapName;
std::filesystem::path minimapPath;
BattleState battle;
Team team;
std::vector<BattleSkill> skills;
BattleSpriteView playerSprite;
BattleSpriteView wildSprite;
BattleMenuMode menuMode = BattleMenuMode::Main;
int selectedIndex = 0;
bool hasCaptureItem = false;
std::vector<BattleItemChoice> itemChoices;
BattlePresentationState presentation;
};
struct BattleSceneDrawHooks {
std::function<Texture2D&(const std::filesystem::path&)> loadTexture;
std::function<void(const BattleSpriteDraw&)> drawSprite;
};
BattleUiCommand ReadBattleUiCommand(
BattleMenuMode& mode,
int& selectedIndex,
int skillCount,
int itemChoiceCount = 0,
int petChoiceCount = 0);
void DrawBattleScene(const BattleSceneView& view, Font font, const BattleSceneDrawHooks& hooks);
+360
View File
@@ -0,0 +1,360 @@
#include "TonoriItems.h"
#include <algorithm>
#include <cctype>
namespace {
std::string NormalizeKey(const std::string& value)
{
std::string normalized;
normalized.reserve(value.size());
for (unsigned char ch : value) {
if (std::isalnum(ch)) {
normalized.push_back(static_cast<char>(std::tolower(ch)));
}
}
return normalized;
}
TonoriItemDefinition Item(
std::string id,
std::string name,
TonoriItemCategory category,
std::string rarity,
std::string effect,
TonoriMerchantKind merchant,
int price,
std::string recipe,
std::string iconFile)
{
TonoriItemDefinition item;
item.id = std::move(id);
item.name = std::move(name);
item.category = category;
item.rarity = std::move(rarity);
item.effect = std::move(effect);
item.merchant = merchant;
item.price = price;
item.recipe = std::move(recipe);
item.iconFile = std::move(iconFile);
return item;
}
TonoriRecoveryEffect Heal(int hp, bool party = false, bool clearStatus = false)
{
TonoriRecoveryEffect effect;
effect.healHp = hp;
effect.party = party;
effect.clearStatus = clearStatus;
return effect;
}
TonoriRecoveryEffect FullHeal()
{
TonoriRecoveryEffect effect;
effect.fullHeal = true;
return effect;
}
TonoriRecoveryEffect Revive(int percent, bool party = false)
{
TonoriRecoveryEffect effect;
effect.revive = true;
effect.revivePercent = percent;
effect.party = party;
return effect;
}
TonoriRecoveryEffect ReviveAndHeal(int percent, bool party = false)
{
TonoriRecoveryEffect effect = Revive(percent, party);
return effect;
}
TonoriItemDefinition Capture(
std::string id,
std::string name,
std::string rarity,
std::string effect,
int price,
std::string recipe,
std::string iconFile,
float multiplier)
{
TonoriItemDefinition item = Item(
std::move(id),
std::move(name),
TonoriItemCategory::Capture,
std::move(rarity),
std::move(effect),
TonoriMerchantKind::Capture,
price,
std::move(recipe),
std::move(iconFile));
item.captureMultiplier = multiplier;
return item;
}
TonoriItemDefinition SkillBook(
std::string id,
std::string name,
std::string rarity,
std::string effect,
int price,
std::string recipe,
std::string iconFile,
std::string skillId)
{
TonoriItemDefinition item = Item(
std::move(id),
std::move(name),
TonoriItemCategory::SkillBook,
std::move(rarity),
std::move(effect),
TonoriMerchantKind::SkillBook,
price,
std::move(recipe),
std::move(iconFile));
item.skillId = std::move(skillId);
return item;
}
TonoriItemDefinition Egg(
std::string id,
std::string name,
std::string rarity,
std::string effect,
int price,
std::string recipe,
std::string iconFile,
std::vector<std::string> species)
{
TonoriItemDefinition item = Item(
std::move(id),
std::move(name),
TonoriItemCategory::PetEgg,
std::move(rarity),
std::move(effect),
TonoriMerchantKind::Hatchery,
price,
std::move(recipe),
std::move(iconFile));
item.eggSpecies = std::move(species);
return item;
}
TonoriItemDefinition Healing(
std::string id,
std::string name,
std::string rarity,
std::string effect,
int price,
std::string recipe,
std::string iconFile,
TonoriRecoveryEffect recovery)
{
TonoriItemDefinition item = Item(
std::move(id),
std::move(name),
TonoriItemCategory::Healing,
std::move(rarity),
std::move(effect),
TonoriMerchantKind::Apothecary,
price,
std::move(recipe),
std::move(iconFile));
item.recovery = recovery;
return item;
}
} // namespace
const std::vector<TonoriItemDefinition>& TonoriItemCatalog()
{
static const std::vector<TonoriItemDefinition> items = {
Item("MAT01", "沙晶粉", TonoriItemCategory::Material, "普通", "沙地、土系、捕捉符基础材料", TonoriMerchantKind::Material, 30, "不可制作", "gem_yellow_triangular.png"),
Item("MAT02", "仙人掌汁", TonoriItemCategory::Material, "普通", "恢复药、草系道具基础材料", TonoriMerchantKind::Material, 35, "不可制作", "wood_berry.png"),
Item("MAT03", "清水", TonoriItemCategory::Material, "普通", "水系道具、药剂基础材料", TonoriMerchantKind::Material, 25, "不可制作", "water_berry.png"),
Item("MAT04", "矿脉核心", TonoriItemCategory::Material, "稀有", "土系宠物蛋、高级捕捉道具", TonoriMerchantKind::Material, 90, "不可制作", "gem_gray_emerald.png"),
Item("MAT05", "古壳碎片", TonoriItemCategory::Material, "稀有", "古代蛋、古符、复活药剂", TonoriMerchantKind::Material, 110, "不可制作", "rhincus_fossil.png"),
Item("MAT06", "绿洲草叶", TonoriItemCategory::Material, "普通", "草系技能书和恢复品", TonoriMerchantKind::Material, 40, "不可制作", "wood_berry.png"),
Item("MAT07", "赤焰碎片", TonoriItemCategory::Material, "稀有", "火系技能书、火焰捕捉符", TonoriMerchantKind::Material, 95, "不可制作", "gem_red_triangular.png"),
Item("MAT08", "潮汐珠", TonoriItemCategory::Material, "稀有", "水系技能书、水域捕捉符", TonoriMerchantKind::Material, 95, "不可制作", "gem_blue_triangular.png"),
Item("MAT09", "孢子粉", TonoriItemCategory::Material, "普通", "状态解除、毒草技能书", TonoriMerchantKind::Material, 45, "不可制作", "venom_berry.png"),
Item("MAT10", "空白技能册", TonoriItemCategory::Material, "普通", "所有技能书的通用书册", TonoriMerchantKind::Material, 80, "不可制作", "tm_generic.png"),
Egg("EGG01", "新手宠物蛋", "普通", "孵化随机新手宠物,适合作为教学奖励", 180, "仙人掌汁 1 份 + 清水 1 份", "ancient_egg.png", {"Lulea", "Piou", "Maggot"}),
Egg("EGG02", "沙纹宠物蛋", "普通", "较高概率孵化土、沙地主题宠物", 260, "沙晶粉 3 份 + 清水 1 份", "mm_earth.png", {"Scorpion", "Sand Snake", "Maggot"}),
Egg("EGG03", "仙人掌宠物蛋", "普通", "较高概率孵化草、沙漠植物主题宠物", 260, "仙人掌汁 2 份 + 绿洲草叶 2 份", "mm_wood.png", {"Peyote", "Mister Prickel", "Spiky Mushroom"}),
Egg("EGG04", "潮汐宠物蛋", "稀有", "较高概率孵化水系宠物", 420, "潮汐珠 2 份 + 清水 3 份", "mm_water.png", {"Little Blub", "Lulea", "Slime"}),
Egg("EGG05", "矿脉宠物蛋", "稀有", "较高概率孵化岩石、矿洞主题宠物", 440, "矿脉核心 2 份 + 沙晶粉 3 份", "mm_metal.png", {"Turtle", "Skeleton", "Salt Slime"}),
Egg("EGG06", "赤焰宠物蛋", "稀有", "较高概率孵化火系宠物", 460, "赤焰碎片 2 份 + 沙晶粉 2 份", "mm_fire.png", {"Fire Goblin", "Fire Skull"}),
Egg("EGG07", "林芽宠物蛋", "稀有", "较高概率孵化草、孢子主题宠物", 430, "绿洲草叶 4 份 + 孢子粉 2 份", "mm_wood.png", {"Evil Mushroom", "Manana Tree", "Coconut Tree"}),
Egg("EGG08", "夜影宠物蛋", "稀有", "较高概率孵化暗影、夜行主题宠物", 520, "孢子粉 3 份 + 古壳碎片 1 份", "mm_metal.png", {"Bat", "Skeleton", "Bicies"}),
Egg("EGG09", "羽翼宠物蛋", "稀有", "较高概率孵化飞行、风系表现宠物", 520, "绿洲草叶 2 份 + 潮汐珠 1 份 + 沙晶粉 2 份", "mm_water.png", {"Bird", "Piou", "Pikpik"}),
Egg("EGG10", "古代宠物蛋", "史诗", "较高概率孵化古代或化石主题宠物", 760, "古壳碎片 4 份 + 矿脉核心 2 份", "ancient_egg.png", {"Turtle", "Skeleton", "Red Queen"}),
Egg("EGG11", "星辉宠物蛋", "史诗", "较高概率孵化稀有异色或高潜力宠物", 900, "潮汐珠 2 份 + 赤焰碎片 2 份 + 古壳碎片 2 份", "mm_metal.png", {"Splatyna", "Red Queen", "Gabriel"}),
Egg("EGG12", "万象宠物蛋", "传说", "随机孵化多属性高潜力宠物,后期昂贵目标", 1400, "古壳碎片 5 份 + 矿脉核心 3 份 + 潮汐珠 3 份 + 赤焰碎片 3 份", "ancient_egg.png", {"Splatyna", "Lizandra", "Red Queen"}),
Capture("CAP01", "基础捕捉符", "普通", "标准捕捉倍率 1.0", 80, "沙晶粉 1 份 + 清水 1 份", "tuxeball.png", 1.0f),
Capture("CAP02", "精制捕捉符", "普通", "捕捉倍率 1.25", 120, "沙晶粉 2 份 + 清水 1 份", "tuxeball_refined.png", 1.25f),
Capture("CAP03", "沙尘捕捉符", "普通", "对土系或沙地宠物倍率 1.6", 150, "沙晶粉 3 份", "tuxeball_earth.png", 1.6f),
Capture("CAP04", "潮汐捕捉符", "普通", "对水系或水边宠物倍率 1.6", 150, "潮汐珠 1 份 + 清水 2 份", "tuxeball_water.png", 1.6f),
Capture("CAP05", "绿芽捕捉符", "普通", "对草系宠物倍率 1.6", 150, "绿洲草叶 3 份", "tuxeball_wood.png", 1.6f),
Capture("CAP06", "赤焰捕捉符", "普通", "对火系宠物倍率 1.6", 160, "赤焰碎片 1 份 + 沙晶粉 1 份", "tuxeball_fire.png", 1.6f),
Capture("CAP07", "安抚捕捉符", "稀有", "对低血量宠物倍率 1.8", 220, "仙人掌汁 2 份 + 绿洲草叶 1 份", "tuxeball_hearty.png", 1.8f),
Capture("CAP08", "古符捕捉符", "稀有", "对古代、化石主题宠物倍率 1.8", 260, "古壳碎片 2 份 + 沙晶粉 2 份", "tuxeball_ancient.png", 1.8f),
Capture("CAP09", "夜行捕捉符", "稀有", "夜晚或洞穴场景倍率 1.7", 240, "孢子粉 2 份 + 古壳碎片 1 份", "tuxeball_nocturnal.png", 1.7f),
Capture("CAP10", "日行捕捉符", "稀有", "白天或旷野场景倍率 1.7", 240, "绿洲草叶 2 份 + 赤焰碎片 1 份", "tuxeball_diurnal.png", 1.7f),
Capture("CAP11", "坚壳捕捉符", "稀有", "对高防御、甲壳宠物倍率 1.7", 250, "矿脉核心 1 份 + 古壳碎片 1 份", "tuxeball_hardened.png", 1.7f),
Capture("CAP12", "矿岩捕捉符", "稀有", "对矿石、岩甲主题宠物倍率 1.7", 250, "矿脉核心 2 份 + 沙晶粉 1 份", "tuxeball_metal.png", 1.7f),
Capture("CAP13", "活力捕捉符", "稀有", "对速度高或弹跳类宠物倍率 1.6", 230, "仙人掌汁 1 份 + 潮汐珠 1 份", "tuxeball_peppy.png", 1.6f),
Capture("CAP14", "糖果捕捉符", "稀有", "对友好、幼体宠物倍率 1.8", 230, "仙人掌汁 2 份 + 孢子粉 1 份", "tuxeball_candy.png", 1.8f),
Capture("CAP15", "酸味捕捉符", "稀有", "对中毒或异常状态目标倍率 1.9", 260, "孢子粉 3 份 + 清水 1 份", "tuxeball_zesty.png", 1.9f),
Capture("CAP16", "盐晶捕捉符", "稀有", "对水边、滩涂、甲壳宠物倍率 1.75", 260, "潮汐珠 1 份 + 沙晶粉 3 份", "tuxeball_salty.png", 1.75f),
Capture("CAP17", "贵族捕捉符", "史诗", "对稀有宠物倍率 2.0", 420, "古壳碎片 2 份 + 赤焰碎片 1 份 + 潮汐珠 1 份", "tuxeball_noble.png", 2.0f),
Capture("CAP18", "华丽捕捉符", "史诗", "提高捕捉后初始亲密度", 450, "绿洲草叶 2 份 + 潮汐珠 2 份 + 仙人掌汁 2 份", "tuxeball_lavish.png", 1.9f),
Capture("CAP19", "强袭捕捉符", "史诗", "开战前 3 回合使用时倍率 2.1", 480, "赤焰碎片 2 份 + 矿脉核心 1 份", "tuxeball_crusher.png", 2.1f),
Capture("CAP20", "公园捕捉符", "史诗", "野外非副本区域倍率 1.9", 420, "绿洲草叶 3 份 + 清水 2 份 + 沙晶粉 2 份", "tuxeball_park.png", 1.9f),
Capture("CAP21", "雄性捕捉符", "普通", "对雄性宠物倍率 1.5", 140, "沙晶粉 1 份 + 赤焰碎片 1 份", "tuxeball_male.png", 1.5f),
Capture("CAP22", "雌性捕捉符", "普通", "对雌性宠物倍率 1.5", 140, "绿洲草叶 1 份 + 潮汐珠 1 份", "tuxeball_female.png", 1.5f),
Capture("CAP23", "全域捕捉符", "史诗", "不看属性,稳定捕捉倍率 2.2", 560, "矿脉核心 2 份 + 潮汐珠 2 份 + 赤焰碎片 2 份", "tuxeball_grand.png", 2.2f),
Capture("CAP24", "万象捕捉符", "传说", "高级捕捉倍率 2.8,适合稀有宠物", 900, "古壳碎片 4 份 + 矿脉核心 2 份 + 潮汐珠 2 份 + 赤焰碎片 2 份", "tuxeball_omni.png", 2.8f),
Healing("REC01", "红苹果", "普通", "单体恢复 20 点体力", 35, "不可制作", "data/graphics/items/usable/apple.png", Heal(20)),
Healing("REC02", "清水瓶", "普通", "单体恢复 15 点体力,并解除轻微口渴类负面状态", 30, "清水 1 份", "data/graphics/items/usable/bottle-water.png", Heal(15, false, true)),
Healing("REC03", "仙人掌饮料", "普通", "单体恢复 35 点体力", 60, "仙人掌汁 2 份 + 清水 1 份", "data/graphics/items/usable/cactus-drink.png", Heal(35)),
Healing("REC04", "仙人掌药剂", "普通", "单体恢复 60 点体力", 95, "仙人掌汁 3 份 + 绿洲草叶 1 份", "data/graphics/items/usable/cactus-potion.png", Heal(60)),
Healing("REC05", "酸味仙人掌糖", "普通", "解除麻痹或迟缓,并恢复 10 点体力", 70, "仙人掌汁 1 份 + 孢子粉 1 份", "data/graphics/items/usable/cactus-sour-candy.png", Heal(10, false, true)),
Healing("REC06", "小型恢复药", "普通", "单体恢复 45 点体力", 75, "清水 2 份 + 绿洲草叶 1 份", "potion.png", Heal(45)),
Healing("REC07", "中型恢复药", "普通", "单体恢复 80 点体力", 130, "清水 2 份 + 仙人掌汁 2 份 + 绿洲草叶 1 份", "super_potion.png", Heal(80)),
Healing("REC08", "高级恢复药", "稀有", "单体恢复 130 点体力", 220, "清水 3 份 + 仙人掌汁 3 份 + 潮汐珠 1 份", "mega_potion.png", Heal(130)),
Healing("REC09", "特级恢复药", "史诗", "单体恢复 220 点体力", 380, "潮汐珠 2 份 + 古壳碎片 1 份 + 清水 3 份", "imperial_potion.png", Heal(220)),
Healing("REC10", "复苏叶", "稀有", "复活单个宠物并恢复 40% 体力", 300, "古壳碎片 2 份 + 绿洲草叶 2 份 + 清水 2 份", "revive.png", Revive(40)),
Healing("REC11", "活力复苏叶", "史诗", "复活单个宠物并恢复 80% 体力", 520, "古壳碎片 3 份 + 潮汐珠 2 份 + 仙人掌汁 2 份", "revive.png", Revive(80)),
Healing("REC12", "队伍茶饮", "稀有", "全队恢复 35 点体力", 260, "绿洲草叶 3 份 + 清水 3 份", "tea.png", Heal(35, true)),
Healing("REC13", "帝国茶饮", "史诗", "全队恢复 80 点体力", 480, "绿洲草叶 4 份 + 潮汐珠 2 份 + 古壳碎片 1 份", "imperial_tea.png", Heal(80, true)),
Healing("REC14", "古代茶饮", "史诗", "全队恢复 50 点体力,并解除一种异常", 520, "古壳碎片 2 份 + 绿洲草叶 3 份 + 清水 3 份", "ancient_tea.png", Heal(50, true, true)),
Healing("REC15", "解毒浆果", "普通", "解除中毒", 65, "孢子粉 2 份 + 清水 1 份", "venom_berry.png", Heal(0, false, true)),
Healing("REC16", "清醒浆果", "普通", "解除睡眠或混乱", 65, "绿洲草叶 2 份 + 清水 1 份", "normal_berry.png", Heal(0, false, true)),
Healing("REC17", "暖身浆果", "普通", "解除冰冻或寒冷", 70, "赤焰碎片 1 份 + 仙人掌汁 1 份", "fire_berry.png", Heal(0, false, true)),
Healing("REC18", "镇静浆果", "普通", "解除恐惧或暴躁", 70, "潮汐珠 1 份 + 绿洲草叶 1 份", "water_berry.png", Heal(0, false, true)),
Healing("REC19", "土息浆果", "普通", "解除流血或破甲", 80, "沙晶粉 2 份 + 矿脉核心 1 份", "earth_berry.png", Heal(0, false, true)),
Healing("REC20", "木息浆果", "普通", "解除灼伤并恢复 20 点体力", 80, "绿洲草叶 2 份 + 清水 1 份", "wood_berry.png", Heal(20, false, true)),
Healing("REC21", "星光药剂", "史诗", "单体恢复全部体力,战斗外使用", 700, "古壳碎片 3 份 + 潮汐珠 3 份 + 赤焰碎片 1 份", "luminescent-potion.png", FullHeal()),
Healing("REC22", "沙旅羊角包", "普通", "全队恢复 20 点体力,战斗外使用", 150, "仙人掌汁 1 份 + 清水 1 份 + 沙晶粉 1 份", "data/graphics/items/usable/croissant.png", Heal(20, true)),
Healing("REC23", "红色急救药剂", "稀有", "单体恢复 100 点体力,并解除一种异常", 260, "清水 2 份 + 绿洲草叶 2 份 + 孢子粉 2 份", "red-potion.png", Heal(100, false, true)),
Healing("REC24", "金色复苏药剂", "传说", "复活全队并恢复 50% 体力,战斗外使用", 1200, "古壳碎片 5 份 + 潮汐珠 3 份 + 绿洲草叶 5 份 + 清水 5 份", "yellow-potion.png", ReviveAndHeal(50, true)),
SkillBook("TM01", "技能书:冲撞", "普通", "学会普通系技能「冲撞」", 120, "空白技能册 1 份 + 沙晶粉 1 份", "tm_generic.png", "tackle"),
SkillBook("TM02", "技能书:啃咬", "普通", "学会普通系技能「啃咬」", 140, "空白技能册 1 份 + 古壳碎片 1 份", "tm_generic.png", "bite"),
SkillBook("TM03", "技能书:利爪", "普通", "学会普通系技能「利爪」", 140, "空白技能册 1 份 + 矿脉核心 1 份", "tm_generic.png", "claw"),
SkillBook("TM04", "技能书:头槌", "普通", "学会普通系技能「头槌」", 160, "空白技能册 1 份 + 沙晶粉 2 份", "tm_generic.png", "headbutt"),
SkillBook("TM05", "技能书:甩尾", "普通", "学会普通系技能「甩尾」", 160, "空白技能册 1 份 + 绿洲草叶 1 份", "tm_generic.png", "tail"),
SkillBook("TM06", "技能书:水泡", "普通", "学会水系技能「水泡」", 150, "空白技能册 1 份 + 清水 2 份", "tm_water.png", "bubble"),
SkillBook("TM07", "技能书:潮汐拍击", "稀有", "学会水系技能「潮汐拍击」", 260, "空白技能册 1 份 + 潮汐珠 2 份", "tm_water.png", "tide"),
SkillBook("TM08", "技能书:水流冲击", "稀有", "学会水系技能「水流冲击」", 300, "空白技能册 1 份 + 潮汐珠 2 份 + 清水 2 份", "tm_water.png", "water_rush"),
SkillBook("TM09", "技能书:黏液弹", "普通", "学会水/毒表现技能「黏液弹」", 180, "空白技能册 1 份 + 孢子粉 2 份 + 清水 1 份", "tm_water.png", "slime_shot"),
SkillBook("TM10", "技能书:沼泽伏击", "稀有", "学会水/土表现技能「沼泽伏击」", 280, "空白技能册 1 份 + 潮汐珠 1 份 + 沙晶粉 2 份", "tm_water.png", "mud_ambush"),
SkillBook("TM11", "技能书:沙尘击", "普通", "学会土系技能「沙尘击」", 150, "空白技能册 1 份 + 沙晶粉 2 份", "tm_earth.png", "dust"),
SkillBook("TM12", "技能书:岩石投掷", "普通", "学会土系技能「岩石投掷」", 190, "空白技能册 1 份 + 矿脉核心 1 份 + 沙晶粉 1 份", "tm_earth.png", "rock_throw"),
SkillBook("TM13", "技能书:岩甲冲锋", "稀有", "学会土系技能「岩甲冲锋」", 320, "空白技能册 1 份 + 矿脉核心 2 份 + 古壳碎片 1 份", "tm_earth.png", "shell_charge"),
SkillBook("TM14", "技能书:钻地突刺", "稀有", "学会土系技能「钻地突刺」", 340, "空白技能册 1 份 + 矿脉核心 2 份 + 沙晶粉 3 份", "tm_earth.png", "tunnel"),
SkillBook("TM15", "技能书:甲壳撞击", "普通", "学会土/普通表现技能「甲壳撞击」", 200, "空白技能册 1 份 + 古壳碎片 1 份 + 沙晶粉 1 份", "tm_earth.png", "shell_hit"),
SkillBook("TM16", "技能书:火苗", "普通", "学会火系技能「火苗」", 150, "空白技能册 1 份 + 赤焰碎片 1 份", "tm_fire.png", "ember"),
SkillBook("TM17", "技能书:火焰拳", "稀有", "学会火系技能「火焰拳」", 300, "空白技能册 1 份 + 赤焰碎片 3 份", "tm_fire.png", "fire_punch"),
SkillBook("TM18", "技能书:日光弹", "稀有", "学会火/光表现技能「日光弹」", 320, "空白技能册 1 份 + 赤焰碎片 2 份 + 绿洲草叶 1 份", "tm_fire.png", "solar_seed"),
SkillBook("TM19", "技能书:藤刺", "普通", "学会草系技能「藤刺」", 150, "空白技能册 1 份 + 绿洲草叶 2 份", "tm_wood.png", "thorn"),
SkillBook("TM20", "技能书:针刺", "普通", "学会草系技能「针刺」", 170, "空白技能册 1 份 + 绿洲草叶 1 份 + 沙晶粉 1 份", "tm_wood.png", "needle"),
SkillBook("TM21", "技能书:孢子弹", "稀有", "学会草/异常表现技能「孢子弹」", 260, "空白技能册 1 份 + 孢子粉 3 份", "tm_wood.png", "spore"),
SkillBook("TM22", "技能书:腐蚀酸液", "稀有", "学会毒系技能「腐蚀酸液」", 280, "空白技能册 1 份 + 孢子粉 2 份 + 赤焰碎片 1 份", "tm_generic.png", "acid_spit"),
SkillBook("TM23", "技能书:毒牙", "稀有", "学会毒系技能「毒牙」", 300, "空白技能册 1 份 + 孢子粉 2 份 + 古壳碎片 1 份", "tm_generic.png", "fang"),
SkillBook("TM24", "技能书:暗影冲击", "稀有", "学会暗影表现技能「暗影冲击」", 340, "空白技能册 1 份 + 古壳碎片 2 份 + 孢子粉 1 份", "tm_generic.png", "dark_charge"),
SkillBook("TM25", "技能书:旋风", "普通", "学会风系表现技能「旋风」", 180, "空白技能册 1 份 + 绿洲草叶 1 份 + 潮汐珠 1 份", "tm_generic.png", "gust"),
SkillBook("TM26", "技能书:羽刃", "稀有", "学会飞行表现技能「羽刃」", 280, "空白技能册 1 份 + 绿洲草叶 2 份 + 赤焰碎片 1 份", "tm_generic.png", "feather_blade"),
SkillBook("TM27", "技能书:啄击", "普通", "学会飞行/普通表现技能「啄击」", 160, "空白技能册 1 份 + 绿洲草叶 1 份 + 古壳碎片 1 份", "tm_generic.png", "peck"),
SkillBook("TM28", "技能书:翅击", "普通", "学会飞行表现技能「翅击」", 190, "空白技能册 1 份 + 绿洲草叶 2 份", "tm_generic.png", "wing"),
SkillBook("TM29", "技能书:超音波", "稀有", "学会干扰技能「超音波」", 320, "空白技能册 1 份 + 潮汐珠 1 份 + 古壳碎片 2 份", "tm_generic.png", "sonic"),
SkillBook("TM30", "技能书:吸血牙", "史诗", "学会暗影/回复表现技能「吸血牙」", 520, "空白技能册 1 份 + 古壳碎片 3 份 + 孢子粉 2 份", "tm_generic.png", "vampire_fang"),
};
return items;
}
const TonoriItemDefinition* FindTonoriItem(const std::string& idOrName)
{
for (const TonoriItemDefinition& item : TonoriItemCatalog()) {
if (item.id == idOrName || item.name == idOrName) {
return &item;
}
}
const std::string key = NormalizeKey(idOrName);
if (key.empty()) {
return nullptr;
}
for (const TonoriItemDefinition& item : TonoriItemCatalog()) {
if (NormalizeKey(item.id) == key || NormalizeKey(item.name) == key) {
return &item;
}
}
return nullptr;
}
std::vector<const TonoriItemDefinition*> TonoriItemsForMerchant(TonoriMerchantKind merchant)
{
std::vector<const TonoriItemDefinition*> result;
for (const TonoriItemDefinition& item : TonoriItemCatalog()) {
if (item.merchant == merchant) {
result.push_back(&item);
}
}
return result;
}
std::string TonoriItemCategoryName(TonoriItemCategory category)
{
switch (category) {
case TonoriItemCategory::Material:
return "制作材料";
case TonoriItemCategory::PetEgg:
return "宠物蛋";
case TonoriItemCategory::Capture:
return "捕捉";
case TonoriItemCategory::Healing:
return "恢复";
case TonoriItemCategory::SkillBook:
return "技能书";
case TonoriItemCategory::Other:
break;
}
return "其他";
}
std::string TonoriMerchantName(TonoriMerchantKind merchant)
{
switch (merchant) {
case TonoriMerchantKind::General:
return "道具商人";
case TonoriMerchantKind::Hatchery:
return "孵化商人";
case TonoriMerchantKind::Capture:
return "捕捉商人";
case TonoriMerchantKind::Apothecary:
return "药剂商人";
case TonoriMerchantKind::SkillBook:
return "技能书商人";
case TonoriMerchantKind::Material:
return "材料商人";
case TonoriMerchantKind::None:
break;
}
return "商人";
}
std::optional<TonoriItemCategory> TonoriCategoryForItem(const std::string& idOrName)
{
if (const TonoriItemDefinition* item = FindTonoriItem(idOrName)) {
return item->category;
}
return std::nullopt;
}
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
enum class TonoriItemCategory {
Material,
PetEgg,
Capture,
Healing,
SkillBook,
Other
};
enum class TonoriMerchantKind {
None,
General,
Hatchery,
Capture,
Apothecary,
SkillBook,
Material
};
struct TonoriRecoveryEffect {
int healHp = 0;
int revivePercent = 0;
bool fullHeal = false;
bool party = false;
bool revive = false;
bool clearStatus = false;
};
struct TonoriItemDefinition {
std::string id;
std::string name;
TonoriItemCategory category = TonoriItemCategory::Other;
std::string rarity;
std::string effect;
TonoriMerchantKind merchant = TonoriMerchantKind::None;
int price = 0;
std::string recipe;
std::string iconFile;
float captureMultiplier = 1.0f;
std::string skillId;
TonoriRecoveryEffect recovery;
std::vector<std::string> eggSpecies;
};
const std::vector<TonoriItemDefinition>& TonoriItemCatalog();
const TonoriItemDefinition* FindTonoriItem(const std::string& idOrName);
std::vector<const TonoriItemDefinition*> TonoriItemsForMerchant(TonoriMerchantKind merchant);
std::string TonoriItemCategoryName(TonoriItemCategory category);
std::string TonoriMerchantName(TonoriMerchantKind merchant);
std::optional<TonoriItemCategory> TonoriCategoryForItem(const std::string& idOrName);
+73
View File
@@ -0,0 +1,73 @@
#include "ElementSystem.h"
#include <algorithm>
#include <map>
#include <set>
namespace {
struct ElementRule {
std::set<std::string> strongAgainst;
std::set<std::string> resistedBy;
};
const std::map<std::string, ElementRule>& ElementRules()
{
static const std::map<std::string, ElementRule> rules = {
{"普通", {{}, {""}}},
{"", {{"", ""}, {"", ""}}},
{"", {{"", ""}, {"", ""}}},
{"", {{"", ""}, {"", "", ""}}},
{"", {{"", ""}, {"", "", ""}}},
{"", {{"", ""}, {"", ""}}},
{"", {{"", "普通"}, {"", ""}}},
{"", {{"普通", ""}, {"", ""}}},
};
return rules;
}
bool MatchesElement(const std::string& value, const std::string& primary, const std::string& secondary)
{
return !value.empty() && (value == primary || value == secondary);
}
} // namespace
ElementEffectiveness ResolveElementEffectiveness(
const std::string& attackElement,
const std::string& defenderPrimaryElement,
const std::string& defenderSecondaryElement)
{
ElementEffectiveness result;
const auto it = ElementRules().find(attackElement);
if (it == ElementRules().end()) {
return result;
}
const auto resolveOne = [&](const std::string& defenderElement) {
if (defenderElement.empty()) {
return;
}
if (it->second.strongAgainst.contains(defenderElement)) {
result.multiplier *= 1.5f;
result.strong = true;
}
if (it->second.resistedBy.contains(defenderElement)) {
result.multiplier *= 0.65f;
result.resisted = true;
}
};
resolveOne(defenderPrimaryElement);
if (defenderSecondaryElement != defenderPrimaryElement) {
resolveOne(defenderSecondaryElement);
}
return result;
}
float SameElementAttackBonus(
const std::string& attackElement,
const std::string& attackerPrimaryElement,
const std::string& attackerSecondaryElement)
{
return MatchesElement(attackElement, attackerPrimaryElement, attackerSecondaryElement) ? 1.15f : 1.0f;
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <string>
struct ElementEffectiveness {
float multiplier = 1.0f;
bool strong = false;
bool resisted = false;
};
ElementEffectiveness ResolveElementEffectiveness(
const std::string& attackElement,
const std::string& defenderPrimaryElement,
const std::string& defenderSecondaryElement = {});
float SameElementAttackBonus(
const std::string& attackElement,
const std::string& attackerPrimaryElement,
const std::string& attackerSecondaryElement = {});
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
#pragma once
#include "PetGrowth.h"
#include "TonoriItems.h"
#include <cstddef>
#include <optional>
#include <string>
#include <vector>
struct Pet {
std::string name;
int level = 1;
int exp = 0;
int maxHp = 1;
int hp = 1;
int attack = 1;
int speed = 1;
PetPotential potential;
std::vector<std::string> learnedSkillIds;
};
struct BattleSkill {
std::string id;
std::string name;
std::string element;
int power = 1;
int accuracy = 100;
};
struct BattleSkillResult {
bool hit = false;
int damage = 0;
};
struct InventoryUseResult {
bool success = false;
bool consumed = false;
std::string hatchedPetSpecies;
bool sentToStorage = false;
std::string message;
};
struct CraftIngredient {
std::string itemId;
std::string name;
int count = 0;
};
struct CaptureItemUse {
bool found = false;
std::string itemName;
std::string displayName;
float multiplier = 1.0f;
};
struct Team {
static constexpr std::size_t MaxPets = 6;
std::vector<Pet> pets;
std::vector<Pet> storage;
};
struct PetCollectionSummary {
std::size_t activeCount = 0;
std::size_t storageCount = 0;
std::size_t totalCount = 0;
std::size_t uniqueSpeciesCount = 0;
bool activeTeamFull = false;
};
struct PetCollectionEntry {
std::string speciesName;
std::size_t activeCount = 0;
std::size_t storageCount = 0;
std::size_t totalCount = 0;
int seenCount = 0;
int caughtCount = 0;
int highestLevel = 1;
bool seen = false;
bool caught = false;
};
struct PetJournalEntry {
std::string speciesName;
int seenCount = 0;
int caughtCount = 0;
};
struct PetJournal {
std::vector<PetJournalEntry> entries;
};
struct InventoryItem {
std::string name;
int count = 0;
};
struct Inventory {
std::vector<InventoryItem> items;
};
struct BattleState {
Pet player;
Pet wild;
bool finished = false;
std::string message;
};
enum class CaptureResult {
Captured,
Failed
};
Pet MakePet(std::string name, int maxHp, int attack);
Pet MakePetAtLevel(std::string name, int level);
Pet MakePetAtLevel(std::string name, int level, PetPotential potential);
void NormalizePetAfterLoad(Pet& pet);
void Attack(const Pet& attacker, Pet& defender);
bool BattleActsBefore(const Pet& first, const Pet& second);
std::string PetDisplayName(const std::string& speciesName);
std::string ItemDisplayName(const std::string& itemName);
std::string ItemDisplayDescription(const std::string& itemName, const std::string& fallback = {});
std::optional<BattleSkill> BattleSkillById(const std::string& id);
std::vector<BattleSkill> BattleSkillsForPet(const Pet& pet);
BattleSkillResult UseBattleSkill(const Pet& attacker, Pet& defender, const BattleSkill& skill, int accuracyRoll);
bool LearnBattleSkill(Pet& pet, const std::string& skillId);
bool SyncActivePetBattleState(Team& team, const Pet& battlePet);
int ExpToNextLevel(const Pet& pet);
std::vector<std::string> GainExp(Pet& pet, int amount);
float CatchChance(const Pet& target);
bool AddPet(Team& team, const Pet& pet);
bool MoveFirstTeamPetToFront(Team& team, const std::string& speciesName);
bool ActivateStoredPet(Team& team, const std::string& speciesName);
PetCollectionSummary BuildPetCollectionSummary(const Team& team);
void RegisterPetSeen(PetJournal& journal, const std::string& speciesName);
void RegisterPetCaught(PetJournal& journal, const std::string& speciesName);
std::vector<PetCollectionEntry> BuildPetCollectionEntries(const Team& team);
std::vector<PetCollectionEntry> BuildPetCollectionEntries(const Team& team, const PetJournal& journal);
std::vector<PetCollectionEntry> BuildPetCollectionEntries(
const Team& team,
const PetJournal& journal,
const std::vector<std::string>& speciesCatalog);
std::size_t TotalPetCount(const Team& team);
bool HasCapturedWildPet(const Team& team);
bool ShouldBlockKaelPetTrialStart(const std::string& startFunction, const Team& team);
std::string ResolveKaelPetTrialStart(const std::string& startFunction, const Team& team);
CaptureResult TryCapture(BattleState& battle, Team& team, float capturePower);
CaptureResult TryCapture(BattleState& battle, Team& team, float capturePower, float captureMultiplier);
std::string CaptureItemName();
bool HasCaptureItem(const Inventory& inventory);
bool ConsumeCaptureItem(Inventory& inventory);
CaptureItemUse ConsumeBestCaptureItem(Inventory& inventory);
void AddItem(Inventory& inventory, const std::string& name, int count = 1);
bool RemoveItem(Inventory& inventory, const std::string& name, int count = 1);
bool HasItem(const Inventory& inventory, const std::string& name, int count = 1);
int ItemCount(const Inventory& inventory, const std::string& name);
std::vector<CraftIngredient> CraftingIngredientsForItem(const std::string& itemName);
bool CanCraftInventoryItem(const Inventory& inventory, const std::string& itemName);
InventoryUseResult CraftInventoryItem(Inventory& inventory, const std::string& itemName);
InventoryUseResult UseInventoryItem(Inventory& inventory, Team& team, const std::string& itemName, std::size_t activePetIndex = 0);
+237
View File
@@ -0,0 +1,237 @@
#include "PetGrowth.h"
#include <algorithm>
#include <cmath>
#include <map>
#include <numeric>
#include <utility>
namespace {
PetGrowthEntry Entry(
std::string species,
std::string primary,
std::string secondary,
PetBaseStats stats,
std::vector<PetLevelSkill> learnset,
std::set<std::string> extraAllowed = {})
{
PetGrowthEntry entry;
entry.speciesName = std::move(species);
entry.primaryElement = std::move(primary);
entry.secondaryElement = std::move(secondary);
entry.stats = std::move(stats);
entry.learnset = std::move(learnset);
entry.allowedSkillElements = {"普通"};
if (!entry.primaryElement.empty()) {
entry.allowedSkillElements.insert(entry.primaryElement);
}
if (!entry.secondaryElement.empty()) {
entry.allowedSkillElements.insert(entry.secondaryElement);
}
entry.allowedSkillElements.insert(extraAllowed.begin(), extraAllowed.end());
return entry;
}
PetBaseStats Stats(int hp, int atk, int speed, int hpGrowth, int atkGrowth, int speedGrowth, int rarity, std::string role)
{
return {hp, atk, speed, hpGrowth, atkGrowth, speedGrowth, rarity, std::move(role)};
}
} // namespace
const std::vector<PetGrowthEntry>& PetGrowthCatalog()
{
static const std::vector<PetGrowthEntry> catalog = {
Entry("Bat", "", "", Stats(25, 8, 14, 5, 4, 5, 2, "cave"), {{1, "sonic"}, {8, "wing"}, {18, "vampire_fang"}, {34, "dark_charge"}}),
Entry("Bird", "", "普通", Stats(24, 7, 13, 4, 4, 5, 1, "common"), {{1, "peck"}, {6, "gust"}, {14, "wing"}, {26, "feather_blade"}}),
Entry("Croc", "", "普通", Stats(34, 9, 7, 7, 5, 2, 3, "water"), {{1, "bite"}, {12, "water_rush"}, {24, "tail"}, {40, "mud_ambush"}}, {""}),
Entry("Drain Slime", "", "", Stats(31, 8, 8, 6, 4, 3, 3, "cave"), {{1, "bubble"}, {10, "slime_shot"}, {22, "vampire_fang"}, {38, "dark_charge"}}, {""}),
Entry("Evil Mushroom", "", "", Stats(29, 9, 7, 5, 5, 2, 3, "cave"), {{1, "spore"}, {9, "thorn"}, {18, "acid_spit"}, {34, "solar_seed"}}),
Entry("Fire Goblin", "", "", Stats(33, 11, 10, 6, 6, 4, 4, "rare"), {{1, "ember"}, {14, "fire_punch"}, {30, "dark_charge"}, {48, "headbutt"}}),
Entry("Fire Skull", "", "", Stats(35, 11, 9, 6, 6, 3, 4, "cave"), {{1, "ember"}, {12, "dark_charge"}, {28, "fire_punch"}, {44, "headbutt"}}),
Entry("Fluffy", "普通", "", Stats(28, 7, 8, 5, 3, 3, 1, "starter"), {{1, "tackle"}, {7, "tail"}, {16, "headbutt"}, {30, "bite"}}),
Entry("Giant Maggot", "", "普通", Stats(38, 10, 6, 7, 5, 2, 3, "cave"), {{1, "bite"}, {14, "tunnel"}, {28, "acid_spit"}, {44, "headbutt"}}, {""}),
Entry("Lizandra", "", "普通", Stats(37, 11, 9, 7, 6, 3, 4, "rare"), {{1, "claw"}, {16, "water_rush"}, {32, "bite"}, {52, "mud_ambush"}}),
Entry("Lizzy", "普通", "", Stats(27, 8, 10, 5, 4, 4, 2, "common"), {{1, "claw"}, {8, "tail"}, {18, "bite"}, {32, "headbutt"}}),
Entry("Lulea", "", "", Stats(30, 7, 8, 6, 4, 3, 1, "starter"), {{1, "bubble"}, {8, "spring_hit"}, {18, "tide"}, {34, "water_rush"}}),
Entry("Lynx", "普通", "", Stats(31, 11, 13, 5, 6, 5, 4, "rare"), {{1, "claw"}, {10, "bite"}, {24, "wing"}, {42, "feather_blade"}}),
Entry("Maggot", "", "普通", Stats(22, 6, 7, 4, 3, 2, 1, "common"), {{1, "bite"}, {7, "slime_shot"}, {18, "tunnel"}, {32, "acid_spit"}}, {""}),
Entry("Peyote", "", "", Stats(28, 8, 7, 5, 4, 2, 2, "desert"), {{1, "needle"}, {8, "thorn"}, {20, "solar_seed"}, {36, "dust"}}, {""}),
Entry("Pinkie", "普通", "", Stats(26, 7, 9, 5, 3, 3, 1, "starter"), {{1, "tackle"}, {8, "spring_hit"}, {18, "tail"}, {32, "headbutt"}}),
Entry("Piou", "", "普通", Stats(23, 7, 12, 4, 4, 5, 1, "starter"), {{1, "peck"}, {6, "gust"}, {14, "wing"}, {26, "feather_blade"}}),
Entry("Ratto", "普通", "", Stats(21, 7, 13, 4, 4, 5, 1, "city"), {{1, "bite"}, {7, "tackle"}, {16, "tail"}, {28, "claw"}}),
Entry("Salt Slime", "", "", Stats(31, 8, 7, 6, 4, 2, 2, "water"), {{1, "bubble"}, {10, "spring_hit"}, {22, "tide"}, {36, "shell_hit"}}),
Entry("Sand Snake", "", "", Stats(26, 8, 14, 5, 4, 5, 2, "desert"), {{1, "fang"}, {8, "tail"}, {18, "dust"}, {34, "tunnel"}}, {"普通"}),
Entry("Scorpion", "", "", Stats(28, 9, 9, 5, 5, 3, 2, "desert"), {{1, "sting"}, {9, "dust"}, {20, "pincer"}, {36, "acid_spit"}}, {"普通"}),
Entry("Skeleton", "", "", Stats(32, 10, 8, 6, 5, 3, 3, "cave"), {{1, "headbutt"}, {12, "rock_throw"}, {24, "dark_charge"}, {42, "claw"}}),
Entry("Slime", "", "", Stats(27, 7, 7, 6, 3, 2, 1, "water"), {{1, "bubble"}, {7, "spring_hit"}, {18, "tide"}, {32, "water_rush"}}, {""}),
Entry("Sludge Slime", "", "", Stats(32, 9, 7, 6, 5, 2, 3, "cave"), {{1, "slime_shot"}, {10, "acid_spit"}, {22, "bubble"}, {38, "tide"}}),
Entry("Snake", "", "", Stats(25, 8, 15, 5, 4, 5, 1, "common"), {{1, "fang"}, {8, "tail"}, {18, "dust"}, {34, "bite"}}, {"普通"}),
Entry("Spiky Mushroom", "", "", Stats(30, 8, 7, 6, 4, 2, 2, "oasis"), {{1, "needle"}, {9, "spore"}, {20, "thorn"}, {36, "acid_spit"}}),
Entry("Splatyna", "", "", Stats(42, 12, 9, 8, 6, 3, 5, "bosslike"), {{1, "bubble"}, {18, "dark_charge"}, {38, "tide"}, {62, "water_rush"}}),
Entry("Turtle", "", "", Stats(36, 8, 5, 8, 4, 1, 2, "water"), {{1, "shell_hit"}, {10, "bubble"}, {24, "shell_charge"}, {40, "tide"}}, {""}),
};
return catalog;
}
const PetGrowthEntry* FindPetGrowthEntry(const std::string& speciesName)
{
const auto& catalog = PetGrowthCatalog();
const auto it = std::find_if(catalog.begin(), catalog.end(), [&](const PetGrowthEntry& entry) {
return entry.speciesName == speciesName;
});
return it == catalog.end() ? nullptr : &*it;
}
int ClampPetLevel(int level)
{
return std::clamp(level, kMinPetLevel, kMaxPetLevel);
}
PetPotential ClampPetPotential(PetPotential potential)
{
potential.hp = std::clamp(potential.hp, 0, 31);
potential.attack = std::clamp(potential.attack, 0, 31);
return potential;
}
PetPotential FixedGiftPotential()
{
return {16, 16};
}
PetPotential GenerateWildPotential(std::mt19937& rng, int minPotential, int maxPotential)
{
std::uniform_int_distribution<int> dist(std::clamp(minPotential, 0, 31), std::clamp(maxPotential, 0, 31));
return {dist(rng), dist(rng)};
}
PetPotential GenerateEggPotential(std::mt19937& rng, const std::string& rarity)
{
if (rarity == "传说") {
return GenerateWildPotential(rng, 18, 31);
}
if (rarity == "史诗") {
return GenerateWildPotential(rng, 12, 28);
}
return GenerateWildPotential(rng, 8, 24);
}
std::string PotentialRank(PetPotential potential)
{
potential = ClampPetPotential(potential);
const int average = (potential.hp + potential.attack) / 2;
if (average >= 24) {
return "卓越";
}
if (average >= 16) {
return "优秀";
}
if (average >= 8) {
return "良好";
}
return "普通";
}
PetComputedStats ComputePetStats(const std::string& speciesName, int level, PetPotential potential, PetComputedStats fallback)
{
const PetGrowthEntry* entry = FindPetGrowthEntry(speciesName);
if (!entry) {
return {
std::max(1, fallback.maxHp),
std::max(1, fallback.attack),
std::max(1, fallback.speed),
};
}
level = ClampPetLevel(level);
potential = ClampPetPotential(potential);
const int hpGrowth = static_cast<int>(std::floor((level - 1) * entry->stats.hpGrowth * 0.85 + level * 0.8));
const int attackGrowth = static_cast<int>(std::floor((level - 1) * entry->stats.attackGrowth * 0.65 + level * 0.35));
const int speedGrowth = static_cast<int>(std::floor((level - 1) * entry->stats.speedGrowth * 0.55 + level * 0.30));
const int hpPotentialBonus = static_cast<int>(std::floor((level - 1) * potential.hp / 31.0 * 0.45));
const int attackPotentialBonus = static_cast<int>(std::floor((level - 1) * potential.attack / 31.0 * 0.30));
return {
std::max(1, entry->stats.baseHp + hpGrowth + hpPotentialBonus),
std::max(1, entry->stats.baseAttack + attackGrowth + attackPotentialBonus),
std::max(1, entry->stats.baseSpeed + speedGrowth),
};
}
std::vector<std::string> LearnedSkillIdsForLevel(const std::string& speciesName, int level)
{
const PetGrowthEntry* entry = FindPetGrowthEntry(speciesName);
if (!entry) {
return {"tackle"};
}
std::vector<std::string> learned;
for (const PetLevelSkill& skill : entry->learnset) {
if (skill.level <= ClampPetLevel(level)) {
learned.push_back(skill.skillId);
if (learned.size() > 4) {
learned.erase(learned.begin());
}
}
}
if (learned.empty()) {
learned.push_back("tackle");
}
return learned;
}
bool IsSkillElementCompatible(const std::string& speciesName, const std::string& skillElement)
{
if (skillElement.empty()) {
return false;
}
const PetGrowthEntry* entry = FindPetGrowthEntry(speciesName);
if (!entry) {
return skillElement == "普通";
}
return entry->allowedSkillElements.contains(skillElement);
}
int SpeciesRarity(const std::string& speciesName)
{
const PetGrowthEntry* entry = FindPetGrowthEntry(speciesName);
return entry ? std::clamp(entry->stats.rarity, 1, 5) : 2;
}
float SpeciesRarityCaptureFactor(const std::string& speciesName)
{
switch (SpeciesRarity(speciesName)) {
case 1:
return 1.0f;
case 2:
return 0.82f;
case 3:
return 0.65f;
case 4:
return 0.48f;
case 5:
return 0.32f;
default:
return 0.82f;
}
}
std::string SpeciesPrimaryElement(const std::string& speciesName)
{
const PetGrowthEntry* entry = FindPetGrowthEntry(speciesName);
return entry ? entry->primaryElement : "普通";
}
std::string SpeciesSecondaryElement(const std::string& speciesName)
{
const PetGrowthEntry* entry = FindPetGrowthEntry(speciesName);
return entry ? entry->secondaryElement : "";
}
int PetExpToNextLevel(int level)
{
level = ClampPetLevel(level);
if (level >= kMaxPetLevel) {
return 0;
}
return 16 + level * level * 3 / 2 + level * 8;
}
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <random>
#include <set>
#include <string>
#include <vector>
struct PetBaseStats {
int baseHp = 1;
int baseAttack = 1;
int baseSpeed = 1;
int hpGrowth = 1;
int attackGrowth = 1;
int speedGrowth = 1;
int rarity = 1;
std::string role;
};
struct PetPotential {
int hp = 15;
int attack = 15;
};
struct PetLevelSkill {
int level = 1;
std::string skillId;
};
struct PetGrowthEntry {
std::string speciesName;
std::string primaryElement;
std::string secondaryElement;
PetBaseStats stats;
std::vector<PetLevelSkill> learnset;
std::set<std::string> allowedSkillElements;
};
struct PetComputedStats {
int maxHp = 1;
int attack = 1;
int speed = 1;
};
constexpr int kMinPetLevel = 1;
constexpr int kMaxPetLevel = 100;
const std::vector<PetGrowthEntry>& PetGrowthCatalog();
const PetGrowthEntry* FindPetGrowthEntry(const std::string& speciesName);
int ClampPetLevel(int level);
PetPotential ClampPetPotential(PetPotential potential);
PetPotential FixedGiftPotential();
PetPotential GenerateWildPotential(std::mt19937& rng, int minPotential = 4, int maxPotential = 20);
PetPotential GenerateEggPotential(std::mt19937& rng, const std::string& rarity);
std::string PotentialRank(PetPotential potential);
PetComputedStats ComputePetStats(const std::string& speciesName, int level, PetPotential potential, PetComputedStats fallback = {});
std::vector<std::string> LearnedSkillIdsForLevel(const std::string& speciesName, int level);
bool IsSkillElementCompatible(const std::string& speciesName, const std::string& skillElement);
int SpeciesRarity(const std::string& speciesName);
float SpeciesRarityCaptureFactor(const std::string& speciesName);
std::string SpeciesPrimaryElement(const std::string& speciesName);
std::string SpeciesSecondaryElement(const std::string& speciesName);
int PetExpToNextLevel(int level);
+63
View File
@@ -0,0 +1,63 @@
#include "PetSpeciesCatalog.h"
#include "TmxMap.h"
#include "WildLevelZone.h"
#include "WildSpawn.h"
#include <algorithm>
#include <map>
#include <set>
std::vector<PetSpeciesCatalogEntry> BuildPetSpeciesCatalogEntries(const TmxWorldIndex& index)
{
std::map<std::string, std::set<std::string>> mapsBySpecies;
for (const auto& [mapName, path] : index.mapsByName) {
const TmxMap& map = LoadTmxMapCached(path);
bool sawMonsterSpawn = false;
for (const TmxObject& object : map.objects) {
if (!IsSpawnObject(object)) {
continue;
}
const auto type = object.properties.find("type");
if (type == object.properties.end() || type->second != "Monster") {
continue;
}
sawMonsterSpawn = true;
const auto speciesPool = object.properties.find("species_pool");
if (speciesPool != object.properties.end() && !speciesPool->second.empty()) {
for (const WildSpeciesSpawnRule& rule : ParseSpeciesPool(speciesPool->second, WildZoneForMap(mapName).defaultBand)) {
mapsBySpecies[rule.speciesName].insert(mapName);
}
} else if (!object.name.empty()) {
mapsBySpecies[object.name].insert(mapName);
}
}
if (ShouldAddFallbackMonsters(mapName, sawMonsterSpawn)) {
const MapWildZone& zone = WildZoneForMap(mapName);
for (const WildSpeciesSpawnRule& rule : zone.species) {
mapsBySpecies[rule.speciesName].insert(mapName);
}
}
}
std::vector<PetSpeciesCatalogEntry> entries;
entries.reserve(mapsBySpecies.size());
for (const auto& [speciesName, mapNames] : mapsBySpecies) {
entries.push_back({speciesName, {mapNames.begin(), mapNames.end()}});
}
std::sort(entries.begin(), entries.end(), [](const PetSpeciesCatalogEntry& a, const PetSpeciesCatalogEntry& b) {
return a.speciesName < b.speciesName;
});
return entries;
}
std::vector<std::string> BuildPetSpeciesCatalog(const TmxWorldIndex& index)
{
const std::vector<PetSpeciesCatalogEntry> entries = BuildPetSpeciesCatalogEntries(index);
std::vector<std::string> species;
species.reserve(entries.size());
for (const PetSpeciesCatalogEntry& entry : entries) {
species.push_back(entry.speciesName);
}
return species;
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "TmxWorld.h"
#include <string>
#include <vector>
struct PetSpeciesCatalogEntry {
std::string speciesName;
std::vector<std::string> mapNames;
};
std::vector<std::string> BuildPetSpeciesCatalog(const TmxWorldIndex& index);
std::vector<PetSpeciesCatalogEntry> BuildPetSpeciesCatalogEntries(const TmxWorldIndex& index);
+80
View File
@@ -0,0 +1,80 @@
#include "DialogueEffects.h"
DialogueEffectApplyResult ApplyDialogueEffects(
Inventory& inventory,
std::map<std::string, std::string>* questStates,
const std::vector<DialogueEffect>& effects)
{
std::map<std::string, int> requiredItems;
for (const DialogueEffect& effect : effects) {
if (effect.kind != DialogueEffectKind::RemoveItem) {
continue;
}
requiredItems[effect.itemName] += effect.count;
}
for (const auto& [itemName, requiredCount] : requiredItems) {
const int available = ItemCount(inventory, itemName);
if (available < requiredCount) {
return {false, itemName, requiredCount, available};
}
}
for (const DialogueEffect& effect : effects) {
if (effect.kind == DialogueEffectKind::RemoveItem) {
RemoveItem(inventory, effect.itemName, effect.count);
} else if (effect.kind == DialogueEffectKind::AddItem) {
AddItem(inventory, effect.itemName, effect.count);
} else if (effect.kind == DialogueEffectKind::SetQuest && questStates) {
(*questStates)[effect.questName] = effect.questState;
}
}
return {true, {}, 0, 0};
}
DialogueEffectApplyResult ApplyDialogueEffects(Inventory& inventory, const std::vector<DialogueEffect>& effects)
{
return ApplyDialogueEffects(inventory, nullptr, effects);
}
DialogueEffectApplyResult ApplyDialogueEffects(
Inventory& inventory,
std::map<std::string, std::string>& questStates,
const std::vector<DialogueEffect>& effects)
{
return ApplyDialogueEffects(inventory, &questStates, effects);
}
DialogueEffectApplyResult ApplyDialogueEffects(
Inventory& inventory,
QuestStateStore& questStates,
const std::vector<DialogueEffect>& effects)
{
std::map<std::string, int> requiredItems;
for (const DialogueEffect& effect : effects) {
if (effect.kind != DialogueEffectKind::RemoveItem) {
continue;
}
requiredItems[effect.itemName] += effect.count;
}
for (const auto& [itemName, requiredCount] : requiredItems) {
const int available = ItemCount(inventory, itemName);
if (available < requiredCount) {
return {false, itemName, requiredCount, available};
}
}
for (const DialogueEffect& effect : effects) {
if (effect.kind == DialogueEffectKind::RemoveItem) {
RemoveItem(inventory, effect.itemName, effect.count);
} else if (effect.kind == DialogueEffectKind::AddItem) {
AddItem(inventory, effect.itemName, effect.count);
} else if (effect.kind == DialogueEffectKind::SetQuest) {
questStates.Set(effect.questName, effect.questState);
}
}
return {true, {}, 0, 0};
}
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "DialogueScript.h"
#include "GameCore.h"
#include "QuestSystem.h"
#include <map>
#include <string>
#include <vector>
struct DialogueEffectApplyResult {
bool applied = false;
std::string blockedItem;
int requiredCount = 0;
int availableCount = 0;
};
DialogueEffectApplyResult ApplyDialogueEffects(Inventory& inventory, const std::vector<DialogueEffect>& effects);
DialogueEffectApplyResult ApplyDialogueEffects(
Inventory& inventory,
std::map<std::string, std::string>& questStates,
const std::vector<DialogueEffect>& effects);
DialogueEffectApplyResult ApplyDialogueEffects(
Inventory& inventory,
QuestStateStore& questStates,
const std::vector<DialogueEffect>& effects);
+614
View File
@@ -0,0 +1,614 @@
#include "DialogueScript.h"
#include <fstream>
#include <algorithm>
#include <cctype>
#include <iterator>
#include <mutex>
#include <regex>
#include <set>
#include <sstream>
namespace {
struct PendingBranchRule {
bool active = false;
std::vector<DialogueBranchRule> rules;
};
std::string ReadTextFile(const std::filesystem::path& path)
{
std::ifstream in(path);
if (!in) {
return {};
}
std::ostringstream buffer;
buffer << in.rdbuf();
return buffer.str();
}
std::filesystem::path ResolveScriptPath(const std::filesystem::path& root, const std::string& scriptPath)
{
constexpr const char* prefix = "res://";
if (scriptPath.rfind(prefix, 0) == 0) {
return root / scriptPath.substr(std::char_traits<char>::length(prefix));
}
return root / "assets/scripts" / scriptPath;
}
std::map<std::filesystem::path, DialogueScript>& DialogueScriptCache()
{
static std::map<std::filesystem::path, DialogueScript> cache;
return cache;
}
std::mutex& DialogueScriptCacheMutex()
{
static std::mutex mutex;
return mutex;
}
bool FindCachedDialogueScript(const std::filesystem::path& path, DialogueScript& script)
{
std::lock_guard<std::mutex> lock(DialogueScriptCacheMutex());
const auto cached = DialogueScriptCache().find(path);
if (cached == DialogueScriptCache().end()) {
return false;
}
script = cached->second;
return true;
}
void StoreCachedDialogueScript(const std::filesystem::path& path, const DialogueScript& script)
{
std::lock_guard<std::mutex> lock(DialogueScriptCacheMutex());
DialogueScriptCache()[path] = script;
}
DialogueKind KindForCall(const std::string& call)
{
if (call == "Choice") {
return DialogueKind::Choice;
}
if (call == "Narrate") {
return DialogueKind::Narration;
}
return DialogueKind::Message;
}
std::set<std::string> ParseFunctionNames(const std::string& text)
{
std::set<std::string> names;
const std::regex functionRegex(R"(^\s*func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\()");
std::istringstream lines(text);
std::string line;
std::smatch match;
while (std::getline(lines, line)) {
if (std::regex_search(line, match, functionRegex)) {
names.insert(match[1].str());
}
}
return names;
}
void AddEntry(DialogueScript& script, DialogueEntry entry)
{
if (!entry.functionName.empty() && !script.functionStart.contains(entry.functionName)) {
script.functionStart[entry.functionName] = static_cast<int>(script.entries.size());
}
script.entries.push_back(std::move(entry));
}
std::string UnescapeGodotString(const std::string& value)
{
std::string result;
result.reserve(value.size());
bool escaped = false;
for (const char ch : value) {
if (escaped) {
if (ch == '"' || ch == '\\') {
result.push_back(ch);
} else if (ch == 'n') {
result.push_back('\n');
} else if (ch == 't') {
result.push_back('\t');
} else {
result.push_back(ch);
}
escaped = false;
} else if (ch == '\\') {
escaped = true;
} else {
result.push_back(ch);
}
}
if (escaped) {
result.push_back('\\');
}
return result;
}
std::string Trim(std::string value)
{
const auto notSpace = [](unsigned char ch) {
return !std::isspace(ch);
};
value.erase(value.begin(), std::find_if(value.begin(), value.end(), notSpace));
value.erase(std::find_if(value.rbegin(), value.rend(), notSpace).base(), value.end());
return value;
}
int LineIndent(const std::string& line)
{
int width = 0;
for (const char ch : line) {
if (ch == ' ') {
++width;
} else if (ch == '\t') {
width += 4;
} else {
break;
}
}
return width;
}
std::string ResolveItemArgument(const std::string& raw, const std::map<std::string, std::string>& itemVariables)
{
const std::string value = Trim(raw);
const std::regex directHashRegex(R"(DB\.GetCellHash\(\s*\"((?:\\.|[^\"\\])*)\"\s*\))");
std::smatch match;
if (std::regex_search(value, match, directHashRegex)) {
return UnescapeGodotString(match[1].str());
}
const auto variable = itemVariables.find(value);
return variable == itemVariables.end() ? std::string{} : variable->second;
}
int ResolveCountArgument(const std::string& raw, const std::map<std::string, int>& countVariables)
{
const std::string value = Trim(raw);
if (value.empty()) {
return 1;
}
if (std::all_of(value.begin(), value.end(), [](unsigned char ch) { return std::isdigit(ch); })) {
return std::stoi(value);
}
const auto variable = countVariables.find(value);
return variable == countVariables.end() ? 1 : variable->second;
}
std::string NormalizeScriptSymbol(const std::string& raw, const std::map<std::string, std::string>& symbolVariables)
{
std::string value = Trim(raw);
const std::size_t comment = value.find('#');
if (comment != std::string::npos) {
value = Trim(value.substr(0, comment));
}
const auto variable = symbolVariables.find(value);
return variable == symbolVariables.end() ? value : variable->second;
}
DialogueConditionOp OpForToken(const std::string& op)
{
if (op == "==") {
return DialogueConditionOp::Equal;
}
if (op == "!=") {
return DialogueConditionOp::NotEqual;
}
if (op == "<") {
return DialogueConditionOp::Less;
}
if (op == ">=") {
return DialogueConditionOp::GreaterEqual;
}
return DialogueConditionOp::Default;
}
int ProgressStateValue(const std::string& state)
{
static const std::map<std::string, int> values = {
{"ProgressCommons.UnknownProgress", 0},
{"ProgressCommons.CompletedProgress", 255},
{"ProgressCommons.SPLATYNA_OFFERING.INACTIVE", 0},
{"ProgressCommons.SPLATYNA_OFFERING.STARTED", 1},
{"ProgressCommons.SPLATYNA_OFFERING.REWARDS_WITHDREW", 255},
{"ProgressCommons.GRAIN_IN_THE_SAND.INACTIVE", 0},
{"ProgressCommons.GRAIN_IN_THE_SAND.STARTED", 1},
{"ProgressCommons.GRAIN_IN_THE_SAND.SEARCHED_CRATES", 2},
{"ProgressCommons.GRAIN_IN_THE_SAND.REWARDS_WITHDREW", 255},
{"ProgressCommons.SNAKE_PIT_THIEF.INACTIVE", 0},
{"ProgressCommons.SNAKE_PIT_THIEF.ALL_CLUES_FOUND", 31},
{"ProgressCommons.SNAKE_PIT_THIEF.RIDDLE_SOLVED", 32},
{"ProgressCommons.SNAKE_PIT_THIEF.REWARDS_WITHDREW", 255},
{"ProgressCommons.SNAKE_PIT_BITING_THIRST.INACTIVE", 0},
{"ProgressCommons.SNAKE_PIT_BITING_THIRST.STARTED", 1},
{"ProgressCommons.SNAKE_PIT_BITING_THIRST.REWARDS_WITHDREW", 255},
{"ProgressCommons.SANDSTORM_MINE_ABANDONED_TREASURE.INACTIVE", 0},
{"ProgressCommons.SANDSTORM_MINE_ABANDONED_TREASURE.KEY_FOUND", 1},
{"ProgressCommons.SANDSTORM_MINE_ABANDONED_TREASURE.REWARDS_WITHDREW", 255},
{"ProgressCommons.DESERT_DEEP_XAKELBAEL.INACTIVE", 0},
{"ProgressCommons.DESERT_DEEP_XAKELBAEL.FIGHTING", 1},
{"ProgressCommons.DESERT_DEEP_XAKELBAEL.DEFEATED", 2},
{"ProgressCommons.TULIMSHAR_OLD_FRIENDSHIP.INACTIVE", 0},
{"ProgressCommons.TULIMSHAR_OLD_FRIENDSHIP.STARTED", 1},
{"ProgressCommons.TULIMSHAR_OLD_FRIENDSHIP.ENVELOPES_FOUND", 2},
{"ProgressCommons.TULIMSHAR_OLD_FRIENDSHIP.LETTERS_DELIVERED", 3},
{"ProgressCommons.TULIMSHAR_OLD_FRIENDSHIP.REWARDS_WITHDREW", 255},
{"ProgressCommons.TUTORIAL.INACTIVE", 0},
{"ProgressCommons.TUTORIAL.INTRO_ITEMS_GIVEN", 1},
{"ProgressCommons.TUTORIAL.POTION_GIVEN", 2},
{"ProgressCommons.TUTORIAL.CLOTHES_GIVEN", 3},
{"ProgressCommons.TUTORIAL.UI_EXPLAINED", 4},
{"ProgressCommons.TUTORIAL.ELANORE_DONE", 5},
{"ProgressCommons.TUTORIAL.KAEL_MET", 6},
{"ProgressCommons.TUTORIAL.KAEL_DONE", 7},
{"ProgressCommons.TUTORIAL.EKINU_DONE", 255},
{"ProgressCommons.ELANORE_POTION.INACTIVE", 0},
{"ProgressCommons.ELANORE_POTION.STARTED", 1},
{"ProgressCommons.NINA_HUNGRY.INACTIVE", 0},
{"ProgressCommons.NINA_HUNGRY.STARTED", 1},
{"ProgressCommons.NINA_HUNGRY.REWARDS_WITHDREW", 255},
{"ProgressCommons.MINE_EXPLORATION.INACTIVE", 0},
{"ProgressCommons.MINE_EXPLORATION.STARTED", 1},
{"ProgressCommons.MINE_EXPLORATION.REWARDS_WITHDREW", 255},
{"ProgressCommons.SANDSTORM_NATHAN_WATER.INACTIVE", 0},
{"ProgressCommons.SANDSTORM_NATHAN_WATER.STARTED", 1},
{"ProgressCommons.SANDSTORM_NATHAN_WATER.REWARDS_WITHDREW", 255},
};
const auto value = values.find(state);
if (value != values.end()) {
return value->second;
}
if (state.ends_with(".INACTIVE")) {
return 0;
}
if (state.ends_with(".REWARDS_WITHDREW")) {
return 255;
}
return 0;
}
bool BranchMatches(const DialogueBranchRule& rule, const std::map<std::string, std::string>& questStates)
{
if (rule.op == DialogueConditionOp::Default) {
return true;
}
const auto current = questStates.find(rule.questName);
const int currentValue = current == questStates.end() ? 0 : ProgressStateValue(current->second);
const int expectedValue = ProgressStateValue(rule.questState);
switch (rule.op) {
case DialogueConditionOp::Equal:
return currentValue == expectedValue;
case DialogueConditionOp::NotEqual:
return currentValue != expectedValue;
case DialogueConditionOp::Less:
return currentValue < expectedValue;
case DialogueConditionOp::GreaterEqual:
return currentValue >= expectedValue;
case DialogueConditionOp::Default:
return true;
}
return false;
}
std::vector<DialogueBranchRule> MakeMatchCaseRules(
const std::string& questName,
const std::string& rawStates,
const std::map<std::string, std::string>& symbolVariables)
{
std::vector<DialogueBranchRule> rules;
const std::regex stateRegex(R"([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|_)");
for (auto it = std::sregex_iterator(rawStates.begin(), rawStates.end(), stateRegex);
it != std::sregex_iterator();
++it) {
DialogueBranchRule rule;
rule.questName = questName;
const std::string state = (*it)[0].str();
if (state == "_") {
rule.op = DialogueConditionOp::Default;
} else {
rule.op = DialogueConditionOp::Equal;
rule.questState = NormalizeScriptSymbol(state, symbolVariables);
}
rules.push_back(std::move(rule));
}
return rules;
}
} // namespace
DialogueScript LoadDialogueScript(const std::filesystem::path& root, const std::string& scriptPath)
{
DialogueScript script;
const std::filesystem::path path = std::filesystem::weakly_canonical(ResolveScriptPath(root, scriptPath));
if (FindCachedDialogueScript(path, script)) {
return script;
}
const std::string text = ReadTextFile(path);
if (text.empty()) {
StoreCachedDialogueScript(path, script);
return script;
}
const std::set<std::string> functionNames = ParseFunctionNames(text);
const std::regex functionRegex(R"(^\s*func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\()");
const std::regex callRegex(R"(\b(Mes|Chat|Narrate|Choice)\s*\(\s*\"((?:\\.|[^\"\\])*)\"\s*(?:,\s*([A-Za-z_][A-Za-z0-9_]*))?)");
const std::regex directCallRegex(R"(^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\()");
const std::regex itemVariableRegex(R"(\b(?:var|const)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=]+)?=\s*DB\.GetCellHash\(\s*\"((?:\\.|[^\"\\])*)\"\s*\))");
const std::regex countVariableRegex(R"(\b(?:var|const)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=]+)?=\s*([0-9]+)\b)");
const std::regex symbolVariableRegex(R"(\b(?:var|const)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=]+)?=\s*(ProgressCommons\.[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\b)");
const std::regex questVariableRegex(R"(\b(?:var\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=]+)?=\s*GetQuest\(\s*([^)]+)\))");
const std::regex matchVariableRegex(R"(^\s*match\s+([A-Za-z_][A-Za-z0-9_]*)\s*:)");
const std::regex matchQuestRegex(R"(^\s*match\s+GetQuest\(\s*([^)]+)\)\s*:)");
const std::regex matchCaseContinuationRegex(R"(^\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|_)\s*,\s*\\?\s*$)");
const std::regex matchCaseRegex(R"(^\s*((?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|_)(?:\s*,\s*\\?\s*(?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*|_))*)\s*:\s*(?:([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\))?)");
const std::regex branchConditionRegex(R"(^\s*(?:if|elif)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(==|!=|<|>=)\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)[^:]*:\s*(?:([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\))?)");
const std::regex elseRegex(R"(^\s*else\s*:\s*(?:([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\))?)");
const std::regex genericConditionalRegex(R"(^\s*(?:if|elif|else|match)\b.*:\s*)");
const std::regex itemEffectRegex(R"(\b(AddItem|RemoveItem)\s*\(\s*(DB\.GetCellHash\(\s*\"(?:\\.|[^\"\\])*\"\s*\)|[A-Za-z_][A-Za-z0-9_]*)\s*(?:,\s*([A-Za-z_][A-Za-z0-9_]*|[0-9]+))?)");
const std::regex setQuestRegex(R"(\bSetQuest\s*\(\s*([^,]+)\s*,\s*([^)]+)\))");
std::istringstream lines(text);
std::string line;
std::string currentFunction;
std::map<std::string, std::string> itemVariables;
std::map<std::string, int> countVariables;
std::map<std::string, std::string> symbolVariables;
std::map<std::string, std::string> questVariables;
std::string activeMatchQuestName;
std::vector<DialogueBranchRule> pendingMatchCaseRules;
std::vector<int> conditionalBlockIndents;
PendingBranchRule pendingBranch;
std::smatch match;
while (std::getline(lines, line)) {
if (std::regex_search(line, match, functionRegex)) {
currentFunction = match[1].str();
questVariables.clear();
activeMatchQuestName.clear();
pendingMatchCaseRules.clear();
conditionalBlockIndents.clear();
pendingBranch = {};
continue;
}
const std::string trimmedLine = Trim(line);
const int indent = LineIndent(line);
if (!trimmedLine.empty()) {
while (!conditionalBlockIndents.empty() && indent <= conditionalBlockIndents.back()) {
conditionalBlockIndents.pop_back();
}
}
const bool insideConditionalBlock = !conditionalBlockIndents.empty();
bool opensConditionalBlock = false;
if (std::regex_search(line, match, itemVariableRegex)) {
itemVariables[match[1].str()] = UnescapeGodotString(match[2].str());
}
if (std::regex_search(line, match, countVariableRegex)) {
countVariables[match[1].str()] = std::stoi(match[2].str());
}
if (std::regex_search(line, match, symbolVariableRegex)) {
symbolVariables[match[1].str()] = match[2].str();
}
if (std::regex_search(line, match, questVariableRegex)) {
questVariables[match[1].str()] = NormalizeScriptSymbol(match[2].str(), symbolVariables);
}
if (!trimmedLine.empty() && trimmedLine.front() == '#') {
continue;
}
if (std::regex_search(line, match, matchVariableRegex)) {
const auto quest = questVariables.find(match[1].str());
activeMatchQuestName = quest == questVariables.end() ? std::string{} : quest->second;
pendingMatchCaseRules.clear();
opensConditionalBlock = true;
} else if (std::regex_search(line, match, matchQuestRegex)) {
activeMatchQuestName = NormalizeScriptSymbol(match[1].str(), symbolVariables);
pendingMatchCaseRules.clear();
opensConditionalBlock = true;
} else if (!activeMatchQuestName.empty() && std::regex_search(line, match, matchCaseContinuationRegex)) {
std::vector<DialogueBranchRule> rules = MakeMatchCaseRules(activeMatchQuestName, match[1].str(), symbolVariables);
pendingMatchCaseRules.insert(
pendingMatchCaseRules.end(),
std::make_move_iterator(rules.begin()),
std::make_move_iterator(rules.end()));
} else if (!activeMatchQuestName.empty() && std::regex_search(line, match, matchCaseRegex)) {
opensConditionalBlock = true;
std::vector<DialogueBranchRule> rules = std::move(pendingMatchCaseRules);
pendingMatchCaseRules.clear();
std::vector<DialogueBranchRule> lineRules = MakeMatchCaseRules(activeMatchQuestName, match[1].str(), symbolVariables);
rules.insert(
rules.end(),
std::make_move_iterator(lineRules.begin()),
std::make_move_iterator(lineRules.end()));
if (match[2].matched && functionNames.contains(match[2].str())) {
for (DialogueBranchRule& rule : rules) {
rule.targetFunction = match[2].str();
script.functionBranchRules[currentFunction].push_back(rule);
}
} else {
pendingBranch.active = true;
pendingBranch.rules = std::move(rules);
}
} else if (std::regex_search(line, match, branchConditionRegex)) {
opensConditionalBlock = true;
const auto quest = questVariables.find(match[1].str());
if (quest != questVariables.end()) {
DialogueBranchRule rule;
rule.questName = quest->second;
rule.op = OpForToken(match[2].str());
rule.questState = NormalizeScriptSymbol(match[3].str(), symbolVariables);
if (match[4].matched && functionNames.contains(match[4].str())) {
rule.targetFunction = match[4].str();
script.functionBranchRules[currentFunction].push_back(rule);
} else {
pendingBranch.active = true;
pendingBranch.rules = {std::move(rule)};
}
}
} else if (std::regex_search(line, match, elseRegex)) {
opensConditionalBlock = true;
DialogueBranchRule rule;
rule.op = DialogueConditionOp::Default;
if (match[1].matched && functionNames.contains(match[1].str())) {
rule.targetFunction = match[1].str();
script.functionBranchRules[currentFunction].push_back(rule);
} else {
pendingBranch.active = true;
pendingBranch.rules = {std::move(rule)};
}
} else if (std::regex_search(line, genericConditionalRegex)) {
opensConditionalBlock = true;
}
if (std::regex_search(line, match, setQuestRegex) && !currentFunction.empty()) {
DialogueEffect effect;
effect.kind = DialogueEffectKind::SetQuest;
effect.questName = NormalizeScriptSymbol(match[1].str(), symbolVariables);
effect.questState = NormalizeScriptSymbol(match[2].str(), symbolVariables);
if (!effect.questName.empty() && !effect.questState.empty()) {
script.functionEffects[currentFunction].push_back(std::move(effect));
}
}
auto effectBegin = std::sregex_iterator(line.begin(), line.end(), itemEffectRegex);
auto effectEnd = std::sregex_iterator();
for (auto it = effectBegin; it != effectEnd; ++it) {
if (currentFunction.empty()) {
continue;
}
DialogueEffect effect;
effect.kind = (*it)[1].str() == "RemoveItem" ? DialogueEffectKind::RemoveItem : DialogueEffectKind::AddItem;
effect.itemName = ResolveItemArgument((*it)[2].str(), itemVariables);
effect.count = ResolveCountArgument((*it)[3].matched ? (*it)[3].str() : std::string{}, countVariables);
if (!effect.itemName.empty() && effect.count > 0) {
script.functionEffects[currentFunction].push_back(std::move(effect));
}
}
bool parsedDialogueCall = false;
auto begin = std::sregex_iterator(line.begin(), line.end(), callRegex);
auto end = std::sregex_iterator();
for (auto it = begin; it != end; ++it) {
DialogueEntry entry;
entry.kind = KindForCall((*it)[1].str());
entry.text = UnescapeGodotString((*it)[2].str());
entry.functionName = currentFunction;
if (entry.kind == DialogueKind::Choice) {
entry.targetFunction = (*it)[3].str();
}
AddEntry(script, std::move(entry));
parsedDialogueCall = true;
}
if (!parsedDialogueCall && std::regex_search(line, match, directCallRegex)) {
const std::string target = match[1].str();
if (functionNames.contains(target)) {
if (pendingBranch.active) {
for (DialogueBranchRule& rule : pendingBranch.rules) {
rule.targetFunction = target;
script.functionBranchRules[currentFunction].push_back(rule);
}
pendingBranch = {};
}
if (!insideConditionalBlock) {
DialogueEntry entry;
entry.kind = DialogueKind::Jump;
entry.text = target;
entry.targetFunction = target;
entry.functionName = currentFunction;
AddEntry(script, std::move(entry));
}
}
}
if (opensConditionalBlock) {
conditionalBlockIndents.push_back(indent);
}
}
StoreCachedDialogueScript(path, script);
return script;
}
void ClearDialogueScriptCache()
{
std::lock_guard<std::mutex> lock(DialogueScriptCacheMutex());
DialogueScriptCache().clear();
}
std::vector<DialogueChoice> DialogueChoicesAt(const DialogueScript& script, int entryIndex)
{
std::vector<DialogueChoice> choices;
if (entryIndex < 0 || entryIndex >= static_cast<int>(script.entries.size())) {
return choices;
}
const DialogueEntry& first = script.entries[static_cast<std::size_t>(entryIndex)];
if (first.kind != DialogueKind::Choice) {
return choices;
}
const std::string functionName = first.functionName;
for (int i = entryIndex; i < static_cast<int>(script.entries.size()); ++i) {
const DialogueEntry& entry = script.entries[static_cast<std::size_t>(i)];
if (entry.kind != DialogueKind::Choice || entry.functionName != functionName) {
break;
}
choices.push_back({i, entry.text, entry.targetFunction});
}
return choices;
}
int ResolveDialogueChoiceTarget(const DialogueScript& script, const DialogueChoice& choice)
{
const auto target = script.functionStart.find(choice.targetFunction);
if (target != script.functionStart.end()) {
return target->second;
}
if (!choice.targetFunction.empty()) {
return -1;
}
const std::vector<DialogueChoice> group = DialogueChoicesAt(script, choice.entryIndex);
if (!group.empty()) {
return group.back().entryIndex + 1;
}
return choice.entryIndex + 1;
}
int SkipDialogueJumps(const DialogueScript& script, int entryIndex)
{
std::set<int> visited;
int index = entryIndex;
while (index >= 0 && index < static_cast<int>(script.entries.size())) {
if (!visited.insert(index).second) {
return index;
}
const DialogueEntry& entry = script.entries[static_cast<std::size_t>(index)];
if (entry.kind != DialogueKind::Jump) {
return index;
}
const auto target = script.functionStart.find(entry.targetFunction);
if (target == script.functionStart.end()) {
return index + 1;
}
index = target->second;
}
return index;
}
std::string ResolveDialogueStartFunction(
const DialogueScript& script,
const std::map<std::string, std::string>& questStates,
const std::string& functionName)
{
const auto rules = script.functionBranchRules.find(functionName);
if (rules == script.functionBranchRules.end()) {
return functionName;
}
for (const DialogueBranchRule& rule : rules->second) {
if (!rule.targetFunction.empty() && BranchMatches(rule, questStates)) {
return rule.targetFunction;
}
}
return functionName;
}
+72
View File
@@ -0,0 +1,72 @@
#pragma once
#include <filesystem>
#include <map>
#include <string>
#include <vector>
enum class DialogueKind {
Message,
Choice,
Narration,
Jump
};
enum class DialogueEffectKind {
AddItem,
RemoveItem,
SetQuest
};
enum class DialogueConditionOp {
Equal,
NotEqual,
Less,
GreaterEqual,
Default
};
struct DialogueEffect {
DialogueEffectKind kind = DialogueEffectKind::AddItem;
std::string itemName;
int count = 1;
std::string questName;
std::string questState;
};
struct DialogueBranchRule {
std::string questName;
DialogueConditionOp op = DialogueConditionOp::Default;
std::string questState;
std::string targetFunction;
};
struct DialogueEntry {
DialogueKind kind = DialogueKind::Message;
std::string text;
std::string functionName;
std::string targetFunction;
};
struct DialogueScript {
std::vector<DialogueEntry> entries;
std::map<std::string, int> functionStart;
std::map<std::string, std::vector<DialogueEffect>> functionEffects;
std::map<std::string, std::vector<DialogueBranchRule>> functionBranchRules;
};
struct DialogueChoice {
int entryIndex = 0;
std::string text;
std::string targetFunction;
};
DialogueScript LoadDialogueScript(const std::filesystem::path& root, const std::string& scriptPath);
void ClearDialogueScriptCache();
std::vector<DialogueChoice> DialogueChoicesAt(const DialogueScript& script, int entryIndex);
int ResolveDialogueChoiceTarget(const DialogueScript& script, const DialogueChoice& choice);
int SkipDialogueJumps(const DialogueScript& script, int entryIndex);
std::string ResolveDialogueStartFunction(
const DialogueScript& script,
const std::map<std::string, std::string>& questStates,
const std::string& functionName);
+91
View File
@@ -0,0 +1,91 @@
#include "ScriptedInteractable.h"
#include <regex>
namespace {
bool ContainsText(const std::string& text, const std::string& needle)
{
return text.find(needle) != std::string::npos;
}
} // namespace
int SnakePitClueNumber(const std::string& playerScript)
{
const std::regex clueRegex(R"(Clue([1-5])\.gd)");
std::smatch match;
return std::regex_search(playerScript, match, clueRegex) ? std::stoi(match[1].str()) : 0;
}
ScriptedInteractableKind DetectScriptedInteractable(
const std::string& objectName,
const std::string& playerScript,
const std::string& ownScript)
{
if (SnakePitClueNumber(playerScript) > 0) {
return ScriptedInteractableKind::SnakePitClue;
}
if (ContainsText(playerScript, "ThiefsChest.gd") || ContainsText(objectName, "Thiefs Chest")) {
return ScriptedInteractableKind::ThiefsChest;
}
if (ContainsText(playerScript, "WaterPondFilthy.gd")) {
return ScriptedInteractableKind::FilthyWaterPond;
}
if (ContainsText(playerScript, "WaterPond.gd") || ContainsText(ownScript, "WaterPondGlobal.gd")) {
return ScriptedInteractableKind::CleanWaterPond;
}
return ScriptedInteractableKind::None;
}
ScriptedInteractionResult ApplyScriptedInteractableReward(
Inventory& inventory,
ScriptedInteractableKind kind,
ScriptedInteractionContext context)
{
ScriptedInteractionResult result;
result.handled = kind != ScriptedInteractableKind::None;
if (!result.handled) {
return result;
}
switch (kind) {
case ScriptedInteractableKind::SnakePitClue:
result.success = true;
result.consumeObject = true;
if (context.allSnakePitCluesFound && !HasItem(inventory, "Thief's Key")) {
AddItem(inventory, "Thief's Key", 1);
result.message = "五个蛇坑线索已拼合 获得" + ItemDisplayName("Thief's Key");
} else {
result.message = "记下了蛇坑线索";
}
return result;
case ScriptedInteractableKind::ThiefsChest:
if (!RemoveItem(inventory, "Thief's Key", 1)) {
result.success = false;
result.consumeObject = false;
result.message = "盗贼宝箱上锁 需要" + ItemDisplayName("Thief's Key");
return result;
}
AddItem(inventory, "Scimitar", 1);
result.success = true;
result.consumeObject = true;
result.message = "打开盗贼宝箱 获得" + ItemDisplayName("Scimitar");
return result;
case ScriptedInteractableKind::CleanWaterPond:
AddItem(inventory, "Water Bottle", 1);
result.success = true;
result.consumeObject = true;
result.message = "在清水池装满" + ItemDisplayName("Water Bottle");
return result;
case ScriptedInteractableKind::FilthyWaterPond:
result.success = false;
result.consumeObject = false;
result.message = "这处水池太脏 不能装水";
return result;
case ScriptedInteractableKind::None:
break;
}
result.handled = false;
return result;
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "GameCore.h"
#include <string>
enum class ScriptedInteractableKind {
None,
SnakePitClue,
ThiefsChest,
CleanWaterPond,
FilthyWaterPond
};
struct ScriptedInteractionContext {
bool allSnakePitCluesFound = false;
};
struct ScriptedInteractionResult {
bool handled = false;
bool success = false;
bool consumeObject = false;
std::string message;
};
ScriptedInteractableKind DetectScriptedInteractable(
const std::string& objectName,
const std::string& playerScript,
const std::string& ownScript);
int SnakePitClueNumber(const std::string& playerScript);
ScriptedInteractionResult ApplyScriptedInteractableReward(
Inventory& inventory,
ScriptedInteractableKind kind,
ScriptedInteractionContext context);
+394
View File
@@ -0,0 +1,394 @@
#include "QuestSystem.h"
#include <algorithm>
#include <fstream>
#include <sstream>
#include <utility>
namespace {
constexpr const char* kUnknownQuestState = "ProgressCommons.UnknownProgress";
constexpr const char* kCompletedToken = ".REWARDS_WITHDREW";
constexpr const char* kTutorialCompleted = "ProgressCommons.TUTORIAL.EKINU_DONE";
constexpr const char* kCompletedProgress = "ProgressCommons.CompletedProgress";
bool HasSuffix(const std::string& value, const std::string& suffix)
{
return value.size() >= suffix.size()
&& value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0;
}
std::string TrimQuestValue(std::string value)
{
while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) {
value.erase(value.begin());
}
while (!value.empty() && (value.back() == ' ' || value.back() == '\t' || value.back() == '\r')) {
value.pop_back();
}
if (value.size() >= 2 && value.front() == '"' && value.back() == '"') {
value = value.substr(1, value.size() - 2);
}
return value;
}
std::string QuestSymbolForId(int id)
{
static const std::map<int, std::string> symbols = {
{0, "ProgressCommons.Quest.SPLATYNA_OFFERING"},
{1, "ProgressCommons.Quest.GRAIN_IN_THE_SAND"},
{2, "ProgressCommons.Quest.SNAKE_PIT_THIEF"},
{3, "ProgressCommons.Quest.SNAKE_PIT_BITING_THIRST"},
{4, "ProgressCommons.Quest.SANDSTORM_MINE_ABANDONED_TREASURE"},
{5, "ProgressCommons.Quest.DESERT_DEEP_XAKELBAEL"},
{6, "ProgressCommons.Quest.TULIMSHAR_OLD_FRIENDSHIP"},
{7, "ProgressCommons.Quest.TUTORIAL"},
{8, "ProgressCommons.Quest.ELANORE_POTION"},
{9, "ProgressCommons.Quest.NINA_HUNGRY"},
{10, "ProgressCommons.Quest.MINE_EXPLORATION"},
{11, "ProgressCommons.Quest.SANDSTORM_NATHAN_WATER"},
};
const auto it = symbols.find(id);
return it == symbols.end() ? std::string{} : it->second;
}
void ApplyQuestField(QuestDefinition& definition, const std::string& key, const std::string& value)
{
if (key == "id") {
definition.id = std::stoi(value);
definition.symbol = QuestSymbolForId(definition.id);
} else if (key == "name") {
definition.name = value;
} else if (key == "description") {
definition.description = value;
} else if (key == "giver") {
definition.giver = value;
} else if (key == "giverLocation") {
definition.giverLocation = value;
} else if (key == "target") {
definition.target = value;
} else if (key == "targetLocation") {
definition.targetLocation = value;
} else if (key == "reward") {
definition.reward = value;
}
}
bool ObjectiveMatchesEvent(const QuestObjectiveProgress& objective, const QuestEvent& event)
{
if (objective.completed) {
return false;
}
if (objective.kind == QuestObjectiveKind::CatchAnyPet && event.kind == QuestObjectiveKind::CatchAnyPet) {
return true;
}
if (objective.kind != event.kind) {
return false;
}
return objective.target.empty() || objective.target == event.target;
}
} // namespace
std::string UnknownQuestState()
{
return kUnknownQuestState;
}
bool IsUnknownQuestState(const std::string& questState)
{
return questState.empty()
|| questState == kUnknownQuestState
|| HasSuffix(questState, ".INACTIVE");
}
bool IsCompletedQuestState(const std::string& questState)
{
return questState == kCompletedProgress
|| questState == kTutorialCompleted
|| HasSuffix(questState, kCompletedToken);
}
std::string QuestStateStore::Get(const std::string& questName) const
{
const auto it = states_.find(questName);
return it == states_.end() ? UnknownQuestState() : it->second;
}
QuestTransition QuestStateStore::Set(const std::string& questName, const std::string& questState)
{
QuestTransition transition;
transition.questName = questName;
transition.previousState = Get(questName);
transition.newState = questState.empty() ? UnknownQuestState() : questState;
transition.changed = transition.previousState != transition.newState;
transition.started = transition.changed
&& IsUnknownQuestState(transition.previousState)
&& !IsUnknownQuestState(transition.newState);
transition.completed = transition.changed && IsCompletedQuestState(transition.newState);
states_[questName] = transition.newState;
return transition;
}
bool QuestStateStore::IsStarted(const std::string& questName) const
{
const std::string state = Get(questName);
return !IsUnknownQuestState(state) && !IsCompletedQuestState(state);
}
bool QuestStateStore::IsCompleted(const std::string& questName) const
{
return IsCompletedQuestState(Get(questName));
}
const std::map<std::string, std::string>& QuestStateStore::RawStates() const
{
return states_;
}
void QuestStateStore::ImportRawStates(const std::map<std::string, std::string>& rawStates)
{
states_ = rawStates;
}
std::vector<QuestDefinition> LoadQuestDefinitionsFromDirectory(const std::filesystem::path& questDirectory)
{
std::vector<QuestDefinition> definitions;
if (!std::filesystem::exists(questDirectory)) {
return definitions;
}
for (const std::filesystem::directory_entry& entry : std::filesystem::directory_iterator(questDirectory)) {
if (!entry.is_regular_file() || entry.path().extension() != ".tres") {
continue;
}
QuestDefinition definition;
std::ifstream in(entry.path());
std::string line;
while (std::getline(in, line)) {
const std::size_t equals = line.find('=');
if (equals == std::string::npos) {
continue;
}
const std::string key = TrimQuestValue(line.substr(0, equals));
const std::string value = TrimQuestValue(line.substr(equals + 1));
ApplyQuestField(definition, key, value);
}
if (!definition.symbol.empty() && !definition.name.empty()) {
definitions.push_back(definition);
}
}
std::sort(definitions.begin(), definitions.end(), [](const QuestDefinition& a, const QuestDefinition& b) {
return a.id < b.id;
});
return definitions;
}
const QuestDefinition* FindQuestDefinition(
const std::vector<QuestDefinition>& definitions,
const std::string& questSymbol)
{
const auto it = std::find_if(definitions.begin(), definitions.end(), [&](const QuestDefinition& definition) {
return definition.symbol == questSymbol;
});
return it == definitions.end() ? nullptr : &*it;
}
std::string QuestTitleForSymbol(
const std::vector<QuestDefinition>& definitions,
const std::string& questSymbol)
{
const QuestDefinition* definition = FindQuestDefinition(definitions, questSymbol);
return definition ? definition->name : questSymbol;
}
QuestEvent QuestEvent::VisitedMap(std::string mapName)
{
return {QuestObjectiveKind::VisitMap, std::move(mapName), 1};
}
QuestEvent QuestEvent::CapturedPet(std::string speciesName)
{
return {QuestObjectiveKind::CatchAnyPet, std::move(speciesName), 1};
}
QuestEvent QuestEvent::SawPet(std::string speciesName)
{
return {QuestObjectiveKind::SeeSpecies, std::move(speciesName), 1};
}
QuestEvent QuestEvent::DefeatedPet(std::string speciesName)
{
return {QuestObjectiveKind::DefeatSpecies, std::move(speciesName), 1};
}
QuestEvent QuestEvent::OwnedItem(std::string itemName, int count)
{
return {QuestObjectiveKind::OwnItem, std::move(itemName), count};
}
QuestEvent QuestEvent::TalkedToNpc(std::string npcName)
{
return {QuestObjectiveKind::TalkToNpc, std::move(npcName), 1};
}
QuestEvent QuestEvent::HatchedEgg(std::string eggName)
{
return {QuestObjectiveKind::HatchEgg, std::move(eggName), 1};
}
QuestEvent QuestEvent::ChoseStarterPet(std::string speciesName)
{
return {QuestObjectiveKind::ChooseStarterPet, std::move(speciesName), 1};
}
void ApplyQuestEvent(QuestRuntime& runtime, const QuestEvent& event)
{
for (QuestObjectiveProgress& objective : runtime.objectives) {
if (!ObjectiveMatchesEvent(objective, event)) {
continue;
}
if (objective.kind == QuestObjectiveKind::OwnItem) {
objective.current = std::max(objective.current, event.amount);
} else {
objective.current += event.amount;
}
objective.completed = objective.current >= objective.required;
}
}
std::vector<QuestJournalEntry> BuildQuestJournal(
const std::vector<QuestDefinition>& definitions,
const QuestRuntime& runtime)
{
std::vector<QuestJournalEntry> journal;
for (const QuestDefinition& definition : definitions) {
const bool started = runtime.states.IsStarted(definition.symbol);
const bool completed = runtime.states.IsCompleted(definition.symbol);
if (!started && !completed) {
continue;
}
QuestJournalEntry entry;
entry.questSymbol = definition.symbol;
entry.title = definition.name;
entry.description = definition.description;
entry.giver = definition.giver;
entry.target = definition.target;
entry.reward = definition.reward;
entry.completed = completed;
for (const QuestObjectiveProgress& objective : runtime.objectives) {
if (!objective.questSymbol.empty() && objective.questSymbol != definition.symbol) {
continue;
}
QuestJournalObjective journalObjective;
journalObjective.title = objective.title;
journalObjective.current = objective.current;
journalObjective.required = objective.required;
journalObjective.completed = objective.completed;
entry.objectives.push_back(journalObjective);
}
journal.push_back(entry);
}
return journal;
}
std::size_t CompletedObjectiveCount(const QuestRuntime& runtime)
{
return static_cast<std::size_t>(std::count_if(
runtime.objectives.begin(),
runtime.objectives.end(),
[](const QuestObjectiveProgress& objective) {
return objective.completed;
}));
}
std::size_t QuestObjectiveWindowStart(const QuestRuntime& runtime, std::size_t maxVisible)
{
if (maxVisible == 0 || runtime.objectives.size() <= maxVisible) {
return 0;
}
const auto firstIncomplete = std::find_if(
runtime.objectives.begin(),
runtime.objectives.end(),
[](const QuestObjectiveProgress& objective) {
return !objective.completed;
});
if (firstIncomplete == runtime.objectives.end()) {
return runtime.objectives.size() - maxVisible;
}
const std::size_t index = static_cast<std::size_t>(std::distance(runtime.objectives.begin(), firstIncomplete));
const std::size_t lead = std::min<std::size_t>(2, maxVisible / 2);
return std::min(index > lead ? index - lead : 0, runtime.objectives.size() - maxVisible);
}
std::optional<std::string> NextQuestTargetMap(const QuestRuntime& runtime)
{
for (const QuestObjectiveProgress& objective : runtime.objectives) {
if (!objective.completed && objective.kind == QuestObjectiveKind::VisitMap && !objective.target.empty()) {
return objective.target;
}
}
return std::nullopt;
}
std::vector<std::string> QuestTargetMaps(const QuestRuntime& runtime)
{
std::vector<std::string> maps;
for (const QuestObjectiveProgress& objective : runtime.objectives) {
if (objective.kind == QuestObjectiveKind::VisitMap && !objective.target.empty()) {
maps.push_back(objective.target);
}
}
return maps;
}
QuestRuntime MakeInitialTonoriQuestRuntime()
{
QuestRuntime runtime;
runtime.states.Set(
"ProgressCommons.Quest.TUTORIAL",
"ProgressCommons.TUTORIAL.INTRO_ITEMS_GIVEN");
runtime.objectives = {
{"visit_tulimshar", "抵达图利姆沙", QuestObjectiveKind::VisitMap, "Tulimshar", 1, 0, false, "ProgressCommons.Quest.TUTORIAL"},
{"talk_kael", "找凯尔确认身体状况", QuestObjectiveKind::TalkToNpc, "Kael", 1, 0, false, "ProgressCommons.Quest.TUTORIAL"},
{"choose_starter_pet", "选择初始宠物", QuestObjectiveKind::ChooseStarterPet, {}, 1, 0, false, "ProgressCommons.Quest.TUTORIAL"},
{"clear_peyotes", "清理 5 只仙人掌怪", QuestObjectiveKind::DefeatSpecies, "Peyote", 5, 0, false, "ProgressCommons.Quest.TUTORIAL"},
{"clear_maggots", "清理 5 只沙虫", QuestObjectiveKind::DefeatSpecies, "Maggot", 5, 0, false, "ProgressCommons.Quest.TUTORIAL"},
{"report_ekinu", "向艾基努汇报农田情况", QuestObjectiveKind::TalkToNpc, "Ekinu", 1, 0, false, "ProgressCommons.Quest.TUTORIAL"},
{"catch_first_pet", "捕捉第一只野外宠物", QuestObjectiveKind::CatchAnyPet, {}, 1, 0, false, "ProgressCommons.Quest.TUTORIAL"},
{"elanore_maggot_slime", "收集 6 份沙虫黏液", QuestObjectiveKind::OwnItem, "Maggot Slime", 6, 0, false, "ProgressCommons.Quest.ELANORE_POTION"},
{"elanore_water", "准备 1 瓶水", QuestObjectiveKind::OwnItem, "Water Bottle", 1, 0, false, "ProgressCommons.Quest.ELANORE_POTION"},
{"elanore_cactus_drink", "准备 1 杯仙人掌饮料", QuestObjectiveKind::OwnItem, "Cactus Drink", 1, 0, false, "ProgressCommons.Quest.ELANORE_POTION"},
{"nina_croissant", "给妮娜带一份可颂", QuestObjectiveKind::OwnItem, "Croissant", 1, 0, false, "ProgressCommons.Quest.NINA_HUNGRY"},
{"riskim_flour", "在港口找到蓝封蜡面粉桶", QuestObjectiveKind::OwnItem, "Flour Barrel", 1, 0, false, "ProgressCommons.Quest.GRAIN_IN_THE_SAND"},
{"talk_dausen", "向城外的道森询问矿洞路线", QuestObjectiveKind::TalkToNpc, "Dausen", 1, 0, false, "ProgressCommons.Quest.MINE_EXPLORATION"},
{"visit_sandstorm", "前往沙漠风暴谷地", QuestObjectiveKind::VisitMap, "Sandstorm", 1, 0, false, "ProgressCommons.Quest.MINE_EXPLORATION"},
{"talk_nathan", "与矿洞入口的内森会合", QuestObjectiveKind::TalkToNpc, "Nathan", 1, 0, false, "ProgressCommons.Quest.MINE_EXPLORATION"},
{"visit_desert_mines", "进入沙漠矿洞", QuestObjectiveKind::VisitMap, "Desert Mines", 1, 0, false, "ProgressCommons.Quest.MINE_EXPLORATION"},
{"defeat_scorpion", "击败矿洞里的沙蝎", QuestObjectiveKind::DefeatSpecies, "Scorpion", 1, 0, false, "ProgressCommons.Quest.MINE_EXPLORATION"},
{"visit_deep_level", "抵达沙漠深层矿道", QuestObjectiveKind::VisitMap, "Desert Deep Level", 1, 0, false, "ProgressCommons.Quest.DESERT_DEEP_XAKELBAEL"},
{"face_xakelbael", "直面夏凯尔贝尔", QuestObjectiveKind::TalkToNpc, "Xakelbael", 1, 0, false, "ProgressCommons.Quest.DESERT_DEEP_XAKELBAEL"},
{"defeat_xakelbael", "击败夏凯尔贝尔", QuestObjectiveKind::DefeatSpecies, "Xakelbael", 1, 0, false, "ProgressCommons.Quest.DESERT_DEEP_XAKELBAEL"},
{"find_mine_key", "找到废弃矿层钥匙", QuestObjectiveKind::OwnItem, "Chest Mine Key", 1, 0, false, "ProgressCommons.Quest.SANDSTORM_MINE_ABANDONED_TREASURE"},
{"open_mine_chest", "打开矿洞遗落宝箱", QuestObjectiveKind::OwnItem, "Short Sword", 1, 0, false, "ProgressCommons.Quest.SANDSTORM_MINE_ABANDONED_TREASURE"},
{"nathan_water", "带给内森一瓶水", QuestObjectiveKind::OwnItem, "Water Bottle", 1, 0, false, "ProgressCommons.Quest.SANDSTORM_NATHAN_WATER"},
{"visit_snake_pit", "调查蛇坑", QuestObjectiveKind::VisitMap, "Snake Pit", 1, 0, false, "ProgressCommons.Quest.SNAKE_PIT_THIEF"},
{"find_snake_clues", "找到 5 条蛇坑线索", QuestObjectiveKind::OwnItem, "Thief's Key", 1, 0, false, "ProgressCommons.Quest.SNAKE_PIT_THIEF"},
{"open_thief_chest", "打开盗贼宝箱", QuestObjectiveKind::OwnItem, "Scimitar", 1, 0, false, "ProgressCommons.Quest.SNAKE_PIT_THIEF"},
{"mauro_accept", "答应帮助毛罗取水", QuestObjectiveKind::TalkToNpc, "Mauro", 1, 0, false, "ProgressCommons.Quest.SNAKE_PIT_BITING_THIRST"},
{"mauro_water", "从蛇坑清水池装满水", QuestObjectiveKind::OwnItem, "Water Bottle", 1, 0, false, "ProgressCommons.Quest.SNAKE_PIT_BITING_THIRST"},
{"old_friend_letters", "从城堡暗处取回旧信件", QuestObjectiveKind::OwnItem, "Sealed Letters", 1, 0, false, "ProgressCommons.Quest.TULIMSHAR_OLD_FRIENDSHIP"},
{"collect_first_pet_partner", "收服 1 只宠物伙伴", QuestObjectiveKind::CatchAnyPet, {}, 1, 0, false, "ProgressCommons.Quest.TUTORIAL"},
};
return runtime;
}
+124
View File
@@ -0,0 +1,124 @@
#pragma once
#include <filesystem>
#include <map>
#include <optional>
#include <string>
#include <vector>
struct QuestTransition {
bool changed = false;
bool started = false;
bool completed = false;
std::string questName;
std::string previousState;
std::string newState;
};
class QuestStateStore {
public:
std::string Get(const std::string& questName) const;
QuestTransition Set(const std::string& questName, const std::string& questState);
bool IsStarted(const std::string& questName) const;
bool IsCompleted(const std::string& questName) const;
const std::map<std::string, std::string>& RawStates() const;
void ImportRawStates(const std::map<std::string, std::string>& rawStates);
private:
std::map<std::string, std::string> states_;
};
struct QuestDefinition {
int id = -1;
std::string symbol;
std::string name;
std::string description;
std::string giver;
std::string giverLocation;
std::string target;
std::string targetLocation;
std::string reward;
};
enum class QuestObjectiveKind {
VisitMap,
CatchAnyPet,
CatchSpecies,
SeeSpecies,
DefeatSpecies,
OwnItem,
TalkToNpc,
HatchEgg,
ChooseStarterPet,
};
struct QuestObjectiveProgress {
std::string id;
std::string title;
QuestObjectiveKind kind = QuestObjectiveKind::VisitMap;
std::string target;
int required = 1;
int current = 0;
bool completed = false;
std::string questSymbol;
};
struct QuestRuntime {
QuestStateStore states;
std::vector<QuestObjectiveProgress> objectives;
};
struct QuestEvent {
QuestObjectiveKind kind = QuestObjectiveKind::VisitMap;
std::string target;
int amount = 1;
static QuestEvent VisitedMap(std::string mapName);
static QuestEvent CapturedPet(std::string speciesName);
static QuestEvent SawPet(std::string speciesName);
static QuestEvent DefeatedPet(std::string speciesName);
static QuestEvent OwnedItem(std::string itemName, int count);
static QuestEvent TalkedToNpc(std::string npcName);
static QuestEvent HatchedEgg(std::string eggName);
static QuestEvent ChoseStarterPet(std::string speciesName);
};
struct QuestJournalObjective {
std::string title;
int current = 0;
int required = 1;
bool completed = false;
};
struct QuestJournalEntry {
std::string questSymbol;
std::string title;
std::string description;
std::string giver;
std::string target;
std::string reward;
bool completed = false;
std::vector<QuestJournalObjective> objectives;
};
bool IsUnknownQuestState(const std::string& questState);
bool IsCompletedQuestState(const std::string& questState);
std::string UnknownQuestState();
std::vector<QuestDefinition> LoadQuestDefinitionsFromDirectory(const std::filesystem::path& questDirectory);
const QuestDefinition* FindQuestDefinition(
const std::vector<QuestDefinition>& definitions,
const std::string& questSymbol);
std::string QuestTitleForSymbol(
const std::vector<QuestDefinition>& definitions,
const std::string& questSymbol);
void ApplyQuestEvent(QuestRuntime& runtime, const QuestEvent& event);
std::vector<QuestJournalEntry> BuildQuestJournal(
const std::vector<QuestDefinition>& definitions,
const QuestRuntime& runtime);
std::size_t CompletedObjectiveCount(const QuestRuntime& runtime);
std::size_t QuestObjectiveWindowStart(const QuestRuntime& runtime, std::size_t maxVisible);
std::optional<std::string> NextQuestTargetMap(const QuestRuntime& runtime);
std::vector<std::string> QuestTargetMaps(const QuestRuntime& runtime);
QuestRuntime MakeInitialTonoriQuestRuntime();
+262
View File
@@ -0,0 +1,262 @@
#include "SaveGame.h"
#include <algorithm>
#include <fstream>
#include <sstream>
namespace {
std::vector<std::string> Split(const std::string& text, char separator)
{
std::vector<std::string> parts;
std::stringstream stream(text);
std::string part;
while (std::getline(stream, part, separator)) {
parts.push_back(part);
}
return parts;
}
std::string Join(const std::vector<std::string>& values, char separator)
{
std::ostringstream out;
for (std::size_t i = 0; i < values.size(); ++i) {
if (i > 0) {
out << separator;
}
out << values[i];
}
return out.str();
}
std::string ValueAfterEquals(const std::string& line)
{
const std::size_t pos = line.find('=');
return pos == std::string::npos ? std::string{} : line.substr(pos + 1);
}
bool StartsWith(const std::string& text, const std::string& prefix)
{
return text.rfind(prefix, 0) == 0;
}
void WritePet(std::ofstream& out, const std::string& key, const Pet& pet)
{
out << key << "=" << pet.name << "|" << pet.maxHp << "|" << pet.hp << "|" << pet.attack << "|" << pet.level << "|" << pet.exp
<< "|" << Join(pet.learnedSkillIds, ',')
<< "|" << pet.potential.hp << "|" << pet.potential.attack << "\n";
}
std::optional<Pet> ReadPet(const std::vector<std::string>& parts)
{
if (parts.size() != 4 && parts.size() != 6 && parts.size() != 7 && parts.size() != 9) {
return std::nullopt;
}
Pet pet = MakePet(parts[0], std::stoi(parts[1]), std::stoi(parts[3]));
pet.hp = std::stoi(parts[2]);
if (parts.size() >= 6) {
pet.level = std::stoi(parts[4]);
pet.exp = std::stoi(parts[5]);
}
if (parts.size() >= 7) {
pet.learnedSkillIds = Split(parts[6], ',');
if (pet.learnedSkillIds.size() == 1 && pet.learnedSkillIds.front().empty()) {
pet.learnedSkillIds.clear();
}
}
if (parts.size() == 9) {
pet.potential.hp = std::stoi(parts[7]);
pet.potential.attack = std::stoi(parts[8]);
}
NormalizePetAfterLoad(pet);
return pet;
}
QuestObjectiveProgress* FindObjective(QuestRuntime& runtime, const std::string& id)
{
const auto it = std::find_if(runtime.objectives.begin(), runtime.objectives.end(), [&](QuestObjectiveProgress& objective) {
return objective.id == id;
});
return it == runtime.objectives.end() ? nullptr : &*it;
}
std::string NewObjectiveIdForLegacyObjective(const std::string& legacyId)
{
static const std::map<std::string, std::string> migrated = {
{"arrive_tulimshar", "visit_tulimshar"},
{"capture_first", "catch_first_pet"},
{"reach_sandstorm", "visit_sandstorm"},
{"enter_mines", "visit_desert_mines"},
{"collect_three", "collect_first_pet_partner"},
{"collect_three_pets", "collect_first_pet_partner"},
{"snake_pit", "visit_snake_pit"},
};
const auto it = migrated.find(legacyId);
return it == migrated.end() ? legacyId : it->second;
}
void ApplyLoadedQuestObjective(QuestRuntime& runtime, const std::string& id, int current, bool completed)
{
QuestObjectiveProgress* objective = FindObjective(runtime, NewObjectiveIdForLegacyObjective(id));
if (!objective) {
return;
}
objective->current = std::max(objective->current, current);
objective->completed = objective->completed || completed || objective->current >= objective->required;
}
} // namespace
bool SaveGameToFile(const std::filesystem::path& path, const SaveState& state)
{
std::ofstream out(path);
if (!out) {
return false;
}
out << "MANA_SAVE_V1\n";
out << "map=" << state.mapName << "\n";
out << "player=" << state.playerX << "," << state.playerY << "\n";
out << "gold=" << state.gold << "\n";
out << "discovered=" << Join(state.discoveredMaps, '|') << "\n";
out << "collected=" << Join(state.collectedObjects, '|') << "\n";
out << "executed_dialogue_effects=" << Join(state.executedDialogueEffects, '|') << "\n";
out << "quest_states=" << state.questRuntime.states.RawStates().size() << "\n";
for (const auto& [questName, questState] : state.questRuntime.states.RawStates()) {
out << "quest_state=" << questName << "|" << questState << "\n";
}
const auto validMonsterRespawns = [&]() {
std::vector<MonsterRespawnSave> valid;
for (const MonsterRespawnSave& respawn : state.monsterRespawns) {
if (!respawn.objectKey.empty() && respawn.remainingSeconds > 0.0) {
valid.push_back(respawn);
}
}
return valid;
}();
out << "monster_respawns=" << validMonsterRespawns.size() << "\n";
for (const MonsterRespawnSave& respawn : validMonsterRespawns) {
out << "monster_respawn=" << respawn.objectKey << "|" << respawn.remainingSeconds << "\n";
}
out << "items=" << state.inventory.items.size() << "\n";
for (const InventoryItem& item : state.inventory.items) {
out << "item=" << item.name << "|" << item.count << "\n";
}
out << "pets=" << state.team.pets.size() << "\n";
for (const Pet& pet : state.team.pets) {
WritePet(out, "pet", pet);
}
out << "stored_pets=" << state.team.storage.size() << "\n";
for (const Pet& pet : state.team.storage) {
WritePet(out, "stored_pet", pet);
}
out << "pet_journal=" << state.petJournal.entries.size() << "\n";
for (const PetJournalEntry& entry : state.petJournal.entries) {
if (!entry.speciesName.empty()) {
out << "pet_journal_entry=" << entry.speciesName << "|" << entry.seenCount << "|" << entry.caughtCount << "\n";
}
}
out << "quest_objectives=" << state.questRuntime.objectives.size() << "\n";
for (const QuestObjectiveProgress& objective : state.questRuntime.objectives) {
out << "quest_objective="
<< objective.id << "|"
<< objective.current << "|"
<< (objective.completed ? "1" : "0") << "\n";
}
return static_cast<bool>(out);
}
std::optional<SaveState> LoadGameFromFile(const std::filesystem::path& path)
{
std::ifstream in(path);
if (!in) {
return std::nullopt;
}
std::string line;
if (!std::getline(in, line) || line != "MANA_SAVE_V1") {
return std::nullopt;
}
SaveState state;
state.questRuntime = MakeInitialTonoriQuestRuntime();
while (std::getline(in, line)) {
if (StartsWith(line, "map=")) {
state.mapName = ValueAfterEquals(line);
} else if (StartsWith(line, "player=")) {
const std::vector<std::string> parts = Split(ValueAfterEquals(line), ',');
if (parts.size() == 2) {
state.playerX = std::stof(parts[0]);
state.playerY = std::stof(parts[1]);
}
} else if (StartsWith(line, "gold=")) {
state.gold = std::max(0, std::stoi(ValueAfterEquals(line)));
} else if (StartsWith(line, "discovered=")) {
state.discoveredMaps = Split(ValueAfterEquals(line), '|');
if (state.discoveredMaps.size() == 1 && state.discoveredMaps.front().empty()) {
state.discoveredMaps.clear();
}
} else if (StartsWith(line, "collected=")) {
state.collectedObjects = Split(ValueAfterEquals(line), '|');
if (state.collectedObjects.size() == 1 && state.collectedObjects.front().empty()) {
state.collectedObjects.clear();
}
} else if (StartsWith(line, "executed_dialogue_effects=")) {
state.executedDialogueEffects = Split(ValueAfterEquals(line), '|');
if (state.executedDialogueEffects.size() == 1 && state.executedDialogueEffects.front().empty()) {
state.executedDialogueEffects.clear();
}
} else if (StartsWith(line, "quest_state=") || StartsWith(line, "script_quest=")) {
const std::vector<std::string> parts = Split(ValueAfterEquals(line), '|');
if (parts.size() == 2 && !parts[0].empty()) {
state.questRuntime.states.Set(parts[0], parts[1]);
}
} else if (StartsWith(line, "monster_respawn=")) {
const std::vector<std::string> parts = Split(ValueAfterEquals(line), '|');
if (parts.size() == 2 && !parts[0].empty()) {
const double remaining = std::stod(parts[1]);
if (remaining > 0.0) {
state.monsterRespawns.push_back({parts[0], remaining});
}
}
} else if (StartsWith(line, "item=")) {
const std::vector<std::string> parts = Split(ValueAfterEquals(line), '|');
if (parts.size() == 2) {
AddItem(state.inventory, parts[0], std::stoi(parts[1]));
}
} else if (StartsWith(line, "pet=")) {
const std::vector<std::string> parts = Split(ValueAfterEquals(line), '|');
const std::optional<Pet> pet = ReadPet(parts);
if (pet.has_value()) {
state.team.pets.push_back(*pet);
}
} else if (StartsWith(line, "stored_pet=")) {
const std::vector<std::string> parts = Split(ValueAfterEquals(line), '|');
const std::optional<Pet> pet = ReadPet(parts);
if (pet.has_value()) {
state.team.storage.push_back(*pet);
}
} else if (StartsWith(line, "pet_journal_entry=")) {
const std::vector<std::string> parts = Split(ValueAfterEquals(line), '|');
if (parts.size() == 3 && !parts[0].empty()) {
state.petJournal.entries.push_back({parts[0], std::stoi(parts[1]), std::stoi(parts[2])});
}
} else if (StartsWith(line, "quest_objective=")) {
const std::vector<std::string> parts = Split(ValueAfterEquals(line), '|');
if (parts.size() == 3 && !parts[0].empty()) {
ApplyLoadedQuestObjective(state.questRuntime, parts[0], std::stoi(parts[1]), parts[2] == "1");
}
} else if (StartsWith(line, "quest=")) {
const std::vector<std::string> parts = Split(ValueAfterEquals(line), '|');
if (parts.size() == 2 && !parts[0].empty()) {
ApplyLoadedQuestObjective(state.questRuntime, parts[0], parts[1] == "1" ? 1 : 0, parts[1] == "1");
}
}
}
if (state.mapName.empty()) {
return std::nullopt;
}
return state;
}
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "GameCore.h"
#include "QuestSystem.h"
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
struct MonsterRespawnSave {
std::string objectKey;
double remainingSeconds = 0.0;
};
struct SaveState {
std::string mapName;
float playerX = 0.0f;
float playerY = 0.0f;
int gold = 3000;
Team team;
PetJournal petJournal;
Inventory inventory;
std::vector<std::string> discoveredMaps;
std::vector<std::string> collectedObjects;
std::vector<std::string> executedDialogueEffects;
std::vector<MonsterRespawnSave> monsterRespawns;
QuestRuntime questRuntime;
};
bool SaveGameToFile(const std::filesystem::path& path, const SaveState& state);
std::optional<SaveState> LoadGameFromFile(const std::filesystem::path& path);
+47
View File
@@ -0,0 +1,47 @@
#include "InteractableVisual.h"
#include "ItemIconCatalog.h"
#include <map>
#include <stdexcept>
namespace {
bool ContainsText(const std::string& text, const std::string& needle)
{
return text.find(needle) != std::string::npos;
}
std::filesystem::path FallbackIconPath(const std::filesystem::path& root, const std::string& entityName)
{
static std::map<std::filesystem::path, ItemIconCatalog> catalogsByRoot;
const auto [it, _] = catalogsByRoot.try_emplace(root, BuildItemIconCatalog(root));
const std::filesystem::path icon = FindItemIcon(it->second, entityName);
if (!icon.empty()) {
return icon;
}
return root / "assets/ui" / (ContainsText(entityName, "Key") ? "key.png" : "item.png");
}
} // namespace
InteractableVisual MakeInteractableVisual(
const std::filesystem::path& root,
const std::string& entityName,
const EntityPreset& preset)
{
return {
preset.texturePath,
preset.spritePreset,
FallbackIconPath(root, entityName),
};
}
InteractableVisual ResolveInteractableVisual(const std::filesystem::path& root, const std::string& entityName)
{
try {
return MakeInteractableVisual(root, entityName, LoadEntityPreset(root, entityName));
} catch (const std::exception&) {
return {{}, {}, FallbackIconPath(root, entityName)};
}
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "EntityPreset.h"
#include <filesystem>
#include <string>
struct InteractableVisual {
std::filesystem::path texturePath;
std::string spritePreset;
std::filesystem::path fallbackIconPath;
};
InteractableVisual MakeInteractableVisual(
const std::filesystem::path& root,
const std::string& entityName,
const EntityPreset& preset);
InteractableVisual ResolveInteractableVisual(const std::filesystem::path& root, const std::string& entityName);
+196
View File
@@ -0,0 +1,196 @@
#include "InventoryUiModel.h"
#include <algorithm>
#include <cctype>
#include <utility>
namespace {
std::string LowerAlphaNum(const std::string& value)
{
std::string normalized;
normalized.reserve(value.size());
for (unsigned char ch : value) {
if (std::isalnum(ch)) {
normalized.push_back(static_cast<char>(std::tolower(ch)));
}
}
return normalized;
}
bool ContainsAny(const std::string& haystack, const std::initializer_list<const char*>& needles)
{
for (const char* needle : needles) {
if (haystack.find(needle) != std::string::npos) {
return true;
}
}
return false;
}
bool ContainsRawAny(const std::string& haystack, const std::initializer_list<const char*>& needles)
{
for (const char* needle : needles) {
if (haystack.find(needle) != std::string::npos) {
return true;
}
}
return false;
}
std::string PathKey(const std::filesystem::path& path)
{
std::string value = path.generic_string();
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return value;
}
} // namespace
InventoryItemCategory ClassifyInventoryItem(const std::string& itemName, const std::filesystem::path& iconPath)
{
if (const std::optional<TonoriItemCategory> category = TonoriCategoryForItem(itemName)) {
switch (*category) {
case TonoriItemCategory::PetEgg:
return InventoryItemCategory::PetEgg;
case TonoriItemCategory::Capture:
return InventoryItemCategory::Capture;
case TonoriItemCategory::Healing:
return InventoryItemCategory::Healing;
case TonoriItemCategory::SkillBook:
return InventoryItemCategory::SkillBook;
case TonoriItemCategory::Material:
case TonoriItemCategory::Other:
return InventoryItemCategory::Other;
}
}
const std::string key = LowerAlphaNum(itemName);
const std::string path = PathKey(iconPath);
if (ContainsRawAny(itemName, {"宠物蛋", ""}) || ContainsAny(key, {"egg", "petegg", "monsteregg"})) {
return InventoryItemCategory::PetEgg;
}
if (ContainsRawAny(itemName, {"捕捉"}) || ContainsAny(key, {"capture", "tuxeball", "ball", "capsule"})) {
return InventoryItemCategory::Capture;
}
if (ContainsRawAny(itemName, {"技能书"}) || ContainsAny(key, {"skillbook", "skillmanual", "manual", "book", "scroll", "technique"})) {
return InventoryItemCategory::SkillBook;
}
if (ContainsAny(path, {"/items/usable/"})) {
return InventoryItemCategory::Healing;
}
if (ContainsAny(key, {"potion", "heal", "revive", "apple", "water", "drink", "candy", "croissant", "remedy"})) {
return InventoryItemCategory::Healing;
}
return InventoryItemCategory::Other;
}
InventoryItemCategory ClassifyItemMetadata(const ItemCatalogEntry& metadata)
{
return ClassifyInventoryItem(metadata.name, metadata.iconPath);
}
bool InventoryTabAccepts(InventoryTab tab, InventoryItemCategory category)
{
switch (tab) {
case InventoryTab::All:
return true;
case InventoryTab::PetEgg:
return category == InventoryItemCategory::PetEgg;
case InventoryTab::Capture:
return category == InventoryItemCategory::Capture;
case InventoryTab::Healing:
return category == InventoryItemCategory::Healing;
case InventoryTab::SkillBook:
return category == InventoryItemCategory::SkillBook;
}
return true;
}
std::string InventoryTabName(InventoryTab tab)
{
switch (tab) {
case InventoryTab::All:
return "全部";
case InventoryTab::PetEgg:
return "宠物蛋";
case InventoryTab::Capture:
return "捕捉";
case InventoryTab::Healing:
return "恢复";
case InventoryTab::SkillBook:
return "技能书";
}
return "全部";
}
InventoryTab NextInventoryTab(InventoryTab tab)
{
const int value = (static_cast<int>(tab) + 1) % 5;
return static_cast<InventoryTab>(value);
}
InventoryTab PreviousInventoryTab(InventoryTab tab)
{
const int value = (static_cast<int>(tab) + 4) % 5;
return static_cast<InventoryTab>(value);
}
std::vector<InventorySlotView> BuildInventoryGrid(
const Inventory& inventory,
const ItemIconCatalog& catalog,
InventoryTab tab,
std::size_t capacity)
{
std::vector<InventorySlotView> slots(capacity);
std::size_t slot = 0;
for (const InventoryItem& item : inventory.items) {
if (slot >= capacity) {
break;
}
const ItemCatalogEntry* metadata = FindItemMetadata(catalog, item.name);
const std::filesystem::path icon = FindItemIcon(catalog, item.name);
const InventoryItemCategory category = metadata ? ClassifyItemMetadata(*metadata) : ClassifyInventoryItem(item.name, icon);
if (!InventoryTabAccepts(tab, category)) {
continue;
}
InventorySlotView view;
view.empty = false;
view.itemKey = item.name;
view.name = ItemDisplayName(metadata && !metadata->name.empty() ? metadata->name : item.name);
view.count = item.count;
view.category = category;
view.iconPath = icon;
if (metadata) {
view.description = ItemDisplayDescription(metadata->name, metadata->description);
view.slot = metadata->slot;
view.weight = metadata->weight;
view.stackable = metadata->stackable;
view.usable = metadata->usable;
} else {
view.description = ItemDisplayDescription(item.name);
view.usable = category == InventoryItemCategory::PetEgg
|| category == InventoryItemCategory::Healing
|| category == InventoryItemCategory::SkillBook;
}
slots[slot] = std::move(view);
++slot;
}
return slots;
}
int MoveInventorySelection(int selected, int dx, int dy, int columns, int slotCount)
{
if (slotCount <= 0 || columns <= 0) {
return 0;
}
const int clamped = std::clamp(selected, 0, slotCount - 1);
const int column = clamped % columns;
const int row = clamped / columns;
const int targetColumn = std::clamp(column + dx, 0, columns - 1);
const int maxRow = (slotCount - 1) / columns;
const int targetRow = std::clamp(row + dy, 0, maxRow);
const int target = targetRow * columns + targetColumn;
return target < slotCount ? target : clamped;
}
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include "GameCore.h"
#include "ItemIconCatalog.h"
#include <cstddef>
#include <filesystem>
#include <string>
#include <vector>
enum class InventoryItemCategory {
PetEgg,
Capture,
Healing,
SkillBook,
Other
};
enum class InventoryTab {
All,
PetEgg,
Capture,
Healing,
SkillBook
};
struct InventorySlotView {
bool empty = true;
std::string itemKey;
std::string name;
int count = 0;
InventoryItemCategory category = InventoryItemCategory::Other;
std::filesystem::path iconPath;
std::string description;
int slot = -1;
float weight = 0.0f;
bool stackable = false;
bool usable = false;
};
InventoryItemCategory ClassifyInventoryItem(const std::string& itemName, const std::filesystem::path& iconPath);
bool InventoryTabAccepts(InventoryTab tab, InventoryItemCategory category);
std::string InventoryTabName(InventoryTab tab);
InventoryTab NextInventoryTab(InventoryTab tab);
InventoryTab PreviousInventoryTab(InventoryTab tab);
std::vector<InventorySlotView> BuildInventoryGrid(
const Inventory& inventory,
const ItemIconCatalog& catalog,
InventoryTab tab,
std::size_t capacity);
int MoveInventorySelection(int selected, int dx, int dy, int columns, int slotCount);
+119
View File
@@ -0,0 +1,119 @@
#include "MapAtmosphere.h"
#include <algorithm>
#include <cctype>
#include <cstdlib>
namespace {
std::string Trim(std::string value)
{
auto first = std::find_if_not(value.begin(), value.end(), [](unsigned char ch) {
return std::isspace(ch);
});
auto last = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char ch) {
return std::isspace(ch);
}).base();
if (first >= last) {
return {};
}
return std::string(first, last);
}
std::string LowerCopy(std::string value)
{
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return value;
}
float Clamp01(float value)
{
return std::clamp(value, 0.0f, 1.0f);
}
float ParseIntensity(const TmxMap& map, float fallback)
{
const auto it = map.properties.find("ambientintensity");
if (it == map.properties.end()) {
return fallback;
}
const std::string text = Trim(it->second);
if (text.empty()) {
return fallback;
}
char* end = nullptr;
const float value = std::strtof(text.c_str(), &end);
if (end == text.c_str()) {
return fallback;
}
return Clamp01(value);
}
MapAmbientKind ParseKind(const std::string& ambientName)
{
const std::string lower = LowerCopy(ambientName);
if (lower == "lighting") {
return MapAmbientKind::Lighting;
}
if (lower == "cloud") {
return MapAmbientKind::Cloud;
}
if (lower == "cloudlight") {
return MapAmbientKind::CloudLight;
}
if (lower == "neutral") {
return MapAmbientKind::Neutral;
}
return MapAmbientKind::Unknown;
}
float LightingOverlayAlpha(float intensity)
{
return std::clamp(0.10f + Clamp01(intensity) * 0.40f, 0.10f, 0.50f);
}
} // namespace
MapAtmosphere ResolveMapAtmosphere(const TmxMap& map)
{
const auto ambientIt = map.properties.find("ambient");
if (ambientIt == map.properties.end()) {
return {};
}
const std::string ambientName = Trim(ambientIt->second);
if (ambientName.empty()) {
return {};
}
MapAtmosphere atmosphere;
atmosphere.ambientName = ambientName;
atmosphere.kind = ParseKind(ambientName);
switch (atmosphere.kind) {
case MapAmbientKind::Lighting:
atmosphere.intensity = ParseIntensity(map, 0.5f);
atmosphere.overlayTint = MapAtmosphereColor{0.10f, 0.08f, 0.15f, LightingOverlayAlpha(atmosphere.intensity)};
atmosphere.darkens = true;
break;
case MapAmbientKind::Cloud:
atmosphere.intensity = ParseIntensity(map, 0.0f);
atmosphere.overlayTint = MapAtmosphereColor{1.0f, 1.0f, 1.0f, 0.03f};
break;
case MapAmbientKind::CloudLight:
atmosphere.intensity = ParseIntensity(map, 0.0f);
atmosphere.overlayTint = MapAtmosphereColor{1.0f, 0.98f, 0.90f, 0.04f};
break;
case MapAmbientKind::Neutral:
return {};
case MapAmbientKind::Unknown:
atmosphere.intensity = ParseIntensity(map, 0.0f);
break;
}
return atmosphere;
}
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include "TmxMap.h"
#include <string>
enum class MapAmbientKind {
Neutral,
Lighting,
Cloud,
CloudLight,
Unknown,
};
struct MapAtmosphereColor {
float r = 1.0f;
float g = 1.0f;
float b = 1.0f;
float a = 0.0f;
};
struct MapAtmosphere {
std::string ambientName = "Neutral";
MapAmbientKind kind = MapAmbientKind::Neutral;
float intensity = 0.0f;
MapAtmosphereColor overlayTint;
bool darkens = false;
};
MapAtmosphere ResolveMapAtmosphere(const TmxMap& map);
+473
View File
@@ -0,0 +1,473 @@
#include "TmxMap.h"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <pugixml.hpp>
#include <sstream>
#include <stdexcept>
namespace {
constexpr std::uint32_t FlippedHorizontally = 0x80000000u;
constexpr std::uint32_t FlippedVertically = 0x40000000u;
constexpr std::uint32_t FlippedDiagonally = 0x20000000u;
constexpr std::uint32_t FlippedHex120 = 0x10000000u;
std::string Trim(std::string value)
{
auto first = std::find_if_not(value.begin(), value.end(), [](unsigned char ch) { return std::isspace(ch); });
auto last = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char ch) { return std::isspace(ch); }).base();
if (first >= last) {
return {};
}
return std::string(first, last);
}
std::string LowerCopy(std::string value)
{
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return value;
}
std::filesystem::path NormalizePath(const std::filesystem::path& base, const char* relative)
{
return std::filesystem::weakly_canonical(base / relative);
}
std::map<std::string, std::string> ReadProperties(const pugi::xml_node& node)
{
std::map<std::string, std::string> result;
for (pugi::xml_node property : node.child("properties").children("property")) {
const std::string name = property.attribute("name").as_string();
const char* valueAttr = property.attribute("value").as_string(nullptr);
if (valueAttr) {
result[name] = valueAttr;
} else {
result[name] = property.text().as_string();
}
}
return result;
}
std::vector<TmxPoint> ParsePoints(const std::string& points);
std::vector<TmxCollisionShape> ParseCollisionShapes(const pugi::xml_node& tile)
{
std::vector<TmxCollisionShape> shapes;
for (pugi::xml_node object : tile.child("objectgroup").children("object")) {
TmxCollisionShape shape;
shape.x = object.attribute("x").as_float();
shape.y = object.attribute("y").as_float();
shape.width = object.attribute("width").as_float();
shape.height = object.attribute("height").as_float();
if (pugi::xml_node polygon = object.child("polygon")) {
shape.polygon = ParsePoints(polygon.attribute("points").as_string());
} else if (pugi::xml_node polyline = object.child("polyline")) {
shape.polygon = ParsePoints(polyline.attribute("points").as_string());
}
if (!shape.polygon.empty() || shape.width > 0.0f || shape.height > 0.0f) {
shapes.push_back(std::move(shape));
}
}
return shapes;
}
TmxTileset ParseTilesetNode(const pugi::xml_node& node, std::uint32_t firstGid, const std::filesystem::path& baseDir)
{
TmxTileset tileset;
tileset.firstGid = firstGid;
tileset.name = node.attribute("name").as_string();
tileset.tileWidth = node.attribute("tilewidth").as_int();
tileset.tileHeight = node.attribute("tileheight").as_int();
tileset.tileCount = node.attribute("tilecount").as_int();
tileset.columns = node.attribute("columns").as_int();
pugi::xml_node image = node.child("image");
if (!image) {
throw std::runtime_error("Tileset has no image: " + tileset.name);
}
tileset.imagePath = NormalizePath(baseDir, image.attribute("source").as_string());
for (pugi::xml_node tile : node.children("tile")) {
const auto tileId = static_cast<std::uint32_t>(tile.attribute("id").as_uint());
std::vector<TmxCollisionShape> collisionShapes = ParseCollisionShapes(tile);
if (!collisionShapes.empty()) {
tileset.collisionShapes[tileId] = std::move(collisionShapes);
}
pugi::xml_node animation = tile.child("animation");
if (!animation) {
continue;
}
std::vector<TmxAnimationFrame> frames;
for (pugi::xml_node frame : animation.children("frame")) {
frames.push_back(TmxAnimationFrame{
static_cast<std::uint32_t>(frame.attribute("tileid").as_uint()),
frame.attribute("duration").as_int(),
});
}
if (!frames.empty()) {
tileset.animations[tileId] = std::move(frames);
}
}
return tileset;
}
TmxTileset ParseTileset(const pugi::xml_node& node, const std::filesystem::path& mapDir)
{
const auto firstGid = static_cast<std::uint32_t>(node.attribute("firstgid").as_uint());
const char* source = node.attribute("source").as_string(nullptr);
if (!source) {
return ParseTilesetNode(node, firstGid, mapDir);
}
const std::filesystem::path tsxPath = NormalizePath(mapDir, source);
pugi::xml_document doc;
pugi::xml_parse_result ok = doc.load_file(tsxPath.c_str());
if (!ok) {
throw std::runtime_error("Could not parse TSX: " + tsxPath.string());
}
return ParseTilesetNode(doc.child("tileset"), firstGid, tsxPath.parent_path());
}
std::vector<std::uint32_t> ParseCsvGids(const std::string& csv)
{
std::vector<std::uint32_t> gids;
std::stringstream stream(csv);
std::string item;
while (std::getline(stream, item, ',')) {
item = Trim(item);
if (!item.empty()) {
gids.push_back(static_cast<std::uint32_t>(std::stoul(item)));
}
}
return gids;
}
std::vector<TmxPoint> ParsePoints(const std::string& points)
{
std::vector<TmxPoint> out;
std::stringstream stream(points);
std::string pair;
while (stream >> pair) {
const std::size_t comma = pair.find(',');
if (comma == std::string::npos) {
continue;
}
TmxPoint point;
point.x = std::stof(pair.substr(0, comma));
point.y = std::stof(pair.substr(comma + 1));
out.push_back(point);
}
return out;
}
} // namespace
namespace {
std::map<std::string, TmxMap>& TmxMapCache()
{
static std::map<std::string, TmxMap> cache;
return cache;
}
} // namespace
std::uint32_t CleanGid(std::uint32_t gid)
{
return gid & ~(FlippedHorizontally | FlippedVertically | FlippedDiagonally | FlippedHex120);
}
TmxMap LoadTmxMap(const std::filesystem::path& path)
{
pugi::xml_document doc;
pugi::xml_parse_result ok = doc.load_file(path.c_str());
if (!ok) {
throw std::runtime_error("Could not parse TMX: " + path.string());
}
pugi::xml_node root = doc.child("map");
if (!root) {
throw std::runtime_error("TMX file has no map root: " + path.string());
}
TmxMap map;
map.sourcePath = std::filesystem::weakly_canonical(path);
map.width = root.attribute("width").as_int();
map.height = root.attribute("height").as_int();
map.tileWidth = root.attribute("tilewidth").as_int();
map.tileHeight = root.attribute("tileheight").as_int();
map.properties = ReadProperties(root);
const std::filesystem::path mapDir = map.sourcePath.parent_path();
for (pugi::xml_node child : root.children()) {
const std::string nodeName = child.name();
if (nodeName == "tileset") {
map.tilesets.push_back(ParseTileset(child, mapDir));
} else if (nodeName == "layer") {
pugi::xml_node data = child.child("data");
const std::string encoding = data.attribute("encoding").as_string();
if (encoding != "csv") {
continue;
}
TmxLayer layer;
layer.name = child.attribute("name").as_string();
layer.width = child.attribute("width").as_int();
layer.height = child.attribute("height").as_int();
layer.visible = child.attribute("visible").as_int(1) != 0;
layer.opacity = child.attribute("opacity").as_float(1.0f);
layer.gids = ParseCsvGids(data.text().as_string());
map.layers.push_back(std::move(layer));
} else if (nodeName == "objectgroup") {
for (pugi::xml_node object : child.children("object")) {
TmxObject out;
out.id = object.attribute("id").as_int();
out.name = object.attribute("name").as_string();
out.type = object.attribute("type").as_string();
out.x = object.attribute("x").as_float();
out.y = object.attribute("y").as_float();
out.width = object.attribute("width").as_float();
out.height = object.attribute("height").as_float();
if (pugi::xml_node polygon = object.child("polygon")) {
out.polygon = ParsePoints(polygon.attribute("points").as_string());
} else if (pugi::xml_node polyline = object.child("polyline")) {
out.polygon = ParsePoints(polyline.attribute("points").as_string());
}
out.properties = ReadProperties(object);
map.objects.push_back(std::move(out));
}
}
}
std::sort(map.tilesets.begin(), map.tilesets.end(), [](const TmxTileset& a, const TmxTileset& b) {
return a.firstGid < b.firstGid;
});
return map;
}
const TmxMap& LoadTmxMapCached(const std::filesystem::path& path)
{
const std::filesystem::path canonical = std::filesystem::weakly_canonical(path);
const std::string key = canonical.string();
std::map<std::string, TmxMap>& cache = TmxMapCache();
auto [it, inserted] = cache.try_emplace(key);
if (inserted) {
it->second = LoadTmxMap(canonical);
}
return it->second;
}
void ClearTmxMapCache()
{
TmxMapCache().clear();
}
const TmxLayer* FindLayer(const TmxMap& map, const std::string& name)
{
auto it = std::find_if(map.layers.begin(), map.layers.end(), [&](const TmxLayer& layer) {
return layer.name == name;
});
return it == map.layers.end() ? nullptr : &*it;
}
const TmxObject* FindObject(const TmxMap& map, const std::string& name)
{
auto it = std::find_if(map.objects.begin(), map.objects.end(), [&](const TmxObject& object) {
return object.name == name;
});
return it == map.objects.end() ? nullptr : &*it;
}
const TmxTileset* FindTilesetForGid(const TmxMap& map, std::uint32_t gid)
{
gid = CleanGid(gid);
const TmxTileset* found = nullptr;
for (const TmxTileset& tileset : map.tilesets) {
if (gid >= tileset.firstGid) {
found = &tileset;
} else {
break;
}
}
return found;
}
namespace {
std::uint32_t LayerGidAt(const TmxLayer& layer, int x, int y)
{
if (x < 0 || y < 0 || x >= layer.width || y >= layer.height) {
return 0;
}
const std::size_t index = static_cast<std::size_t>(y * layer.width + x);
return index < layer.gids.size() ? CleanGid(layer.gids[index]) : 0;
}
bool PointInRect(float x, float y, const TmxCollisionShape& shape)
{
return x >= shape.x
&& y >= shape.y
&& x < shape.x + shape.width
&& y < shape.y + shape.height;
}
bool PointOnSegment(float px, float py, TmxPoint a, TmxPoint b)
{
const float cross = (px - a.x) * (b.y - a.y) - (py - a.y) * (b.x - a.x);
if (std::abs(cross) > 0.001f) {
return false;
}
return px >= std::min(a.x, b.x) - 0.001f
&& px <= std::max(a.x, b.x) + 0.001f
&& py >= std::min(a.y, b.y) - 0.001f
&& py <= std::max(a.y, b.y) + 0.001f;
}
bool PointInPolygon(float x, float y, const TmxCollisionShape& shape)
{
if (shape.polygon.size() < 3) {
return false;
}
std::vector<TmxPoint> points;
points.reserve(shape.polygon.size());
for (const TmxPoint& point : shape.polygon) {
points.push_back({shape.x + point.x, shape.y + point.y});
}
bool inside = false;
for (std::size_t i = 0, j = points.size() - 1; i < points.size(); j = i++) {
const TmxPoint a = points[i];
const TmxPoint b = points[j];
if (PointOnSegment(x, y, a, b)) {
return true;
}
const bool crosses = ((a.y > y) != (b.y > y))
&& (x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x);
if (crosses) {
inside = !inside;
}
}
return inside;
}
bool PointInCollisionShape(float x, float y, const TmxCollisionShape& shape)
{
if (!shape.polygon.empty()) {
return PointInPolygon(x, y, shape);
}
return PointInRect(x, y, shape);
}
} // namespace
bool IsPointBlockedByCollision(const TmxMap& map, float worldX, float worldY)
{
if (worldX < 0.0f || worldY < 0.0f
|| worldX >= static_cast<float>(map.width * map.tileWidth)
|| worldY >= static_cast<float>(map.height * map.tileHeight)) {
return true;
}
if (map.tileWidth <= 0 || map.tileHeight <= 0) {
return false;
}
const int tileX = static_cast<int>(std::floor(worldX / static_cast<float>(map.tileWidth)));
const int tileY = static_cast<int>(std::floor(worldY / static_cast<float>(map.tileHeight)));
for (const TmxLayer& layer : map.layers) {
if (!layer.visible || !IsTmxCollisionLayer(layer.name)) {
continue;
}
const std::uint32_t gid = LayerGidAt(layer, tileX, tileY);
if (gid == 0) {
continue;
}
const TmxTileset* tileset = FindTilesetForGid(map, gid);
if (!tileset) {
continue;
}
const std::uint32_t localTileId = gid - tileset->firstGid;
const auto shapes = tileset->collisionShapes.find(localTileId);
if (shapes == tileset->collisionShapes.end() || shapes->second.empty()) {
return true;
}
const float localX = (worldX - static_cast<float>(tileX * map.tileWidth))
* static_cast<float>(std::max(1, tileset->tileWidth)) / static_cast<float>(map.tileWidth);
const float localY = (worldY - static_cast<float>(tileY * map.tileHeight))
* static_cast<float>(std::max(1, tileset->tileHeight)) / static_cast<float>(map.tileHeight);
for (const TmxCollisionShape& shape : shapes->second) {
if (PointInCollisionShape(localX, localY, shape)) {
return true;
}
}
}
return false;
}
bool IsTmxCollisionLayer(const std::string& layerName)
{
return LowerCopy(layerName).find("collision") != std::string::npos;
}
bool IsTmxOverlayLayer(const std::string& layerName)
{
const std::string lower = LowerCopy(layerName);
return lower.find("fringe") != std::string::npos || lower.find("over") != std::string::npos;
}
std::uint32_t AnimatedLocalTileId(const TmxTileset& tileset, std::uint32_t localTileId, int elapsedMs)
{
const auto it = tileset.animations.find(localTileId);
if (it == tileset.animations.end() || it->second.empty()) {
return localTileId;
}
int totalDuration = 0;
for (const TmxAnimationFrame& frame : it->second) {
totalDuration += std::max(0, frame.durationMs);
}
if (totalDuration <= 0) {
return localTileId;
}
int cursor = std::max(0, elapsedMs) % totalDuration;
for (const TmxAnimationFrame& frame : it->second) {
const int duration = std::max(0, frame.durationMs);
if (cursor < duration) {
return frame.tileId;
}
cursor -= duration;
}
return it->second.back().tileId;
}
TileBounds VisibleTileBounds(
const TmxMap& map,
float cameraTargetX,
float cameraTargetY,
float screenWidth,
float screenHeight,
float zoom,
int paddingTiles)
{
const float safeZoom = std::max(0.1f, zoom);
const float halfWorldWidth = screenWidth / safeZoom * 0.5f;
const float halfWorldHeight = screenHeight / safeZoom * 0.5f;
const float minWorldX = cameraTargetX - halfWorldWidth;
const float maxWorldX = cameraTargetX + halfWorldWidth;
const float minWorldY = cameraTargetY - halfWorldHeight;
const float maxWorldY = cameraTargetY + halfWorldHeight;
TileBounds bounds;
bounds.minX = std::clamp(static_cast<int>(std::floor(minWorldX / static_cast<float>(map.tileWidth))) - paddingTiles, 0, std::max(0, map.width - 1));
bounds.maxX = std::clamp(static_cast<int>(std::ceil(maxWorldX / static_cast<float>(map.tileWidth))) + paddingTiles, 0, std::max(0, map.width - 1));
bounds.minY = std::clamp(static_cast<int>(std::floor(minWorldY / static_cast<float>(map.tileHeight))) - paddingTiles, 0, std::max(0, map.height - 1));
bounds.maxY = std::clamp(static_cast<int>(std::ceil(maxWorldY / static_cast<float>(map.tileHeight))) + paddingTiles, 0, std::max(0, map.height - 1));
return bounds;
}
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include <cstdint>
#include <filesystem>
#include <map>
#include <string>
#include <vector>
struct TmxAnimationFrame {
std::uint32_t tileId = 0;
int durationMs = 0;
};
struct TmxPoint {
float x = 0.0f;
float y = 0.0f;
};
struct TmxCollisionShape {
float x = 0.0f;
float y = 0.0f;
float width = 0.0f;
float height = 0.0f;
std::vector<TmxPoint> polygon;
};
struct TmxTileset {
std::uint32_t firstGid = 0;
std::string name;
int tileWidth = 0;
int tileHeight = 0;
int tileCount = 0;
int columns = 0;
std::filesystem::path imagePath;
std::map<std::uint32_t, std::vector<TmxAnimationFrame>> animations;
std::map<std::uint32_t, std::vector<TmxCollisionShape>> collisionShapes;
};
struct TmxLayer {
std::string name;
int width = 0;
int height = 0;
bool visible = true;
float opacity = 1.0f;
std::vector<std::uint32_t> gids;
};
struct TmxObject {
int id = 0;
std::string name;
std::string type;
float x = 0.0f;
float y = 0.0f;
float width = 0.0f;
float height = 0.0f;
std::vector<TmxPoint> polygon;
std::map<std::string, std::string> properties;
};
struct TmxMap {
int width = 0;
int height = 0;
int tileWidth = 0;
int tileHeight = 0;
std::filesystem::path sourcePath;
std::map<std::string, std::string> properties;
std::vector<TmxTileset> tilesets;
std::vector<TmxLayer> layers;
std::vector<TmxObject> objects;
};
struct TileBounds {
int minX = 0;
int maxX = 0;
int minY = 0;
int maxY = 0;
};
TmxMap LoadTmxMap(const std::filesystem::path& path);
const TmxMap& LoadTmxMapCached(const std::filesystem::path& path);
void ClearTmxMapCache();
const TmxLayer* FindLayer(const TmxMap& map, const std::string& name);
const TmxObject* FindObject(const TmxMap& map, const std::string& name);
const TmxTileset* FindTilesetForGid(const TmxMap& map, std::uint32_t gid);
std::uint32_t CleanGid(std::uint32_t gid);
bool IsTmxCollisionLayer(const std::string& layerName);
bool IsTmxOverlayLayer(const std::string& layerName);
bool IsPointBlockedByCollision(const TmxMap& map, float worldX, float worldY);
std::uint32_t AnimatedLocalTileId(const TmxTileset& tileset, std::uint32_t localTileId, int elapsedMs);
TileBounds VisibleTileBounds(
const TmxMap& map,
float cameraTargetX,
float cameraTargetY,
float screenWidth,
float screenHeight,
float zoom,
int paddingTiles);
+105
View File
@@ -0,0 +1,105 @@
#include "TmxMapPool.h"
#include <set>
#include <vector>
namespace {
std::vector<std::string> PoolTargets(const TmxWorldIndex& index, const std::string& currentMapName, int maxSize)
{
std::vector<std::string> targets;
if (maxSize <= 0 || !index.mapsByName.contains(currentMapName)) {
return targets;
}
targets.push_back(currentMapName);
for (const std::string& adjacent : AdjacentMaps(index, currentMapName)) {
if (static_cast<int>(targets.size()) >= maxSize) {
break;
}
if (adjacent != currentMapName) {
targets.push_back(adjacent);
}
}
return targets;
}
std::vector<std::string> PoolTargets(
const TmxWorldIndex& index,
const std::vector<WarpEdge>& warpGraph,
const std::string& currentMapName,
int maxSize)
{
std::vector<std::string> targets;
if (maxSize <= 0 || !index.mapsByName.contains(currentMapName)) {
return targets;
}
targets.push_back(currentMapName);
for (const std::string& adjacent : AdjacentMaps(index, warpGraph, currentMapName)) {
if (static_cast<int>(targets.size()) >= maxSize) {
break;
}
if (adjacent != currentMapName) {
targets.push_back(adjacent);
}
}
return targets;
}
void RefreshTmxMapPoolTargets(
TmxMapPool& pool,
const TmxWorldIndex& index,
const std::vector<std::string>& targets)
{
const std::set<std::string> keep(targets.begin(), targets.end());
for (auto it = pool.maps.begin(); it != pool.maps.end();) {
if (!keep.contains(it->first)) {
it = pool.maps.erase(it);
} else {
++it;
}
}
for (const std::string& mapName : targets) {
if (!pool.maps.contains(mapName)) {
pool.maps[mapName] = LoadNamedMap(index, mapName);
}
}
}
} // namespace
void RefreshTmxMapPool(TmxMapPool& pool, const TmxWorldIndex& index, const std::string& currentMapName, int maxSize)
{
const std::vector<std::string> targets = PoolTargets(index, currentMapName, maxSize);
RefreshTmxMapPoolTargets(pool, index, targets);
}
void RefreshTmxMapPool(
TmxMapPool& pool,
const TmxWorldIndex& index,
const std::vector<WarpEdge>& warpGraph,
const std::string& currentMapName,
int maxSize)
{
const std::vector<std::string> targets = PoolTargets(index, warpGraph, currentMapName, maxSize);
RefreshTmxMapPoolTargets(pool, index, targets);
}
void RefreshTmxMapPool(
TmxMapPool& pool,
const TmxWorldIndex& index,
const std::string& currentMapName,
int maxSize,
const std::vector<WarpEdge>& warpGraph)
{
RefreshTmxMapPool(pool, index, warpGraph, currentMapName, maxSize);
}
const TmxMap* FindPooledMap(const TmxMapPool& pool, const std::string& mapName)
{
const auto it = pool.maps.find(mapName);
return it == pool.maps.end() ? nullptr : &it->second;
}
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include "TmxMap.h"
#include "TmxWorld.h"
#include <map>
#include <string>
#include <vector>
struct TmxMapPool {
std::map<std::string, TmxMap> maps;
};
void RefreshTmxMapPool(TmxMapPool& pool, const TmxWorldIndex& index, const std::string& currentMapName, int maxSize);
void RefreshTmxMapPool(
TmxMapPool& pool,
const TmxWorldIndex& index,
const std::vector<WarpEdge>& warpGraph,
const std::string& currentMapName,
int maxSize);
void RefreshTmxMapPool(
TmxMapPool& pool,
const TmxWorldIndex& index,
const std::string& currentMapName,
int maxSize,
const std::vector<WarpEdge>& warpGraph);
const TmxMap* FindPooledMap(const TmxMapPool& pool, const std::string& mapName);
+442
View File
@@ -0,0 +1,442 @@
#include "TmxWorld.h"
#include <algorithm>
#include <cctype>
#include <queue>
#include <set>
#include <stdexcept>
namespace {
std::string PropertyOr(const TmxObject& object, const std::string& key, const std::string& fallback = {})
{
const auto it = object.properties.find(key);
return it == object.properties.end() ? fallback : it->second;
}
int PropertyIntOr(const TmxObject& object, const std::string& key, int fallback = 0)
{
const std::string value = PropertyOr(object, key);
return value.empty() ? fallback : std::stoi(value);
}
float PropertyFloatOr(const TmxObject& object, const std::string& key, float fallback)
{
const std::string value = PropertyOr(object, key);
return value.empty() ? fallback : std::stof(value);
}
std::string LowerCopy(std::string value)
{
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return value;
}
bool PropertyBoolOr(const TmxObject& object, const std::string& key, bool fallback)
{
const std::string value = LowerCopy(PropertyOr(object, key));
if (value == "true" || value == "1") {
return true;
}
if (value == "false" || value == "0") {
return false;
}
return fallback;
}
bool ContainsText(const std::string& text, const std::string& needle)
{
return text.find(needle) != std::string::npos;
}
bool LooksLikeIndoorMapName(const std::string& mapName)
{
if (mapName == "Indoor") {
return true;
}
return !mapName.empty() && std::all_of(mapName.begin(), mapName.end(), [](unsigned char ch) {
return std::isdigit(ch) || ch == '-';
});
}
} // namespace
std::string MapDisplayName(const TmxMap& map)
{
const auto it = map.properties.find("name");
if (it != map.properties.end() && !it->second.empty()) {
return it->second;
}
return map.sourcePath.stem().string();
}
TmxWorldIndex BuildTmxWorldIndex(const std::filesystem::path& mapsRoot)
{
TmxWorldIndex index;
for (const std::filesystem::directory_entry& entry : std::filesystem::recursive_directory_iterator(mapsRoot)) {
if (!entry.is_regular_file() || entry.path().extension() != ".tmx") {
continue;
}
const TmxMap& map = LoadTmxMapCached(entry.path());
index.mapsByName[MapDisplayName(map)] = std::filesystem::weakly_canonical(entry.path());
}
return index;
}
TmxMap LoadNamedMap(const TmxWorldIndex& index, const std::string& mapName)
{
const auto it = index.mapsByName.find(mapName);
if (it == index.mapsByName.end()) {
throw std::runtime_error("Unknown TMX map: " + mapName);
}
return LoadTmxMapCached(it->second);
}
bool IsSpawnObject(const TmxObject& object)
{
return LowerCopy(object.type) == "spawn";
}
bool IsWarpObject(const TmxObject& object)
{
const std::string type = LowerCopy(object.type);
return type == "warp" || type == "port";
}
bool IsCollectibleSpawnObject(const TmxObject& object)
{
if (!IsSpawnObject(object)) {
return false;
}
const std::string script = PropertyOr(object, "player_script") + " " + PropertyOr(object, "own_script");
return ContainsText(object.name, "Chest")
|| ContainsText(object.name, "Key")
|| ContainsText(object.name, "Letter")
|| ContainsText(object.name, "Stash")
|| ContainsText(object.name, "Clue")
|| ContainsText(object.name, "Pond")
|| ContainsText(object.name, "Well")
|| ContainsText(script, "Chest")
|| ContainsText(script, "MineKey");
}
std::string SpawnDisplayName(const TmxObject& object)
{
return PropertyOr(object, "nick", object.name);
}
std::string SpawnStateName(const TmxObject& object)
{
std::string state = LowerCopy(PropertyOr(object, "state"));
return state == "walk" || state == "sit" || state == "attack" ? state : "idle";
}
TmxObjectPosition SpawnObjectPosition(const TmxObject& object)
{
return {
object.x + std::max(0.0f, object.width) * 0.5f,
object.y + std::max(0.0f, object.height) * 0.5f,
};
}
bool WarpAutoWarps(const TmxObject& warp)
{
return PropertyBoolOr(warp, "auto_warp", true);
}
float TriggerRadiusForObject(const TmxObject& object, float fallback)
{
return PropertyFloatOr(object, "trigger_radius", fallback);
}
TmxObjectBounds WarpTriggerBounds(const TmxObject& warp)
{
TmxObjectBounds bounds{warp.x, warp.y, warp.width, warp.height};
const int triggerTilesX = PropertyIntOr(warp, "trigger_x", 0);
const int triggerTilesY = PropertyIntOr(warp, "trigger_y", 0);
if (triggerTilesX <= 0 || triggerTilesY <= 0) {
return bounds;
}
const float tileWidth = warp.width > 0.0f ? warp.width : 32.0f;
const float tileHeight = warp.height > 0.0f ? warp.height : 32.0f;
bounds.width = static_cast<float>(triggerTilesX) * tileWidth;
bounds.height = static_cast<float>(triggerTilesY) * tileHeight;
bounds.x = warp.x + (warp.width - bounds.width) * 0.5f;
bounds.y = warp.y + (warp.height - bounds.height) * 0.5f;
return bounds;
}
WarpTarget ReadWarpTarget(const TmxObject& warp)
{
WarpTarget target;
target.mapName = PropertyOr(warp, "dest_map");
if (target.mapName.empty()) {
target.mapName = PropertyOr(warp, "map");
}
if (LowerCopy(warp.type) == "port" && !PropertyOr(warp, "sail_pos_x").empty() && !PropertyOr(warp, "sail_pos_y").empty()) {
target.tileX = PropertyIntOr(warp, "sail_pos_x");
target.tileY = PropertyIntOr(warp, "sail_pos_y");
} else {
target.tileX = PropertyIntOr(warp, "dest_pos_x", PropertyIntOr(warp, "dest_x"));
target.tileY = PropertyIntOr(warp, "dest_pos_y", PropertyIntOr(warp, "dest_y"));
}
target.originalMapName = target.mapName;
return target;
}
bool WarpHasExplicitTileDestination(const TmxObject& warp)
{
if (LowerCopy(warp.type) == "port"
&& !PropertyOr(warp, "sail_pos_x").empty()
&& !PropertyOr(warp, "sail_pos_y").empty()) {
return true;
}
return (!PropertyOr(warp, "dest_pos_x").empty() && !PropertyOr(warp, "dest_pos_y").empty())
|| (!PropertyOr(warp, "dest_x").empty() && !PropertyOr(warp, "dest_y").empty());
}
bool WarpTargetResolved(const TmxWorldIndex& index, const TmxObject& warp)
{
const WarpTarget target = ReadWarpTarget(warp);
if (target.mapName == "Indoor") {
return index.mapsByName.contains(IndoorAliasMapName());
}
return !target.mapName.empty() && index.mapsByName.contains(target.mapName);
}
bool PointLooksLikeTonoriBuildingEntrance(const TmxMap& map, float x, float y)
{
if (map.tileWidth <= 0 || map.tileHeight <= 0) {
return false;
}
const int tileX = static_cast<int>(x / static_cast<float>(map.tileWidth));
const int tileY = static_cast<int>(y / static_cast<float>(map.tileHeight));
if (tileX < 0 || tileY < 0 || tileX >= map.width || tileY >= map.height) {
return false;
}
std::vector<std::uint32_t> houseFirstGids;
for (const TmxTileset& tileset : map.tilesets) {
if (tileset.name == "House") {
houseFirstGids.push_back(tileset.firstGid);
}
}
if (houseFirstGids.empty()) {
return false;
}
const auto isHouseEntranceGid = [&](std::uint32_t rawGid) {
const std::uint32_t gid = CleanGid(rawGid);
if (gid == 0) {
return false;
}
for (const std::uint32_t firstGid : houseFirstGids) {
if (gid < firstGid) {
continue;
}
const std::uint32_t localId = gid - firstGid;
// House.tsx rows used by Tonori exterior door thresholds and ladder entries.
if (localId == 48 || localId == 49 || localId == 50 || localId == 51 || localId == 112) {
return true;
}
}
return false;
};
for (const TmxLayer& layer : map.layers) {
if (!layer.visible || IsTmxCollisionLayer(layer.name)) {
continue;
}
if (tileX >= layer.width || tileY >= layer.height) {
continue;
}
const std::size_t index = static_cast<std::size_t>(tileY * layer.width + tileX);
if (index < layer.gids.size() && isHouseEntranceGid(layer.gids[index])) {
return true;
}
}
return false;
}
std::string IndoorAliasMapName()
{
return "Tulimshar West Chamber";
}
std::vector<MapSummary> ListMapsSorted(const TmxWorldIndex& index)
{
std::vector<MapSummary> summaries;
summaries.reserve(index.mapsByName.size());
for (const auto& [name, path] : index.mapsByName) {
const TmxMap& map = LoadTmxMapCached(path);
MapSummary summary;
summary.name = name;
summary.path = path;
summary.widthPixels = map.width * map.tileWidth;
summary.heightPixels = map.height * map.tileHeight;
for (const TmxObject& object : map.objects) {
if (IsSpawnObject(object)) {
++summary.spawnCount;
} else if (IsWarpObject(object)) {
++summary.warpCount;
}
}
summaries.push_back(std::move(summary));
}
std::sort(summaries.begin(), summaries.end(), [](const MapSummary& a, const MapSummary& b) {
return a.name < b.name;
});
return summaries;
}
std::vector<WarpEdge> BuildWarpGraph(const TmxWorldIndex& index)
{
std::vector<WarpEdge> edges;
for (const auto& [name, path] : index.mapsByName) {
const TmxMap& map = LoadTmxMapCached(path);
for (const TmxObject& object : map.objects) {
if (!IsWarpObject(object)) {
continue;
}
const WarpTarget target = ReadWarpTarget(object);
if (target.mapName.empty()) {
continue;
}
WarpEdge edge;
edge.fromMap = name;
edge.toMap = target.mapName;
edge.objectName = object.name;
edge.tileX = target.tileX;
edge.tileY = target.tileY;
edge.resolved = WarpTargetResolved(index, object);
edges.push_back(std::move(edge));
}
}
std::sort(edges.begin(), edges.end(), [](const WarpEdge& a, const WarpEdge& b) {
if (a.fromMap != b.fromMap) {
return a.fromMap < b.fromMap;
}
if (a.toMap != b.toMap) {
return a.toMap < b.toMap;
}
return a.objectName < b.objectName;
});
return edges;
}
std::vector<WarpEdge> MissingWarpEdges(const TmxWorldIndex& index)
{
return MissingWarpEdges(BuildWarpGraph(index));
}
std::vector<WarpEdge> MissingWarpEdges(const std::vector<WarpEdge>& graph)
{
std::vector<WarpEdge> missing;
for (const WarpEdge& edge : graph) {
if (!edge.resolved) {
missing.push_back(edge);
}
}
return missing;
}
std::vector<std::string> ReachableMaps(const TmxWorldIndex& index, const std::vector<WarpEdge>& graph, const std::string& startMapName)
{
std::map<std::string, std::vector<std::string>> adjacency;
for (const WarpEdge& edge : graph) {
if (edge.resolved && index.mapsByName.contains(edge.toMap)) {
adjacency[edge.fromMap].push_back(edge.toMap);
}
}
std::vector<std::string> reachable;
if (!index.mapsByName.contains(startMapName)) {
return reachable;
}
std::set<std::string> seen;
std::queue<std::string> pending;
seen.insert(startMapName);
pending.push(startMapName);
while (!pending.empty()) {
const std::string current = pending.front();
pending.pop();
reachable.push_back(current);
for (const std::string& next : adjacency[current]) {
if (seen.insert(next).second) {
pending.push(next);
}
}
}
std::sort(reachable.begin(), reachable.end());
return reachable;
}
std::vector<std::string> ReachableMaps(const TmxWorldIndex& index, const std::string& startMapName)
{
return ReachableMaps(index, BuildWarpGraph(index), startMapName);
}
std::vector<std::string> AdjacentMaps(const TmxWorldIndex& index, const std::vector<WarpEdge>& graph, const std::string& mapName)
{
std::set<std::string> adjacent;
for (const WarpEdge& edge : graph) {
if (!edge.resolved) {
continue;
}
if (edge.fromMap == mapName) {
if (index.mapsByName.contains(edge.toMap)) {
adjacent.insert(edge.toMap);
}
}
}
return {adjacent.begin(), adjacent.end()};
}
std::vector<std::string> AdjacentMaps(const TmxWorldIndex& index, const std::string& mapName, const std::vector<WarpEdge>& graph)
{
return AdjacentMaps(index, graph, mapName);
}
std::vector<std::string> AdjacentMaps(const TmxWorldIndex& index, const std::string& mapName)
{
return AdjacentMaps(index, BuildWarpGraph(index), mapName);
}
WorldAudit BuildWorldAudit(const TmxWorldIndex& index)
{
WorldAudit audit;
audit.mapCount = static_cast<int>(index.mapsByName.size());
for (const auto& [_, path] : index.mapsByName) {
const TmxMap& map = LoadTmxMapCached(path);
for (const TmxObject& object : map.objects) {
if (IsSpawnObject(object)) {
++audit.spawnCount;
continue;
}
if (!IsWarpObject(object)) {
continue;
}
++audit.warpCount;
if (!WarpAutoWarps(object)) {
++audit.manualWarpCount;
}
const WarpTarget target = ReadWarpTarget(object);
if (target.mapName.empty()) {
continue;
}
if (WarpTargetResolved(index, object)) {
++audit.resolvedWarpCount;
} else {
++audit.missingWarpCount;
if (LooksLikeIndoorMapName(target.mapName)) {
++audit.missingIndoorWarpCount;
}
}
}
}
return audit;
}
+88
View File
@@ -0,0 +1,88 @@
#pragma once
#include "TmxMap.h"
#include <filesystem>
#include <map>
#include <string>
#include <vector>
struct TmxWorldIndex {
std::map<std::string, std::filesystem::path> mapsByName;
};
struct MapSummary {
std::string name;
std::filesystem::path path;
int widthPixels = 0;
int heightPixels = 0;
int spawnCount = 0;
int warpCount = 0;
};
struct WarpEdge {
std::string fromMap;
std::string toMap;
std::string objectName;
int tileX = 0;
int tileY = 0;
bool resolved = false;
};
struct WarpTarget {
std::string mapName;
std::string originalMapName;
int tileX = 0;
int tileY = 0;
bool aliased = false;
};
struct TmxObjectBounds {
float x = 0.0f;
float y = 0.0f;
float width = 0.0f;
float height = 0.0f;
};
struct TmxObjectPosition {
float x = 0.0f;
float y = 0.0f;
};
struct WorldAudit {
int mapCount = 0;
int spawnCount = 0;
int warpCount = 0;
int resolvedWarpCount = 0;
int missingWarpCount = 0;
int manualWarpCount = 0;
int missingIndoorWarpCount = 0;
};
TmxWorldIndex BuildTmxWorldIndex(const std::filesystem::path& mapsRoot);
TmxMap LoadNamedMap(const TmxWorldIndex& index, const std::string& mapName);
bool IsSpawnObject(const TmxObject& object);
bool IsWarpObject(const TmxObject& object);
bool IsCollectibleSpawnObject(const TmxObject& object);
std::string SpawnDisplayName(const TmxObject& object);
std::string SpawnStateName(const TmxObject& object);
TmxObjectPosition SpawnObjectPosition(const TmxObject& object);
bool WarpAutoWarps(const TmxObject& warp);
float TriggerRadiusForObject(const TmxObject& object, float fallback);
TmxObjectBounds WarpTriggerBounds(const TmxObject& warp);
WarpTarget ReadWarpTarget(const TmxObject& warp);
bool WarpHasExplicitTileDestination(const TmxObject& warp);
std::string IndoorAliasMapName();
bool WarpTargetResolved(const TmxWorldIndex& index, const TmxObject& warp);
bool PointLooksLikeTonoriBuildingEntrance(const TmxMap& map, float x, float y);
std::string MapDisplayName(const TmxMap& map);
std::vector<MapSummary> ListMapsSorted(const TmxWorldIndex& index);
std::vector<WarpEdge> BuildWarpGraph(const TmxWorldIndex& index);
std::vector<WarpEdge> MissingWarpEdges(const std::vector<WarpEdge>& graph);
std::vector<WarpEdge> MissingWarpEdges(const TmxWorldIndex& index);
std::vector<std::string> ReachableMaps(const TmxWorldIndex& index, const std::vector<WarpEdge>& graph, const std::string& startMapName);
std::vector<std::string> ReachableMaps(const TmxWorldIndex& index, const std::string& startMapName);
std::vector<std::string> AdjacentMaps(const TmxWorldIndex& index, const std::vector<WarpEdge>& graph, const std::string& mapName);
std::vector<std::string> AdjacentMaps(const TmxWorldIndex& index, const std::string& mapName, const std::vector<WarpEdge>& graph);
std::vector<std::string> AdjacentMaps(const TmxWorldIndex& index, const std::string& mapName);
WorldAudit BuildWorldAudit(const TmxWorldIndex& index);
+187
View File
@@ -0,0 +1,187 @@
#include "TmxWorldLayout.h"
#include <algorithm>
#include <fstream>
#include <limits>
#include <regex>
#include <sstream>
namespace {
std::string ReadTextFile(const std::filesystem::path& path)
{
std::ifstream in(path);
if (!in) {
return {};
}
std::ostringstream buffer;
buffer << in.rdbuf();
return buffer.str();
}
std::string JsonStringField(const std::string& block, const std::string& key)
{
const std::regex field("\\\"" + key + "\\\"\\s*:\\s*\\\"([^\\\"]+)\\\"");
std::smatch match;
return std::regex_search(block, match, field) ? match[1].str() : "";
}
int JsonIntField(const std::string& block, const std::string& key)
{
const std::regex field("\\\"" + key + "\\\"\\s*:\\s*(-?\\d+)");
std::smatch match;
return std::regex_search(block, match, field) ? std::stoi(match[1].str()) : 0;
}
std::string MapNameForPath(const TmxWorldIndex& index, const std::filesystem::path& path)
{
const std::filesystem::path canonical = std::filesystem::weakly_canonical(path);
for (const auto& [name, indexedPath] : index.mapsByName) {
if (std::filesystem::weakly_canonical(indexedPath) == canonical) {
return name;
}
}
return {};
}
bool ContainsPoint(const TmxWorldPlacement& placement, float worldX, float worldY)
{
return worldX >= static_cast<float>(placement.x)
&& worldY >= static_cast<float>(placement.y)
&& worldX < static_cast<float>(placement.x + placement.width)
&& worldY < static_cast<float>(placement.y + placement.height);
}
} // namespace
TmxWorldLayout LoadTmxWorldLayout(const std::filesystem::path& worldFile, const TmxWorldIndex& index)
{
TmxWorldLayout layout;
const std::string text = ReadTextFile(worldFile);
if (text.empty()) {
return layout;
}
const std::regex mapBlockRegex(R"(\{[^{}]*"fileName"[^{}]*\})");
for (auto it = std::sregex_iterator(text.begin(), text.end(), mapBlockRegex); it != std::sregex_iterator(); ++it) {
const std::string block = it->str();
const std::string fileName = JsonStringField(block, "fileName");
if (fileName.empty()) {
continue;
}
const std::filesystem::path mapPath = worldFile.parent_path() / fileName;
const std::string mapName = MapNameForPath(index, mapPath);
if (mapName.empty()) {
continue;
}
TmxWorldPlacement placement;
placement.mapName = mapName;
placement.path = std::filesystem::weakly_canonical(mapPath);
placement.x = JsonIntField(block, "x");
placement.y = JsonIntField(block, "y");
placement.width = JsonIntField(block, "width");
placement.height = JsonIntField(block, "height");
layout.placements.push_back(std::move(placement));
}
return layout;
}
TmxWorldLayout BuildCompleteWorldLayout(const std::filesystem::path& worldFile, const TmxWorldIndex& index)
{
TmxWorldLayout layout = LoadTmxWorldLayout(worldFile, index);
std::vector<MapSummary> unplaced;
int maxWidth = 0;
int maxHeight = 0;
for (const MapSummary& summary : ListMapsSorted(index)) {
if (FindWorldPlacement(layout, summary.name) != nullptr) {
continue;
}
unplaced.push_back(summary);
maxWidth = std::max(maxWidth, summary.widthPixels);
maxHeight = std::max(maxHeight, summary.heightPixels);
}
if (unplaced.empty()) {
return layout;
}
constexpr int gap = 512;
constexpr int columns = 4;
const TmxWorldBounds bounds = WorldLayoutBounds(layout);
const int shelfX = bounds.x + bounds.width + gap;
const int shelfY = bounds.y;
const int cellWidth = std::max(1, maxWidth) + gap;
const int cellHeight = std::max(1, maxHeight) + gap;
for (std::size_t i = 0; i < unplaced.size(); ++i) {
const MapSummary& summary = unplaced[i];
TmxWorldPlacement placement;
placement.mapName = summary.name;
placement.path = summary.path;
placement.x = shelfX + static_cast<int>(i % columns) * cellWidth;
placement.y = shelfY + static_cast<int>(i / columns) * cellHeight;
placement.width = summary.widthPixels;
placement.height = summary.heightPixels;
layout.placements.push_back(std::move(placement));
}
return layout;
}
const TmxWorldPlacement* FindWorldPlacement(const TmxWorldLayout& layout, const std::string& mapName)
{
for (const TmxWorldPlacement& placement : layout.placements) {
if (placement.mapName == mapName) {
return &placement;
}
}
return nullptr;
}
const TmxWorldPlacement* FindWorldPlacementAt(
const TmxWorldLayout& layout,
float worldX,
float worldY,
const std::string& excludedMapName)
{
for (const TmxWorldPlacement& placement : layout.placements) {
if (!excludedMapName.empty() && placement.mapName == excludedMapName) {
continue;
}
if (ContainsPoint(placement, worldX, worldY)) {
return &placement;
}
}
return nullptr;
}
TmxWorldPoint LocalToWorld(const TmxWorldPlacement& placement, float localX, float localY)
{
return {static_cast<float>(placement.x) + localX, static_cast<float>(placement.y) + localY};
}
TmxWorldPoint WorldToLocal(const TmxWorldPlacement& placement, float worldX, float worldY)
{
return {worldX - static_cast<float>(placement.x), worldY - static_cast<float>(placement.y)};
}
TmxWorldBounds WorldLayoutBounds(const TmxWorldLayout& layout)
{
if (layout.placements.empty()) {
return {};
}
int minX = std::numeric_limits<int>::max();
int minY = std::numeric_limits<int>::max();
int maxX = std::numeric_limits<int>::min();
int maxY = std::numeric_limits<int>::min();
for (const TmxWorldPlacement& placement : layout.placements) {
minX = std::min(minX, placement.x);
minY = std::min(minY, placement.y);
maxX = std::max(maxX, placement.x + placement.width);
maxY = std::max(maxY, placement.y + placement.height);
}
return {minX, minY, maxX - minX, maxY - minY};
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include "TmxWorld.h"
#include <filesystem>
#include <string>
#include <vector>
struct TmxWorldPoint {
float x = 0.0f;
float y = 0.0f;
};
struct TmxWorldPlacement {
std::string mapName;
std::filesystem::path path;
int x = 0;
int y = 0;
int width = 0;
int height = 0;
};
struct TmxWorldBounds {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
};
struct TmxWorldLayout {
std::vector<TmxWorldPlacement> placements;
};
TmxWorldLayout LoadTmxWorldLayout(const std::filesystem::path& worldFile, const TmxWorldIndex& index);
TmxWorldLayout BuildCompleteWorldLayout(const std::filesystem::path& worldFile, const TmxWorldIndex& index);
const TmxWorldPlacement* FindWorldPlacement(const TmxWorldLayout& layout, const std::string& mapName);
const TmxWorldPlacement* FindWorldPlacementAt(
const TmxWorldLayout& layout,
float worldX,
float worldY,
const std::string& excludedMapName);
TmxWorldPoint LocalToWorld(const TmxWorldPlacement& placement, float localX, float localY);
TmxWorldPoint WorldToLocal(const TmxWorldPlacement& placement, float worldX, float worldY);
TmxWorldBounds WorldLayoutBounds(const TmxWorldLayout& layout);
+83
View File
@@ -0,0 +1,83 @@
#include "WarpAuditReport.h"
#include "TmxMap.h"
#include <algorithm>
#include <cctype>
bool WarpAuditTargetLooksIndoor(std::string_view mapName)
{
if (mapName == "Indoor") {
return true;
}
return WarpAuditTargetLooksLegacyNumericIndoor(mapName);
}
bool WarpAuditTargetLooksLegacyNumericIndoor(std::string_view mapName)
{
if (mapName.empty()) {
return false;
}
bool hasDigit = false;
bool hasDash = false;
for (const unsigned char ch : mapName) {
if (std::isdigit(ch)) {
hasDigit = true;
continue;
}
if (ch == '-') {
hasDash = true;
continue;
}
return false;
}
return hasDigit && hasDash;
}
std::string WarpAuditMissingCategory(std::string_view mapName)
{
if (WarpAuditTargetLooksLegacyNumericIndoor(mapName)) {
return "legacy_numeric_indoor";
}
if (mapName == "Indoor") {
return "indoor_alias";
}
return "unknown_map";
}
WarpAuditReport BuildWarpAuditReport(const TmxWorldIndex& index)
{
WarpAuditReport report;
const std::vector<WarpEdge> graph = BuildWarpGraph(index);
report.totalWarpCount = static_cast<int>(graph.size());
for (const WarpEdge& edge : graph) {
if (edge.resolved) {
report.resolvedEdges.push_back(edge);
continue;
}
report.missingEdges.push_back(edge);
++report.missingByFromMap[edge.fromMap];
++report.missingByToMap[edge.toMap];
++report.missingByCategory[WarpAuditMissingCategory(edge.toMap)];
if (WarpAuditTargetLooksIndoor(edge.toMap)) {
++report.missingIndoorWarpCount;
}
if (WarpAuditTargetLooksLegacyNumericIndoor(edge.toMap)) {
++report.missingLegacyNumericIndoorWarpCount;
}
}
report.resolvedWarpCount = static_cast<int>(report.resolvedEdges.size());
report.missingWarpCount = static_cast<int>(report.missingEdges.size());
for (const auto& [_, path] : index.mapsByName) {
const TmxMap map = LoadTmxMap(path);
report.manualWarpCount += static_cast<int>(std::count_if(map.objects.begin(), map.objects.end(), [](const TmxObject& object) {
return IsWarpObject(object) && !WarpAutoWarps(object);
}));
}
return report;
}
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include "TmxWorld.h"
#include <map>
#include <string>
#include <string_view>
#include <vector>
struct WarpAuditReport {
int totalWarpCount = 0;
int resolvedWarpCount = 0;
int missingWarpCount = 0;
int manualWarpCount = 0;
int missingIndoorWarpCount = 0;
int missingLegacyNumericIndoorWarpCount = 0;
std::vector<WarpEdge> resolvedEdges;
std::vector<WarpEdge> missingEdges;
std::map<std::string, int> missingByFromMap;
std::map<std::string, int> missingByToMap;
std::map<std::string, int> missingByCategory;
};
bool WarpAuditTargetLooksIndoor(std::string_view mapName);
bool WarpAuditTargetLooksLegacyNumericIndoor(std::string_view mapName);
std::string WarpAuditMissingCategory(std::string_view mapName);
WarpAuditReport BuildWarpAuditReport(const TmxWorldIndex& index);
+86
View File
@@ -0,0 +1,86 @@
#include "WarpInteraction.h"
namespace {
bool LooksLikeLegacyIndoorId(const std::string& mapName)
{
if (mapName.empty() || mapName == "Indoor") {
return false;
}
bool hasDigit = false;
bool hasDash = false;
for (const unsigned char ch : mapName) {
if (ch >= '0' && ch <= '9') {
hasDigit = true;
continue;
}
if (ch == '-') {
hasDash = true;
continue;
}
return false;
}
return hasDigit && hasDash;
}
bool LooksLikeUnavailableIndoorTarget(const std::string& mapName)
{
return mapName == "Indoor" || LooksLikeLegacyIndoorId(mapName);
}
std::string WarpFieldLabel(const TmxObject& warp, const WarpTarget& target)
{
(void)warp;
if (LooksLikeUnavailableIndoorTarget(target.mapName)) {
return "室内入口";
}
return "未知入口";
}
} // namespace
WarpInteraction BuildWarpInteraction(const TmxWorldIndex& index, const TmxObject& warp)
{
WarpInteraction interaction;
interaction.target = ReadWarpTarget(warp);
interaction.autoWarp = WarpAutoWarps(warp);
interaction.resolved = WarpTargetResolved(index, warp);
interaction.fieldLabel = WarpFieldLabel(warp, interaction.target);
if (interaction.target.mapName.empty()) {
interaction.prompt = "传送目标缺失";
} else if (!interaction.resolved) {
interaction.prompt = LooksLikeLegacyIndoorId(interaction.target.mapName)
? "旧室内入口暂不可用"
: "室内入口暂不可用";
} else if (interaction.autoWarp) {
interaction.fieldLabel = "入口";
interaction.prompt = "进入入口";
} else {
interaction.fieldLabel = "入口";
interaction.prompt = "进入入口";
}
return interaction;
}
bool CanOpenWarpConfirmation(const WarpInteraction& interaction)
{
return !interaction.target.mapName.empty() && interaction.resolved;
}
bool WarpNameLooksLikeBuildingEntrance(const std::string& name)
{
return name.find("House") != std::string::npos
|| name.find("Building") != std::string::npos
|| name.find("Barracks") != std::string::npos
|| name.find("Tower") != std::string::npos
|| name.find("Inn") != std::string::npos
|| name.find("Shop") != std::string::npos
|| name.find("Store") != std::string::npos;
}
bool WarpShouldUseIndoorAlias(const TmxWorldIndex& index, const WarpInteraction& interaction)
{
return interaction.target.originalMapName == "Indoor"
&& index.mapsByName.contains(IndoorAliasMapName());
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "TmxWorld.h"
#include <string>
struct WarpInteraction {
WarpTarget target;
bool autoWarp = true;
bool resolved = false;
std::string fieldLabel;
std::string prompt;
};
WarpInteraction BuildWarpInteraction(const TmxWorldIndex& index, const TmxObject& warp);
bool CanOpenWarpConfirmation(const WarpInteraction& interaction);
bool WarpNameLooksLikeBuildingEntrance(const std::string& name);
bool WarpShouldUseIndoorAlias(const TmxWorldIndex& index, const WarpInteraction& interaction);
+196
View File
@@ -0,0 +1,196 @@
#include "WildLevelZone.h"
#include "PetGrowth.h"
#include <algorithm>
#include <cctype>
#include <map>
#include <sstream>
#include <utility>
namespace {
WildSpeciesSpawnRule Rule(std::string species, int weight, WildLevelBand band)
{
return {std::move(species), std::max(1, weight), NormalizeWildLevelBand(band)};
}
const MapWildZone& DefaultZone()
{
static const MapWildZone zone = {
"",
{3, 15},
20,
3,
{
Rule("Piou", 18, {3, 12}),
Rule("Bird", 18, {3, 12}),
Rule("Fluffy", 16, {3, 12}),
Rule("Pinkie", 12, {3, 12}),
Rule("Maggot", 14, {4, 14}),
Rule("Snake", 12, {6, 15}),
Rule("Lizzy", 10, {8, 15}),
Rule("Peyote", 8, {6, 15}),
Rule("Spiky Mushroom", 6, {8, 15}),
Rule("Ratto", 4, {3, 10}),
},
};
return zone;
}
const std::vector<MapWildZone>& ExactZones()
{
static const std::vector<MapWildZone> zones = {
{"Overworld", {3, 18}, 22, 0, {Rule("Bird", 18, {3, 15}), Rule("Piou", 16, {3, 12}), Rule("Fluffy", 14, {3, 12}), Rule("Pinkie", 10, {3, 12}), Rule("Maggot", 12, {4, 14}), Rule("Snake", 12, {6, 18}), Rule("Lizzy", 10, {8, 18}), Rule("Peyote", 8, {6, 16}), Rule("Spiky Mushroom", 6, {8, 18}), Rule("Lulea", 4, {8, 18})}},
{"Tulimshar", {1, 8}, 0, 3, {Rule("Maggot", 62, {1, 8}), Rule("Peyote", 38, {2, 8})}},
{"Tulimshar Center", {1, 4}, 0, 3, {Rule("Piou", 25, {1, 3}), Rule("Fluffy", 30, {2, 4}), Rule("Ratto", 30, {2, 4}), Rule("Pinkie", 15, {2, 4})}},
{"Ship First Deck", {1, 4}, 0, 3, {Rule("Ratto", 70, {1, 4}), Rule("Piou", 30, {1, 3})}},
{"Ship Nard's Room", {1, 4}, 0, 2, {Rule("Ratto", 100, {1, 4})}},
{"Manayir Beach", {10, 32}, 22, 0, {Rule("Lulea", 20, {10, 24}), Rule("Turtle", 18, {15, 30}), Rule("Salt Slime", 16, {14, 30}), Rule("Slime", 15, {10, 26}), Rule("Croc", 10, {22, 32}), Rule("Bird", 8, {10, 24}), Rule("Piou", 6, {8, 18}), Rule("Spiky Mushroom", 4, {12, 26}), Rule("Peyote", 3, {10, 22})}},
{"Tulimshar Beach", {8, 30}, 22, 0, {Rule("Lulea", 18, {8, 22}), Rule("Turtle", 16, {12, 28}), Rule("Salt Slime", 15, {12, 28}), Rule("Slime", 14, {8, 24}), Rule("Bird", 12, {6, 20}), Rule("Piou", 10, {5, 16}), Rule("Fluffy", 8, {5, 14}), Rule("Maggot", 6, {5, 14}), Rule("Peyote", 6, {6, 18}), Rule("Spiky Mushroom", 5, {8, 20}), Rule("Croc", 4, {20, 30})}},
{"Tulimshar Western Cave", {18, 40}, 26, 0, {Rule("Bat", 24, {18, 36}), Rule("Skeleton", 18, {22, 40}), Rule("Sludge Slime", 16, {20, 38}), Rule("Drain Slime", 14, {22, 40}), Rule("Scorpion", 12, {18, 34}), Rule("Giant Maggot", 10, {24, 40}), Rule("Evil Mushroom", 8, {20, 38}), Rule("Spiky Mushroom", 6, {18, 32})}},
{"Sandstorm", {8, 32}, 30, 0, {Rule("Scorpion", 16, {10, 28}), Rule("Maggot", 14, {8, 22}), Rule("Peyote", 12, {8, 26}), Rule("Sand Snake", 12, {10, 30}), Rule("Snake", 9, {8, 24}), Rule("Fire Goblin", 9, {16, 32}), Rule("Lizzy", 8, {8, 24}), Rule("Bird", 7, {6, 22}), Rule("Piou", 6, {5, 18}), Rule("Fluffy", 6, {5, 18}), Rule("Pinkie", 5, {5, 18}), Rule("Spiky Mushroom", 5, {10, 24}), Rule("Evil Mushroom", 4, {14, 28}), Rule("Lynx", 4, {18, 32}), Rule("Giant Maggot", 3, {22, 32})}},
{"Desert Mines", {18, 40}, 28, 0, {Rule("Bat", 18, {18, 34}), Rule("Skeleton", 20, {22, 40}), Rule("Scorpion", 16, {18, 36}), Rule("Giant Maggot", 16, {24, 40}), Rule("Sludge Slime", 12, {22, 40}), Rule("Fire Goblin", 10, {22, 40}), Rule("Drain Slime", 5, {24, 40}), Rule("Evil Mushroom", 3, {20, 36})}},
{"Desert Abandoned Level", {25, 55}, 30, 0, {Rule("Fire Skull", 18, {35, 55}), Rule("Skeleton", 20, {25, 55}), Rule("Giant Maggot", 16, {25, 50}), Rule("Fire Goblin", 12, {28, 52}), Rule("Lynx", 10, {35, 55}), Rule("Croc", 8, {35, 55}), Rule("Sludge Slime", 12, {28, 50}), Rule("Drain Slime", 4, {32, 52})}},
{"Desert Deep Level", {45, 75}, 30, 0, {Rule("Splatyna", 10, {55, 75}), Rule("Lizandra", 14, {55, 75}), Rule("Fire Skull", 22, {45, 75}), Rule("Skeleton", 22, {45, 75}), Rule("Lynx", 18, {50, 75}), Rule("Fire Goblin", 8, {48, 72}), Rule("Croc", 6, {50, 72})}},
{"Splatyna Cave Entrance", {45, 75}, 22, 0, {Rule("Splatyna", 20, {55, 80}), Rule("Drain Slime", 25, {45, 70}), Rule("Sludge Slime", 25, {45, 70}), Rule("Skeleton", 20, {45, 75}), Rule("Lizandra", 10, {60, 80})}},
{"Splatyna's Chamber", {75, 100}, 16, 0, {Rule("Splatyna", 35, {75, 100}), Rule("Lizandra", 25, {75, 100}), Rule("Fire Goblin", 20, {75, 100}), Rule("Fire Skull", 20, {75, 100})}},
};
return zones;
}
bool ContainsCaseInsensitive(const std::string& text, const std::string& needle)
{
const auto lower = [](std::string value) {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return value;
};
return lower(text).find(lower(needle)) != std::string::npos;
}
MapWildZone PatternZone(const std::string& mapName)
{
if (ContainsCaseInsensitive(mapName, "Cave") || ContainsCaseInsensitive(mapName, "Mine") || ContainsCaseInsensitive(mapName, "Sewer")) {
return {"", {18, 40}, 26, 0, {Rule("Bat", 22, {18, 36}), Rule("Skeleton", 18, {22, 40}), Rule("Scorpion", 16, {18, 36}), Rule("Sludge Slime", 16, {20, 40}), Rule("Giant Maggot", 14, {24, 42}), Rule("Drain Slime", 8, {22, 40}), Rule("Fire Goblin", 4, {24, 42}), Rule("Evil Mushroom", 4, {20, 38})}};
}
if (ContainsCaseInsensitive(mapName, "Beach") || ContainsCaseInsensitive(mapName, "Ocean") || ContainsCaseInsensitive(mapName, "Bay")) {
return {"", {8, 32}, 22, 0, {Rule("Lulea", 18, {8, 24}), Rule("Turtle", 16, {12, 30}), Rule("Salt Slime", 15, {12, 30}), Rule("Slime", 14, {8, 26}), Rule("Croc", 8, {22, 32}), Rule("Bird", 8, {6, 22}), Rule("Piou", 7, {5, 18}), Rule("Peyote", 5, {6, 20}), Rule("Spiky Mushroom", 4, {8, 22}), Rule("Maggot", 4, {5, 16})}};
}
if (ContainsCaseInsensitive(mapName, "Tulimshar") && !ContainsCaseInsensitive(mapName, "Hills")) {
return {"", {1, 8}, 0, 3, {Rule("Maggot", 62, {1, 8}), Rule("Peyote", 38, {2, 8})}};
}
if (ContainsCaseInsensitive(mapName, "Desert") || ContainsCaseInsensitive(mapName, "Hills") || ContainsCaseInsensitive(mapName, "Sandstorm")) {
return {"", {8, 32}, 30, 0, {Rule("Scorpion", 16, {10, 28}), Rule("Sand Snake", 14, {10, 30}), Rule("Maggot", 12, {8, 22}), Rule("Peyote", 12, {8, 26}), Rule("Snake", 9, {8, 24}), Rule("Lizzy", 8, {8, 24}), Rule("Bird", 7, {6, 22}), Rule("Piou", 6, {5, 18}), Rule("Fluffy", 6, {5, 18}), Rule("Pinkie", 5, {5, 18}), Rule("Spiky Mushroom", 5, {10, 24}), Rule("Evil Mushroom", 4, {14, 28}), Rule("Lulea", 4, {10, 26}), Rule("Turtle", 4, {14, 30}), Rule("Salt Slime", 3, {12, 28}), Rule("Fire Goblin", 3, {18, 32}), Rule("Lynx", 3, {18, 32}), Rule("Giant Maggot", 2, {22, 32})}};
}
return DefaultZone();
}
std::string Trim(std::string value)
{
const auto first = std::find_if_not(value.begin(), value.end(), [](unsigned char ch) { return std::isspace(ch); });
const auto last = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char ch) { return std::isspace(ch); }).base();
if (first >= last) {
return {};
}
return {first, last};
}
} // namespace
WildLevelBand NormalizeWildLevelBand(WildLevelBand band)
{
band.minLevel = ClampPetLevel(band.minLevel);
band.maxLevel = ClampPetLevel(band.maxLevel);
if (band.minLevel > band.maxLevel) {
std::swap(band.minLevel, band.maxLevel);
}
return band;
}
const MapWildZone& WildZoneForMap(const std::string& mapName)
{
for (const MapWildZone& zone : ExactZones()) {
if (zone.mapName == mapName) {
return zone;
}
}
static std::map<std::string, MapWildZone> cache;
auto [it, _] = cache.try_emplace(mapName, PatternZone(mapName));
it->second.mapName = mapName;
return it->second;
}
int RandomLevelInBand(WildLevelBand band, std::mt19937& rng)
{
band = NormalizeWildLevelBand(band);
std::uniform_int_distribution<int> dist(band.minLevel, band.maxLevel);
return dist(rng);
}
std::string ChooseWildSpeciesFromRules(const std::vector<WildSpeciesSpawnRule>& rules, std::mt19937& rng)
{
if (rules.empty()) {
return "Piou";
}
int totalWeight = 0;
for (const WildSpeciesSpawnRule& rule : rules) {
totalWeight += std::max(1, rule.weight);
}
std::uniform_int_distribution<int> dist(1, std::max(1, totalWeight));
int roll = dist(rng);
for (const WildSpeciesSpawnRule& rule : rules) {
roll -= std::max(1, rule.weight);
if (roll <= 0) {
return rule.speciesName;
}
}
return rules.back().speciesName;
}
std::string ChooseWildSpecies(const MapWildZone& zone, std::mt19937& rng)
{
return ChooseWildSpeciesFromRules(zone.species, rng);
}
WildLevelBand LevelBandForWildSpecies(const MapWildZone& zone, const std::string& speciesName)
{
for (const WildSpeciesSpawnRule& rule : zone.species) {
if (rule.speciesName == speciesName) {
return NormalizeWildLevelBand(rule.levelBand);
}
}
return NormalizeWildLevelBand(zone.defaultBand);
}
std::vector<WildSpeciesSpawnRule> ParseSpeciesPool(const std::string& text, WildLevelBand fallbackBand)
{
std::vector<WildSpeciesSpawnRule> rules;
std::stringstream stream(text);
std::string part;
while (std::getline(stream, part, ',')) {
part = Trim(part);
if (part.empty()) {
continue;
}
int weight = 1;
const std::size_t marker = part.rfind(':');
if (marker != std::string::npos && marker + 1 < part.size()) {
weight = std::max(1, std::stoi(part.substr(marker + 1)));
part = Trim(part.substr(0, marker));
}
if (!part.empty()) {
rules.push_back(Rule(part, weight, fallbackBand));
}
}
return rules;
}
int FallbackCountForWildZone(const MapWildZone& zone)
{
if (zone.targetOutdoorCount > 0) {
return zone.targetOutdoorCount;
}
return zone.targetCityCount;
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <optional>
#include <random>
#include <string>
#include <vector>
struct WildLevelBand {
int minLevel = 1;
int maxLevel = 1;
};
struct WildSpeciesSpawnRule {
std::string speciesName;
int weight = 1;
WildLevelBand levelBand;
};
struct MapWildZone {
std::string mapName;
WildLevelBand defaultBand;
int targetOutdoorCount = 0;
int targetCityCount = 0;
std::vector<WildSpeciesSpawnRule> species;
};
WildLevelBand NormalizeWildLevelBand(WildLevelBand band);
const MapWildZone& WildZoneForMap(const std::string& mapName);
int RandomLevelInBand(WildLevelBand band, std::mt19937& rng);
std::string ChooseWildSpecies(const MapWildZone& zone, std::mt19937& rng);
WildLevelBand LevelBandForWildSpecies(const MapWildZone& zone, const std::string& speciesName);
std::vector<WildSpeciesSpawnRule> ParseSpeciesPool(const std::string& text, WildLevelBand fallbackBand);
std::string ChooseWildSpeciesFromRules(const std::vector<WildSpeciesSpawnRule>& rules, std::mt19937& rng);
int FallbackCountForWildZone(const MapWildZone& zone);
+219
View File
@@ -0,0 +1,219 @@
#include "WildSpawn.h"
#include "WildLevelZone.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
namespace {
constexpr float kMinimumSpawnExtent = 1.0f;
constexpr float kDefaultRespawnDelaySeconds = 30.0f;
constexpr int kMaximumSpawnCount = 256;
void HashText(std::uint32_t& hash, const std::string& text)
{
for (const unsigned char ch : text) {
hash ^= ch;
hash *= 16777619u;
}
hash ^= 0xffu;
hash *= 16777619u;
}
bool ContainsText(const std::string& text, const std::string& needle)
{
return text.find(needle) != std::string::npos;
}
bool AllowsFallbackZone(const std::string& mapName)
{
return mapName == "Overworld"
|| mapName == "Tulimshar"
|| mapName == "Ship First Deck"
|| mapName == "Ship Nard's Room"
|| mapName == "Splatyna Cave Entrance"
|| mapName == "Desert Deep Level"
|| mapName == "Candor Arena"
|| ContainsText(mapName, "Beach")
|| ContainsText(mapName, "Hills")
|| ContainsText(mapName, "Cave")
|| ContainsText(mapName, "Desert")
|| ContainsText(mapName, "Sandstorm")
|| ContainsText(mapName, "Sewer")
|| ContainsText(mapName, "Ocean");
}
} // namespace
SpawnArea EffectiveSpawnArea(SpawnArea area)
{
area.width = std::max(kMinimumSpawnExtent, area.width);
area.height = std::max(kMinimumSpawnExtent, area.height);
return area;
}
SpawnArea ResolveSpawnArea(SpawnArea objectArea, float mapWidthPixels, float mapHeightPixels)
{
if (objectArea.x < 0.0f || objectArea.y < 0.0f) {
return EffectiveSpawnArea({0.0f, 0.0f, mapWidthPixels, mapHeightPixels});
}
return EffectiveSpawnArea(objectArea);
}
bool SpawnAreaContains(SpawnArea area, SpawnPoint point)
{
const SpawnArea effective = EffectiveSpawnArea(area);
return point.x >= effective.x
&& point.x <= effective.x + effective.width
&& point.y >= effective.y
&& point.y <= effective.y + effective.height;
}
SpawnPoint ClampToSpawnArea(SpawnArea area, SpawnPoint point)
{
const SpawnArea effective = EffectiveSpawnArea(area);
return {
std::clamp(point.x, effective.x, effective.x + effective.width),
std::clamp(point.y, effective.y, effective.y + effective.height),
};
}
SpawnPoint ApplySpawnDrift(SpawnArea area, SpawnPoint position, SpawnPoint drift)
{
return ClampToSpawnArea(area, {position.x + drift.x, position.y + drift.y});
}
int MonsterSpawnCount(std::optional<int> count, std::optional<int> maxBeings)
{
if (count.has_value()) {
return std::clamp(*count, 1, kMaximumSpawnCount);
}
if (maxBeings.has_value()) {
return std::clamp(*maxBeings, 1, kMaximumSpawnCount);
}
return 1;
}
float MonsterRespawnDelay(std::optional<float> configuredDelay)
{
if (configuredDelay.has_value() && *configuredDelay >= 0.0f) {
return *configuredDelay >= 1000.0f ? *configuredDelay / 1000.0f : *configuredDelay;
}
return kDefaultRespawnDelaySeconds;
}
float TickRespawnTimer(float currentTimer, float deltaSeconds)
{
return std::max(0.0f, currentTimer - std::max(0.0f, deltaSeconds));
}
bool RespawnTimerReady(float currentTimer)
{
return currentTimer <= 0.0f;
}
double MonsterRespawnUntil(double nowSeconds, float respawnDelaySeconds)
{
return nowSeconds + static_cast<double>(std::max(0.0f, respawnDelaySeconds));
}
float RemainingRespawnTime(double nowSeconds, double respawnUntilSeconds)
{
return static_cast<float>(std::max(0.0, respawnUntilSeconds - nowSeconds));
}
bool RespawnDue(double nowSeconds, double respawnUntilSeconds)
{
return nowSeconds >= respawnUntilSeconds;
}
std::string FallbackMonsterNameForMap(const std::string& mapName)
{
if (!AllowsFallbackZone(mapName)) {
return {};
}
const MapWildZone& zone = WildZoneForMap(mapName);
if (!zone.species.empty()) {
return zone.species.front().speciesName;
}
return {};
}
int FallbackMonsterSpawnCount(const std::string& mapName)
{
if (!AllowsFallbackZone(mapName)) {
return 0;
}
return FallbackCountForWildZone(WildZoneForMap(mapName));
}
bool ShouldAddFallbackMonsters(const std::string& mapName, bool sawMonsterSpawn)
{
(void)sawMonsterSpawn;
return FallbackMonsterSpawnCount(mapName) > 0;
}
unsigned int StableSpawnSeed(const std::string& mapName, const std::string& objectName, int objectId)
{
std::uint32_t hash = 2166136261u;
HashText(hash, mapName);
HashText(hash, objectName);
hash ^= static_cast<std::uint32_t>(objectId);
hash *= 16777619u;
return hash == 0 ? 1u : hash;
}
SpawnPoint RandomPointInSpawnArea(SpawnArea area, std::mt19937& rng)
{
if (area.width <= 0.0f && area.height <= 0.0f) {
return {area.x, area.y};
}
const SpawnArea effective = EffectiveSpawnArea(area);
std::uniform_real_distribution<float> xDist(effective.x, effective.x + effective.width);
std::uniform_real_distribution<float> yDist(effective.y, effective.y + effective.height);
return {xDist(rng), yDist(rng)};
}
std::optional<SpawnPoint> FindWalkableSpawnPoint(
SpawnArea area,
std::mt19937& rng,
const std::function<bool(SpawnPoint)>& isWalkable,
int randomAttempts,
float fallbackStep)
{
for (int attempt = 0; attempt < randomAttempts; ++attempt) {
const SpawnPoint point = RandomPointInSpawnArea(area, rng);
if (isWalkable(point)) {
return point;
}
}
const SpawnArea effective = EffectiveSpawnArea(area);
const SpawnPoint center{
effective.x + effective.width * 0.5f,
effective.y + effective.height * 0.5f,
};
if (isWalkable(center)) {
return center;
}
const float step = std::max(kMinimumSpawnExtent, fallbackStep);
const float maxRadius = std::max(effective.width, effective.height);
for (float radius = step; radius <= maxRadius + step * 0.5f; radius += step) {
for (float dx = -radius; dx <= radius; dx += step) {
for (float dy = -radius; dy <= radius; dy += step) {
if (std::abs(dx) < radius && std::abs(dy) < radius) {
continue;
}
const SpawnPoint candidate = ClampToSpawnArea(effective, {center.x + dx, center.y + dy});
if (isWalkable(candidate)) {
return candidate;
}
}
}
}
return std::nullopt;
}
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <optional>
#include <functional>
#include <random>
#include <string>
struct SpawnPoint {
float x = 0.0f;
float y = 0.0f;
};
struct SpawnArea {
float x = 0.0f;
float y = 0.0f;
float width = 0.0f;
float height = 0.0f;
};
SpawnArea EffectiveSpawnArea(SpawnArea area);
SpawnArea ResolveSpawnArea(SpawnArea objectArea, float mapWidthPixels, float mapHeightPixels);
bool SpawnAreaContains(SpawnArea area, SpawnPoint point);
SpawnPoint ClampToSpawnArea(SpawnArea area, SpawnPoint point);
SpawnPoint ApplySpawnDrift(SpawnArea area, SpawnPoint position, SpawnPoint drift);
int MonsterSpawnCount(std::optional<int> count, std::optional<int> maxBeings);
float MonsterRespawnDelay(std::optional<float> configuredDelay);
float TickRespawnTimer(float currentTimer, float deltaSeconds);
bool RespawnTimerReady(float currentTimer);
double MonsterRespawnUntil(double nowSeconds, float respawnDelaySeconds);
float RemainingRespawnTime(double nowSeconds, double respawnUntilSeconds);
bool RespawnDue(double nowSeconds, double respawnUntilSeconds);
std::string FallbackMonsterNameForMap(const std::string& mapName);
int FallbackMonsterSpawnCount(const std::string& mapName);
bool ShouldAddFallbackMonsters(const std::string& mapName, bool sawMonsterSpawn);
unsigned int StableSpawnSeed(const std::string& mapName, const std::string& objectName, int objectId);
SpawnPoint RandomPointInSpawnArea(SpawnArea area, std::mt19937& rng);
std::optional<SpawnPoint> FindWalkableSpawnPoint(
SpawnArea area,
std::mt19937& rng,
const std::function<bool(SpawnPoint)>& isWalkable,
int randomAttempts = 24,
float fallbackStep = 32.0f);