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
+263
View File
@@ -0,0 +1,263 @@
# PartyUI (P8/P11) —— 组队成员信息板(1:1 迁移 `assets/root/uiparty.py` 的
# `PartyMemberInfoBoard` + `PartyMemberInfoWindow`;布局照 `partymemberinfoboard.py` 的
# 每员 106×36 条:StateButton(角色图标) + NameSlot + Gauge(HP) + 附加图标行)。
#
# var pu := preload("res://ui/party_ui.gd").new()
# add_child(pu)
# pu.setup(m2client, canvas_parent, dialogs) # dialogs 可空
#
# 每员一条 strip:角色状态按钮(队长可点 → 分配角色 / 踢人)、名字(+★队长)、HP 条、
# 附加效果格(affects[7] 非零 → 小图标 + tooltip)。整条点击 → 选中该员 vid。
# 顶部:EXP 分配开关(均分 / 不均分 → party_set_distribute+ 组队治疗(party_use_skill)。
# `party_changed` 刷新;`party_invite_ask(leader_pid)` → 确认框 → party_answer。
extends Node
# PythonPlayer.h EPartyRole
const ROLE_NORMAL := 0
const ROLE_ATTACKER := 2
const ROLE_TANKER := 3
const ROLE_BUFFER := 4
const ROLE_SKILL_MASTER := 5
const ROLE_BERSERKER := 6
const ROLE_DEFENDER := 7
const ROLE_LABEL := {
0: "", 1: "", 2: "", 3: "", 4: "", 5: "", 6: "", 7: "",
}
const ROLE_MENU := [
[ROLE_NORMAL, "普通"], [ROLE_ATTACKER, "攻击"], [ROLE_TANKER, "坦克"],
[ROLE_BERSERKER, "狂战"], [ROLE_BUFFER, "辅助"], [ROLE_SKILL_MASTER, "宗师"],
[ROLE_DEFENDER, "防御"],
]
# Packet.h EPartyExpDistributionType
const EXP_NON_PARITY := 0 # 不均分(按贡献)
const EXP_PARITY := 1 # 均分
# uiparty.PartyMemberInfoBoard.PARTY_SKILL_*
const PARTY_SKILL_HEAL := 1
# affects[7] 槽位含义(暂定,按 partymemberinfoboard.py 图标顺序)
const AFFECT_LABEL := ["经验", "攻击", "防御", "辅助", "宗师", "时间", "回复"]
var client: Node
var dialogs: Node
var _root: Control
var _list: VBoxContainer
var _dist_btn: Button
var _role_popup: Control = null
func setup(m2client: Node, parent: Node, dlg: Node = null) -> void:
client = m2client
dialogs = dlg
_build(parent)
if client.has_signal("party_changed"):
client.party_changed.connect(refresh)
if client.has_signal("party_invite_ask"):
client.party_invite_ask.connect(_on_invite)
if client.has_signal("vitals_changed"):
client.vitals_changed.connect(func(_v): if is_open(): refresh())
refresh()
func is_open() -> bool:
return _root != null and _root.visible
# --- 로컬 플레이어가 파티장인가 ---
func _local_is_leader() -> bool:
if client == null or not client.has_method("get_main_vid"):
return false
var my_vid := int(client.get_main_vid())
for m in client.get_party():
if int(m.get("vid", 0)) == my_vid:
return bool(m.get("leader", false))
return false
# --- 새로고침 ------------------------------------------------------------
func refresh() -> void:
if client == null or not client.has_method("get_party"):
return
_dismiss_role_popup()
for c in _list.get_children():
_list.remove_child(c)
c.queue_free()
var members: Array = client.get_party()
_root.visible = not members.is_empty()
if members.is_empty():
return
_refresh_dist_btn()
for m in members:
_list.add_child(_strip(m))
func _refresh_dist_btn() -> void:
if not is_instance_valid(_dist_btn):
return
var mode := int(client.get_party_distribute_mode()) if client.has_method("get_party_distribute_mode") else 0
_dist_btn.text = "EXP:均分" if mode == EXP_PARITY else "EXP:不均分"
_dist_btn.disabled = not _local_is_leader()
# --- 한 명 strip ------------------------------------------------------
func _strip(m: Dictionary) -> Control:
var pid := int(m.get("pid", 0))
var vid := int(m.get("vid", 0))
var is_leader := bool(m.get("leader", false))
var role := int(m.get("state", 0)) & 0x7F
var strip := PanelContainer.new()
strip.custom_minimum_size = Vector2(206, 40)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 4)
strip.add_child(row)
# StateButton —— 角色图标(队长可点)
var state_btn := Button.new()
state_btn.custom_minimum_size = Vector2(22, 34)
state_btn.text = "" if is_leader else ROLE_LABEL.get(role, "")
state_btn.tooltip_text = "队长" if is_leader else ("角色:%s" % ROLE_LABEL.get(role, ""))
state_btn.disabled = not _local_is_leader()
state_btn.pressed.connect(func() -> void: _open_role_popup(state_btn, pid, role))
row.add_child(state_btn)
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 1)
col.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(col)
# NameSlot / NamePrint
var name_lbl := Button.new()
name_lbl.flat = true
name_lbl.alignment = HORIZONTAL_ALIGNMENT_LEFT
name_lbl.text = ("" if is_leader else "") + String(m.get("name", "?"))
name_lbl.add_theme_font_size_override("font_size", 12)
name_lbl.pressed.connect(func() -> void: _select_member(vid))
col.add_child(name_lbl)
# Gauge (HP)
var bar := ProgressBar.new()
bar.min_value = 0
bar.max_value = 100
bar.value = int(m.get("hp_pct", 0))
bar.show_percentage = false
bar.custom_minimum_size = Vector2(180, 8)
col.add_child(bar)
# 附가효과 아이콘 행
var affects: Array = m.get("affects", [])
var chips := HBoxContainer.new()
chips.add_theme_constant_override("separation", 2)
col.add_child(chips)
for i in affects.size():
var v := int(affects[i])
if v == 0:
continue
var chip := Label.new()
chip.text = AFFECT_LABEL[i] if i < AFFECT_LABEL.size() else "?%d" % i
chip.add_theme_font_size_override("font_size", 9)
chip.add_theme_color_override("font_color", Color(0.7, 0.9, 1.0))
chip.tooltip_text = "%s +%d" % [chip.text, v]
chips.add_child(chip)
return strip
func _select_member(vid: int) -> void:
if vid <= 0 or client == null:
return
if client.has_method("set_target"):
client.set_target(vid)
elif client.has_method("target"):
client.target(vid)
# --- 角色分配 팝업 (uiparty.__ShowStateButton + OnSelectState / OnExpel) ---
func _open_role_popup(anchor: Control, pid: int, cur_role: int) -> void:
_dismiss_role_popup()
if not _local_is_leader():
return
var pop := VBoxContainer.new()
pop.add_theme_constant_override("separation", 2)
var bg := PanelContainer.new()
bg.add_child(pop)
for entry in ROLE_MENU:
var r: int = entry[0]
var b := Button.new()
b.text = entry[1] + ("" if r == cur_role else "")
b.custom_minimum_size = Vector2(84, 22)
b.pressed.connect(func() -> void:
if r == ROLE_NORMAL:
# OnSelectState(-1): 清掉当前角色
client.party_set_state(pid, cur_role, false)
else:
client.party_set_state(pid, r, true)
_dismiss_role_popup())
pop.add_child(b)
var expel := Button.new()
expel.text = "踢出队伍"
expel.custom_minimum_size = Vector2(84, 22)
expel.add_theme_color_override("font_color", Color(1, 0.5, 0.5))
expel.pressed.connect(func() -> void:
client.party_leave(pid) # OnExpel -> SendPartyRemovePacket
_dismiss_role_popup())
pop.add_child(expel)
bg.position = anchor.get_global_position() + Vector2(24, 0)
_root.get_parent().add_child(bg)
_role_popup = bg
func _dismiss_role_popup() -> void:
if is_instance_valid(_role_popup):
_role_popup.queue_free()
_role_popup = null
# --- 邀请确认 ---------------------------------------------------------
func _on_invite(leader_pid: int) -> void:
var accept := func() -> void: client.party_answer(leader_pid, true)
var decline := func() -> void: client.party_answer(leader_pid, false)
if dialogs and dialogs.has_method("confirm"):
dialogs.confirm("有人邀请你加入队伍,接受?", accept, decline)
else:
accept.call()
# --- EXP 분배 / 组队治疗 --------------------------------------------
func _toggle_distribute() -> void:
if client == null or not _local_is_leader():
return
var mode := int(client.get_party_distribute_mode()) if client.has_method("get_party_distribute_mode") else 0
client.party_set_distribute(EXP_NON_PARITY if mode == EXP_PARITY else EXP_PARITY)
func _party_heal() -> void:
if client and client.has_method("party_use_skill"):
client.party_use_skill(PARTY_SKILL_HEAL, 0) # OnPartyUseSkill
# --- 레이아웃 ------------------------------------------------------------
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_TOP_LEFT)
_root.position = Vector2(16, 150)
_root.size = Vector2(226, 320)
_root.visible = false
parent.add_child(_root)
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.06, 0.07, 0.09, 0.85)
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
var header := HBoxContainer.new()
header.position = Vector2(8, 6)
header.add_theme_constant_override("separation", 6)
_root.add_child(header)
_dist_btn = Button.new()
_dist_btn.text = "EXP:均分"
_dist_btn.add_theme_font_size_override("font_size", 11)
_dist_btn.pressed.connect(_toggle_distribute)
header.add_child(_dist_btn)
var heal := Button.new()
heal.text = "组队治疗"
heal.add_theme_font_size_override("font_size", 11)
heal.pressed.connect(_party_heal)
header.add_child(heal)
_list = VBoxContainer.new()
_list.position = Vector2(8, 32)
_list.add_theme_constant_override("separation", 4)
_root.add_child(_list)