Files
mtgodot-poc/project/ui/char_select_screen.gd
T
2026-09-07 14:53:36 +08:00

895 lines
31 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")
const EquipModel = preload("res://ui/equip_model.gd")
const DESIGN_SIZE := Vector2(1472, 854)
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 change_name_requested(index: int, name: String)
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
}
# §1.10 名称长度校验统一:与 wire 契约 CHARACTER_NAME_MAX_LEN(=24, name[25])
# 对齐。角色名可含多字节字符,wire 缓冲是定长 25 字节,故按 UTF-8 字节长度设限,
# 与 guild_ui.gd 的公会名校验(非空 / 超长 / 控制字符)保持同一形状。
const CHAR_NAME_MAX_BYTES := 24
const CHAR_NAME_MIN_CHARS := 2
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 _last_empire := -1
var _rename_pending := false
var _layout: Control
var _board: Control
var _proto: Node
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()
_layout = Control.new()
_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_layout)
_build_class_name()
_build_panel()
_build_navigation()
_build_status()
resized.connect(_resize_layout)
_resize_layout()
_last_empire = _empire_id()
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
# §1.10:角色名校验("" = 合法)。创建与改名共用同一实现。
func _validate_char_name(nm: String) -> String:
if nm.length() < CHAR_NAME_MIN_CHARS:
return "名称太短"
if nm.to_utf8_buffer().size() > CHAR_NAME_MAX_BYTES:
return "名称太长(最多 %d 字节)" % CHAR_NAME_MAX_BYTES
for ch in nm:
if ch.unicode_at(0) < 32:
return "名称包含非法字符"
return ""
# 由 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:
if _rename_pending:
_rename_pending = false
const RENAME_R := {0: "名称不合法", 1: "该名称已被使用", 100: "角色槽位无效"}
_set_status("改名失败:%s" % RENAME_R.get(reason_type, "错误码 %d" % reason_type))
return
const R := {1: "该名称已被使用", 2: "名称不合法", 3: "该槽位已有角色", 4: "权限不足"}
_set_status("创建失败:%s" % R.get(reason_type, "错误码 %d" % reason_type))
func on_char_name_send_failed() -> void:
_rename_pending = false
_set_status("改名请求发送失败")
func on_char_name_changed(pid: int, name: String) -> void:
var matched := false
for c in _chars:
if int(c.get("id", 0)) == pid:
matched = true
c["name"] = name
c["change_name"] = false
if not matched and _rename_pending:
return
_rename_pending = false
_close_dialog()
_refresh_class_name()
_refresh_panel()
_set_status("角色已改名:%s" % name)
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
# The original stretches the 1024x768 atlas region to the window.
tr.stretch_mode = TextureRect.STRETCH_SCALE
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 = 0.9
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.25
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.8, 0.8, 0.8)
e.ambient_light_energy = 0.65
e.tonemap_mode = Environment.TONE_MAPPER_LINEAR
we.environment = e
root.add_child(we)
_pivot = Node3D.new()
_pivot.rotation.y = -PI * 0.5
root.add_child(_pivot)
_cam = Camera3D.new()
_cam.fov = 10.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 height := maxf(aabb.size.y, 1.0)
# Reference: head at 14%, feet at 85%, center at 59% of the window.
var view_height := height / 0.71
var dist := view_height / (2.0 * tan(deg_to_rad(_cam.fov * 0.5)))
var aspect := size.x / maxf(size.y, 1.0)
var target := aabb.get_center()
target.x -= view_height * aspect * 0.09
target.y -= height * 0.007
_cam.position = target + Vector3(0, dist * sin(deg_to_rad(6.0)), dist * cos(deg_to_rad(6.0)))
_cam.look_at(target, Vector3.UP)
func _rebuild_model() -> void:
if _pivot == null:
return
if _pv != null and is_instance_valid(_pv):
_pv.hide()
_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, 7)
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
if _proto == null and ClassDB.class_exists("Metin2Proto"):
_proto = ClassDB.instantiate("Metin2Proto")
add_child(_proto)
for lang in ["en", "common"]:
var ip := _assets.path_join("locale/locale/%s/item_proto" % lang)
if FileAccess.file_exists(ip):
_proto.call("load_item_proto", ip)
break
var equip := EquipModel.new()
pv.add_child(equip)
equip.setup_remote(null, func() -> Node: return pv, _assets, job,
[int(c.get("main_part", 0)), 0, 0, int(c.get("hair_part", 0))], _proto)
# Expansion armor textures live beside their GR2, outside the base PC pack.
var body_path := String(pv.model.get("gr2_path"))
pv.model.set("texture_dir", body_path.get_base_dir())
# Shape zero uses the head/hair already included in the armor mesh.
if int(c.get("hair_part", 0)) == 0:
pv.model.set("hair_gr2", "")
pv._ground_model()
var intro := pv.motion_dir.get_base_dir().path_join("intro/wait.msa")
if pv.anim and FileAccess.file_exists(intro):
pv.anim.set("anim_path", intro)
pv.anim.set("loop", true)
# 蒙皮 / 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(92, 164)
_class_img.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
_class_img.mouse_filter = Control.MOUSE_FILTER_IGNORE
_layout.add_child(_class_img)
_class_lbl = Label.new()
_class_lbl.position = Vector2(120, 180)
_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
_layout.add_child(_class_lbl)
func _refresh_class_name() -> void:
var job := int(_cur().get("job", 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, "thinboard", 16, 16)
_board = board
board.position = Vector2(119, 313)
board.size = Vector2(208, 323)
board.mouse_filter = Control.MOUSE_FILTER_IGNORE
_layout.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
_layout.add_child(tint)
_panel_box = VBoxContainer.new()
_panel_box.position = board.position + Vector2(14, 12)
_panel_box.custom_minimum_size = Vector2(180, 0)
_panel_box.add_theme_constant_override("separation", 6)
_layout.add_child(_panel_box)
func _refresh_panel() -> void:
for c in _panel_box.get_children():
_panel_box.remove_child(c)
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(70, 40)
crest.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
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 "—", 10, Color(0.95, 0.9, 0.7)))
var gname := String(d.get("guild_name", "")).strip_edges()
gbox.add_child(_plain(gname if gname != "" else "没有所属帮会", 10, 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())
# A fresh account has no empire yet. 40250 keeps the connection in the
# select phase and expects CG_EMPIRE before character creation/selection;
# expose that real protocol step instead of leaving the page apparently
# idle with a permanently disabled start button.
var empire_required := _empire_id() == 0
if empire_required:
_panel_box.add_child(_plain("请选择阵营", 14, Color(0.95, 0.85, 0.55)))
var erow := HBoxContainer.new()
erow.add_theme_constant_override("separation", 6)
_panel_box.add_child(erow)
for eid in [1, 2, 3]:
var empire_id: int = eid
var eb := Button.new()
eb.text = _empire_name_for(empire_id)
eb.custom_minimum_size = Vector2(82, 30)
eb.pressed.connect(func():
if _client == null or not _client.has_method("select_empire"):
_set_status("当前网络后端不支持选择阵营")
return
if bool(_client.call("select_empire", empire_id)):
_set_status("已发送阵营选择:%s" % _empire_name_for(empire_id))
else:
_set_status("阵营选择发送失败")
)
erow.add_child(eb)
# 按钮
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(180, 22)
_style_button(start)
start.disabled = not has_char or empire_required
start.pressed.connect(_do_start)
_panel_box.add_child(start)
_panel_box.move_child(start, _panel_box.get_child_count() - 2)
start.visible = has_char
var create := Button.new()
create.text = "创建"
create.custom_minimum_size = Vector2(180, 22)
_style_button(create)
create.disabled = has_char or empire_required
create.pressed.connect(_open_create_dialog)
_panel_box.add_child(create)
_panel_box.move_child(create, _panel_box.get_child_count() - 2)
create.visible = not has_char
var del := Button.new()
del.text = "删除"
del.custom_minimum_size = Vector2(86, 20)
_style_button(del)
del.disabled = not has_char or empire_required
del.pressed.connect(_open_delete_dialog)
brow.add_child(del)
var quit := Button.new()
quit.text = "退出"
quit.custom_minimum_size = Vector2(86, 20)
_style_button(quit)
quit.pressed.connect(func(): back_requested.emit())
brow.add_child(quit)
func _style_button(button: Button) -> void:
button.add_theme_font_size_override("font_size", 10)
for state in ["normal", "hover", "pressed", "disabled"]:
var suffix := "02" if state == "hover" else "03" if state == "pressed" else "01"
var prefix := "XLarge" if button.custom_minimum_size.x > 100 else "Large"
var texture := UiAssets.load_tex(_assets,
"d:/ymir work/ui/public/%s_Button_%s.sub" % [prefix, suffix])
if texture:
var textured := StyleBoxTexture.new()
textured.texture = texture
textured.set_texture_margin_all(3)
button.add_theme_stylebox_override(state, textured)
continue
var style := StyleBoxFlat.new()
style.bg_color = Color(0.22, 0.22, 0.20, 0.95)
if state == "hover":
style.bg_color = Color(0.32, 0.31, 0.27)
style.border_color = Color(0.48, 0.46, 0.39)
style.set_border_width_all(1)
style.set_corner_radius_all(3)
button.add_theme_stylebox_override(state, style)
func _build_navigation() -> void:
for direction in [-1, 1]:
var button := TextureButton.new()
var side := "left" if direction < 0 else "right"
var base := "d:/ymir work/ui/intro/select/dragon_%s_button_" % side
button.texture_normal = UiAssets.load_tex(_assets, base + "01.sub")
button.texture_hover = UiAssets.load_tex(_assets, base + "02.sub")
button.texture_pressed = UiAssets.load_tex(_assets, base + "03.sub")
button.position = Vector2(714 if direction < 0 else 1024, 712)
button.custom_minimum_size = Vector2(160, 44)
button.tooltip_text = "上一个角色" if direction < 0 else "下一个角色"
button.pressed.connect(func(): _select(wrapi(_sel + direction, 0, _chars.size())))
_layout.add_child(button)
func _resize_layout() -> void:
if _layout:
_layout.size = DESIGN_SIZE
_layout.scale = size / DESIGN_SIZE
_frame_model()
# --- 底部状态 --------------------------------------------------------------
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
# 40250's original select screen forces a character marked with
# change_name to complete the rename flow before entering the world.
if bool(d.get("change_name", false)):
_open_change_name_dialog()
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 = CHAR_NAME_MAX_BYTES
name_edit.custom_minimum_size = Vector2(240, 0)
name_edit.placeholder_text = "角色名(最多 24 字节)"
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()
var err := _validate_char_name(nm)
if err != "":
_set_status(err)
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_change_name_dialog() -> void:
var d := _cur()
if String(d.get("name", "")) == "":
_set_status("空槽位不能改名")
return
var slot := _slot_index()
var box := _modal_root("修改角色名称", 400, 250)
box.add_child(_plain("该角色需要先修改名称才能进入游戏。", 13,
Color(0.95, 0.75, 0.7)))
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 = CHAR_NAME_MAX_BYTES
name_edit.custom_minimum_size = Vector2(220, 0)
name_edit.placeholder_text = "角色名(最多 24 字节)"
nrow.add_child(name_edit)
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()
var err := _validate_char_name(nm)
if err != "":
_set_status(err)
return
_rename_pending = true
_close_dialog()
_set_status("改名中…")
change_name_requested.emit(slot, nm))
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:
return _empire_name_for(_empire_id())
func _empire_name_for(e: int) -> String:
# 优先 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:
var empire := _empire_id()
if empire != _last_empire:
_last_empire = empire
_refresh_panel()
# --- 小工具 --------------------------------------------------------------
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)
row.custom_minimum_size.y = 20
var kl := _plain(k, 10, Color(0.82, 0.82, 0.80))
kl.custom_minimum_size = Vector2(42, 0)
row.add_child(kl)
var value := _plain(v, 10, Color(1, 1, 1))
value.size_flags_horizontal = Control.SIZE_EXPAND_FILL
value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
var style := StyleBoxFlat.new()
style.bg_color = Color(0, 0, 0, 0.85)
style.border_color = Color(0.4, 0.4, 0.37)
style.set_border_width_all(1)
value.add_theme_stylebox_override("normal", style)
row.add_child(value)
return row
func _stat(label: String, value: int, col: Color) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
row.custom_minimum_size.y = 20
var kl := _plain(label, 10, Color(0.82, 0.82, 0.80))
kl.custom_minimum_size = Vector2(26, 0)
row.add_child(kl)
var track := ColorRect.new()
track.color = Color(0, 0, 0, 0.5)
track.custom_minimum_size = Vector2(98, 8)
track.size_flags_vertical = Control.SIZE_SHRINK_CENTER
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 / 90.0, 0.0, 1.0) * 96.0)
track.add_child(fill)
row.add_child(_plain(str(value), 10, Color(1, 1, 1)))
return row