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
+193
View File
@@ -0,0 +1,193 @@
# GameOptionUI (P11) —— 游戏设置窗(1:1 迁移 `assets/root/uigameoption.py` `OptionDialog`)。
#
# var go := preload("res://ui/game_option_ui.gd").new()
# add_child(go)
# go.setup(ui_manager, m2client, assets_root)
# go.toggle() # 由 system_menu_ui 的 game_option_button 打开
#
# 布局走真 `assets/uiscript/uiscript/gameoptiondialog.py`。绑定逐字对照 uigameoption.py
# block_{exchange,party,guild,whisper,friend,party_request}_button (toggle) →
# `/setblockmode <mask ^ bit>`EBlockAction 位:1<<0..1<<5)—— 真聊天命令
# pvp_{peace,revenge,guild,free} (radio) → `/pkmode {0,1,4,2}` —— 真聊天命令
# name_color / target_board / view_chat / always_show_name / show_damage / salestext (radio)
# —— 客户端显示开关,持久化到 user://system_option.cfg [gameopt];渲染侧钩子待补。
extends Node
# Packet.h EBlockAction
const BLOCK_BITS := {
"block_exchange_button": 1 << 0,
"block_party_button": 1 << 1,
"block_guild_button": 1 << 2,
"block_whisper_button": 1 << 3,
"block_friend_button": 1 << 4,
"block_party_request_button": 1 << 5,
}
# uigameoption: peace /pkmode 0, revenge /pkmode 1, free /pkmode 2, guild /pkmode 4
const PK_CMD := {"pvp_peace": 0, "pvp_revenge": 1, "pvp_free": 2, "pvp_guild": 4}
# 客户端显示开关:{radio 名 -> [cfg key, 该按钮代表的值]}
const DISPLAY_RADIOS := {
"name_color_normal": ["name_color", 0], "name_color_empire": ["name_color", 1],
"target_board_no_view": ["target_board", 0], "target_board_view": ["target_board", 1],
"view_chat_on_button": ["view_chat", 1], "view_chat_off_button": ["view_chat", 0],
"always_show_name_on_button": ["always_show_name", 1], "always_show_name_off_button": ["always_show_name", 0],
"show_damage_on_button": ["show_damage", 1], "show_damage_off_button": ["show_damage", 0],
"salestext_on_button": ["salestext", 1], "salestext_off_button": ["salestext", 0],
}
const RADIO_GROUPS := [
["name_color_normal", "name_color_empire"],
["target_board_no_view", "target_board_view"],
["view_chat_on_button", "view_chat_off_button"],
["always_show_name_on_button", "always_show_name_off_button"],
["show_damage_on_button", "show_damage_off_button"],
["salestext_on_button", "salestext_off_button"],
]
const LABELS := {
"titlename": "游戏设置", "name_color": "名字颜色", "target_board": "目标框",
"pvp_mode": "PK 模式", "block": "屏蔽", "chat": "聊天显示",
"always_show_name": "总显示名字", "effect_on_off": "伤害数字", "salestext_on_off": "叫卖文字",
"name_color_normal": "普通", "name_color_empire": "阵营",
"target_board_no_view": "不看他国", "target_board_view": "看他国",
"pvp_peace": "和平", "pvp_revenge": "反击", "pvp_guild": "帮会", "pvp_free": "自由",
"block_exchange_button": "交易", "block_party_button": "组队", "block_guild_button": "帮会",
"block_whisper_button": "密语", "block_friend_button": "好友", "block_party_request_button": "组队申请",
"view_chat_on_button": "", "view_chat_off_button": "",
"always_show_name_on_button": "", "always_show_name_off_button": "",
"show_damage_on_button": "", "show_damage_off_button": "",
"salestext_on_button": "", "salestext_off_button": "",
}
const CFG_PATH := "user://system_option.cfg"
var ui: CanvasLayer
var client: Node
var assets_root := ""
var uiscript_dir := ""
var _win: Dictionary = {}
var _cfg := ConfigFile.new()
var _block_mode := 0 # blockMode(本地跟踪;服务器回包同步待补)
var _display := {} # cfg [gameopt] 快照
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")
_cfg.load(CFG_PATH)
for k in ["name_color", "target_board", "view_chat", "always_show_name", "show_damage", "salestext"]:
_display[k] = int(_cfg.get_value("gameopt", k, 1 if k in ["view_chat", "always_show_name", "show_damage"] else 0))
_block_mode = int(_cfg.get_value("gameopt", "block_mode", 0))
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() -> void:
if is_open():
return
var path := uiscript_dir.path_join("gameoptiondialog.py")
if not FileAccess.file_exists(path):
push_warning("GameOptionUI: no gameoptiondialog.py at " + path)
return
_win = ui.open_script(path, assets_root)
if not is_open():
return
_relabel()
_wire()
_sync()
func _node(nm: String) -> Control:
if _win.is_empty():
return null
var n = _win.get("nodes", {}).get(nm, null)
return n if n is Control else null
func _relabel() -> void:
for nm in LABELS:
var n := _node(nm)
if n and n.has_method("set_text"):
n.set_text(LABELS[nm])
func _wire() -> void:
# 屏蔽 toggle → /setblockmode
for nm in BLOCK_BITS:
var b := _node(nm)
if b is BaseButton:
b.toggle_mode = true
var bit: int = BLOCK_BITS[nm]
b.pressed.connect(func(): _toggle_block(bit))
# PK radio → /pkmode
_bind_radio(PK_CMD.keys(), func(nm):
if client and client.has_method("say"):
client.say(0, "/pkmode %d" % PK_CMD[nm])) # CHAT_TYPE_TALKING
# 显示开关 radio → 持久化
for group in RADIO_GROUPS:
_bind_radio(group, func(nm):
var spec: Array = DISPLAY_RADIOS[nm]
_display[spec[0]] = int(spec[1])
_save())
var tb := _node("titlebar")
if tb:
for x in tb.find_children("*", "BaseButton", true, false):
x.pressed.connect(close)
func _bind_radio(names: Array, on_pick: Callable) -> void:
var boxes := {}
for nm: String in names:
boxes[nm] = _node(nm)
for nm: String in names:
var b = boxes[nm]
if not (b is BaseButton):
continue
b.toggle_mode = true
var picked: String = nm
b.pressed.connect(func():
for other: String in names:
if boxes[other] is BaseButton:
boxes[other].set_pressed_no_signal(other == picked)
on_pick.call(picked))
func _toggle_block(bit: int) -> void:
_block_mode ^= bit
if client and client.has_method("say"):
client.say(0, "/setblockmode %d" % _block_mode)
_cfg.set_value("gameopt", "block_mode", _block_mode)
_cfg.save(CFG_PATH)
_sync_block()
# 开窗时把状态回填到控件
func _sync() -> void:
_sync_block()
for group in RADIO_GROUPS:
var key: String = DISPLAY_RADIOS[group[0]][0]
var cur := int(_display.get(key, 0))
for nm in group:
var b := _node(nm)
if b is BaseButton:
b.set_pressed_no_signal(int(DISPLAY_RADIOS[nm][1]) == cur)
func _sync_block() -> void:
for nm in BLOCK_BITS:
var b := _node(nm)
if b is BaseButton:
b.set_pressed_no_signal((_block_mode & int(BLOCK_BITS[nm])) != 0)
func _save() -> void:
for k in _display:
_cfg.set_value("gameopt", k, int(_display[k]))
_cfg.set_value("gameopt", "block_mode", _block_mode)
_cfg.save(CFG_PATH)