Metin2 game client (P0–P11) + mobile asset pipeline
Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
phases, EntityStore world model, ~all GC/CG headers. char create/delete,
private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
token), system-option + game-option + ESC system menu, private-shop 39-grid,
party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.
Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.
Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).
ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
#include "asset_resolver.h"
|
||||
#include "m2_tokvec.h" // fmt::read_file(host 可注入 FileAccess)
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <sstream>
|
||||
|
||||
namespace fmt {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string lower(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(),
|
||||
[](unsigned char c) { return (char)std::tolower(c); });
|
||||
return s;
|
||||
}
|
||||
|
||||
// "a/b/./c/../d" -> "a/b/d"(不越过根;越根的 ".." 直接丢)
|
||||
std::string collapse(const std::string& in) {
|
||||
std::vector<std::string> parts;
|
||||
std::string cur;
|
||||
auto flush = [&] {
|
||||
if (cur.empty()) return;
|
||||
if (cur == ".") {
|
||||
} else if (cur == "..") {
|
||||
if (!parts.empty()) parts.pop_back();
|
||||
} else {
|
||||
parts.push_back(cur);
|
||||
}
|
||||
cur.clear();
|
||||
};
|
||||
for (char c : in) {
|
||||
if (c == '/') flush();
|
||||
else cur += c;
|
||||
}
|
||||
flush();
|
||||
std::string out;
|
||||
for (size_t i = 0; i < parts.size(); ++i) {
|
||||
if (i) out += '/';
|
||||
out += parts[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const char* kYmir = "ymir work/";
|
||||
|
||||
// 若 path 含 "ymir work/",返回从那里起的后缀,否则 ""。
|
||||
std::string ymir_suffix(const std::string& norm) {
|
||||
auto p = norm.find(kYmir);
|
||||
if (p == std::string::npos) return "";
|
||||
return norm.substr(p);
|
||||
}
|
||||
|
||||
// 一级目录归类:0=基础(在 priority 里),1=普通,2=patch/补丁
|
||||
int pack_rank(const std::string& top, const std::vector<std::string>& prio) {
|
||||
std::string t = lower(top);
|
||||
for (size_t i = 0; i < prio.size(); ++i)
|
||||
if (lower(prio[i]) == t) return (int)i; // 0..N-1
|
||||
if (t.rfind("metin2_patch_", 0) == 0 || t.rfind("patch", 0) == 0) return 100000;
|
||||
return 50000;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::string> AssetResolver::default_priority() {
|
||||
return {"Zone", "Terrain", "ETC", "PC", "Tree", "Property", "Effect",
|
||||
"Monster", "NPC", "season2", "season3_eu"};
|
||||
}
|
||||
|
||||
std::string AssetResolver::normalize(const std::string& vp) {
|
||||
std::string s = vp;
|
||||
for (char& c : s)
|
||||
if (c == '\\') c = '/';
|
||||
s = lower(s);
|
||||
// 去盘符 "d:/..." / "d:..."
|
||||
if (s.size() >= 2 && std::isalpha((unsigned char)s[0]) && s[1] == ':')
|
||||
s = s.substr(2);
|
||||
while (!s.empty() && s[0] == '/') s.erase(s.begin());
|
||||
return collapse(s);
|
||||
}
|
||||
|
||||
bool AssetResolver::build(const std::string& root, const std::vector<std::string>& prio,
|
||||
std::string* err) {
|
||||
assets_root = root;
|
||||
std::error_code ec;
|
||||
if (!fs::exists(root, ec)) {
|
||||
if (err) *err = "assets 根不存在: " + root;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 收集 (rank, real_path, rel_norm) 后按 rank 稳定排序 —— 保证先注册者 = 高优先级
|
||||
struct Rec {
|
||||
int rank;
|
||||
std::string real, rel;
|
||||
};
|
||||
std::vector<Rec> recs;
|
||||
fs::path base(root);
|
||||
for (auto it = fs::recursive_directory_iterator(
|
||||
root, fs::directory_options::skip_permission_denied, ec);
|
||||
it != fs::recursive_directory_iterator(); it.increment(ec)) {
|
||||
if (ec) { ec.clear(); continue; }
|
||||
if (!it->is_regular_file(ec)) continue;
|
||||
std::string rel_real = fs::relative(it->path(), base, ec).generic_string();
|
||||
std::string rel = lower(rel_real);
|
||||
if (rel.empty()) continue;
|
||||
std::string top = rel.substr(0, rel.find('/'));
|
||||
// real_path 存**相对** assets_root(保留原始大小写);resolve() 拼回。
|
||||
recs.push_back({pack_rank(top, prio), rel_real, rel});
|
||||
}
|
||||
std::stable_sort(recs.begin(), recs.end(),
|
||||
[](const Rec& a, const Rec& b) { return a.rank < b.rank; });
|
||||
|
||||
std::vector<std::string> seen_conflicts;
|
||||
for (auto& r : recs) {
|
||||
++files_indexed;
|
||||
// by_rel
|
||||
{
|
||||
auto& e = by_rel[r.rel];
|
||||
if (e.real_path.empty()) e.real_path = r.real;
|
||||
e.all_candidates.push_back(r.real);
|
||||
}
|
||||
// by_ymir
|
||||
std::string suf = ymir_suffix(r.rel);
|
||||
if (!suf.empty()) {
|
||||
auto& e = by_ymir[suf];
|
||||
if (e.real_path.empty()) {
|
||||
e.real_path = r.real;
|
||||
} else {
|
||||
++ymir_conflicts;
|
||||
if (seen_conflicts.size() < 50 &&
|
||||
std::find(seen_conflicts.begin(), seen_conflicts.end(), suf) ==
|
||||
seen_conflicts.end()) {
|
||||
seen_conflicts.push_back(suf);
|
||||
conflict_keys.push_back(suf);
|
||||
}
|
||||
}
|
||||
e.all_candidates.push_back(r.real);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string AssetResolver::resolve(const std::string& vp, std::string* reason) const {
|
||||
auto join = [this](const std::string& rel) {
|
||||
if (rel.empty()) return std::string();
|
||||
if (assets_root.empty()) return rel;
|
||||
std::string r = assets_root;
|
||||
if (r.back() != '/') r += '/';
|
||||
return r + rel;
|
||||
};
|
||||
std::string norm = normalize(vp);
|
||||
std::string suf = ymir_suffix(norm);
|
||||
if (!suf.empty()) {
|
||||
auto it = by_ymir.find(suf);
|
||||
if (it != by_ymir.end()) return join(it->second.real_path);
|
||||
if (reason) *reason = "未在 ymir 索引找到: " + suf;
|
||||
return "";
|
||||
}
|
||||
auto it = by_rel.find(norm);
|
||||
if (it != by_rel.end()) return join(it->second.real_path);
|
||||
if (reason) *reason = "未在相对路径索引找到: " + norm;
|
||||
return "";
|
||||
}
|
||||
|
||||
// --- 索引烘焙 / 装载 ---------------------------------------------------
|
||||
|
||||
std::string AssetResolver::save_index() const {
|
||||
// 行式:MTIDX1 头 + 每行 "Y|R\t<key>\t<rel>"。只存被采用的 real_path。
|
||||
std::ostringstream ss;
|
||||
ss << "MTIDX1\n";
|
||||
for (const auto& kv : by_ymir) {
|
||||
ss << "Y\t" << kv.first << '\t' << kv.second.real_path << '\n';
|
||||
}
|
||||
for (const auto& kv : by_rel) {
|
||||
ss << "R\t" << kv.first << '\t' << kv.second.real_path << '\n';
|
||||
}
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
bool AssetResolver::load_index(const std::string& root, const std::string& text,
|
||||
std::string* err) {
|
||||
by_ymir.clear();
|
||||
by_rel.clear();
|
||||
files_indexed = 0;
|
||||
ymir_conflicts = 0;
|
||||
conflict_keys.clear();
|
||||
assets_root = root;
|
||||
|
||||
std::istringstream in(text);
|
||||
std::string line;
|
||||
if (!std::getline(in, line) || line.rfind("MTIDX1", 0) != 0) {
|
||||
if (err) *err = "asset_index:坏头(期望 MTIDX1)";
|
||||
return false;
|
||||
}
|
||||
while (std::getline(in, line)) {
|
||||
if (line.empty() || line[0] == '#') continue;
|
||||
// tag \t key \t rel
|
||||
size_t t1 = line.find('\t');
|
||||
size_t t2 = (t1 == std::string::npos) ? std::string::npos : line.find('\t', t1 + 1);
|
||||
if (t2 == std::string::npos) continue;
|
||||
std::string tag = line.substr(0, t1);
|
||||
std::string key = line.substr(t1 + 1, t2 - t1 - 1);
|
||||
std::string rel = line.substr(t2 + 1);
|
||||
if (!rel.empty() && rel.back() == '\r') rel.pop_back();
|
||||
if (key.empty() || rel.empty()) continue;
|
||||
Entry e;
|
||||
e.real_path = rel;
|
||||
if (tag == "Y") {
|
||||
by_ymir.emplace(std::move(key), std::move(e));
|
||||
} else if (tag == "R") {
|
||||
by_rel.emplace(std::move(key), std::move(e));
|
||||
}
|
||||
++files_indexed;
|
||||
}
|
||||
if (by_ymir.empty() && by_rel.empty()) {
|
||||
if (err) *err = "asset_index:空索引";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AssetResolver::build_or_load(const std::string& root,
|
||||
const std::vector<std::string>& prio, std::string* err) {
|
||||
std::string text;
|
||||
if (read_file(root + "/asset_index.txt", text) && text.rfind("MTIDX1", 0) == 0) {
|
||||
return load_index(root, text, err);
|
||||
}
|
||||
return build(root, prio, err);
|
||||
}
|
||||
|
||||
bool AssetResolver::exists(const std::string& vp) const {
|
||||
return !resolve(vp, nullptr).empty();
|
||||
}
|
||||
|
||||
std::vector<std::string> AssetResolver::all_rel() const {
|
||||
std::vector<std::string> out;
|
||||
out.reserve(by_rel.size());
|
||||
for (const auto& kv : by_rel) out.push_back(kv.second.real_path);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace fmt
|
||||
Reference in New Issue
Block a user