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
+152
View File
@@ -0,0 +1,152 @@
# char_status_ui_test —— P11 角色状态窗 headless 自检(假 client + 真 uiscript)。
# godot --headless --path project --script char_status_ui_test.gd
# 校验 characterwindow.py 装载、RefreshStatus 数值绑定(逐字对照 uicharacter.py)、
# /stat 加点命令、页签切换。缺 uiscript 资产时优雅跳过。
extends SceneTree
const UiManager = preload("res://ui/ui_manager.gd")
const CharStatusUI = preload("res://ui/char_status_ui.gd")
class FakeClient extends Node:
signal points_changed(points: Dictionary)
signal entity_main_set(vid: int)
var pts: Array = []
var says: Array = []
var _main_vid := 7
func _init() -> void:
pts.resize(255)
pts.fill(0)
pts[1] = 42 # LEVEL
pts[3] = 123456 # EXP
pts[4] = 200000 # NEXT_EXP
pts[5] = 850 # HP
pts[6] = 900 # MAX_HP
pts[7] = 120 # SP
pts[8] = 150 # MAX_SP
pts[12] = 33 # ST
pts[13] = 27 # HT
pts[14] = 18 # DX
pts[15] = 12 # IQ
pts[16] = 250 # ATT_POWER
pts[17] = 145 # ATT_SPEED
pts[18] = 40 # EVADE_RATE
pts[19] = 165 # MOV_SPEED
pts[20] = 88 # DEF_GRADE
pts[21] = 100 # CASTING_SPEED
pts[22] = 55 # MAGIC_ATT_GRADE
pts[23] = 44 # MAGIC_DEF_GRADE
pts[26] = 5 # STAT (남은 포인트)
pts[29] = 210 # MIN_ATK
pts[30] = 210 # MAX_ATK
pts[95] = 15 # ATT_GRADE_BONUS
func get_main_vid() -> int: return _main_vid
func get_points() -> Dictionary:
return {
"points": pts, "hp": pts[5], "max_hp": pts[6], "sp": pts[7], "max_sp": pts[8],
"level": pts[1], "exp": pts[3], "next_exp": pts[4], "gold": 0,
}
func get_entity(vid: int) -> Dictionary:
if vid != _main_vid:
return {}
return {"name": "TestHero", "guild": 9, "race": 5} # race 5 -> job 1 (assassin)
func get_guild_name(gid: int) -> String:
return "銀河聯盟" if gid == 9 else ""
func say(type: int, text: String) -> bool:
says.append([type, text]); return true
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: char_status_ui_test (layout + RefreshStatus + /stat + tabs)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _txt(cs: Node, nm: String) -> String:
var n: Control = cs._node(nm)
return String(n.text) if n and ("text" in n) else "<missing:%s>" % nm
func _run() -> void:
var assets := AssetRoot.path()
if not FileAccess.file_exists(assets.path_join("uiscript/uiscript/characterwindow.py")):
print(" (skip: no characterwindow.py — assets checkout missing)")
return
var ui: CanvasLayer = UiManager.new()
get_root().add_child(ui)
ui._ready()
var fc := FakeClient.new()
get_root().add_child(fc)
var cs: Node = CharStatusUI.new()
get_root().add_child(cs)
cs.setup(ui, fc, assets)
cs.open()
_ck(cs.is_open(), "character window opened")
if not cs.is_open():
return
# --- RefreshStatus 数值绑定 ---
_ck(_txt(cs, "Level_Value") == "42", "Level_Value = 42 (got %s)" % _txt(cs, "Level_Value"))
_ck(_txt(cs, "Exp_Value") == "123456", "Exp_Value = 123456 (got %s)" % _txt(cs, "Exp_Value"))
_ck(_txt(cs, "RestExp_Value") == "76544", "RestExp_Value = next-exp (got %s)" % _txt(cs, "RestExp_Value"))
_ck(_txt(cs, "HP_Value") == "850/900", "HP_Value = 850/900 (got %s)" % _txt(cs, "HP_Value"))
_ck(_txt(cs, "SP_Value") == "120/150", "SP_Value = 120/150 (got %s)" % _txt(cs, "SP_Value"))
_ck(_txt(cs, "STR_Value") == "33", "STR_Value = 33 (got %s)" % _txt(cs, "STR_Value"))
_ck(_txt(cs, "DEX_Value") == "18", "DEX_Value = 18 (got %s)" % _txt(cs, "DEX_Value"))
_ck(_txt(cs, "HTH_Value") == "27", "HTH_Value = 27 (got %s)" % _txt(cs, "HTH_Value"))
_ck(_txt(cs, "INT_Value") == "12", "INT_Value = 12 (got %s)" % _txt(cs, "INT_Value"))
# ATT: min==max==210, bonus = 95(15) + 91(0) -> 225
_ck(_txt(cs, "ATT_Value") == "225", "ATT_Value = 210+15 (got %s)" % _txt(cs, "ATT_Value"))
_ck(_txt(cs, "DEF_Value") == "88", "DEF_Value = 88 (got %s)" % _txt(cs, "DEF_Value"))
_ck(_txt(cs, "MATT_Value") == "55", "MATT_Value = 55 (no magic-wep) (got %s)" % _txt(cs, "MATT_Value"))
_ck(_txt(cs, "MDEF_Value") == "44", "MDEF_Value = 44 (got %s)" % _txt(cs, "MDEF_Value"))
_ck(_txt(cs, "ASPD_Value") == "145", "ASPD_Value = 145 (got %s)" % _txt(cs, "ASPD_Value"))
_ck(_txt(cs, "MSPD_Value") == "165", "MSPD_Value = 165 (got %s)" % _txt(cs, "MSPD_Value"))
_ck(_txt(cs, "CSPD_Value") == "100", "CSPD_Value = 100 (got %s)" % _txt(cs, "CSPD_Value"))
_ck(_txt(cs, "ER_Value") == "40", "ER_Value = 40 (got %s)" % _txt(cs, "ER_Value"))
_ck(_txt(cs, "Status_Plus_Value") == "5", "Status_Plus_Value = 5 (got %s)" % _txt(cs, "Status_Plus_Value"))
# --- identity ---
_ck(_txt(cs, "Character_Name") == "TestHero", "Character_Name = TestHero (got %s)" % _txt(cs, "Character_Name"))
_ck(_txt(cs, "Guild_Name") == "銀河聯盟", "Guild_Name resolved (got %s)" % _txt(cs, "Guild_Name"))
# --- +/- 按钮:STAT>0 时可见,命令与 uicharacter.statusPlusCommandDict 一致 ---
var hth_plus: Control = cs._node("HTH_Plus")
_ck(hth_plus != null and hth_plus.visible, "HTH_Plus visible while STAT>0")
if hth_plus is BaseButton:
hth_plus.pressed.emit()
_ck(fc.says.size() == 1 and fc.says[0][0] == 0 and fc.says[0][1] == "/stat ht",
"HTH_Plus -> say(0, '/stat ht') (got %s)" % str(fc.says))
var int_minus: Control = cs._node("INT_Minus")
if int_minus is BaseButton:
int_minus.pressed.emit()
_ck(fc.says.size() == 2 and fc.says[1][1] == "/stat- iq",
"INT_Minus -> say(0, '/stat- iq') (got %s)" % str(fc.says))
# --- STAT == 0 -> 加点按钮隐藏 + 刷新走 points_changed ---
fc.pts[26] = 0
fc.points_changed.emit(fc.get_points())
_ck(_txt(cs, "Status_Plus_Value") == "0", "Status_Plus_Value = 0 after change")
var hth_plus2: Control = cs._node("HTH_Plus")
_ck(hth_plus2 != null and not hth_plus2.visible, "HTH_Plus hidden while STAT==0")
# --- 页签切换(uicharacter.SetState ---
var char_page: Control = cs._node("Character_Page")
var skill_page: Control = cs._node("Skill_Page")
_ck(char_page != null and char_page.visible, "STATUS page visible by default")
cs._set_state("SKILL")
_ck(char_page != null and not char_page.visible, "Character_Page hidden after SetState(SKILL)")
_ck(skill_page != null and skill_page.visible, "Skill_Page visible after SetState(SKILL)")
cs._set_state("STATUS")
_ck(char_page.visible, "Character_Page visible again after SetState(STATUS)")
cs.close()
_ck(not cs.is_open(), "character window closed")