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:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+245
View File
@@ -0,0 +1,245 @@
#include "asset_resolver.h"
#include "m2_tokvec.h" // fmt::read_filehost 可注入 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