最终整理版
This commit is contained in:
+7327
File diff suppressed because it is too large
Load Diff
@@ -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"));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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")};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user