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
+683
View File
@@ -0,0 +1,683 @@
# CharSelectScreen —— Metin2 风格的选人界面。
#
# var s := preload("res://ui/char_select_screen.gd").new()
# parent.add_child(s)
# s.setup(m2client, assets_root, chars) # chars = M2Client.char_list 数组
# s.select_requested.connect(func(idx): client.select_character(idx))
# s.back_requested.connect(_goto_login)
#
# 组成:全屏背景(select.jpg+ 右侧 3D 角色(SubViewport 里的 PlayerView,缓转 + wait
# + 左侧信息板(thinboard:帮会徽 / 帮会名 / 名称 / 等级 / 游戏时间 / 体力·智力·力量·敏捷)
# + 左上职业名(locale name_<class>.dds,解不出就用中文描边字)+ 底部 开始/创建/删除/退出。
# 所有素材都是可选的:缺哪个降级哪个,绝不崩。
extends Control
const UiAssets = preload("res://ui/ui_assets.gd")
const UiKit = preload("res://ui_kit.gd")
const PlayerView = preload("res://ui/player_view.gd")
signal select_requested(index: int)
signal delete_requested(index: int, private_code: String)
signal create_requested(spec: Dictionary) # {slot, name, job, shape, con, int, str, dex}
signal back_requested()
const CLASS_CN := ["猛将", "刺客", "术士", "巫女"]
const CLASS_DIR := ["warrior", "assassin", "sura", "shaman"]
# 建号默认四维(CON, INT, STR, DEX)—— 对齐经典 Metin2 各职业起始属性。
const JOB_BASE_STATS := {
0: {"con": 4, "int": 3, "str": 6, "dex": 3}, # warrior
1: {"con": 3, "int": 3, "str": 4, "dex": 6}, # assassin
2: {"con": 3, "int": 5, "str": 5, "dex": 3}, # sura
3: {"con": 4, "int": 6, "str": 3, "dex": 3}, # shaman
}
const EMPIRE_KEY := {1: "EMPIRE_A", 2: "EMPIRE_B", 3: "EMPIRE_C"}
const EMPIRE_FALLBACK := {0: "", 1: "神獸王國", 2: "天朝王國", 3: "眞魔王國"}
const EMPIRE_FLAG := {1: "a", 2: "b", 3: "c"}
const STAT_ROWS := [
{"key": "ht", "label": "体力", "color": Color(0.90, 0.30, 0.30)},
{"key": "iq", "label": "智力", "color": Color(0.85, 0.45, 0.95)},
{"key": "st", "label": "力量", "color": Color(0.70, 0.55, 1.00)},
{"key": "dx", "label": "敏捷", "color": Color(0.35, 0.75, 1.00)},
]
var _client: Node
var _assets := ""
var _chars: Array = []
var _sel := 0
var _viewport: SubViewport
var _pivot: Node3D
var _cam: Camera3D
var _pv: Node # PlayerView
var _panel_box: VBoxContainer
var _class_img: TextureRect
var _class_lbl: Label
var _status: Label
var _slot_lbl: Label
var _spin := 0.0
func setup(client: Node, assets_root: String, chars: Array) -> void:
_client = client
_assets = assets_root
_chars = _pad_slots(chars)
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_build_background()
_build_stage()
_build_class_name()
_build_panel()
_build_status()
set_process(true)
_select(_first_occupied())
# char_list 只带占位了角色的槽;补齐到 slot_count 个(空槽 name=""),◀▶ 能走到空槽建号。
func _pad_slots(chars: Array) -> Array:
var n := 3
if _client and _client.has_method("get_slot_count"):
n = maxi(int(_client.get_slot_count()), chars.size())
n = maxi(n, chars.size())
var by_idx := {}
for c in chars:
by_idx[int(c.get("index", 0))] = c
var out: Array = []
for i in n:
out.append(by_idx.get(i, {"index": i, "name": "", "job": 0, "level": 0}))
return out
func _first_occupied() -> int:
for i in _chars.size():
if String(_chars[i].get("name", "")) != "":
return i
return 0
# 由 app_flow 在收到新的 char_list(建/删号后 M2Client 重发)时调用,保持当前选中槽。
func set_chars(chars: Array) -> void:
var keep := _slot_index()
_chars = _pad_slots(chars)
for i in _chars.size():
if int(_chars[i].get("index", -1)) == keep:
_sel = i
break
_sel = clampi(_sel, 0, maxi(_chars.size() - 1, 0))
_refresh_class_name()
_refresh_panel()
_rebuild_model()
func on_char_created(slot: int) -> void:
_close_dialog()
_set_status("角色已创建(槽位 %d" % slot)
func on_char_create_failed(reason_type: int) -> void:
const R := {1: "该名称已被使用", 2: "名称不合法", 3: "该槽位已有角色", 4: "权限不足"}
_set_status("创建失败:%s" % R.get(reason_type, "错误码 %d" % reason_type))
func on_char_deleted(slot: int) -> void:
_close_dialog()
_set_status("角色已删除(槽位 %d" % slot)
func on_char_delete_failed() -> void:
var e: Node = _dialog_node()
if e:
var code := e.find_child("Code", true, false)
if code is LineEdit:
(code as LineEdit).text = ""
_set_status("删除失败:删除码(社交号)不正确")
# --- 背景 ---------------------------------------------------------------------
func _build_background() -> void:
var tex := UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/select/select.sub")
var cr := ColorRect.new()
cr.color = Color(0.05, 0.06, 0.07)
cr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
cr.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(cr)
if tex != null:
var tr := TextureRect.new()
tr.texture = tex
tr.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
tr.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
tr.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(tr)
# --- 右侧 3D 角色 -----------------------------------------------------------
func _build_stage() -> void:
var vpc := SubViewportContainer.new()
vpc.stretch = true
vpc.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vpc.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(vpc)
_viewport = SubViewport.new()
_viewport.transparent_bg = true
_viewport.own_world_3d = true
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
_viewport.msaa_3d = Viewport.MSAA_4X
vpc.add_child(_viewport)
var root := Node3D.new()
_viewport.add_child(root)
var key := DirectionalLight3D.new()
key.rotation_degrees = Vector3(-32, 28, 0)
key.light_energy = 1.7
key.light_color = Color(1.0, 0.96, 0.90)
root.add_child(key)
var rim := DirectionalLight3D.new()
rim.rotation_degrees = Vector3(-6, 200, 0)
rim.light_energy = 0.7
rim.light_color = Color(0.75, 0.83, 1.0)
root.add_child(rim)
var we := WorldEnvironment.new()
var e := Environment.new()
e.background_mode = Environment.BG_COLOR
e.background_color = Color(0, 0, 0, 0)
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(0.42, 0.45, 0.55)
e.ambient_light_energy = 0.9
e.tonemap_mode = Environment.TONE_MAPPER_FILMIC
we.environment = e
root.add_child(we)
_pivot = Node3D.new()
root.add_child(_pivot)
_cam = Camera3D.new()
_cam.fov = 30.0
_cam.position = Vector3(0.0, 1.0, 4.0)
root.add_child(_cam)
_cam.make_current()
# 相机按模型包围球自适应:球心 + 半径/sin(fovV/2) 保证整模无论姿势都不出框,
# 再沿 -X 平移“半径的一小截”把人物推到右半屏(横向视锥比纵向宽,不会切到远侧手)。
func _frame_model() -> void:
if _cam == null or _pv == null or not is_instance_valid(_pv):
return
var aabb := AABB()
var first := true
for mi in (_pv as Node).find_children("*", "MeshInstance3D", true, false):
var m := mi as MeshInstance3D
if m.mesh == null:
continue
var wb: AABB = m.get_global_transform() * m.get_aabb()
aabb = wb if first else aabb.merge(wb)
first = false
if first:
return
var c := aabb.get_center()
var r: float = maxf(aabb.size.length() * 0.5, 0.9)
var dist: float = r / sin(deg_to_rad(_cam.fov * 0.5)) * 1.12 # 12% 余量
var off := Vector3(-r * 0.5, 0.0, 0.0)
_cam.position = c + off + Vector3(0.0, r * 0.04, dist)
_cam.look_at(c + off, Vector3.UP)
func _rebuild_model() -> void:
if _pivot == null:
return
if _pv != null and is_instance_valid(_pv):
_pv.queue_free()
_pv = null
var c := _cur()
if c.is_empty() or String(c.get("name", "")) == "":
return
var job := clampi(int(c.get("job", 0)), 0, 3)
var pump: Callable = _client.net_poll if _client and _client.has_method("net_poll") else Callable()
var pv := PlayerView.new()
_pivot.add_child(pv)
if pv.build(_assets, job, pump):
_pv = pv
pv.set_anim_state("wait")
# 蒙皮 / LOD 定型要几帧,多 fit 两次取稳定包围盒
for i in 4:
await get_tree().process_frame
if pv != _pv:
return
_frame_model()
else:
pv.queue_free()
# --- 左上职业名 -----------------------------------------------------------
func _build_class_name() -> void:
_class_img = TextureRect.new()
_class_img.position = Vector2(48, 60)
_class_img.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
_class_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_class_img)
_class_lbl = Label.new()
_class_lbl.position = Vector2(50, 54)
_class_lbl.add_theme_font_size_override("font_size", 84)
_class_lbl.add_theme_color_override("font_color", Color(0.86, 0.11, 0.09))
_class_lbl.add_theme_color_override("font_outline_color", Color(0.15, 0.05, 0.02))
_class_lbl.add_theme_constant_override("outline_size", 10)
_class_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_class_lbl)
func _refresh_class_name() -> void:
var job := clampi(int(_cur().get("job", 0)), 0, 3)
var has_char := not _cur().is_empty() and String(_cur().get("name", "")) != ""
var tex: Texture2D = null
if has_char:
var cn: String = CLASS_DIR[job]
for loc in ["en", "de", "tr", "gr", "ru"]:
tex = UiAssets.load_tex(_assets, "locale/locale/%s/ui/select/name_%s.sub" % [loc, cn])
if tex != null:
break
_class_img.texture = tex
_class_img.visible = tex != null
_class_lbl.visible = tex == null and has_char
_class_lbl.text = CLASS_CN[job] if has_char else ""
# --- 左侧信息板 --------------------------------------------------------------
func _build_panel() -> void:
var board := UiKit.board(_assets, "board", 24, 96)
board.position = Vector2(56, 168)
board.size = Vector2(300, 396)
board.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(board)
# 板底纹太透就垫一层
var tint := ColorRect.new()
tint.color = Color(0.03, 0.05, 0.06, 0.62)
tint.position = board.position + Vector2(10, 10)
tint.size = board.size - Vector2(20, 20)
tint.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(tint)
_panel_box = VBoxContainer.new()
_panel_box.position = board.position + Vector2(22, 22)
_panel_box.custom_minimum_size = Vector2(256, 0)
_panel_box.add_theme_constant_override("separation", 9)
add_child(_panel_box)
func _refresh_panel() -> void:
for c in _panel_box.get_children():
c.queue_free()
var d := _cur()
var has_char := not d.is_empty() and String(d.get("name", "")) != ""
# 帮会徽 + 帮会名
var head := HBoxContainer.new()
head.add_theme_constant_override("separation", 10)
_panel_box.add_child(head)
var crest := TextureRect.new()
crest.custom_minimum_size = Vector2(56, 56)
crest.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
var gid := int(d.get("guild_id", 0))
if has_char and gid != 0 and _client and _client.has_method("get_guild_mark_image"):
var gimg = _client.call("get_guild_mark_image", gid)
if gimg is Image and gimg.get_width() > 0:
crest.texture = ImageTexture.create_from_image(gimg)
if crest.texture == null and has_char:
crest.texture = _empire_flag_tex() # 无会徽时退国旗
if crest.texture == null:
var ph := Image.create_empty(48, 48, false, Image.FORMAT_RGBA8)
ph.fill(Color(0.16, 0.20, 0.28, 0.9))
crest.texture = ImageTexture.create_from_image(ph)
head.add_child(crest)
var gbox := VBoxContainer.new()
head.add_child(gbox)
gbox.add_child(_plain(_empire_name() if has_char else "", 14, Color(0.95, 0.9, 0.7)))
var gname := String(d.get("guild_name", "")).strip_edges()
gbox.add_child(_plain(gname if gname != "" else "没有所属帮会", 13, Color(0.8, 0.82, 0.86)))
_panel_box.add_child(_sep())
# 名称 / 等级 / 游戏时间
_panel_box.add_child(_kv("名称", String(d.get("name", "")) if has_char else "空槽位"))
_panel_box.add_child(_kv("等级", str(int(d.get("level", 0))) if has_char else "-"))
var mins := int(d.get("play_minutes", 0))
_panel_box.add_child(_kv("游戏时间", ("%d%d" % [mins / 60, mins % 60]) if has_char else "-"))
_panel_box.add_child(_sep())
# 四维
for row in STAT_ROWS:
var rd: Dictionary = row
var v := int(d.get(rd["key"], 0)) if has_char else 0
_panel_box.add_child(_stat(String(rd["label"]), v, Color(rd["color"])))
_panel_box.add_child(_sep())
# 槽位切换
if _chars.size() > 1:
var nav := HBoxContainer.new()
nav.alignment = BoxContainer.ALIGNMENT_CENTER
nav.add_theme_constant_override("separation", 12)
_panel_box.add_child(nav)
var prev := Button.new(); prev.text = ""; prev.custom_minimum_size = Vector2(38, 28)
prev.pressed.connect(func(): _select(wrapi(_sel - 1, 0, _chars.size())))
nav.add_child(prev)
_slot_lbl = _plain("%d / %d" % [_sel + 1, _chars.size()], 14, Color(1, 1, 1))
nav.add_child(_slot_lbl)
var nxt := Button.new(); nxt.text = ""; nxt.custom_minimum_size = Vector2(38, 28)
nxt.pressed.connect(func(): _select(wrapi(_sel + 1, 0, _chars.size())))
nav.add_child(nxt)
# 按钮
var brow := GridContainer.new()
brow.columns = 2
brow.add_theme_constant_override("h_separation", 8)
brow.add_theme_constant_override("v_separation", 8)
_panel_box.add_child(brow)
var start := Button.new()
start.text = "开始"
start.custom_minimum_size = Vector2(122, 34)
start.disabled = not has_char
start.pressed.connect(_do_start)
brow.add_child(start)
var create := Button.new()
create.text = "创建"
create.custom_minimum_size = Vector2(122, 34)
create.disabled = has_char
create.pressed.connect(_open_create_dialog)
brow.add_child(create)
var del := Button.new()
del.text = "删除"
del.custom_minimum_size = Vector2(122, 34)
del.disabled = not has_char
del.pressed.connect(_open_delete_dialog)
brow.add_child(del)
var quit := Button.new()
quit.text = "退出"
quit.custom_minimum_size = Vector2(122, 34)
quit.pressed.connect(func(): back_requested.emit())
brow.add_child(quit)
# --- 底部状态 --------------------------------------------------------------
func _build_status() -> void:
_status = Label.new()
_status.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
_status.position = Vector2(24, -32)
_status.add_theme_font_size_override("font_size", 13)
_status.add_theme_color_override("font_color", Color(0.9, 0.92, 0.95))
_status.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_status.add_theme_constant_override("outline_size", 4)
add_child(_status)
func set_status(t: String) -> void:
_set_status(t)
# 会徽下载完成后由 app_flow 调用,重绘信息板即可拿到真徽记。
func refresh_crest() -> void:
_refresh_panel()
func _set_status(t: String) -> void:
if _status:
_status.text = t
# --- 选择 / 循环 ----------------------------------------------------------
func _select(i: int) -> void:
if _chars.is_empty():
_sel = 0
_refresh_class_name()
_refresh_panel()
return
_sel = clampi(i, 0, _chars.size() - 1)
if _slot_lbl:
_slot_lbl.text = "%d / %d" % [_sel + 1, _chars.size()]
_refresh_class_name()
_refresh_panel()
_rebuild_model()
var nm := String(_cur().get("name", ""))
_set_status("已选:%s" % nm if nm != "" else "空槽位(点“创建”建号)")
func _do_start() -> void:
var d := _cur()
if d.is_empty() or String(d.get("name", "")) == "":
_set_status("空槽位不能进入")
return
select_requested.emit(_slot_index())
_set_status("进入游戏:%s" % d.get("name"))
# --- 建号 / 删号弹窗 ------------------------------------------------------
var _dialog: Control
func _close_dialog() -> void:
if _dialog and is_instance_valid(_dialog):
_dialog.queue_free()
_dialog = null
func _dialog_node() -> Node:
return _dialog if _dialog and is_instance_valid(_dialog) else null
func _modal_root(title: String, w: int, h: int) -> VBoxContainer:
_close_dialog()
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.55)
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(dim)
_dialog = dim
var board := UiKit.board(_assets, "board", 24, 96)
board.set_anchors_preset(Control.PRESET_CENTER)
board.position = Vector2(-w / 2.0, -h / 2.0)
board.size = Vector2(w, h)
dim.add_child(board)
var bg := ColorRect.new()
bg.color = Color(0.04, 0.06, 0.07, 0.92)
bg.position = Vector2(10, 10)
bg.size = Vector2(w - 20, h - 20)
board.add_child(bg)
var box := VBoxContainer.new()
box.position = Vector2(24, 22)
box.custom_minimum_size = Vector2(w - 48, 0)
box.add_theme_constant_override("separation", 10)
board.add_child(box)
var t := _plain(title, 18, Color(0.95, 0.85, 0.55))
box.add_child(t)
box.add_child(_sep())
return box
func _open_create_dialog() -> void:
var d := _cur()
if String(d.get("name", "")) != "":
_set_status("该槽位已有角色")
return
var slot := _slot_index()
# 空槽默认按 slot 序轮职业,让四个槽能各建一职
var job := clampi(slot, 0, 3)
var box := _modal_root("创建角色 · 槽位 %d" % slot, 420, 360)
var jrow := HBoxContainer.new()
jrow.add_theme_constant_override("separation", 8)
box.add_child(jrow)
jrow.add_child(_plain("职业", 13, Color(0.72, 0.78, 0.85)))
var jopt := OptionButton.new()
jopt.name = "Job"
for i in 4:
jopt.add_item("%s / %s" % [CLASS_CN[i], CLASS_DIR[i]], i)
jopt.selected = job
jrow.add_child(jopt)
var nrow := HBoxContainer.new()
nrow.add_theme_constant_override("separation", 8)
box.add_child(nrow)
nrow.add_child(_plain("名称", 13, Color(0.72, 0.78, 0.85)))
var name_edit := LineEdit.new()
name_edit.name = "Name"
name_edit.max_length = 12
name_edit.custom_minimum_size = Vector2(240, 0)
name_edit.placeholder_text = "角色名(最多 12"
nrow.add_child(name_edit)
# 四维(起始属性,只读,随职业变)
var stat_box := VBoxContainer.new()
stat_box.name = "Stats"
stat_box.add_theme_constant_override("separation", 5)
box.add_child(stat_box)
var render_stats := func(j: int) -> void:
for c in stat_box.get_children():
c.queue_free()
var bs: Dictionary = JOB_BASE_STATS.get(j, JOB_BASE_STATS[0])
stat_box.add_child(_stat("体力", int(bs["con"]), Color(0.90, 0.30, 0.30)))
stat_box.add_child(_stat("智力", int(bs["int"]), Color(0.85, 0.45, 0.95)))
stat_box.add_child(_stat("力量", int(bs["str"]), Color(0.70, 0.55, 1.00)))
stat_box.add_child(_stat("敏捷", int(bs["dex"]), Color(0.35, 0.75, 1.00)))
render_stats.call(job)
jopt.item_selected.connect(func(j: int): render_stats.call(j))
box.add_child(_sep())
var brow := HBoxContainer.new()
brow.alignment = BoxContainer.ALIGNMENT_CENTER
brow.add_theme_constant_override("separation", 16)
box.add_child(brow)
var ok := Button.new(); ok.text = "创建"; ok.custom_minimum_size = Vector2(120, 34)
ok.pressed.connect(func():
var nm := name_edit.text.strip_edges()
if nm.length() < 2:
_set_status("名称太短")
return
var j := jopt.get_selected_id()
var bs: Dictionary = JOB_BASE_STATS.get(j, JOB_BASE_STATS[0])
create_requested.emit({
"slot": slot, "name": nm, "job": j, "shape": 0,
"con": int(bs["con"]), "int": int(bs["int"]),
"str": int(bs["str"]), "dex": int(bs["dex"]),
})
_set_status("创建中…"))
brow.add_child(ok)
var cancel := Button.new(); cancel.text = "取消"; cancel.custom_minimum_size = Vector2(120, 34)
cancel.pressed.connect(_close_dialog)
brow.add_child(cancel)
func _open_delete_dialog() -> void:
var d := _cur()
if String(d.get("name", "")) == "":
return
var slot := _slot_index()
var box := _modal_root("删除角色", 400, 240)
box.add_child(_plain("将永久删除 “%s” (Lv.%d)。" % [d.get("name", ""), int(d.get("level", 0))],
13, Color(0.95, 0.75, 0.7)))
var crow := HBoxContainer.new()
crow.add_theme_constant_override("separation", 8)
box.add_child(crow)
crow.add_child(_plain("删除码", 13, Color(0.72, 0.78, 0.85)))
var code := LineEdit.new()
code.name = "Code"
code.secret = true
code.max_length = 7
code.custom_minimum_size = Vector2(200, 0)
code.placeholder_text = "社交号 / 删除码"
crow.add_child(code)
box.add_child(_sep())
var brow := HBoxContainer.new()
brow.alignment = BoxContainer.ALIGNMENT_CENTER
brow.add_theme_constant_override("separation", 16)
box.add_child(brow)
var ok := Button.new(); ok.text = "删除"; ok.custom_minimum_size = Vector2(120, 34)
ok.pressed.connect(func():
delete_requested.emit(slot, code.text)
_set_status("删除中…"))
brow.add_child(ok)
var cancel := Button.new(); cancel.text = "取消"; cancel.custom_minimum_size = Vector2(120, 34)
cancel.pressed.connect(_close_dialog)
brow.add_child(cancel)
# --- 国家 --------------------------------------------------------------
func _empire_id() -> int:
if _client and _client.has_method("get_empire"):
return int(_client.get_empire())
return 0
func _empire_name() -> String:
var e := _empire_id()
# 优先 locale_game.txt 的 EMPIRE_A/B/C
var key: String = EMPIRE_KEY.get(e, "")
if key != "":
var loc := _locale_line(key)
if loc != "":
return loc
return EMPIRE_FALLBACK.get(e, "")
static var _loc_cache: Dictionary = {}
func _locale_line(key: String) -> String:
if _loc_cache.has(key):
return _loc_cache[key]
var val := ""
for rel in ["locale/locale/en/locale_game.txt", "locale/locale/de/locale_game.txt"]:
var p := _assets.path_join(rel)
if not FileAccess.file_exists(p):
continue
var f := FileAccess.open(p, FileAccess.READ)
while f and not f.eof_reached():
var parts := f.get_line().split("\t", false)
if parts.size() >= 2 and String(parts[0]) == key:
val = String(parts[1]).strip_edges()
break
if val != "":
break
_loc_cache[key] = val
return val
func _empire_flag_tex() -> Texture2D:
var f: String = EMPIRE_FLAG.get(_empire_id(), "")
if f == "":
return null
return UiAssets.load_tex(_assets, "d:/ymir work/ui/intro/empire/empireflag_%s.sub" % f)
func _process(dt: float) -> void:
_spin += dt
if _pivot:
# 正面朝相机 + 轻微来回摆
_pivot.rotation.y = PI + sin(_spin * 0.3) * 0.35
# --- 小工具 --------------------------------------------------------------
func _cur() -> Dictionary:
return _chars[_sel] if _sel >= 0 and _sel < _chars.size() else {}
func _slot_index() -> int:
return int(_cur().get("index", _sel))
func _plain(text: String, size: int, col: Color) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", col)
return l
func _sep() -> Control:
var c := ColorRect.new()
c.color = Color(1, 1, 1, 0.12)
c.custom_minimum_size = Vector2(0, 2)
return c
func _kv(k: String, v: String) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
var kl := _plain(k, 13, Color(0.72, 0.78, 0.85))
kl.custom_minimum_size = Vector2(64, 0)
row.add_child(kl)
row.add_child(_plain(v, 14, Color(1, 1, 1)))
return row
func _stat(label: String, value: int, col: Color) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
var kl := _plain(label, 13, Color(0.72, 0.78, 0.85))
kl.custom_minimum_size = Vector2(44, 0)
row.add_child(kl)
var track := ColorRect.new()
track.color = Color(0, 0, 0, 0.5)
track.custom_minimum_size = Vector2(150, 14)
row.add_child(track)
var fill := ColorRect.new()
fill.color = col
fill.anchor_left = 0.0
fill.anchor_top = 0.0
fill.anchor_bottom = 1.0
fill.offset_left = 1
fill.offset_top = 1
fill.offset_bottom = -1
fill.offset_right = 1 + int(clampf(value / 25.0, 0.0, 1.0) * 148.0)
track.add_child(fill)
row.add_child(_plain(str(value), 13, Color(1, 1, 1)))
return row