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
+126
View File
@@ -0,0 +1,126 @@
// M2 T1 —— Metin2 文本脚本 token 树解析。
#include "textscript.h"
#include "m2_tokvec.h" // fmt::read_file (host-injectable; res:// on mobile)
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
namespace fmt {
namespace {
bool ieq(const std::string& a, const char* b) {
if (a.size() != std::strlen(b)) return false;
for (size_t i = 0; i < a.size(); ++i)
if (std::tolower((unsigned char)a[i]) != std::tolower((unsigned char)b[i])) return false;
return true;
}
// tokenize 一行为止:支持 "quoted"(含空白)、# 注释
std::vector<std::string> tokenize_all(const std::string& text) {
std::vector<std::string> toks;
size_t i = 0, n = text.size();
while (i < n) {
char c = text[i];
if (c == '#' || (c == '/' && i + 1 < n && text[i + 1] == '/')) { // 行注释
while (i < n && text[i] != '\n') ++i;
continue;
}
if (std::isspace((unsigned char)c)) { ++i; continue; }
if (c == '{' || c == '}') { toks.emplace_back(1, c); ++i; continue; }
if (c == '"') {
++i; std::string s;
while (i < n && text[i] != '"') s += text[i++];
if (i < n) ++i; // 跳过右引号
toks.push_back(s);
continue;
}
std::string s;
while (i < n && !std::isspace((unsigned char)text[i]) && text[i] != '{' && text[i] != '}')
s += text[i++];
toks.push_back(s);
}
return toks;
}
// 递归解析一个 Group 体(已消费到 '{' 之后;到匹配的 '}' 或流尾停)
bool parse_body(const std::vector<std::string>& t, size_t& i, Node& node, std::string* err) {
while (i < t.size()) {
const std::string& tok = t[i];
if (tok == "}") { ++i; return true; }
if (ieq(tok, "Group")) {
if (i + 2 >= t.size()) { if (err) *err = "Group 缺名字 / {"; return false; }
Node sub;
sub.name = t[i + 1];
if (t[i + 2] != "{") { if (err) *err = "Group '" + sub.name + "' 后缺 {"; return false; }
i += 3;
if (!parse_body(t, i, sub, err)) return false;
node.groups.push_back(std::move(sub));
continue;
}
// 普通行:从 tok 起,直到下一个会开启新语义的 token(Group / } / 或另一行的 key
// .msa/.msm 是「一行一 key」,我们靠换行还原不了(已丢),改用:
// key + 其后所有非 Group/非} token 直到遇到一个「像 key 的」…太脆。
// 实际这些文件每行 = key + 定长值,值都是数字 / 带引号串 / 简单标识。
// 简化:把连续的 token 收进当前行,遇到 Group/} 或「首字母大写且后面还跟 Group 结构」时断。
// 经验规则:值不会是 "Group"key 之后收 1..N 个值,遇到下一个满足「不是纯数字、不是带过引号」
// 的裸标识就当新行开始。对 dance_1.msa / warrior_w.msm 足够。
std::vector<std::string> line;
line.push_back(t[i++]);
while (i < t.size()) {
const std::string& x = t[i];
if (x == "}" || ieq(x, "Group")) break;
// 判断 x 是否是「新行的 key」:不含数字开头、不含 '.'、'/'、'\\'、'"' 迹象,
// 且当前行已至少有 1 个值
bool looks_key = line.size() >= 2 &&
(std::isalpha((unsigned char)x[0]) || x[0] == '_') &&
x.find_first_of("/\\.:") == std::string::npos;
// 数字 / 负号 / 小数一律当值
bool is_num = !x.empty() && (x[0] == '-' || x[0] == '+' || x[0] == '.' ||
std::isdigit((unsigned char)x[0]));
if (looks_key && !is_num) break;
line.push_back(x);
++i;
}
node.lines.push_back(std::move(line));
}
return true; // 流尾(根节点)
}
} // namespace
const std::vector<std::string>* Node::find(const char* key) const {
for (const auto& l : lines) if (!l.empty() && ieq(l[0], key)) return &l;
return nullptr;
}
std::string Node::str(const char* key, const std::string& def) const {
auto* l = find(key);
return (l && l->size() >= 2) ? (*l)[1] : def;
}
float Node::num(const char* key, float def) const {
auto* l = find(key);
return (l && l->size() >= 2) ? float(std::atof((*l)[1].c_str())) : def;
}
int Node::inum(const char* key, int def) const {
auto* l = find(key);
return (l && l->size() >= 2) ? std::atoi((*l)[1].c_str()) : def;
}
const Node* Node::group(const char* name) const {
for (const auto& g : groups) if (ieq(g.name, name)) return &g;
return nullptr;
}
bool parse_textscript(const std::string& text, Node& root, std::string* err) {
root = Node{};
auto toks = tokenize_all(text);
size_t i = 0;
return parse_body(toks, i, root, err);
}
bool parse_textscript_file(const std::string& path, Node& root, std::string* err) {
std::string s;
if (!read_file(path, s)) { if (err) *err = "cannot open " + path; return false; }
return parse_textscript(s, root, err);
}
} // namespace fmt