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
+282
View File
@@ -0,0 +1,282 @@
# CharStatusUI (P11) —— 角色状态窗(1:1 迁移 `assets/root/uicharacter.py` 的 STATUS 页)。
#
# var cs := preload("res://ui/char_status_ui.gd").new()
# add_child(cs)
# cs.setup(ui_manager, m2client)
# cs.toggle() # V 键
#
# 布局直接走 `assets/uiscript/uiscript/characterwindow.py`UiScript → UiBuild),
# 数值绑定逐字对照 uicharacter.py.RefreshStatus
# Level/Exp/RestExp、HP/SP、STR/DEX/HTH/INT、ATT/DEF、MATT/MDEF、ASPD/MSPD/CSPD/ER。
# 加点:HTH/INT/STR/DEX 的 / 按钮发聊天命令 `/stat ht` `/stat- ht`(与原客户端
# `statusPlusCommandDict` 完全一致)。points_changed 信号来时刷新。
#
# 技能 / 表情 / 任务三页只做页签切换(保留 uiscript 静态布局),实际数据在各自
# 的专用窗(SkillUI / QuestLog)里。
extends Node
const UiScript = preload("res://ui/uiscript.gd")
const UiBuild = preload("res://ui/ui_build.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
# --- EPointTypesm2dev Packet.h),只列本窗要用的 ---
const P_LEVEL := 1
const P_EXP := 3
const P_NEXT_EXP := 4
const P_HP := 5
const P_MAX_HP := 6
const P_SP := 7
const P_MAX_SP := 8
const P_ST := 12 # 근력 STR
const P_HT := 13 # 체력 HTH
const P_DX := 14 # 민첩 DEX
const P_IQ := 15 # 정신 INT
const P_ATT_POWER := 16
const P_ATT_SPEED := 17
const P_EVADE_RATE := 18
const P_MOV_SPEED := 19
const P_DEF_GRADE := 20
const P_CASTING_SPEED := 21
const P_MAGIC_ATT_GRADE := 22
const P_MAGIC_DEF_GRADE := 23
const P_STAT := 26 # 남은 능력치 포인트
const P_MIN_ATK := 29
const P_MAX_ATK := 30
const P_PARTY_ATT_GRADE := 91 # player.ATTACKER_BONUS
const P_ATT_GRADE_BONUS := 95 # player.ATT_BONUS
const P_DEF_GRADE_BONUS := 96 # player.DEF_BONUS
const P_MIN_MAGIC_WEP := 202
const P_MAX_MAGIC_WEP := 203
# uicharacter.py statusPlusCommandDict / statusMinusCommandDict
const PLUS_CMD := {"HTH": "/stat ht", "INT": "/stat iq", "STR": "/stat st", "DEX": "/stat dx"}
const MINUS_CMD := {"HTH": "/stat- ht", "INT": "/stat- iq", "STR": "/stat- st", "DEX": "/stat- dx"}
const STATES := ["STATUS", "SKILL", "EMOTICON", "QUEST"]
const PAGE := {"STATUS": "Character_Page", "SKILL": "Skill_Page", "EMOTICON": "Emoticon_Page", "QUEST": "Quest_Page"}
const TITLEBAR := {"STATUS": "Character_TitleBar", "SKILL": "Skill_TitleBar", "EMOTICON": "Emoticon_TitleBar", "QUEST": "Quest_TitleBar"}
const TAB_BUTTON := {"STATUS": "Tab_Button_01", "SKILL": "Tab_Button_02", "EMOTICON": "Tab_Button_03", "QUEST": "Tab_Button_04"}
const FACE_BY_JOB := ["face_warrior", "face_assassin", "face_sura", "face_shaman"]
var ui: CanvasLayer # UiManager
var client: Node # M2Client
var assets_root := ""
var uiscript_dir := ""
var _win: Dictionary = {} # { root, nodes }
var _state := "STATUS"
func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void:
ui = ui_manager
client = m2client
assets_root = assets
if assets_root == "" and ui and "assets_root" in ui:
assets_root = ui.assets_root
uiscript_dir = assets_root.path_join("uiscript/uiscript")
if not DirAccess.dir_exists_absolute(uiscript_dir):
uiscript_dir = assets_root.path_join("uiscript")
if client and client.has_signal("points_changed"):
client.points_changed.connect(_on_points_changed)
if client and client.has_signal("entity_main_set"):
client.entity_main_set.connect(func(_v): if is_open(): _refresh())
# --- open / close -----------------------------------------------------
func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func toggle() -> void:
if is_open(): close()
else: open()
func close() -> void:
if is_open():
ui.close(_win["root"])
_win = {}
func open(state := "") -> void:
if state != "" and state in STATES:
_state = state
if is_open():
_set_state(_state)
_refresh()
return
var path := uiscript_dir.path_join("characterwindow.py")
if not FileAccess.file_exists(path):
push_warning("CharStatusUI: no characterwindow.py at " + path)
return
_win = ui.open_script(path, assets_root)
if not is_open():
return
_wire_tabs()
_wire_stat_buttons()
_set_state(_state)
_refresh()
func _node(nm: String) -> Control:
if _win.is_empty():
return null
var nodes: Dictionary = _win.get("nodes", {})
var n = nodes.get(nm, null)
return n if n is Control else null
# --- tab switching (uicharacter.py.SetState) -------------------------
func _wire_tabs() -> void:
for st: String in STATES:
var key := st
var btn := _node(TAB_BUTTON[key])
if btn is BaseButton:
btn.pressed.connect(func(): _set_state(key))
func _set_state(state: String) -> void:
if not state in STATES:
return
_state = state
for st: String in STATES:
var on := (st == state)
var page := _node(PAGE[st])
if page:
page.visible = on
var bar := _node(TITLEBAR[st])
if bar:
bar.visible = on
var btn := _node(TAB_BUTTON[st])
if btn is BaseButton and btn.toggle_mode:
btn.set_pressed_no_signal(on)
# --- stat +/- buttons ----------------------------------------------------
func _wire_stat_buttons() -> void:
for key: String in PLUS_CMD:
var pb := _node(key + "_Plus")
if pb is BaseButton:
var cmd: String = PLUS_CMD[key]
pb.pressed.connect(func(): _send_stat(cmd))
var mb := _node(key + "_Minus")
if mb is BaseButton:
var mcmd: String = MINUS_CMD[key]
mb.pressed.connect(func(): _send_stat(mcmd))
func _send_stat(cmd: String) -> void:
if client and client.has_method("say"):
client.say(0, cmd) # CHAT_TYPE_TALKING —— 与 net.SendChatPacket 一致
# --- value binding (uicharacter.py.RefreshStatus) ----------------------
func _on_points_changed(_pts: Dictionary) -> void:
if is_open():
_refresh()
func _u32(v: int) -> int:
return v & 0xFFFFFFFF
func _refresh() -> void:
if not is_open() or client == null:
return
var pd: Dictionary = client.get_points() if client.has_method("get_points") else {}
if pd.is_empty():
return
var pts: Array = pd.get("points", [])
var get := func(i: int) -> int:
return int(pts[i]) if i >= 0 and i < pts.size() else 0
_set_text("Level_Value", str(get.call(P_LEVEL)))
_set_text("Exp_Value", str(_u32(int(pd.get("exp", get.call(P_EXP))))))
var rest_exp := _u32(int(pd.get("next_exp", get.call(P_NEXT_EXP)))) - _u32(int(pd.get("exp", get.call(P_EXP))))
_set_text("RestExp_Value", str(rest_exp))
var hp := int(pd.get("hp", get.call(P_HP)))
var max_hp := int(pd.get("max_hp", get.call(P_MAX_HP)))
var sp := int(pd.get("sp", get.call(P_SP)))
var max_sp := int(pd.get("max_sp", get.call(P_MAX_SP)))
_set_text("HP_Value", "%d/%d" % [hp, max_hp])
_set_text("SP_Value", "%d/%d" % [sp, max_sp])
_set_text("STR_Value", str(get.call(P_ST)))
_set_text("DEX_Value", str(get.call(P_DX)))
_set_text("HTH_Value", str(get.call(P_HT)))
_set_text("INT_Value", str(get.call(P_IQ)))
_set_text("ATT_Value", _att_text(get))
_set_text("DEF_Value", _def_text(get))
_set_text("MATT_Value", _matt_text(get))
_set_text("MDEF_Value", str(get.call(P_MAGIC_DEF_GRADE)))
_set_text("ASPD_Value", str(get.call(P_ATT_SPEED)))
_set_text("MSPD_Value", str(get.call(P_MOV_SPEED)))
_set_text("CSPD_Value", str(get.call(P_CASTING_SPEED)))
_set_text("ER_Value", str(get.call(P_EVADE_RATE)))
_refresh_stat_points(get.call(P_STAT))
_refresh_identity()
# uicharacter.__GetTotalAtkText: (min|max) + ATT_BONUS + ATTACKER_BONUS
func _att_text(get: Callable) -> String:
var lo := int(get.call(P_MIN_ATK))
var hi := int(get.call(P_MAX_ATK))
var bonus := int(get.call(P_ATT_GRADE_BONUS)) + int(get.call(P_PARTY_ATT_GRADE))
if lo == 0 and hi == 0:
# 服务器未下发 MIN/MAX_ATK 时退回等效攻击力(原客户端此处会显示 0)
var eff := int(get.call(P_ATT_POWER)) + bonus
return str(eff)
if lo == hi:
return str(lo + bonus)
return "%d-%d" % [lo + bonus, hi + bonus]
# uicharacter.__GetTotalDefText: DEF_GRADE (+ DEF_BONUS if ADD_DEF_BONUS_ENABLE)
func _def_text(get: Callable) -> String:
var d := int(get.call(P_DEF_GRADE))
var db := int(get.call(P_DEF_GRADE_BONUS))
if db != 0:
d += db
return str(d)
# uicharacter.__GetTotalMagAtkText: MAG_ATT + (MIN|MAX)_MAGIC_WEP
func _matt_text(get: Callable) -> String:
var base := int(get.call(P_MAGIC_ATT_GRADE))
var lo := base + int(get.call(P_MIN_MAGIC_WEP))
var hi := base + int(get.call(P_MAX_MAGIC_WEP))
if lo == hi:
return str(lo)
return "%d-%d" % [lo, hi]
# uicharacter.__RefreshStatusPlusButtonList
func _refresh_stat_points(stat_points: int) -> void:
_set_text("Status_Plus_Value", str(max(0, stat_points)))
var lbl := _node("Status_Plus_Label")
if lbl:
lbl.visible = stat_points > 0
for key: String in PLUS_CMD:
var pb := _node(key + "_Plus")
if pb:
pb.visible = stat_points > 0
func _refresh_identity() -> void:
var vid := int(client.get_main_vid()) if client.has_method("get_main_vid") else 0
var ent: Dictionary = client.get_entity(vid) if (vid != 0 and client.has_method("get_entity")) else {}
_set_text("Character_Name", String(ent.get("name", "")))
var guild_id := int(ent.get("guild", 0))
var guild_name := ""
if guild_id != 0 and client.has_method("get_guild_name"):
guild_name = String(client.get_guild_name(guild_id))
_set_text("Guild_Name", guild_name)
# 职业头像:race % 4 -> warrior/assassin/sura/shaman
var face := _node("Face_Image")
if face is TextureRect:
var job := int(ent.get("race", 0)) % 4
var tex := UiAssets.load_tex(assets_root,
"d:/ymir work/ui/game/windows/%s.sub" % FACE_BY_JOB[job])
if tex:
face.texture = tex
func _set_text(nm: String, value: String) -> void:
var n := _node(nm)
if n == null:
return
if n is Label:
n.text = value
elif n is Button:
n.text = value
elif n.has_method("set_text"):
n.set_text(value)