Files
mtgodot-poc/project/ui/char_select_screen.gd
T
shenandClaude Sonnet 5 47baf6c0c6 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
2026-08-31 20:02:12 +09:00

684 lines
23 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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