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
+245
View File
@@ -0,0 +1,245 @@
# SystemOptionUI (P11) —— 系统设置窗(1:1 迁移 `assets/root/uisystemoption.py` `OptionDialog`)。
#
# var so := preload("res://ui/system_option_ui.gd").new()
# add_child(so)
# so.setup(ui_manager, assets_root, audio_node, func(): return cam, func(): return env)
# so.toggle() # ESC(无其它窗打开时)
#
# 布局走真 `assets/uiscript/uiscript/systemoptiondialog.py`UiScript → UiBuild)。
# 控件绑定逐字对照 uisystemoption.py
# music_volume_controller → snd.SetMusicVolume + systemSetting.SetMusicVolume
# sound_volume_controller → snd.SetSoundVolume + systemSetting.SetSoundVolume
# camera_short / camera_long → systemSetting 镜头最大距离(近/远)
# fog_level0 / 1 / 2 (浓/中/淡) → systemSetting.SetFogLevel(0..2)
# tiling_cpu / tiling_gpu / apply → 原为 CPU/GPU 分块渲染,Godot 渲染器无对应,保留为占位
# 设置持久化到 `user://system_option.cfg`= 原 systemSetting 的配置文件)。
extends Node
const CFG_PATH := "user://system_option.cfg"
const CAMERA_MAX := [11.0, 20.0] # 近 / 远(game_camera.max_dist
const FOG_DENSITY := [0.055, 0.018, 0.004] # 浓 / 中 / 淡(Environment.fog_density
# uiScriptLocale 未接全局 Locale 时的中文兜底
const LABELS := {
"titlename": "系统设置", "music_name": "背景音乐", "sound_name": "音效",
"bgm_button": "更换", "bgm_file": "默认主题", "camera_mode": "镜头距离",
"camera_short": "", "camera_long": "", "fog_mode": "",
"fog_level0": "", "fog_level1": "", "fog_level2": "",
"tiling_mode": "图形内存", "tiling_cpu": "CPU", "tiling_gpu": "GPU", "tiling_apply": "应用",
}
var ui: CanvasLayer
var assets_root := ""
var uiscript_dir := ""
var audio: Node
var _cam_get: Callable = Callable()
var _env_get: Callable = Callable()
var _win: Dictionary = {}
var _cfg := ConfigFile.new()
# --- 状态(= systemSetting 值)---
var music_volume := 0.6
var sound_volume := 0.9
var camera_mode := 1 # 0 近, 1 远
var fog_level := 1 # 0 浓, 1 中, 2 淡
var tiling_mode := 1 # 0 CPU, 1 GPU(占位)
func setup(ui_manager: CanvasLayer, assets: String, audio_node: Node = null,
cam_getter := Callable(), env_getter := Callable()) -> void:
ui = ui_manager
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")
audio = audio_node
_cam_get = cam_getter
_env_get = env_getter
_load_cfg()
_apply_all() # 持久化的设置在开窗前就生效
# --- 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() -> void:
if is_open():
return
var path := uiscript_dir.path_join("systemoptiondialog.py")
if not FileAccess.file_exists(path):
push_warning("SystemOptionUI: no systemoptiondialog.py at " + path)
return
_win = ui.open_script(path, assets_root)
if not is_open():
return
_relabel()
_wire()
_sync_controls()
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
# uiScriptLocale 兜底:把 `<KEY>` 文本换成中文
func _relabel() -> void:
for nm in LABELS:
var n := _node(nm)
if n and n.has_method("set_text"):
n.set_text(LABELS[nm])
# --- wiring (uisystemoption.__Load) ---------------------------------
func _wire() -> void:
var mv := _node("music_volume_controller")
if mv is Range:
mv.min_value = 0.0
mv.max_value = 1.0
mv.step = 0.01
mv.value = music_volume
mv.value_changed.connect(_on_music_volume)
var sv := _node("sound_volume_controller")
if sv is Range:
sv.min_value = 0.0
sv.max_value = 1.0
sv.step = 0.01
sv.value = sound_volume
sv.value_changed.connect(_on_sound_volume)
_bind_radio(["camera_short", "camera_long"], func(i):
camera_mode = i
_apply_camera()
_save())
_bind_radio(["fog_level0", "fog_level1", "fog_level2"], func(i):
fog_level = i
_apply_fog()
_save())
_bind_radio(["tiling_cpu", "tiling_gpu"], func(i):
tiling_mode = i
_save())
var apply_btn := _node("tiling_apply")
if apply_btn is BaseButton:
apply_btn.pressed.connect(func():
# 原 __OnClickTilingApplyButton 会重启客户端应用 CPU/GPU 分块;
# Godot 渲染器无此项,仅记录选择。
_save())
var bgm_btn := _node("bgm_button")
if bgm_btn is BaseButton:
bgm_btn.disabled = true # 换 BGM 需音乐文件选择器,暂缺
func _bind_radio(names: Array, on_pick: Callable) -> void:
var boxes: Array = []
for nm in names:
boxes.append(_node(nm))
for i in boxes.size():
var b = boxes[i]
if not (b is BaseButton):
continue
b.toggle_mode = true
var idx := i
b.pressed.connect(func():
for j in boxes.size():
if boxes[j] is BaseButton:
boxes[j].set_pressed_no_signal(j == idx)
on_pick.call(idx))
# 把当前状态回填到控件(开窗时)
func _sync_controls() -> void:
_set_radio(["camera_short", "camera_long"], camera_mode)
_set_radio(["fog_level0", "fog_level1", "fog_level2"], fog_level)
_set_radio(["tiling_cpu", "tiling_gpu"], tiling_mode)
func _set_radio(names: Array, active: int) -> void:
for i in names.size():
var b = _node(names[i])
if b is BaseButton:
b.set_pressed_no_signal(i == active)
# --- change handlers ----------------------------------------------------
func _on_music_volume(v: float) -> void:
music_volume = clampf(v, 0.0, 1.0)
_apply_music()
_save()
func _on_sound_volume(v: float) -> void:
sound_volume = clampf(v, 0.0, 1.0)
_apply_sound()
_save()
# --- apply to engine --------------------------------------------------
func _apply_all() -> void:
_apply_music()
_apply_sound()
_apply_camera()
_apply_fog()
func _apply_music() -> void:
if audio == null:
return
if "master_bgm" in audio:
audio.master_bgm = music_volume
# 已在播放的 BGM 声道即时跟随
if "_bgm" in audio and "_bgm_cur" in audio:
var pl = audio._bgm[audio._bgm_cur]
if is_instance_valid(pl):
pl.volume_db = linear_to_db(maxf(music_volume, 0.0001))
func _apply_sound() -> void:
if audio and "master_sfx" in audio:
audio.master_sfx = sound_volume # play_ui / play_at 每次读取
func _apply_camera() -> void:
if not _cam_get.is_valid():
return
var c = _cam_get.call()
if c == null:
return
var m: float = CAMERA_MAX[clampi(camera_mode, 0, 1)]
if "max_dist" in c:
c.max_dist = m
if "dist" in c:
c.dist = minf(c.dist, m)
func _apply_fog() -> void:
if not _env_get.is_valid():
return
var e = _env_get.call()
if not (e is Environment):
return
e.fog_enabled = true
e.fog_density = FOG_DENSITY[clampi(fog_level, 0, 2)]
# --- persistence (= systemSetting 配置文件) --------------------------
func _load_cfg() -> void:
if _cfg.load(CFG_PATH) != OK:
return
music_volume = float(_cfg.get_value("audio", "music_volume", music_volume))
sound_volume = float(_cfg.get_value("audio", "sound_volume", sound_volume))
camera_mode = int(_cfg.get_value("video", "camera_mode", camera_mode))
fog_level = int(_cfg.get_value("video", "fog_level", fog_level))
tiling_mode = int(_cfg.get_value("video", "tiling_mode", tiling_mode))
func _save() -> void:
_cfg.set_value("audio", "music_volume", music_volume)
_cfg.set_value("audio", "sound_volume", sound_volume)
_cfg.set_value("video", "camera_mode", camera_mode)
_cfg.set_value("video", "fog_level", fog_level)
_cfg.set_value("video", "tiling_mode", tiling_mode)
_cfg.save(CFG_PATH)