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
+168
View File
@@ -0,0 +1,168 @@
# SkillTable (P6) —— 读 `locale/locale/<lang>/skilldesc.txt`skill id → 名/职业/动作。
#
# 列(TSV1-indexedPythonSkill.cpp __LoadSkillDesc):
# 1 id · 2 JOB(WARRIOR/ASSASSIN/SURA/SHAMAN/SUPPORT/HORSE/GUILD) · 3-5 name/name2/name3
# 6-9 desc1-4 · 10 (空) · 11 attributes(ATTACK_SKILL|PASSIVE|TOGGLE|STANDING_SKILL|…)
# 12 weapon_limit(SWORD|BOW|…) · 13 motion_name · 14 motion_idx · 15 grade_count · 16+ 公式
#
# var st := preload("res://ui/skill_table.gd").new()
# st.load_file("<assets>/locale/locale/en/skilldesc.txt")
# st.entry(1) # { id, job, name, …, attrs, motion, motion_idx, grades, cooltime }
# st.for_job("WARRIOR") # 主动职业技能 [id, …]
# st.for_category("SUPPORT") # 辅助 / 被动树
# st.is_passive(121) # true
extends RefCounted
var _by_id := {}
var count := 0
const JOB_DIR := {
"WARRIOR": "warrior",
"ASSASSIN": "assassin",
"SURA": "sura",
"SHAMAN": "shaman",
}
func load_file(path: String) -> bool:
_by_id.clear()
count = 0
if not FileAccess.file_exists(path):
return false
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return false
while not f.eof_reached():
var line := f.get_line()
if line.strip_edges() == "":
continue
var t := line.split("\t")
if t.size() < 14:
continue
var id := int(t[0])
if id == 0:
continue
# 0-indexed: id=0 job=1 name=2..4 desc=5..8 (9 空) attrs=10 weapon=11
# motion_name=12 motion_idx=13 grades=14
var entry := {
"id": id,
"job": t[1].strip_edges(),
"name": t[2].strip_edges(),
"name2": t[3].strip_edges(),
"name3": t[4].strip_edges(),
"attrs": t[10].strip_edges() if t.size() > 10 else "",
"weapon": t[11].strip_edges() if t.size() > 11 else "",
"motion": t[12].strip_edges() if t.size() > 12 else "",
"motion_idx": int(t[13]) if t.size() > 13 else 0,
"grades": int(t[14]) if t.size() > 14 else 1,
}
_load_cooltime(entry)
_by_id[id] = entry
count = _by_id.size()
return count > 0
func has(id: int) -> bool:
return _by_id.has(id)
func entry(id: int) -> Dictionary:
return _by_id.get(id, {})
func name_of(id: int) -> String:
return String(_by_id.get(id, {}).get("name", "skill %d" % id))
func motion_idx_of(id: int) -> int:
return int(_by_id.get(id, {}).get("motion_idx", 0))
func _attrs(id: int) -> String:
return String(_by_id.get(id, {}).get("attrs", ""))
func is_attack(id: int) -> bool:
return _attrs(id).contains("ATTACK_SKILL")
# 原版只有弓类飞行攻击在动作事件中发送 CG_SHOOT;把 weapon_limit 保留下来,
# 让上层可以只为远程技能安排 shoot,而不会给近战技能误发该包。
func is_ranged(id: int) -> bool:
return String(_by_id.get(id, {}).get("weapon", "")).to_upper().contains("BOW")
func is_passive(id: int) -> bool:
return _attrs(id).contains("PASSIVE")
func is_toggle(id: int) -> bool:
return _attrs(id).contains("TOGGLE")
func can_level_up(id: int) -> bool:
return _by_id.has(id) and not _attrs(id).contains("CANNOT_LEVEL_UP")
# 读取对应 .msk 中的 CoolTimeFormula 和 MaxLevel。原客户端用同一份公式
# 按技能效率百分比计算本地冷却;这里在发送后进行同样的本地预测,GC_SKILL_COOLTIME_END
# 仍是服务器的提前解锁信号。
func _load_cooltime(entry: Dictionary) -> void:
var job_dir := String(JOB_DIR.get(String(entry.get("job", "")), ""))
var motion := String(entry.get("motion", ""))
if job_dir == "" or motion == "":
return
var path := AssetRoot.path().path_join("PC/ymir work/pc/%s/skill/%s.msk" % [job_dir, motion])
if not FileAccess.file_exists(path):
return
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return
# .msk 常含 CP949/CP1252 本地化文本;只扫描 ASCII 配置行,不能把整份
# 文件当 UTF-8 逐行读,否则 Godot 会为无关文案输出解码警告。
var bytes := file.get_buffer(file.get_length())
var formula := _ascii_field(bytes, "CoolTimeFormula")
if formula != "":
entry["cooltime_formula"] = formula.trim_prefix("\"").trim_suffix("\"")
var max_level := _ascii_field(bytes, "MaxLevel")
if max_level != "":
entry["max_level"] = max(1, int(max_level.trim_prefix("\"").trim_suffix("\"")))
func _ascii_field(bytes: PackedByteArray, field: String) -> String:
var needle := field.to_ascii_buffer()
if needle.is_empty():
return ""
for start in range(bytes.size() - needle.size() + 1):
var found := true
for offset in needle.size():
if bytes[start + offset] != needle[offset]:
found = false
break
if not found:
continue
var out := PackedByteArray()
var i := start + needle.size()
while i < bytes.size() and bytes[i] != 10 and bytes[i] != 13:
if bytes[i] >= 32 and bytes[i] <= 126:
out.append(bytes[i])
i += 1
return out.get_string_from_ascii().strip_edges()
return ""
func cooldown_of(id: int, level: int) -> float:
var data := entry(id)
var formula := String(data.get("cooltime_formula", ""))
if formula == "":
return 0.0
var max_level: int = max(1, int(data.get("max_level", 20)))
var skill_point: float = clampf(float(level) / float(max_level), 0.0, 1.0)
var expression := Expression.new()
if expression.parse(formula, ["SkillPoint"]) != OK:
return 0.0
var value: Variant = expression.execute([skill_point])
if expression.has_execute_failed() or not (value is float or value is int):
return 0.0
return maxf(0.0, float(value))
# JOB 列的分类:主动树是 WARRIOR/ASSASSIN/SURA/SHAMAN;辅助/被动树是 SUPPORT;还有 HORSE / GUILD
func category_of(id: int) -> String:
return String(_by_id.get(id, {}).get("job", ""))
func for_job(job: String) -> Array:
return for_category(job)
func for_category(cat: String) -> Array:
var out := []
for id in _by_id:
if _by_id[id].get("job", "") == cat:
out.append(id)
out.sort()
return out